puzzle单机实现
This commit is contained in:
@@ -192,6 +192,9 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
|
|||||||
3. 会总结,不只会追问
|
3. 会总结,不只会追问
|
||||||
4. 会补缺,不会平均盘问
|
4. 会补缺,不会平均盘问
|
||||||
5. 进度基于真实锚点完成度,而不是机械轮次
|
5. 进度基于真实锚点完成度,而不是机械轮次
|
||||||
|
6. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。
|
||||||
|
- 该动作只向 Agent 发送“请补充剩余关键字。”,由后端 Agent 根据当前锚点补齐缺失关键词。
|
||||||
|
- 前端不得自行推断成长阶梯、风险节奏或视觉母题,也不得直接改写锚点状态。
|
||||||
|
|
||||||
## 7.3 大鱼吃小鱼玩法的 4 个最小高杠杆锚点
|
## 7.3 大鱼吃小鱼玩法的 4 个最小高杠杆锚点
|
||||||
|
|
||||||
@@ -948,6 +951,12 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
|
|||||||
5. `src/services/big-fish-*`
|
5. `src/services/big-fish-*`
|
||||||
- 前端 client、adapter、view model
|
- 前端 client、adapter、view model
|
||||||
|
|
||||||
|
后端字段边界要求:
|
||||||
|
|
||||||
|
1. LLM 输出、HTTP 响应和前端交互契约可以使用 `camelCase`,例如 `nextAnchorPack`、`gameplayPromise`、`ecologyVisualTheme`。
|
||||||
|
2. 写入 SpacetimeDB 的 `anchor_pack_json`、`draft_json`、`asset_coverage_json`、`snapshot_json` 必须序列化 Rust 领域结构,字段名保持 `snake_case`。
|
||||||
|
3. `api-server` 负责在 LLM/HTTP 边界显式翻译字段名,不能把前端响应层 JSON 直接透传为 SpacetimeDB 持久化 JSON。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 22. 平台内脚本命名规范
|
## 22. 平台内脚本命名规范
|
||||||
|
|||||||
@@ -91,6 +91,16 @@
|
|||||||
10. 游戏画面必须显示作者信息和关卡名。
|
10. 游戏画面必须显示作者信息和关卡名。
|
||||||
11. 前端只负责表现和交互输入,逻辑、数据、关卡裁决、推荐计算、状态存储全部放到 `server-rs` 后端,由 `Axum + SpacetimeDB + OSS` 方案承接。
|
11. 前端只负责表现和交互输入,逻辑、数据、关卡裁决、推荐计算、状态存储全部放到 `server-rs` 后端,由 `Axum + SpacetimeDB + OSS` 方案承接。
|
||||||
|
|
||||||
|
### 第一版单机例外说明 2026-04-24
|
||||||
|
|
||||||
|
为了先把拼图玩法跑通,第一版运行态采用单机本地版本,作为上面总原则的阶段性例外:
|
||||||
|
|
||||||
|
1. Agent 会话、结果页草稿、正式候选图生成、封面确认、发布、作品读取,仍然全部走 Rust 后端。
|
||||||
|
2. 进入拼图玩法后的 `run` 只在前端本地内存中存在。
|
||||||
|
3. 交换、拖动、通关判断不写回后端。
|
||||||
|
4. 关闭玩法后不保留本次运行态,不做断点续玩。
|
||||||
|
5. 后续如果要做跨端续玩、多端同步或排行榜,再把运行态真相源收回后端。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 明确不做
|
## 4. 明确不做
|
||||||
@@ -191,7 +201,10 @@
|
|||||||
1. 优先接住创作者的画面灵感,而不是立刻列问卷。
|
1. 优先接住创作者的画面灵感,而不是立刻列问卷。
|
||||||
2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。
|
2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。
|
||||||
3. 当创作者已经说出足够信息时,优先总结,不重复追问。
|
3. 当创作者已经说出足够信息时,优先总结,不重复追问。
|
||||||
4. 在进入结果页前,至少确认:
|
4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。
|
||||||
|
- 该动作只向 Agent 发送“请补充剩余关键字。”,不在前端补数据、不伪造锚点状态。
|
||||||
|
- Agent 收到后应优先补齐仍为 `待补充` / 空值的锚点关键词,并保持每次回复清爽直接。
|
||||||
|
5. 在进入结果页前,至少确认:
|
||||||
- 一句题材承诺
|
- 一句题材承诺
|
||||||
- 一个主要视觉主体
|
- 一个主要视觉主体
|
||||||
- 一组气质描述
|
- 一组气质描述
|
||||||
@@ -294,6 +307,12 @@ interface PuzzleAnchorPack {
|
|||||||
2. 创作者选择 `1` 张作为正式图
|
2. 创作者选择 `1` 张作为正式图
|
||||||
3. 正式图确定后,写回作品主图
|
3. 正式图确定后,写回作品主图
|
||||||
|
|
||||||
|
后端落地契约:
|
||||||
|
|
||||||
|
1. `api-server` 写入 SpacetimeDB 的候选图 JSON 必须使用 `module-puzzle::PuzzleGeneratedImageCandidate` 持久化结构。
|
||||||
|
2. 持久化字段名保持 Rust 侧 `snake_case`,例如 `candidate_id`、`image_src`、`asset_id`、`actual_prompt`、`source_type`。
|
||||||
|
3. 面向前端的 HTTP 响应仍由 `shared-contracts` 单独映射为 `camelCase`,不能把响应层字段名直接写入 SpacetimeDB JSON。
|
||||||
|
|
||||||
## 7.6 拼图图片资产要求
|
## 7.6 拼图图片资产要求
|
||||||
|
|
||||||
拼图图片的正式资产要求:
|
拼图图片的正式资产要求:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
env, fs,
|
collections::BTreeMap,
|
||||||
path::{Path, PathBuf},
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -13,7 +12,12 @@ use axum::{
|
|||||||
sse::{Event, Sse},
|
sse::{Event, Sse},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use module_assets::{
|
||||||
|
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||||
|
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||||
|
};
|
||||||
|
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
use shared_contracts::{
|
use shared_contracts::{
|
||||||
puzzle_agent::{
|
puzzle_agent::{
|
||||||
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
|
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
|
||||||
@@ -50,6 +54,7 @@ use spacetime_client::{
|
|||||||
SpacetimeClientError,
|
SpacetimeClientError,
|
||||||
};
|
};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
@@ -67,6 +72,10 @@ const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
|
|||||||
const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works";
|
const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works";
|
||||||
const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
|
const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
|
||||||
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
|
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
|
||||||
|
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
|
||||||
|
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||||
|
const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||||
|
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
|
||||||
|
|
||||||
pub async fn create_puzzle_agent_session(
|
pub async fn create_puzzle_agent_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -438,12 +447,15 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.unwrap_or_else(|| draft.summary.clone());
|
.unwrap_or_else(|| draft.summary.clone());
|
||||||
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
||||||
let candidates = build_placeholder_puzzle_candidates(
|
let candidates = generate_puzzle_image_candidates(
|
||||||
|
&state,
|
||||||
|
owner_user_id.as_str(),
|
||||||
&session.session_id,
|
&session.session_id,
|
||||||
&draft.level_name,
|
&draft.level_name,
|
||||||
&prompt,
|
&prompt,
|
||||||
candidate_count,
|
candidate_count,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.map_err(SpacetimeClientError::Runtime);
|
.map_err(SpacetimeClientError::Runtime);
|
||||||
match candidates {
|
match candidates {
|
||||||
Ok(candidates) => {
|
Ok(candidates) => {
|
||||||
@@ -1380,27 +1392,47 @@ fn puzzle_sse_error_event_message(message: String) -> Event {
|
|||||||
Event::default().event("error").data(payload)
|
Event::default().event("error").data(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_placeholder_puzzle_candidates(
|
async fn generate_puzzle_image_candidates(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
level_name: &str,
|
level_name: &str,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
candidate_count: u32,
|
candidate_count: u32,
|
||||||
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
||||||
let count = candidate_count.clamp(1, 2);
|
let count = candidate_count.clamp(1, 2);
|
||||||
let mut items = Vec::with_capacity(count as usize);
|
let settings =
|
||||||
|
require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?;
|
||||||
|
let http_client = build_puzzle_dashscope_http_client(&settings)
|
||||||
|
.map_err(|error| error.message().to_string())?;
|
||||||
|
let generated = create_puzzle_text_to_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
"1024*1024",
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.message().to_string())?;
|
||||||
|
let mut items = Vec::with_capacity(generated.images.len());
|
||||||
|
|
||||||
for index in 0..count {
|
for (index, image) in generated.images.into_iter().enumerate() {
|
||||||
let asset = save_placeholder_puzzle_asset(
|
let candidate_id = format!("{session_id}-candidate-{}", index + 1);
|
||||||
|
let asset = persist_puzzle_generated_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
session_id,
|
session_id,
|
||||||
level_name,
|
level_name,
|
||||||
&format!("candidate-{}", index + 1),
|
candidate_id.as_str(),
|
||||||
"cover",
|
generated.task_id.as_str(),
|
||||||
"1536*1536",
|
image,
|
||||||
Some(prompt),
|
current_utc_micros(),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.map_err(|error| error.message().to_string())?;
|
.map_err(|error| error.message().to_string())?;
|
||||||
items.push(PuzzleGeneratedImageCandidateResponse {
|
items.push(PuzzleGeneratedImageCandidateResponse {
|
||||||
candidate_id: format!("{session_id}-candidate-{}", index + 1),
|
candidate_id,
|
||||||
image_src: asset.image_src,
|
image_src: asset.image_src,
|
||||||
asset_id: asset.asset_id,
|
asset_id: asset.asset_id,
|
||||||
prompt: prompt.to_string(),
|
prompt: prompt.to_string(),
|
||||||
@@ -1424,98 +1456,452 @@ fn build_placeholder_puzzle_candidates(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PuzzleDashScopeSettings {
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
request_timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PuzzleGeneratedImages {
|
||||||
|
task_id: String,
|
||||||
|
images: Vec<PuzzleDownloadedImage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PuzzleDownloadedImage {
|
||||||
|
extension: String,
|
||||||
|
mime_type: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
struct GeneratedPuzzleAssetResponse {
|
struct GeneratedPuzzleAssetResponse {
|
||||||
image_src: String,
|
image_src: String,
|
||||||
asset_id: String,
|
asset_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_placeholder_puzzle_asset(
|
fn require_puzzle_dashscope_settings(state: &AppState) -> Result<PuzzleDashScopeSettings, AppError> {
|
||||||
session_segment_seed: &str,
|
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||||
work_segment_seed: &str,
|
if base_url.is_empty() {
|
||||||
leaf_segment_seed: &str,
|
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
file_stem: &str,
|
"provider": "dashscope",
|
||||||
|
"reason": "DASHSCOPE_BASE_URL 未配置",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_key = state
|
||||||
|
.config
|
||||||
|
.dashscope_api_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"reason": "DASHSCOPE_API_KEY 未配置",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(PuzzleDashScopeSettings {
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
api_key: api_key.to_string(),
|
||||||
|
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_puzzle_dashscope_http_client(
|
||||||
|
settings: &PuzzleDashScopeSettings,
|
||||||
|
) -> Result<reqwest::Client, AppError> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": format!("构造拼图 DashScope HTTP 客户端失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_puzzle_text_to_image_generation(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &PuzzleDashScopeSettings,
|
||||||
|
prompt: &str,
|
||||||
|
negative_prompt: &str,
|
||||||
size: &str,
|
size: &str,
|
||||||
prompt: Option<&str>,
|
candidate_count: u32,
|
||||||
|
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||||
|
let mut parameters = Map::from_iter([
|
||||||
|
("n".to_string(), json!(candidate_count.clamp(1, 2))),
|
||||||
|
("size".to_string(), Value::String(size.to_string())),
|
||||||
|
("prompt_extend".to_string(), Value::Bool(true)),
|
||||||
|
("watermark".to_string(), Value::Bool(false)),
|
||||||
|
]);
|
||||||
|
parameters.insert(
|
||||||
|
"negative_prompt".to_string(),
|
||||||
|
Value::String(negative_prompt.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = http_client
|
||||||
|
.post(format!(
|
||||||
|
"{}/services/aigc/text2image/image-synthesis",
|
||||||
|
settings.base_url
|
||||||
|
))
|
||||||
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", settings.api_key),
|
||||||
|
)
|
||||||
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header("X-DashScope-Async", "enable")
|
||||||
|
.json(&json!({
|
||||||
|
"model": PUZZLE_TEXT_TO_IMAGE_MODEL,
|
||||||
|
"input": { "prompt": prompt },
|
||||||
|
"parameters": parameters,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| map_puzzle_dashscope_request_error(format!("创建拼图图片生成任务失败:{error}")))?;
|
||||||
|
let status = response.status();
|
||||||
|
let response_text = response.text().await.map_err(|error| {
|
||||||
|
map_puzzle_dashscope_request_error(format!("读取拼图图片生成响应失败:{error}"))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(map_puzzle_dashscope_upstream_error(
|
||||||
|
response_text.as_str(),
|
||||||
|
"创建拼图图片生成任务失败",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图图片生成响应失败")?;
|
||||||
|
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": "拼图图片生成任务未返回 task_id",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
|
||||||
|
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let poll_response = http_client
|
||||||
|
.get(format!("{}/tasks/{}", settings.base_url, task_id))
|
||||||
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", settings.api_key),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}"))
|
||||||
|
})?;
|
||||||
|
let poll_status = poll_response.status();
|
||||||
|
let poll_text = poll_response.text().await.map_err(|error| {
|
||||||
|
map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}"))
|
||||||
|
})?;
|
||||||
|
if !poll_status.is_success() {
|
||||||
|
return Err(map_puzzle_dashscope_upstream_error(
|
||||||
|
poll_text.as_str(),
|
||||||
|
"查询拼图图片生成任务失败",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let poll_payload = parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
|
||||||
|
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if task_status == "SUCCEEDED" {
|
||||||
|
let image_urls = extract_puzzle_image_urls(&poll_payload);
|
||||||
|
if image_urls.is_empty() {
|
||||||
|
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": "拼图图片生成成功但未返回图片地址",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
let mut images = Vec::with_capacity(image_urls.len());
|
||||||
|
for image_url in image_urls.into_iter().take(candidate_count.clamp(1, 2) as usize) {
|
||||||
|
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||||
|
}
|
||||||
|
return Ok(PuzzleGeneratedImages { task_id, images });
|
||||||
|
}
|
||||||
|
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
|
||||||
|
return Err(map_puzzle_dashscope_upstream_error(
|
||||||
|
poll_text.as_str(),
|
||||||
|
"拼图图片生成任务失败",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": "拼图图片生成超时或未返回图片地址",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_puzzle_remote_image(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
image_url: &str,
|
||||||
|
) -> Result<PuzzleDownloadedImage, AppError> {
|
||||||
|
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||||
|
map_puzzle_dashscope_request_error(format!("下载拼图正式图片失败:{error}"))
|
||||||
|
})?;
|
||||||
|
let status = response.status();
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("image/jpeg")
|
||||||
|
.to_string();
|
||||||
|
let bytes = response.bytes().await.map_err(|error| {
|
||||||
|
map_puzzle_dashscope_request_error(format!("读取拼图正式图片内容失败:{error}"))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": "下载拼图正式图片失败",
|
||||||
|
"status": status.as_u16(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
|
||||||
|
Ok(PuzzleDownloadedImage {
|
||||||
|
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
|
||||||
|
mime_type,
|
||||||
|
bytes: bytes.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_puzzle_generated_asset(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
level_name: &str,
|
||||||
|
candidate_id: &str,
|
||||||
|
task_id: &str,
|
||||||
|
image: PuzzleDownloadedImage,
|
||||||
|
generated_at_micros: i64,
|
||||||
) -> Result<GeneratedPuzzleAssetResponse, AppError> {
|
) -> Result<GeneratedPuzzleAssetResponse, AppError> {
|
||||||
let asset_id = format!("{file_stem}-{}", current_utc_millis());
|
let oss_client = state.oss_client().ok_or_else(|| {
|
||||||
let relative_dir = PathBuf::from("generated-puzzle-covers")
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
.join(sanitize_path_segment(session_segment_seed, "session"))
|
"provider": "aliyun-oss",
|
||||||
.join(sanitize_path_segment(work_segment_seed, "puzzle"))
|
"reason": "OSS 未完成环境变量配置",
|
||||||
.join(sanitize_path_segment(leaf_segment_seed, "candidate"))
|
}))
|
||||||
.join(&asset_id);
|
})?;
|
||||||
let output_dir = resolve_public_output_dir(&relative_dir)?;
|
let http_client = reqwest::Client::new();
|
||||||
fs::create_dir_all(&output_dir).map_err(io_error)?;
|
let asset_id = format!("asset-{generated_at_micros}");
|
||||||
let file_name = format!("{file_stem}.svg");
|
let put_result = oss_client
|
||||||
let svg = build_puzzle_placeholder_svg(size, prompt.unwrap_or(file_stem));
|
.put_object(
|
||||||
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
|
&http_client,
|
||||||
|
OssPutObjectRequest {
|
||||||
|
prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||||
|
path_segments: vec![
|
||||||
|
sanitize_path_segment(session_id, "session"),
|
||||||
|
sanitize_path_segment(level_name, "puzzle"),
|
||||||
|
sanitize_path_segment(candidate_id, "candidate"),
|
||||||
|
asset_id.clone(),
|
||||||
|
],
|
||||||
|
file_name: format!("image.{}", image.extension),
|
||||||
|
content_type: Some(image.mime_type.clone()),
|
||||||
|
access: OssObjectAccess::Private,
|
||||||
|
metadata: build_puzzle_asset_metadata(owner_user_id, session_id, candidate_id),
|
||||||
|
body: image.bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_asset_oss_error)?;
|
||||||
|
let head = oss_client
|
||||||
|
.head_object(
|
||||||
|
&http_client,
|
||||||
|
OssHeadObjectRequest {
|
||||||
|
object_key: put_result.object_key.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_asset_oss_error)?;
|
||||||
|
let asset_object = state
|
||||||
|
.spacetime_client()
|
||||||
|
.confirm_asset_object(
|
||||||
|
build_asset_object_upsert_input(
|
||||||
|
generate_asset_object_id(generated_at_micros),
|
||||||
|
head.bucket,
|
||||||
|
head.object_key,
|
||||||
|
AssetObjectAccessPolicy::Private,
|
||||||
|
head.content_type.or(Some(image.mime_type)),
|
||||||
|
head.content_length,
|
||||||
|
head.etag,
|
||||||
|
"puzzle_cover_image".to_string(),
|
||||||
|
Some(task_id.to_string()),
|
||||||
|
Some(owner_user_id.to_string()),
|
||||||
|
None,
|
||||||
|
Some(session_id.to_string()),
|
||||||
|
generated_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(map_puzzle_asset_field_error)?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_asset_spacetime_error)?;
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.bind_asset_object_to_entity(
|
||||||
|
build_asset_entity_binding_input(
|
||||||
|
generate_asset_binding_id(generated_at_micros),
|
||||||
|
asset_object.asset_object_id,
|
||||||
|
PUZZLE_ENTITY_KIND.to_string(),
|
||||||
|
session_id.to_string(),
|
||||||
|
candidate_id.to_string(),
|
||||||
|
"puzzle_cover_image".to_string(),
|
||||||
|
Some(owner_user_id.to_string()),
|
||||||
|
None,
|
||||||
|
generated_at_micros,
|
||||||
|
)
|
||||||
|
.map_err(map_puzzle_asset_field_error)?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_asset_spacetime_error)?;
|
||||||
|
|
||||||
Ok(GeneratedPuzzleAssetResponse {
|
Ok(GeneratedPuzzleAssetResponse {
|
||||||
image_src: format!(
|
image_src: put_result.legacy_public_path,
|
||||||
"/{}/{}",
|
|
||||||
relative_dir.to_string_lossy().replace('\\', "/"),
|
|
||||||
file_name
|
|
||||||
),
|
|
||||||
asset_id,
|
asset_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_puzzle_placeholder_svg(size: &str, label: &str) -> String {
|
fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||||
let (width, height) = parse_size(size);
|
|
||||||
format!(
|
format!(
|
||||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
"生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。"
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#201a0f"/>
|
|
||||||
<stop offset="50%" stop-color="#4a2c24"/>
|
|
||||||
<stop offset="100%" stop-color="#10243a"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
|
||||||
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,244,214,0.12)"/>
|
|
||||||
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(115,194,255,0.14)"/>
|
|
||||||
<rect x="{frame_x}" y="{frame_y}" width="{frame_w}" height="{frame_h}" rx="{frame_r}" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.18)"/>
|
|
||||||
<text x="50%" y="47%" text-anchor="middle" fill="#f6e9cf" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
|
||||||
<text x="50%" y="57%" text-anchor="middle" fill="#d4e8ff" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Puzzle placeholder</text>
|
|
||||||
</svg>"##,
|
|
||||||
width = width,
|
|
||||||
height = height,
|
|
||||||
cx1 = width / 4,
|
|
||||||
cy1 = height / 3,
|
|
||||||
r1 = (width.min(height) / 5).max(42),
|
|
||||||
cx2 = width * 3 / 4,
|
|
||||||
cy2 = height / 4,
|
|
||||||
r2 = (width.min(height) / 7).max(30),
|
|
||||||
frame_x = width / 9,
|
|
||||||
frame_y = height / 9,
|
|
||||||
frame_w = width * 7 / 9,
|
|
||||||
frame_h = height * 7 / 9,
|
|
||||||
frame_r = (width.min(height) / 20).max(18),
|
|
||||||
font_main = (width.min(height) / 14).max(22),
|
|
||||||
font_sub = (width.min(height) / 30).max(12),
|
|
||||||
title = escape_svg_text(label),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_size(size: &str) -> (u32, u32) {
|
fn build_puzzle_asset_metadata(
|
||||||
let mut parts = size.split('*');
|
owner_user_id: &str,
|
||||||
let width = parts
|
session_id: &str,
|
||||||
.next()
|
candidate_id: &str,
|
||||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
) -> BTreeMap<String, String> {
|
||||||
.filter(|value| *value > 0)
|
BTreeMap::from([
|
||||||
.unwrap_or(1536);
|
("asset_kind".to_string(), "puzzle_cover_image".to_string()),
|
||||||
let height = parts
|
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||||||
.next()
|
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
|
||||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
("entity_id".to_string(), session_id.to_string()),
|
||||||
.filter(|value| *value > 0)
|
("slot".to_string(), candidate_id.to_string()),
|
||||||
.unwrap_or(1536);
|
])
|
||||||
(width, height)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_svg_text(value: &str) -> String {
|
fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
|
||||||
value
|
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||||
.replace('&', "&")
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
.replace('<', "<")
|
"provider": "dashscope",
|
||||||
.replace('>', ">")
|
"message": format!("{fallback_message}:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_puzzle_task_id(payload: &Value) -> Option<String> {
|
||||||
|
find_first_puzzle_string_by_key(payload, "task_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_puzzle_image_urls(payload: &Value) -> Vec<String> {
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
collect_puzzle_strings_by_key(payload, "image", &mut urls);
|
||||||
|
collect_puzzle_strings_by_key(payload, "url", &mut urls);
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
for url in urls {
|
||||||
|
if !deduped.contains(&url) {
|
||||||
|
deduped.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
collect_puzzle_strings_by_key(payload, target_key, &mut results);
|
||||||
|
results.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_puzzle_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||||
|
match payload {
|
||||||
|
Value::Array(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
collect_puzzle_strings_by_key(entry, target_key, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
for (key, value) in object {
|
||||||
|
if key == target_key
|
||||||
|
&& let Some(text) = value.as_str()
|
||||||
|
{
|
||||||
|
results.push(text.to_string());
|
||||||
|
}
|
||||||
|
collect_puzzle_strings_by_key(value, target_key, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
|
||||||
|
let mime_type = content_type
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("image/jpeg");
|
||||||
|
match mime_type {
|
||||||
|
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||||
|
mime_type.to_string()
|
||||||
|
}
|
||||||
|
_ => "image/jpeg".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn puzzle_mime_to_extension(mime_type: &str) -> &str {
|
||||||
|
match mime_type {
|
||||||
|
"image/png" => "png",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
"image/gif" => "gif",
|
||||||
|
_ => "jpg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_puzzle_dashscope_request_error(message: String) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_puzzle_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "dashscope",
|
||||||
|
"message": parse_puzzle_api_error_message(raw_text, fallback_message),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||||
|
let trimmed = raw_text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return fallback_message.to_string();
|
||||||
|
}
|
||||||
|
if let Ok(payload) = serde_json::from_str::<Value>(trimmed)
|
||||||
|
&& let Some(message) = find_first_puzzle_string_by_key(&payload, "message")
|
||||||
|
{
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
fallback_message.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "aliyun-oss",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_puzzle_asset_field_error(error: AssetObjectFieldError) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "asset-object",
|
||||||
|
"message": error.to_string(),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
|
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
|
||||||
@@ -1539,31 +1925,9 @@ fn sanitize_path_segment(value: &str, fallback: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_public_output_dir(relative_dir: &Path) -> Result<PathBuf, AppError> {
|
|
||||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.ancestors()
|
|
||||||
.nth(3)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
.with_message("无法定位仓库根目录")
|
|
||||||
})?;
|
|
||||||
Ok(workspace_root.join("public").join(relative_dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn io_error(error: std::io::Error) -> AppError {
|
|
||||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_utc_micros() -> i64 {
|
fn current_utc_micros() -> i64 {
|
||||||
let duration = SystemTime::now()
|
let duration = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
|
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_utc_millis() -> u128 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub enum LegacyAssetPrefix {
|
|||||||
Characters,
|
Characters,
|
||||||
Animations,
|
Animations,
|
||||||
BigFishAssets,
|
BigFishAssets,
|
||||||
|
PuzzleAssets,
|
||||||
CustomWorldScenes,
|
CustomWorldScenes,
|
||||||
CustomWorldCovers,
|
CustomWorldCovers,
|
||||||
QwenSprites,
|
QwenSprites,
|
||||||
@@ -209,6 +210,7 @@ impl LegacyAssetPrefix {
|
|||||||
"generated-characters" => Some(Self::Characters),
|
"generated-characters" => Some(Self::Characters),
|
||||||
"generated-animations" => Some(Self::Animations),
|
"generated-animations" => Some(Self::Animations),
|
||||||
"generated-big-fish-assets" => Some(Self::BigFishAssets),
|
"generated-big-fish-assets" => Some(Self::BigFishAssets),
|
||||||
|
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
|
||||||
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
|
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
|
||||||
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
|
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
|
||||||
"generated-qwen-sprites" => Some(Self::QwenSprites),
|
"generated-qwen-sprites" => Some(Self::QwenSprites),
|
||||||
@@ -222,6 +224,7 @@ impl LegacyAssetPrefix {
|
|||||||
Self::Characters => "generated-characters",
|
Self::Characters => "generated-characters",
|
||||||
Self::Animations => "generated-animations",
|
Self::Animations => "generated-animations",
|
||||||
Self::BigFishAssets => "generated-big-fish-assets",
|
Self::BigFishAssets => "generated-big-fish-assets",
|
||||||
|
Self::PuzzleAssets => "generated-puzzle-assets",
|
||||||
Self::CustomWorldScenes => "generated-custom-world-scenes",
|
Self::CustomWorldScenes => "generated-custom-world-scenes",
|
||||||
Self::CustomWorldCovers => "generated-custom-world-covers",
|
Self::CustomWorldCovers => "generated-custom-world-covers",
|
||||||
Self::QwenSprites => "generated-qwen-sprites",
|
Self::QwenSprites => "generated-qwen-sprites",
|
||||||
|
|||||||
@@ -58,11 +58,11 @@ import {
|
|||||||
} from '../../services/puzzle-agent';
|
} from '../../services/puzzle-agent';
|
||||||
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery';
|
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery';
|
||||||
import {
|
import {
|
||||||
advancePuzzleNextLevel,
|
advanceLocalPuzzleLevel,
|
||||||
dragPuzzlePieceOrGroup,
|
dragLocalPuzzlePiece,
|
||||||
startPuzzleRun,
|
startLocalPuzzleRun,
|
||||||
swapPuzzlePieces,
|
swapLocalPuzzlePieces,
|
||||||
} from '../../services/puzzle-runtime';
|
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||||
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
|
||||||
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||||
@@ -1024,8 +1024,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleError(null);
|
setPuzzleError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { run } = await startPuzzleRun({ profileId });
|
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||||
setPuzzleRun(run);
|
setSelectedPuzzleDetail(item);
|
||||||
|
setPuzzleRun(startLocalPuzzleRun(item));
|
||||||
setSelectionStage('puzzle-runtime');
|
setSelectionStage('puzzle-runtime');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
||||||
@@ -1060,28 +1061,19 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const swapPuzzlePiecesInRun = useCallback(
|
const swapPuzzlePiecesInRun = useCallback(
|
||||||
async (payload: { firstPieceId: string; secondPieceId: string }) => {
|
(payload: { firstPieceId: string; secondPieceId: string }) => {
|
||||||
if (!puzzleRun || isPuzzleBusy) {
|
if (!puzzleRun || isPuzzleBusy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPuzzleBusy(true);
|
|
||||||
setPuzzleError(null);
|
setPuzzleError(null);
|
||||||
|
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
|
||||||
try {
|
|
||||||
const { run } = await swapPuzzlePieces(puzzleRun.runId, payload);
|
|
||||||
setPuzzleRun(run);
|
|
||||||
} catch (error) {
|
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
|
|
||||||
} finally {
|
|
||||||
setIsPuzzleBusy(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
[isPuzzleBusy, puzzleRun],
|
||||||
);
|
);
|
||||||
|
|
||||||
const dragPuzzlePiece = useCallback(
|
const dragPuzzlePiece = useCallback(
|
||||||
async (payload: {
|
(payload: {
|
||||||
pieceId: string;
|
pieceId: string;
|
||||||
targetRow: number;
|
targetRow: number;
|
||||||
targetCol: number;
|
targetCol: number;
|
||||||
@@ -1090,19 +1082,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPuzzleBusy(true);
|
|
||||||
setPuzzleError(null);
|
setPuzzleError(null);
|
||||||
|
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
|
||||||
try {
|
|
||||||
const { run } = await dragPuzzlePieceOrGroup(puzzleRun.runId, payload);
|
|
||||||
setPuzzleRun(run);
|
|
||||||
} catch (error) {
|
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
|
|
||||||
} finally {
|
|
||||||
setIsPuzzleBusy(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
[isPuzzleBusy, puzzleRun],
|
||||||
);
|
);
|
||||||
|
|
||||||
const advancePuzzleLevel = useCallback(async () => {
|
const advancePuzzleLevel = useCallback(async () => {
|
||||||
@@ -1110,18 +1093,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPuzzleBusy(true);
|
|
||||||
setPuzzleError(null);
|
setPuzzleError(null);
|
||||||
|
setPuzzleRun(advanceLocalPuzzleLevel(puzzleRun));
|
||||||
try {
|
}, [isPuzzleBusy, puzzleRun]);
|
||||||
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
|
|
||||||
setPuzzleRun(run);
|
|
||||||
} catch (error) {
|
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '进入下一关失败。'));
|
|
||||||
} finally {
|
|
||||||
setIsPuzzleBusy(false);
|
|
||||||
}
|
|
||||||
}, [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage]);
|
|
||||||
|
|
||||||
const leaveAgentWorkspace = useCallback(() => {
|
const leaveAgentWorkspace = useCallback(() => {
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
|
|||||||
195
src/services/puzzle-runtime/puzzleLocalRuntime.ts
Normal file
195
src/services/puzzle-runtime/puzzleLocalRuntime.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import type {
|
||||||
|
DragPuzzlePieceRequest,
|
||||||
|
PuzzleBoardSnapshot,
|
||||||
|
PuzzleGridSize,
|
||||||
|
PuzzlePieceState,
|
||||||
|
PuzzleRunSnapshot,
|
||||||
|
SwapPuzzlePiecesRequest,
|
||||||
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||||
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
|
|
||||||
|
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||||
|
return clearedLevelCount >= 3 ? 4 : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialPositions(gridSize: PuzzleGridSize) {
|
||||||
|
const positions = Array.from({ length: gridSize * gridSize }, (_, index) => ({
|
||||||
|
row: Math.floor(index / gridSize),
|
||||||
|
col: index % gridSize,
|
||||||
|
}));
|
||||||
|
return positions.slice(1).concat(positions.slice(0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildBoardSnapshot(
|
||||||
|
gridSize: PuzzleGridSize,
|
||||||
|
pieces: PuzzlePieceState[],
|
||||||
|
): PuzzleBoardSnapshot {
|
||||||
|
const resolvedPieceIds = new Set(
|
||||||
|
pieces
|
||||||
|
.filter(
|
||||||
|
(piece) =>
|
||||||
|
piece.currentRow === piece.correctRow &&
|
||||||
|
piece.currentCol === piece.correctCol,
|
||||||
|
)
|
||||||
|
.map((piece) => piece.pieceId),
|
||||||
|
);
|
||||||
|
const allTilesResolved = resolvedPieceIds.size === pieces.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: gridSize,
|
||||||
|
cols: gridSize,
|
||||||
|
pieces: pieces.map((piece) => ({
|
||||||
|
...piece,
|
||||||
|
mergedGroupId: resolvedPieceIds.has(piece.pieceId)
|
||||||
|
? 'resolved-main'
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
mergedGroups: resolvedPieceIds.size
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
groupId: 'resolved-main',
|
||||||
|
pieceIds: Array.from(resolvedPieceIds),
|
||||||
|
occupiedCells: pieces
|
||||||
|
.filter((piece) => resolvedPieceIds.has(piece.pieceId))
|
||||||
|
.map((piece) => ({
|
||||||
|
row: piece.currentRow,
|
||||||
|
col: piece.currentCol,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
selectedPieceId: null,
|
||||||
|
allTilesResolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot {
|
||||||
|
const shuffledPositions = buildInitialPositions(gridSize);
|
||||||
|
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
|
||||||
|
const correctRow = Math.floor(index / gridSize);
|
||||||
|
const correctCol = index % gridSize;
|
||||||
|
const current = shuffledPositions[index];
|
||||||
|
return {
|
||||||
|
pieceId: `piece-${index}`,
|
||||||
|
correctRow,
|
||||||
|
correctCol,
|
||||||
|
currentRow: current.row,
|
||||||
|
currentCol: current.col,
|
||||||
|
mergedGroupId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return rebuildBoardSnapshot(gridSize, pieces);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNextBoard(
|
||||||
|
run: PuzzleRunSnapshot,
|
||||||
|
nextBoard: PuzzleBoardSnapshot,
|
||||||
|
): PuzzleRunSnapshot {
|
||||||
|
if (!run.currentLevel) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
clearedLevelCount:
|
||||||
|
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||||
|
? run.clearedLevelCount + 1
|
||||||
|
: run.clearedLevelCount,
|
||||||
|
currentLevel: {
|
||||||
|
...run.currentLevel,
|
||||||
|
board: nextBoard,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||||||
|
const gridSize = resolvePuzzleGridSize(0);
|
||||||
|
return {
|
||||||
|
runId: `local-puzzle-run-${item.profileId}-${Date.now()}`,
|
||||||
|
entryProfileId: item.profileId,
|
||||||
|
clearedLevelCount: 0,
|
||||||
|
currentLevelIndex: 1,
|
||||||
|
currentGridSize: gridSize,
|
||||||
|
playedProfileIds: [item.profileId],
|
||||||
|
previousLevelTags: item.themeTags,
|
||||||
|
currentLevel: {
|
||||||
|
runId: `local-puzzle-run-${item.profileId}`,
|
||||||
|
levelIndex: 1,
|
||||||
|
gridSize,
|
||||||
|
profileId: item.profileId,
|
||||||
|
levelName: item.levelName,
|
||||||
|
authorDisplayName: item.authorDisplayName,
|
||||||
|
themeTags: item.themeTags,
|
||||||
|
coverImageSrc: item.coverImageSrc,
|
||||||
|
board: buildInitialBoard(gridSize),
|
||||||
|
status: 'playing',
|
||||||
|
},
|
||||||
|
recommendedNextProfileId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swapLocalPuzzlePieces(
|
||||||
|
run: PuzzleRunSnapshot,
|
||||||
|
payload: SwapPuzzlePiecesRequest,
|
||||||
|
): PuzzleRunSnapshot {
|
||||||
|
const currentLevel = run.currentLevel;
|
||||||
|
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||||
|
const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId);
|
||||||
|
const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId);
|
||||||
|
if (!first || !second) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
const firstPosition = { row: first.currentRow, col: first.currentCol };
|
||||||
|
first.currentRow = second.currentRow;
|
||||||
|
first.currentCol = second.currentCol;
|
||||||
|
second.currentRow = firstPosition.row;
|
||||||
|
second.currentCol = firstPosition.col;
|
||||||
|
|
||||||
|
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dragLocalPuzzlePiece(
|
||||||
|
run: PuzzleRunSnapshot,
|
||||||
|
payload: DragPuzzlePieceRequest,
|
||||||
|
): PuzzleRunSnapshot {
|
||||||
|
const currentLevel = run.currentLevel;
|
||||||
|
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload.targetRow < 0 ||
|
||||||
|
payload.targetCol < 0 ||
|
||||||
|
payload.targetRow >= currentLevel.gridSize ||
|
||||||
|
payload.targetCol >= currentLevel.gridSize
|
||||||
|
) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||||
|
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
|
||||||
|
if (!moving) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
const occupying = pieces.find(
|
||||||
|
(piece) =>
|
||||||
|
piece.pieceId !== payload.pieceId &&
|
||||||
|
piece.currentRow === payload.targetRow &&
|
||||||
|
piece.currentCol === payload.targetCol,
|
||||||
|
);
|
||||||
|
const source = { row: moving.currentRow, col: moving.currentCol };
|
||||||
|
moving.currentRow = payload.targetRow;
|
||||||
|
moving.currentCol = payload.targetCol;
|
||||||
|
if (occupying) {
|
||||||
|
occupying.currentRow = source.row;
|
||||||
|
occupying.currentCol = source.col;
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user