Files
m3te/tests/anim_plan.sx
swipelab 0b858f7724 P6.1: swap/clear/fall move tweens (sx, iOS sim)
Add a purely-visual animation timeline so the board no longer snaps on a
move. board_anim.sx records, on a value-copy of the pre-move board, the
swap and each cascade round's matched cells + per-column fall provenance,
then BoardView plays it over delta_time: the two swapped gems SLIDE between
cells (and ping out-and-back on an illegal swap), matched gems SCALE OUT,
and survivors FALL into place while refills drop in from above the grid.

The model stays authoritative: plan_and_commit still calls commit_swap on
the real board exactly as before, and the recording replays the identical
primitives from the identical cells + RNG state, so the timeline ends ON
the model's settled board. tests/anim_plan.sx is the determinism guard —
it asserts the committed board, score, moves, and the timeline's final
state all equal an independent commit_swap of the same move, that the
rounds are contiguous, and that an illegal swap records nothing and leaves
the board untouched. All pre-existing logic/cascade goldens stay green.

Evidence (sx-test-metal, iOS 26.0, time-sampled with temporarily-lengthened
durations; committed durations are the short production values):
goldens/p6_anim_swap.png  gems sliding between (5,4)/(6,4)
goldens/p6_anim_clear.png matched reds scaling out in row 4
goldens/p6_anim_fall.png  gems mid-fall with gaps + refill dropping in
goldens/p6_anim_after.png settled board == model (SCORE 30, MOVES 29/30)
2026-06-05 01:06:02 +03:00

118 lines
4.7 KiB
Plaintext

// Animation-layer determinism guard (P6.1): prove the swap/clear/fall animation
// timeline is PURELY VISUAL — it never changes the model's result. `plan_and_commit`
// commits the move on the real board (authoritative) AND records the visual
// timeline on a value-copy; this test asserts, on the SAME seed the app renders
// (SEED 1337):
// - the board `plan_and_commit` leaves is byte-for-byte identical to an
// independent `commit_swap` of the same move, with the same score + moves;
// - the recorded timeline ENDS on that exact state: `move.final` equals the
// model board, the rounds are contiguous (round 0 starts on the swapped board,
// each later round starts on the prior round's settled board), and the last
// round's `after` equals `final`;
// - an illegal swap records no rounds and leaves the board untouched.
// No rendering — it calls exactly what BoardView.handle_event calls. Links headless
// like tests/swipe_commit.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.
#import "modules/std.sx";
#import "board.sx";
#import "board_anim.sx";
SEED :: 1337;
boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) {
if !(x.cells[i] == y.cells[i]) { return false; }
}
true
}
main :: () -> s32 {
fails : s64 = 0;
// ── Legal swap: plan == model, timeline ends on the model ───────────────
// (5,4)->(6,4): brings R into (5,4), completing R,R,R across cols 3-5 of row
// 4 — the same legal swap tests/swipe_commit.sx commits.
print("== legal swap: plan matches model ==\n");
a := Cell.{ col = 5, row = 4 };
b := Cell.{ col = 6, row = 4 };
bm : Board = ---;
bm.init(SEED);
mvm := commit_swap(@bm, a, b);
ba : Board = ---;
ba.init(SEED);
move := plan_and_commit(@ba, a, b);
print("model: legal {} depth {} score {} moves {}\n",
mvm.legal, mvm.cascade.depth, bm.score, bm.moves_made);
print("plan: legal {} rounds {} score {} moves {}\n",
move.legal, move.rounds.len, ba.score, ba.moves_made);
if !move.legal { fails += 1; }
if !boards_equal(@ba, @bm) { fails += 1; } // committed board == model
if ba.score != bm.score { fails += 1; }
if ba.moves_made != bm.moves_made { fails += 1; }
if move.rounds.len != mvm.cascade.depth { fails += 1; }
// move.final equals the model board.
final_eq := true;
for 0..BOARD_CELLS: (i) {
if !(move.final[i] == bm.cells[i]) { final_eq = false; }
}
if !final_eq { fails += 1; }
print("final==model {}\n", final_eq);
// Timeline contiguity: round 0 starts on the swapped pre board; each later
// round starts on the previous round's settled board; final == last after.
contiguous := true;
if move.rounds.len > 0 {
ai := Board.idx(a.col, a.row);
bi := Board.idx(b.col, b.row);
r0 := @move.rounds.items[0];
for 0..BOARD_CELLS: (i) {
expect : Gem = move.pre[i];
if i == ai { expect = move.pre[bi]; }
else if i == bi { expect = move.pre[ai]; }
if !(r0.before[i] == expect) { contiguous = false; }
}
for 1..move.rounds.len: (k) {
prev := @move.rounds.items[k - 1];
cur := @move.rounds.items[k];
for 0..BOARD_CELLS: (i) {
if !(cur.before[i] == prev.after[i]) { contiguous = false; }
}
}
last := @move.rounds.items[move.rounds.len - 1];
for 0..BOARD_CELLS: (i) {
if !(last.after[i] == move.final[i]) { contiguous = false; }
}
}
if !contiguous { fails += 1; }
print("contiguous {}\n", contiguous);
out("final board:\n");
out(board_dump(@bm));
// ── Illegal swap: no timeline, board untouched ──────────────────────────
// (0,0)->(1,0): two reds → no match. plan_and_commit must leave the board
// exactly as it was, spend no move, and record zero rounds.
print("== illegal swap: untouched ==\n");
bi2 : Board = ---;
bi2.init(SEED);
pre2 : Board = bi2;
mi := plan_and_commit(@bi2, Cell.{ col = 0, row = 0 }, Cell.{ col = 1, row = 0 });
print("legal {} rounds {} score {} moves {}\n", mi.legal, mi.rounds.len, bi2.score, bi2.moves_made);
if mi.legal { fails += 1; }
if mi.rounds.len != 0 { fails += 1; }
if !boards_equal(@pre2, @bi2) { fails += 1; }
if bi2.score != 0 { fails += 1; }
if bi2.moves_made != 0 { fails += 1; }
if fails == 0 {
print("ok: animation layer leaves the model result unchanged\n");
return 0;
}
print("FAIL: {} anim-determinism checks failed\n", fails);
return 1;
}