// 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. // It also guards the P6.1 input-lock fix: `accepts_input` rejects a gesture for // the FULL in-flight animation window (mouse_down → settle), so a swipe begun // while a move animates never latches a drag and never commits — even if it is // released after the timeline ends. // 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; } // ── Input gate: locked for the FULL in-flight animation ───────────────── // The view begins a drag on mouse_down only when `accepts_input` is true, // and commits on mouse_up only if a drag latched. So a gesture that BEGINS // while a move animates must NEVER commit — even if it is released after the // animation has fully settled. This guards the P6.1 input-lock fix. print("== input gate: locked while animating ==\n"); gate : BoardAnim = ---; gate.init(); idle_ok := accepts_input(@gate); // no move in flight → accept gate.begin(move); // a legal move's timeline starts busy_ok := accepts_input(@gate); // mouse_down DURING animation → reject while gate.active { gate.tick(0.05); } // player holds; timeline plays out settled_ok := accepts_input(@gate); // animation fully settled → accept print("accepts idle {} busy {} settled {}\n", idle_ok, busy_ok, settled_ok); if !idle_ok { fails += 1; } if busy_ok { fails += 1; } // MUST be locked while animating if !settled_ok { fails += 1; } // The board MUST decide a gesture at PRESS, not at RELEASE. Over the exact // failure scenario — a gesture PRESSED while animating and RELEASED after the // timeline has settled — the two policies diverge: // release-gate: commit unless animating AT RELEASE → COMMITS (the timeline // finished first), letting input slip through mid-transition. // press-gate: latch only if input accepted AT PRESS → DROPS, because // input was locked for the whole window the timeline ran. gate.init(); gate.begin(move); accept_at_press := accepts_input(@gate); // mouse_down while animating while gate.active { gate.tick(0.05); } accept_at_release := accepts_input(@gate); // mouse_up after settle release_gate_commits := accept_at_release; press_gate_commits := accept_at_press; print("release_gate_commits {} press_gate_commits {}\n", release_gate_commits, press_gate_commits); if !release_gate_commits { fails += 1; } // the scenario release-gating lets through if press_gate_commits { fails += 1; } // the board press-gates: MUST NOT commit 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; }