Add generationStatus and match3d/runtime fixes

Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
This commit is contained in:
2026-05-16 22:59:02 +08:00
parent bb60ca91ef
commit a45e358e83
42 changed files with 3872 additions and 443 deletions

View File

@@ -459,6 +459,11 @@ fn compile_match3d_draft_tx(
config.theme_text.as_str(),
);
let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref());
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
input.generated_item_assets_json.as_deref(),
existing_work.as_ref(),
)?;
let draft = Match3DDraftSnapshot {
profile_id: input.profile_id.clone(),
game_name: game_name.clone(),
@@ -467,12 +472,9 @@ fn compile_match3d_draft_tx(
tags: tags.clone(),
clear_count: config.clear_count,
difficulty: config.difficulty,
// 中文注释:草稿响应本身也携带生成素材快照,避免 HTTP facade 回读 work 详情失败时丢失背景/容器图。
generated_item_assets_json: generated_item_assets_json.clone(),
};
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
input.generated_item_assets_json.as_deref(),
existing_work.as_ref(),
)?;
let previous_publication_status = existing_work
.as_ref()
.map(|work| work.publication_status.clone())
@@ -1889,6 +1891,32 @@ mod tests {
);
}
#[test]
fn match3d_draft_snapshot_keeps_generated_item_assets_json() {
let draft = Match3DDraftSnapshot {
profile_id: "profile-1".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags: vec!["水果".to_string()],
clear_count: 3,
difficulty: 3,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","backgroundAsset":{"prompt":"果园背景","imageSrc":"/generated-match3d-assets/session/profile/background/background.png","containerImageSrc":"/generated-match3d-assets/session/profile/ui-container/container.png","status":"image_ready"},"status":"image_ready"}]"#
.to_string(),
),
};
let row_json = to_json_string(&draft);
let restored =
parse_json::<Match3DDraftSnapshot>(&row_json, "match3d draft_json").unwrap();
assert_eq!(
restored.generated_item_assets_json.as_deref(),
draft.generated_item_assets_json.as_deref()
);
}
#[test]
fn match3d_work_update_preserves_assets_and_allows_empty_summary() {
let existing = Match3DWorkProfileRow {

View File

@@ -256,6 +256,8 @@ pub struct Match3DDraftSnapshot {
pub tags: Vec<String>,
pub clear_count: u32,
pub difficulty: u32,
#[serde(default)]
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -870,7 +870,10 @@ fn compile_puzzle_agent_draft_tx(
}
let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text));
let messages = list_session_messages(ctx, &row.session_id);
let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text));
let draft = mark_puzzle_draft_generation_status(
compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)),
"generating",
);
// 创作中心的拼图草稿卡只是 Agent session 的列表投影,
// 每次编译结果页时同步 upsert保证后续能按 source_session_id 恢复聊天。
upsert_puzzle_draft_work_profile(
@@ -2472,10 +2475,52 @@ fn profile_for_single_level(
level: &module_puzzle::PuzzleDraftLevel,
) -> PuzzleWorkProfile {
let mut next_profile = profile.clone();
let ui_background_carrier = profile.levels.iter().find(|candidate| {
candidate
.ui_background_image_src
.as_deref()
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false)
|| candidate
.ui_background_image_object_key
.as_deref()
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false)
});
let mut single_level = level.clone();
if single_level
.ui_background_image_src
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
&& single_level
.ui_background_image_object_key
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
&& let Some(carrier) = ui_background_carrier
{
single_level.ui_background_image_src = carrier
.ui_background_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
single_level.ui_background_image_object_key = carrier
.ui_background_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_start_matches('/').to_string());
}
next_profile.level_name = level.level_name.clone();
next_profile.cover_image_src = level.cover_image_src.clone();
next_profile.cover_asset_id = level.cover_asset_id.clone();
next_profile.levels = vec![level.clone()];
next_profile.levels = vec![single_level];
next_profile
}
@@ -2496,6 +2541,17 @@ fn micros_to_millis(value: i64) -> u64 {
(value as u64).saturating_div(1_000)
}
fn mark_puzzle_draft_generation_status(
mut draft: PuzzleResultDraft,
generation_status: &str,
) -> PuzzleResultDraft {
draft.generation_status = generation_status.to_string();
for level in &mut draft.levels {
level.generation_status = generation_status.to_string();
}
draft
}
fn upsert_puzzle_draft_work_profile(
ctx: &TxContext,
session_id: &str,
@@ -3466,6 +3522,37 @@ mod tests {
assert!(preview.publish_ready);
}
#[test]
fn puzzle_draft_generation_status_updates_all_levels() {
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));
let mut draft = compile_result_draft(&anchor_pack, &[]);
draft.levels.push(module_puzzle::PuzzleDraftLevel {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
});
let draft = mark_puzzle_draft_generation_status(draft, "generating");
assert_eq!(draft.generation_status, "generating");
assert!(
draft
.levels
.iter()
.all(|level| level.generation_status == "generating")
);
}
#[test]
fn puzzle_generated_images_replace_existing_candidate() {
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));

View File

@@ -215,8 +215,7 @@ fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now
&& row.subtitle == "分支叙事体验"
&& row.image_src == "/creation-type-references/visual-novel.webp"
&& row.visible
&& ((row.badge == "可创建" && row.open)
|| (row.badge == "敬请期待" && !row.open))
&& ((row.badge == "可创建" && row.open) || (row.badge == "敬请期待" && !row.open))
&& row.sort_order == 60;
if !still_old_visible_default {
return;