From 7d6963980fe22369a8dfbeb7c9668255c4ce89f7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 22:03:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=BC=E5=9B=BE=E5=80=99=E9=80=89=E5=9B=BE?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=A2=9E=E5=8A=A0=E8=80=8C=E9=9D=9E=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .idea/Genarrative.iml | 8 --- ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md | 2 + .../crates/api-server/src/custom_world_ai.rs | 24 ++++++--- server-rs/crates/api-server/src/puzzle.rs | 9 +++- .../crates/spacetime-module/src/puzzle.rs | 53 ++++++++++++++++++- 6 files changed, 80 insertions(+), 17 deletions(-) delete mode 100644 .idea/Genarrative.iml diff --git a/.gitignore b/.gitignore index f05fabe7..b95c2b1f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage/ /.codex-cargo-home-*/ /.codex-cache*/ /.tmp*/ +/.idea/ .preview.* tmp_* tmp/ diff --git a/.idea/Genarrative.iml b/.idea/Genarrative.iml deleted file mode 100644 index c956989b..00000000 --- a/.idea/Genarrative.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file 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 d262256c..54c436a8 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 @@ -313,6 +313,8 @@ interface PuzzleAnchorPack { 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。 +4. 多次生成候选图时必须追加到当前候选池,不能清空已有候选图;已有正式选择保持不变,新追加候选图默认不抢占 `selected` 状态。 +5. 追加生成时 `candidate_id` 必须按当前候选数量续号,避免前端列表 key 与后端选择动作命中旧候选图。 ## 7.6 拼图图片资产要求 diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index b6819e1b..4f3c47c8 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -2549,12 +2549,24 @@ mod tests { description: Some("古老礁石上的半沉神殿。".to_string()), }; let manual_prompt = build_custom_world_scene_image_prompt( - &profile_input, - &landmark, - user_prompt, - false, - Some("礁石神殿"), - "雾海群岛", + SceneImagePromptParams { + profile: SceneImagePromptProfile { + name: profile_input.name.as_deref().unwrap_or_default(), + subtitle: profile_input.subtitle.as_deref().unwrap_or_default(), + tone: profile_input.tone.as_deref().unwrap_or_default(), + player_goal: profile_input.player_goal.as_deref().unwrap_or_default(), + summary: profile_input.summary.as_deref().unwrap_or_default(), + setting_text: profile_input.setting_text.as_deref().unwrap_or_default(), + }, + landmark: SceneImagePromptLandmark { + name: landmark.name.as_deref().unwrap_or_default(), + description: landmark.description.as_deref().unwrap_or_default(), + }, + user_prompt, + has_reference_image: false, + fallback_landmark_name: Some("礁石神殿"), + fallback_world_name: "雾海群岛", + }, ); let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest { diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index c2a2bb33..c1ba06b7 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -468,6 +468,7 @@ 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 candidate_start_index = draft.candidates.len(); let candidates = generate_puzzle_image_candidates( &state, owner_user_id.as_str(), @@ -475,6 +476,7 @@ pub async fn execute_puzzle_agent_action( &draft.level_name, &prompt, candidate_count, + candidate_start_index, ) .await .map_err(SpacetimeClientError::Runtime); @@ -1474,6 +1476,7 @@ async fn compile_puzzle_draft_with_initial_cover( &draft.level_name, &draft.summary, 2, + draft.candidates.len(), ) .await .map_err(SpacetimeClientError::Runtime)?; @@ -1616,6 +1619,7 @@ async fn generate_puzzle_image_candidates( level_name: &str, prompt: &str, candidate_count: u32, + candidate_start_index: usize, ) -> Result, String> { let count = candidate_count.clamp(1, 2); let settings = @@ -1635,7 +1639,7 @@ async fn generate_puzzle_image_candidates( let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { - let candidate_id = format!("{session_id}-candidate-{}", index + 1); + let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + index + 1); let asset = persist_puzzle_generated_asset( state, owner_user_id, @@ -1655,7 +1659,7 @@ async fn generate_puzzle_image_candidates( prompt: prompt.to_string(), actual_prompt: Some(prompt.to_string()), source_type: "generated".to_string(), - selected: index == 0, + selected: candidate_start_index == 0 && index == 0, }); } @@ -1729,6 +1733,7 @@ async fn build_local_next_puzzle_run( &draft.level_name, &draft.summary, 2, + draft.candidates.len(), ) .await .map_err(|message| { diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index f6a0fa14..944d8a0a 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -684,7 +684,7 @@ fn save_puzzle_generated_images_tx( if candidates.is_empty() { return Err("拼图候选图不能为空".to_string()); } - draft.candidates = candidates; + append_generated_candidates(&mut draft, candidates); draft.generation_status = "ready".to_string(); if let Some(selected) = draft .candidates @@ -1507,6 +1507,23 @@ fn increment_puzzle_profile_play_count( ); } +fn append_generated_candidates( + draft: &mut PuzzleResultDraft, + candidates: Vec, +) { + let has_selected_candidate = draft.candidates.iter().any(|entry| entry.selected); + // 再次生成图片是扩充候选池,不覆盖创作者已经看到或已经选择的候选图。 + // 若已有正式选择,新追加候选图保持未选中,避免同一草稿出现多个 selected。 + draft + .candidates + .extend(candidates.into_iter().map(|mut candidate| { + if has_selected_candidate { + candidate.selected = false; + } + candidate + })); +} + fn list_published_puzzle_profiles(ctx: &TxContext) -> Result, String> { ctx.db .puzzle_work_profile() @@ -1613,6 +1630,40 @@ mod tests { assert!(preview.publish_ready); } + #[test] + fn puzzle_generated_images_are_appended_without_clearing_existing_candidates() { + let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪")); + let mut draft = compile_result_draft(&anchor_pack, &[]); + draft.candidates = vec![PuzzleGeneratedImageCandidate { + candidate_id: "session-1-candidate-1".to_string(), + image_src: "/generated-puzzle-assets/session-1/old/cover.png".to_string(), + asset_id: "asset-old".to_string(), + prompt: "旧提示词".to_string(), + actual_prompt: Some("旧提示词".to_string()), + source_type: "generated".to_string(), + selected: true, + }]; + + append_generated_candidates( + &mut draft, + vec![PuzzleGeneratedImageCandidate { + candidate_id: "session-1-candidate-2".to_string(), + image_src: "/generated-puzzle-assets/session-1/new/cover.png".to_string(), + asset_id: "asset-new".to_string(), + prompt: "新提示词".to_string(), + actual_prompt: Some("新提示词".to_string()), + source_type: "generated".to_string(), + selected: true, + }], + ); + + assert_eq!(draft.candidates.len(), 2); + assert_eq!(draft.candidates[0].candidate_id, "session-1-candidate-1"); + assert!(draft.candidates[0].selected); + assert_eq!(draft.candidates[1].candidate_id, "session-1-candidate-2"); + assert!(!draft.candidates[1].selected); + } + #[test] fn puzzle_recommendation_score_prefers_same_author_weight() { let left = PuzzleWorkProfile {