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:
19
board.sx
19
board.sx
@@ -882,6 +882,20 @@ reshuffle :: (board: *Board) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// After a committed move's cascade has settled, recover a deadlocked board so the
|
||||
// player is never stranded: if the level is still in progress yet no legal swap
|
||||
// remains, `reshuffle` the gems in place. A reshuffle is NOT a move and never runs
|
||||
// on a finished (won/lost) level, so win/lose and turn accounting are untouched.
|
||||
// Returns whether a reshuffle ran. BOTH the headless turn loop (`play_turn`) and
|
||||
// the animated UI commit (`plan_and_commit`) call this, so the rendered game obeys
|
||||
// the identical no-moves rule — neither path can leave the board stuck.
|
||||
reshuffle_if_deadlocked :: (board: *Board) -> bool {
|
||||
if level_status(board) == .in_progress and !has_legal_swap(board) {
|
||||
return reshuffle(board);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Reset to a fresh, reproducible level: `init(seed)` reseeds the board (same
|
||||
// seed → identical starting layout), zeroes `score` and `moves_made`, and
|
||||
// restores the default move budget and score goal, so `level_status` reads
|
||||
@@ -921,9 +935,6 @@ play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult {
|
||||
return TurnResult.{ accepted = false, move = frozen, status = status, reshuffled = false };
|
||||
}
|
||||
move := commit_swap(board, a, b);
|
||||
reshuffled := false;
|
||||
if level_status(board) == .in_progress and !has_legal_swap(board) {
|
||||
reshuffled = reshuffle(board);
|
||||
}
|
||||
reshuffled := reshuffle_if_deadlocked(board);
|
||||
TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
1
tests/expected/swipe_reshuffle.exit
Normal file
1
tests/expected/swipe_reshuffle.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
23
tests/expected/swipe_reshuffle.stdout
Normal file
23
tests/expected/swipe_reshuffle.stdout
Normal file
@@ -0,0 +1,23 @@
|
||||
== deadlocked board: UI swipe-commit path must reshuffle ==
|
||||
ROYGBPRO
|
||||
PROYGBPR
|
||||
BPROYGBP
|
||||
GBPROYGB
|
||||
YGBPROYG
|
||||
OYGBPROY
|
||||
ROYGBPRO
|
||||
PROYGBPR
|
||||
before: matches 0 legal_swaps 0 has_legal_swap false status in_progress
|
||||
intent (0,0)->(1,0)
|
||||
commit: legal false rounds 0 awarded 0
|
||||
after: matches 0 legal_swaps 9 has_legal_swap true status in_progress
|
||||
after: score 0 moves_made 0 moves_remaining 10
|
||||
BGGYORYR
|
||||
RRYGOPBY
|
||||
YRYBPRGB
|
||||
OOBGBPRG
|
||||
RPRPYRPO
|
||||
OBBPOOPG
|
||||
OBGGOPGY
|
||||
YPRYBORP
|
||||
ok: UI swipe-commit path reshuffles a deadlocked board
|
||||
143
tests/swipe_reshuffle.sx
Normal file
143
tests/swipe_reshuffle.sx
Normal file
@@ -0,0 +1,143 @@
|
||||
// UI deadlock-recovery golden (final-review F1): prove the RENDERED swipe-commit
|
||||
// path reshuffles a deadlocked board, just like the headless turn loop's no-moves
|
||||
// rule (tests/level.sx). The iOS/macOS view commits a swipe through
|
||||
// `plan_and_commit` (NOT `play_turn`), so the reshuffle must live on THAT shared
|
||||
// path or a stuck board stays stuck on screen. This test drives exactly what
|
||||
// BoardView.handle_event does — resolve a drag with `swipe_intent`, feed the intent
|
||||
// into `plan_and_commit` — on the provably deadlocked diagonal-Latin-square board
|
||||
// from tests/level.sx, asserting:
|
||||
// - BEFORE: no immediate match, zero legal swaps, status in_progress (stuck);
|
||||
// - AFTER the UI commit path resolves: the board RESHUFFLED — `has_legal_swap`
|
||||
// is true and >=1 legal swap exists, still with no immediate match — and the
|
||||
// reshuffle itself spent no move and no score (turn accounting unchanged).
|
||||
// Pre-fix `plan_and_commit` skipped the reshuffle, so `has_legal_swap` stayed false
|
||||
// after and this FAILS; with the shared `reshuffle_if_deadlocked` wired in it PASSES.
|
||||
// No rendering, no model reach-around. Links headless like tests/anim_plan.sx;
|
||||
// avoids tests/test.sx (its trace.sx pulls in a second `Frame` that collides with
|
||||
// the UI one). Failure is a non-zero exit code (the runner checks exit + stdout).
|
||||
#import "modules/std.sx";
|
||||
#import "board.sx";
|
||||
#import "board_anim.sx";
|
||||
#import "board_layout.sx";
|
||||
#import "swipe.sx";
|
||||
|
||||
SEED :: 1337;
|
||||
|
||||
// Inverse of `gem_char`: map a board character back to its Gem so the deadlocked
|
||||
// board can be written as a human-readable grid (mirrors tests/level.sx).
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
if c == EMPTY_CHAR { return .empty; }
|
||||
for 0..GEM_COUNT: (i) {
|
||||
if GEM_CHARS[i] == c { return cast(Gem) i; }
|
||||
}
|
||||
.red
|
||||
}
|
||||
|
||||
// Load an 8x8 board from `rows` (top row first), seeded RNG, score zeroed, turn
|
||||
// counters reset to a fresh game, and the per-level goal set.
|
||||
load_board :: (rows: []string, seed: s64, move_limit: s64, target_score: s64) -> Board {
|
||||
b : Board = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
b.set(col, row, char_to_gem(line[col]));
|
||||
}
|
||||
}
|
||||
b.rng = rng_seeded(seed);
|
||||
b.score = 0;
|
||||
b.moves_made = 0;
|
||||
b.move_limit = move_limit;
|
||||
b.target_score = target_score;
|
||||
b
|
||||
}
|
||||
|
||||
cell_center :: (lay: *BoardLayout, col: s64, row: s64) -> Point {
|
||||
cf := lay.cell_frame(col, row);
|
||||
Point.{ x = cf.mid_x(), y = cf.mid_y() }
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
fails : s64 = 0;
|
||||
|
||||
// 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A 60px
|
||||
// drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis — the
|
||||
// SAME layout/threshold tests/swipe_commit.sx drives the UI path through.
|
||||
lay : BoardLayout = ---;
|
||||
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
|
||||
D : f32 = 60.0;
|
||||
|
||||
// The provably deadlocked board from tests/level.sx: gem(col,row)=(col-row) mod
|
||||
// 6, a diagonal Latin square. Equal gems lie only on slope-1 diagonals, so no
|
||||
// row/column holds two of a kind within reach — no immediate match and no
|
||||
// orthogonal swap can line up three.
|
||||
print("== deadlocked board: UI swipe-commit path must reshuffle ==\n");
|
||||
b := load_board(.[
|
||||
"ROYGBPRO",
|
||||
"PROYGBPR",
|
||||
"BPROYGBP",
|
||||
"GBPROYGB",
|
||||
"YGBPROYG",
|
||||
"OYGBPROY",
|
||||
"ROYGBPRO",
|
||||
"PROYGBPR",
|
||||
], SEED, 10, 1500);
|
||||
out(board_dump(@b));
|
||||
|
||||
pre_m := find_matches(@b);
|
||||
pre_sw := legal_swaps(@b);
|
||||
print("before: matches {} legal_swaps {} has_legal_swap {} status {}\n",
|
||||
pre_m.count(), pre_sw.len, has_legal_swap(@b), status_name(level_status(@b)));
|
||||
if pre_m.count() != 0 { fails += 1; }
|
||||
if pre_sw.len != 0 { fails += 1; }
|
||||
if has_legal_swap(@b) { fails += 1; }
|
||||
if level_status(@b) != .in_progress { fails += 1; }
|
||||
|
||||
score_before := b.score;
|
||||
made_before := b.moves_made;
|
||||
remain_before := b.moves_remaining();
|
||||
|
||||
// Drive the EXACT UI path: a rightward drag on (0,0) resolves to the (0,0)->(1,0)
|
||||
// swap intent, which BoardView.handle_event feeds straight into plan_and_commit.
|
||||
// On this deadlocked board every swap is illegal (no match), so the swipe itself
|
||||
// commits nothing — the recovery must come from the post-settle reshuffle.
|
||||
a0 := cell_center(@lay, 0, 0);
|
||||
if s := swipe_intent(@lay, a0, Point.{ x = a0.x + D, y = a0.y }) {
|
||||
print("intent ({},{})->({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row);
|
||||
if !(s.a.col == 0 and s.a.row == 0 and s.b.col == 1 and s.b.row == 0) { fails += 1; }
|
||||
mv := plan_and_commit(@b, s.a, s.b);
|
||||
print("commit: legal {} rounds {} awarded {}\n", mv.legal, mv.rounds.len, mv.awarded);
|
||||
// The illegal swipe spends nothing; its timeline is the bare ping-back.
|
||||
if mv.legal { fails += 1; }
|
||||
if mv.rounds.len != 0 { fails += 1; }
|
||||
} else {
|
||||
print("intent none\n");
|
||||
fails += 1;
|
||||
}
|
||||
|
||||
post_m := find_matches(@b);
|
||||
post_sw := legal_swaps(@b);
|
||||
print("after: matches {} legal_swaps {} has_legal_swap {} status {}\n",
|
||||
post_m.count(), post_sw.len, has_legal_swap(@b), status_name(level_status(@b)));
|
||||
print("after: score {} moves_made {} moves_remaining {}\n",
|
||||
b.score, b.moves_made, b.moves_remaining());
|
||||
out(board_dump(@b));
|
||||
|
||||
// The fix: the UI commit path reshuffled the deadlock away. has_legal_swap flips
|
||||
// false -> true; >=1 legal swap exists; still no immediate match.
|
||||
if !has_legal_swap(@b) { fails += 1; }
|
||||
if post_sw.len <= 0 { fails += 1; }
|
||||
if post_m.count() != 0 { fails += 1; }
|
||||
|
||||
// The reshuffle is NOT a move: score, moves spent, and budget are untouched.
|
||||
if b.score != score_before { fails += 1; }
|
||||
if b.moves_made != made_before { fails += 1; }
|
||||
if b.moves_remaining() != remain_before { fails += 1; }
|
||||
if level_status(@b) != .in_progress { fails += 1; }
|
||||
|
||||
if fails == 0 {
|
||||
print("ok: UI swipe-commit path reshuffles a deadlocked board\n");
|
||||
return 0;
|
||||
}
|
||||
print("FAIL: {} UI-reshuffle checks failed\n", fails);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user