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

@@ -786,6 +786,45 @@ fn first_profile_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel>
.next()
}
fn first_profile_ui_background_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel> {
normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone())
.into_iter()
.find(|level| {
level
.ui_background_image_src
.as_deref()
.and_then(normalize_required_string)
.is_some()
|| level
.ui_background_image_object_key
.as_deref()
.and_then(normalize_required_string)
.is_some()
})
}
fn resolve_puzzle_runtime_ui_background_fields(
level: Option<&PuzzleDraftLevel>,
fallback_level: Option<&PuzzleDraftLevel>,
) -> (Option<String>, Option<String>) {
for candidate in [level, fallback_level].into_iter().flatten() {
let image_src = candidate
.ui_background_image_src
.as_deref()
.and_then(normalize_required_string);
let object_key = candidate
.ui_background_image_object_key
.as_deref()
.and_then(|value| normalize_required_string(value.trim_start_matches('/')));
if image_src.is_some() || object_key.is_some() {
return (image_src, object_key);
}
}
(None, None)
}
pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
let time_limit_ms = if level.time_limit_ms == 0 {
resolve_puzzle_level_time_limit_ms_by_index(level.level_index)
@@ -1047,6 +1086,12 @@ pub fn start_run_with_shuffle_seed_at(
let grid_size = level_config.grid_size;
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let current_profile_level = first_profile_level(entry_profile);
let ui_background_level = first_profile_ui_background_level(entry_profile);
let (ui_background_image_src, ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
@@ -1067,12 +1112,8 @@ pub fn start_run_with_shuffle_seed_at(
author_display_name: entry_profile.author_display_name.clone(),
theme_tags: entry_profile.theme_tags.clone(),
cover_image_src: entry_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -1326,6 +1367,16 @@ pub fn advance_next_level_at(
let mut played_profile_ids = run.played_profile_ids.clone();
played_profile_ids.push(next_profile.profile_id.clone());
let current_profile_level = first_profile_level(next_profile);
let ui_background_level = first_profile_ui_background_level(next_profile);
let (mut ui_background_image_src, mut ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
if ui_background_image_src.is_none() && ui_background_image_object_key.is_none() {
ui_background_image_src = current_level.ui_background_image_src.clone();
ui_background_image_object_key = current_level.ui_background_image_object_key.clone();
}
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
@@ -1347,12 +1398,8 @@ pub fn advance_next_level_at(
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -1408,6 +1455,12 @@ pub fn advance_to_new_work_first_level_at(
played_profile_ids.push(next_profile.profile_id.clone());
}
let current_profile_level = first_profile_level(next_profile);
let ui_background_level = first_profile_ui_background_level(next_profile);
let (ui_background_image_src, ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
@@ -1429,12 +1482,8 @@ pub fn advance_to_new_work_first_level_at(
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -3151,8 +3200,7 @@ mod tests {
.background_music
.as_ref()
.map(|music| music.audio_src.as_str()),
Some("/generated-puzzle-assets/background.mp3".to_string())
.as_deref()
Some("/generated-puzzle-assets/background.mp3".to_string()).as_deref()
);
assert_eq!(
current_level.ui_background_image_object_key.as_deref(),
@@ -3175,8 +3223,8 @@ mod tests {
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_run =
advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000).expect("next run");
let next_run = advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000)
.expect("next run");
assert_eq!(
next_run
@@ -3187,6 +3235,52 @@ mod tests {
);
}
#[test]
fn same_work_next_level_inherits_first_available_ui_background() {
let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
profile.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/entry-ui.png".to_string());
profile.levels.push(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: Some("/level-2.png".to_string()),
cover_asset_id: None,
generation_status: "ready".to_string(),
});
let mut run = start_run("run-same-work-ui".to_string(), &profile, 0).expect("run");
run.cleared_level_count = run.current_level_index;
let current_level = run.current_level.as_mut().expect("level");
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_level = selected_profile_level_after_runtime_level(&profile, current_level)
.expect("same work next level");
let mut next_profile = profile.clone();
next_profile.level_name = next_level.level_name.clone();
next_profile.cover_image_src = next_level.cover_image_src.clone();
next_profile.cover_asset_id = next_level.cover_asset_id.clone();
next_profile.levels = vec![next_level];
let next_run = advance_next_level_at(&run, &next_profile, 3_000).expect("next run");
assert_eq!(
next_run
.current_level
.as_ref()
.and_then(|level| level.ui_background_image_src.as_deref()),
Some("/generated-puzzle-assets/entry-ui.png")
);
}
#[test]
fn swap_pieces_marks_cleared_when_back_to_origin() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);