Merge branch 'flow/m3te/fix-final-2' into m3te-plan

This commit is contained in:
swipelab
2026-06-06 15:00:13 +03:00
5 changed files with 199 additions and 14 deletions

View File

@@ -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 }
}

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
}

View File

@@ -0,0 +1 @@
0

View 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
View 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;
}