FX2: wire no-moves reshuffle into the UI swipe-commit path

The rendered swipe-commit path (`plan_and_commit`) bypassed the turn-loop's
no-moves rule: a deadlocked board (no legal swap) stayed stuck on screen because
only `play_turn` checked `!has_legal_swap` and reshuffled, and the UI never calls
`play_turn`.

Factor the post-settle "no legal swaps -> reshuffle" check into a shared
`reshuffle_if_deadlocked` in board.sx and call it from BOTH `play_turn` and
`plan_and_commit`, so the animated UI commit obeys the identical model rule. The
reshuffle runs after the cascade settles (post-`commit_swap`); the AnimMove's
recorded `final` stays the settled pre-reshuffle board, so the cascade animation,
per-round audio, and input gating are unchanged — the reshuffled layout renders on
the next settled frame. No win/lose/turn-accounting change; a reshuffle spends no
move and no score.

Regression test tests/swipe_reshuffle.sx drives the exact UI path (swipe_intent ->
plan_and_commit) on the deadlocked board from tests/level.sx: before = no legal
swaps / in_progress; after = reshuffled (has_legal_swap true, 9 legal swaps, no
immediate match), score/moves/budget unchanged. It FAILS pre-fix (board stays
stuck, has_legal_swap false) and PASSES post-fix.
This commit is contained in:
swipelab
2026-06-06 14:55:38 +03:00
parent 2a196943aa
commit cd89a5c9c0
5 changed files with 199 additions and 14 deletions

View File

@@ -1,12 +1,13 @@
// Board motion animation (P6.1) — a PURELY VISUAL timeline the view plays over
// one player move. The logical model (commit_swap / resolve) stays authoritative:
// `plan_and_commit` commits the move on the real board exactly as before, then
// replays the SAME operations on a value-copy of the pre-move board to record the
// per-step geometry (the swap, each cascade round's matched cells, and each
// round's per-column fall provenance). Because the copy starts from the identical
// cells AND RNG state and runs the identical primitives, its recorded `final`
// board equals the model's settled board gem-for-gem — the animation only ever
// ends ON the already-decided result, never changes it.
// `plan_and_commit` commits the move on the real board (and, like the headless
// turn loop, reshuffles a deadlocked board afterwards), then replays the SAME
// commit operations on a value-copy of the pre-move board to record the per-step
// geometry (the swap, each cascade round's matched cells, and each round's
// per-column fall provenance). Because the copy starts from the identical cells
// AND RNG state and runs the identical primitives, its recorded `final` board
// equals the move's settled (pre-reshuffle) board gem-for-gem — the animation only
// ever ends ON the already-decided cascade result, never changes it.
//
// Per-gem idle/select/clear gem animations (P6.3) and score popups / particle FX
// (P6.2) are NOT here; this step animates board MOTION only: swap slide, matched
@@ -231,9 +232,13 @@ delivering_round :: (mv: *AnimMove, i: s64, kmax: s64) -> s64 {
}
// Commit the player's swap authoritatively AND record its visual timeline. The
// real board is mutated by `commit_swap` exactly as the non-animated path did;
// the recording runs on a separate value-copy taken BEFORE the commit, so it
// replays the identical cells + RNG stream and its `final` equals `board.cells`.
// real board is mutated by `commit_swap`, then — exactly like the headless
// `play_turn` — `reshuffle_if_deadlocked` recovers a stranded board so the rendered
// game obeys the same no-moves rule. The recording runs on a value-copy taken
// BEFORE the commit, so it replays the identical cells + RNG stream; the recorded
// `final` is the SETTLED board the animation ends on. It equals the live board
// unless a deadlock reshuffle then re-arranged it: that reshuffle is a model step,
// not part of this move's timeline, so it renders on the next settled frame.
plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
move : AnimMove = ---;
move.a = a;
@@ -251,6 +256,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
move.awarded = mv.cascade.awarded;
if !mv.legal {
move.final = board.cells;
reshuffle_if_deadlocked(board);
return move;
}
@@ -296,6 +302,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
}
move.final = scratch.cells;
reshuffle_if_deadlocked(board);
move
}