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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user