From 0940a1945ce53a37dddbd319588b61d615ceb915 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 24 Apr 2026 20:41:24 +0800 Subject: [PATCH] =?UTF-8?q?puzzle=E5=8D=95=E6=9C=BA=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md | 9 + ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md | 21 +- server-rs/crates/api-server/src/puzzle.rs | 582 ++++++++++++++---- server-rs/crates/platform-oss/src/lib.rs | 3 + .../PlatformEntryFlowShellImpl.tsx | 58 +- .../puzzle-runtime/puzzleLocalRuntime.ts | 195 ++++++ 6 files changed, 716 insertions(+), 152 deletions(-) create mode 100644 src/services/puzzle-runtime/puzzleLocalRuntime.ts diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md index dc717eb6..d92dbf73 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md @@ -192,6 +192,9 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: 3. 会总结,不只会追问 4. 会补缺,不会平均盘问 5. 进度基于真实锚点完成度,而不是机械轮次 +6. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。 + - 该动作只向 Agent 发送“请补充剩余关键字。”,由后端 Agent 根据当前锚点补齐缺失关键词。 + - 前端不得自行推断成长阶梯、风险节奏或视觉母题,也不得直接改写锚点状态。 ## 7.3 大鱼吃小鱼玩法的 4 个最小高杠杆锚点 @@ -948,6 +951,12 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: 5. `src/services/big-fish-*` - 前端 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. 平台内脚本命名规范 diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index 1b5e3141..0421243e 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -91,6 +91,16 @@ 10. 游戏画面必须显示作者信息和关卡名。 11. 前端只负责表现和交互输入,逻辑、数据、关卡裁决、推荐计算、状态存储全部放到 `server-rs` 后端,由 `Axum + SpacetimeDB + OSS` 方案承接。 +### 第一版单机例外说明 2026-04-24 + +为了先把拼图玩法跑通,第一版运行态采用单机本地版本,作为上面总原则的阶段性例外: + +1. Agent 会话、结果页草稿、正式候选图生成、封面确认、发布、作品读取,仍然全部走 Rust 后端。 +2. 进入拼图玩法后的 `run` 只在前端本地内存中存在。 +3. 交换、拖动、通关判断不写回后端。 +4. 关闭玩法后不保留本次运行态,不做断点续玩。 +5. 后续如果要做跨端续玩、多端同步或排行榜,再把运行态真相源收回后端。 + --- ## 4. 明确不做 @@ -191,7 +201,10 @@ 1. 优先接住创作者的画面灵感,而不是立刻列问卷。 2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。 3. 当创作者已经说出足够信息时,优先总结,不重复追问。 -4. 在进入结果页前,至少确认: +4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。 + - 该动作只向 Agent 发送“请补充剩余关键字。”,不在前端补数据、不伪造锚点状态。 + - Agent 收到后应优先补齐仍为 `待补充` / 空值的锚点关键词,并保持每次回复清爽直接。 +5. 在进入结果页前,至少确认: - 一句题材承诺 - 一个主要视觉主体 - 一组气质描述 @@ -294,6 +307,12 @@ interface PuzzleAnchorPack { 2. 创作者选择 `1` 张作为正式图 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 拼图图片资产要求 拼图图片的正式资产要求: diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index a094c8bd..a2f9420e 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,7 +1,6 @@ use std::{ - env, fs, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, + collections::BTreeMap, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use axum::{ @@ -13,7 +12,12 @@ use axum::{ 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::{ puzzle_agent::{ CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest, @@ -50,6 +54,7 @@ use spacetime_client::{ SpacetimeClientError, }; use std::convert::Infallible; +use tokio::time::sleep; use crate::{ 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_GALLERY_PROVIDER: &str = "puzzle-gallery"; 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( State(state): State, @@ -438,12 +447,15 @@ pub async fn execute_puzzle_agent_action( .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| draft.summary.clone()); 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, &draft.level_name, &prompt, candidate_count, ) + .await .map_err(SpacetimeClientError::Runtime); match candidates { Ok(candidates) => { @@ -1380,27 +1392,47 @@ fn puzzle_sse_error_event_message(message: String) -> Event { 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, level_name: &str, prompt: &str, candidate_count: u32, ) -> Result, String> { 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 { - let asset = save_placeholder_puzzle_asset( + for (index, image) in generated.images.into_iter().enumerate() { + let candidate_id = format!("{session_id}-candidate-{}", index + 1); + let asset = persist_puzzle_generated_asset( + state, + owner_user_id, session_id, level_name, - &format!("candidate-{}", index + 1), - "cover", - "1536*1536", - Some(prompt), + candidate_id.as_str(), + generated.task_id.as_str(), + image, + current_utc_micros(), ) + .await .map_err(|error| error.message().to_string())?; items.push(PuzzleGeneratedImageCandidateResponse { - candidate_id: format!("{session_id}-candidate-{}", index + 1), + candidate_id, image_src: asset.image_src, asset_id: asset.asset_id, prompt: prompt.to_string(), @@ -1424,98 +1456,452 @@ fn build_placeholder_puzzle_candidates( .collect()) } +struct PuzzleDashScopeSettings { + base_url: String, + api_key: String, + request_timeout_ms: u64, +} + +struct PuzzleGeneratedImages { + task_id: String, + images: Vec, +} + +struct PuzzleDownloadedImage { + extension: String, + mime_type: String, + bytes: Vec, +} + struct GeneratedPuzzleAssetResponse { image_src: String, asset_id: String, } -fn save_placeholder_puzzle_asset( - session_segment_seed: &str, - work_segment_seed: &str, - leaf_segment_seed: &str, - file_stem: &str, +fn require_puzzle_dashscope_settings(state: &AppState) -> Result { + let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "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::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, - prompt: Option<&str>, + candidate_count: u32, +) -> Result { + 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 { + 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 { - let asset_id = format!("{file_stem}-{}", current_utc_millis()); - let relative_dir = PathBuf::from("generated-puzzle-covers") - .join(sanitize_path_segment(session_segment_seed, "session")) - .join(sanitize_path_segment(work_segment_seed, "puzzle")) - .join(sanitize_path_segment(leaf_segment_seed, "candidate")) - .join(&asset_id); - let output_dir = resolve_public_output_dir(&relative_dir)?; - fs::create_dir_all(&output_dir).map_err(io_error)?; - let file_name = format!("{file_stem}.svg"); - let svg = build_puzzle_placeholder_svg(size, prompt.unwrap_or(file_stem)); - fs::write(output_dir.join(&file_name), svg).map_err(io_error)?; + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let http_client = reqwest::Client::new(); + let asset_id = format!("asset-{generated_at_micros}"); + let put_result = oss_client + .put_object( + &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 { - image_src: format!( - "/{}/{}", - relative_dir.to_string_lossy().replace('\\', "/"), - file_name - ), + image_src: put_result.legacy_public_path, asset_id, }) } -fn build_puzzle_placeholder_svg(size: &str, label: &str) -> String { - let (width, height) = parse_size(size); +fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { format!( - r##" - - - - - - - - - - - -{title} -Puzzle placeholder -"##, - 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), + "生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。" ) } -fn parse_size(size: &str) -> (u32, u32) { - let mut parts = size.split('*'); - let width = parts - .next() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(1536); - let height = parts - .next() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(1536); - (width, height) +fn build_puzzle_asset_metadata( + owner_user_id: &str, + session_id: &str, + candidate_id: &str, +) -> BTreeMap { + BTreeMap::from([ + ("asset_kind".to_string(), "puzzle_cover_image".to_string()), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), + ("entity_id".to_string(), session_id.to_string()), + ("slot".to_string(), candidate_id.to_string()), + ]) } -fn escape_svg_text(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") +fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result { + serde_json::from_str::(raw_text).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": format!("{fallback_message}:{error}"), + })) + }) +} + +fn extract_puzzle_task_id(payload: &Value) -> Option { + find_first_puzzle_string_by_key(payload, "task_id") +} + +fn extract_puzzle_image_urls(payload: &Value) -> Vec { + 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 { + 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) { + 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::(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 { @@ -1539,31 +1925,9 @@ fn sanitize_path_segment(value: &str, fallback: &str) -> String { } } -fn resolve_public_output_dir(relative_dir: &Path) -> Result { - 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 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); (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() -} diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index ce2dceb0..b719a1d6 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -40,6 +40,7 @@ pub enum LegacyAssetPrefix { Characters, Animations, BigFishAssets, + PuzzleAssets, CustomWorldScenes, CustomWorldCovers, QwenSprites, @@ -209,6 +210,7 @@ impl LegacyAssetPrefix { "generated-characters" => Some(Self::Characters), "generated-animations" => Some(Self::Animations), "generated-big-fish-assets" => Some(Self::BigFishAssets), + "generated-puzzle-assets" => Some(Self::PuzzleAssets), "generated-custom-world-scenes" => Some(Self::CustomWorldScenes), "generated-custom-world-covers" => Some(Self::CustomWorldCovers), "generated-qwen-sprites" => Some(Self::QwenSprites), @@ -222,6 +224,7 @@ impl LegacyAssetPrefix { Self::Characters => "generated-characters", Self::Animations => "generated-animations", Self::BigFishAssets => "generated-big-fish-assets", + Self::PuzzleAssets => "generated-puzzle-assets", Self::CustomWorldScenes => "generated-custom-world-scenes", Self::CustomWorldCovers => "generated-custom-world-covers", Self::QwenSprites => "generated-qwen-sprites", diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 17cf0584..faff5c95 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -58,11 +58,11 @@ import { } from '../../services/puzzle-agent'; import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery'; import { - advancePuzzleNextLevel, - dragPuzzlePieceOrGroup, - startPuzzleRun, - swapPuzzlePieces, -} from '../../services/puzzle-runtime'; + advanceLocalPuzzleLevel, + dragLocalPuzzlePiece, + startLocalPuzzleRun, + swapLocalPuzzlePieces, +} from '../../services/puzzle-runtime/puzzleLocalRuntime'; import { listPuzzleWorks } from '../../services/puzzle-works'; import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry'; import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient'; @@ -1024,8 +1024,9 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); try { - const { run } = await startPuzzleRun({ profileId }); - setPuzzleRun(run); + const { item } = await getPuzzleGalleryDetail(profileId); + setSelectedPuzzleDetail(item); + setPuzzleRun(startLocalPuzzleRun(item)); setSelectionStage('puzzle-runtime'); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。')); @@ -1060,28 +1061,19 @@ export function PlatformEntryFlowShellImpl({ ); const swapPuzzlePiecesInRun = useCallback( - async (payload: { firstPieceId: string; secondPieceId: string }) => { + (payload: { firstPieceId: string; secondPieceId: string }) => { if (!puzzleRun || isPuzzleBusy) { return; } - setIsPuzzleBusy(true); setPuzzleError(null); - - try { - const { run } = await swapPuzzlePieces(puzzleRun.runId, payload); - setPuzzleRun(run); - } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。')); - } finally { - setIsPuzzleBusy(false); - } + setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload)); }, - [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage], + [isPuzzleBusy, puzzleRun], ); const dragPuzzlePiece = useCallback( - async (payload: { + (payload: { pieceId: string; targetRow: number; targetCol: number; @@ -1090,19 +1082,10 @@ export function PlatformEntryFlowShellImpl({ return; } - setIsPuzzleBusy(true); setPuzzleError(null); - - try { - const { run } = await dragPuzzlePieceOrGroup(puzzleRun.runId, payload); - setPuzzleRun(run); - } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。')); - } finally { - setIsPuzzleBusy(false); - } + setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload)); }, - [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage], + [isPuzzleBusy, puzzleRun], ); const advancePuzzleLevel = useCallback(async () => { @@ -1110,18 +1093,9 @@ export function PlatformEntryFlowShellImpl({ return; } - setIsPuzzleBusy(true); setPuzzleError(null); - - try { - const { run } = await advancePuzzleNextLevel(puzzleRun.runId); - setPuzzleRun(run); - } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '进入下一关失败。')); - } finally { - setIsPuzzleBusy(false); - } - }, [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage]); + setPuzzleRun(advanceLocalPuzzleLevel(puzzleRun)); + }, [isPuzzleBusy, puzzleRun]); const leaveAgentWorkspace = useCallback(() => { enterCreateTab(); diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts new file mode 100644 index 00000000..5480b24c --- /dev/null +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -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; +}