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

@@ -237,7 +237,9 @@ pub fn confirm_click_at(
return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable));
}
let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else {
let Some(slot_index) =
insert_item_into_tray_after_same_type(&mut next.tray_slots, &mut next.items, item_index)
else {
next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id);
return Ok(rejected(next, Match3DClickRejectReason::TrayFull));
};
@@ -246,7 +248,6 @@ pub fn confirm_click_at(
next.items[item_index].state = Match3DItemState::InTray;
next.items[item_index].clickable = false;
next.items[item_index].tray_slot_index = Some(slot_index);
fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]);
let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id);
compact_tray(&mut next);
@@ -540,12 +541,64 @@ fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option<u32> {
.map(|slot| slot.slot_index)
}
fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) {
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
slot.item_instance_id = Some(item.item_instance_id.clone());
slot.item_type_id = Some(item.item_type_id.clone());
slot.visual_key = Some(item.visual_key.clone());
fn insert_item_into_tray_after_same_type(
slots: &mut [Match3DTraySlot],
items: &mut [Match3DItemSnapshot],
item_index: usize,
) -> Option<u32> {
let occupied = slots
.iter()
.filter_map(|slot| {
Some((
slot.item_instance_id.clone()?,
slot.item_type_id.clone()?,
slot.visual_key.clone()?,
))
})
.collect::<Vec<_>>();
if occupied.len() >= slots.len() {
return None;
}
let item = items.get(item_index)?.clone();
let insertion_index = occupied
.iter()
.rposition(|(_, item_type_id, _)| item_type_id == &item.item_type_id)
.map(|index| index + 1)
.unwrap_or(occupied.len());
let mut next_occupied = occupied;
next_occupied.insert(
insertion_index,
(
item.item_instance_id.clone(),
item.item_type_id.clone(),
item.visual_key.clone(),
),
);
for slot in slots.iter_mut() {
slot.item_instance_id = None;
slot.item_type_id = None;
slot.visual_key = None;
}
for (index, (item_instance_id, item_type_id, visual_key)) in
next_occupied.into_iter().enumerate()
{
let slot_index = index as u32;
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
slot.item_instance_id = Some(item_instance_id.clone());
slot.item_type_id = Some(item_type_id);
slot.visual_key = Some(visual_key);
}
if let Some(entry) = items
.iter_mut()
.find(|entry| entry.item_instance_id == item_instance_id)
{
entry.tray_slot_index = Some(slot_index);
}
}
Some(insertion_index as u32)
}
fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<String> {
@@ -579,6 +632,7 @@ fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<S
slot.visual_key = None;
}
}
compact_tray(run);
matched_slot_item_ids
}
@@ -1005,8 +1059,16 @@ mod tests {
for item in board_items {
let quadrant = format!(
"{}-{}",
if item.x >= MATCH3D_BOARD_CENTER { "r" } else { "l" },
if item.y >= MATCH3D_BOARD_CENTER { "b" } else { "t" },
if item.x >= MATCH3D_BOARD_CENTER {
"r"
} else {
"l"
},
if item.y >= MATCH3D_BOARD_CENTER {
"b"
} else {
"t"
},
);
*quadrants.entry(quadrant).or_default() += 1;
}
@@ -1108,6 +1170,82 @@ mod tests {
);
}
#[test]
fn clicking_item_inserts_after_same_type_and_shifts_following_slots() {
let mut run = Match3DRunSnapshot {
run_id: "run-insert".to_string(),
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
status: Match3DRunStatus::Running,
started_at_ms: 0,
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
clear_count: 3,
total_item_count: 4,
cleared_item_count: 0,
board_version: 1,
items: vec![
manual_item("apple-3", "apple", None),
manual_item("apple-1", "apple", Some(0)),
manual_item("apple-2", "apple", Some(1)),
manual_item("pear-1", "pear", Some(2)),
],
tray_slots: empty_tray_slots(),
failure_reason: None,
last_confirmed_action_id: None,
};
run.tray_slots[0].item_instance_id = Some("apple-1".to_string());
run.tray_slots[0].item_type_id = Some("apple".to_string());
run.tray_slots[0].visual_key = Some("apple".to_string());
run.tray_slots[1].item_instance_id = Some("apple-2".to_string());
run.tray_slots[1].item_type_id = Some("apple".to_string());
run.tray_slots[1].visual_key = Some("apple".to_string());
run.tray_slots[2].item_instance_id = Some("pear-1".to_string());
run.tray_slots[2].item_type_id = Some("pear".to_string());
run.tray_slots[2].visual_key = Some("pear".to_string());
let confirmed = confirm_click_at(
&run,
&Match3DClickInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
item_instance_id: "apple-3".to_string(),
client_action_id: "action-insert".to_string(),
snapshot_version: run.board_version,
clicked_at_ms: 1_000,
},
)
.expect("click should confirm");
assert_eq!(confirmed.entered_slot_index, Some(2));
assert_eq!(
confirmed
.run
.tray_slots
.iter()
.map(|slot| slot.item_instance_id.as_deref())
.collect::<Vec<_>>(),
vec![Some("pear-1"), None, None, None, None, None, None]
);
assert_eq!(
confirmed
.run
.items
.iter()
.find(|item| item.item_instance_id == "pear-1")
.and_then(|item| item.tray_slot_index),
Some(0)
);
assert_eq!(
confirmed.cleared_item_instance_ids,
vec![
"apple-1".to_string(),
"apple-2".to_string(),
"apple-3".to_string()
]
);
}
#[test]
fn tray_full_fails_when_no_triple_can_clear() {
let mut run = Match3DRunSnapshot {