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:
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