P7.1: freeze finished level — reject moves after won/lost

play_turn now checks level_status before committing: a won or lost
level rejects the swap (accepted=false) with no move spent and no
score change, until restart returns it to in_progress. Adds an
accepted flag to TurnResult so the renderer can show the move was
ignored. Regression in tests/level.sx asserts post-won and post-lost
play_turn leaves score/moves/status unchanged and that restart
re-enables play.
This commit is contained in:
swipelab
2026-06-05 08:37:28 +03:00
parent a40a994ae1
commit e77c470546
3 changed files with 88 additions and 10 deletions

View File

@@ -890,27 +890,40 @@ restart :: (board: *Board, seed: s64) {
board.init(seed); board.init(seed);
} }
// Outcome of one turn through the goal loop: the underlying `PlayerMove`, the // Outcome of one turn through the goal loop: whether the turn was `accepted`
// level `status` AFTER it, and whether a deadlock `reshuffle` ran (so P7.2 can // (false only when a finished level rejected the move), the underlying
// flash a "shuffled" note). The status is recomputed from the model, never // `PlayerMove`, the level `status` AFTER it, and whether a deadlock `reshuffle`
// stored. // ran (so P7.2 can flash a "shuffled" note). When `accepted` is false the move
// is a no-op (illegal, depth-0 cascade) and `status` is the terminal status that
// caused the rejection. The status is recomputed from the model, never stored.
TurnResult :: struct { TurnResult :: struct {
accepted: bool;
move: PlayerMove; move: PlayerMove;
status: Status; status: Status;
reshuffled: bool; reshuffled: bool;
} }
// Play one turn: attempt the swap via `commit_swap` (an illegal swap changes // Play one turn. A FINISHED level is frozen: once `level_status` is won or lost
// nothing and spends no move), then — only while the level is still in progress — // the move is REJECTED (`accepted = false`) — no swap, no move spent, no score
// reshuffle if the board has deadlocked (no legal swaps left), so the player is // change, status unchanged — until `restart` reseeds a fresh level. P7.2 reads
// `accepted` to tell the player the input was ignored because the level is over.
// While in progress the swap is attempted via `commit_swap` (an illegal swap
// changes nothing and spends no move); then — only if still in progress — the
// board reshuffles if it has deadlocked (no legal swaps left), so the player is
// never stranded. A reshuffle costs no move. A winning or losing move skips the // never stranded. A reshuffle costs no move. A winning or losing move skips the
// reshuffle: the level is over. Returns the move outcome, the resulting status, // reshuffle: the level is over. Returns whether the turn was accepted, the move
// and whether a reshuffle ran. // outcome, the resulting status, and whether a reshuffle ran.
play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult { play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult {
status := level_status(board);
if status != .in_progress {
empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
frozen := PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
return TurnResult.{ accepted = false, move = frozen, status = status, reshuffled = false };
}
move := commit_swap(board, a, b); move := commit_swap(board, a, b);
reshuffled := false; reshuffled := false;
if level_status(board) == .in_progress and !has_legal_swap(board) { if level_status(board) == .in_progress and !has_legal_swap(board) {
reshuffled = reshuffle(board); reshuffled = reshuffle(board);
} }
TurnResult.{ move = move, status = level_status(board), reshuffled = reshuffled } TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled }
} }

View File

@@ -29,6 +29,9 @@ OGOGOGOG
GOGOGOGO GOGOGOGO
OGOGOGOG OGOGOGOG
GOGOGOGO GOGOGOGO
== frozen-when-won ==
post-win play_turn: accepted false legal false status won
unchanged: score 30 moves_made 1 moves_remaining 4
== lose-transition == == lose-transition ==
RROGOGOG RROGOGOG
GGROGOGO GGROGOGO
@@ -40,6 +43,9 @@ OGOGOGOG
GOGOGOGO GOGOGOGO
before: score 0 target 1000000 moves_remaining 1 status in_progress before: score 0 target 1000000 moves_remaining 1 status in_progress
after: legal true score 30 moves_remaining 0 status lost after: legal true score 30 moves_remaining 0 status lost
== frozen-when-lost ==
post-lose play_turn: accepted false legal false status lost
unchanged: score 30 moves_made 1 moves_remaining 0
== deadlock-reshuffle == == deadlock-reshuffle ==
ROYGBPRO ROYGBPRO
PROYGBPR PROYGBPR
@@ -71,4 +77,5 @@ OGBRRORY
BYRRPRBG BYRRPRBG
YOYYROBB YOYYROBB
OROBPPRB OROBPPRB
re-enabled: (3,0)-(3,1) accepted true legal true moves_made 1
ok: level / turn-goal state machine ok: level / turn-goal state machine

View File

@@ -100,6 +100,29 @@ main :: () -> s32 {
t.expect(bw.moves_remaining() > 0, "win: won with moves to spare"); t.expect(bw.moves_remaining() > 0, "win: won with moves to spare");
t.expect(!tw.reshuffled, "win: no reshuffle once won"); t.expect(!tw.reshuffled, "win: no reshuffle once won");
// ── Frozen when WON: a finished level rejects further moves ──────────────
// The level is over the instant it is won. Until `restart`, every `play_turn`
// must be REJECTED (accepted=false): no swap applied, no move spent, no score
// change, status stays won — even for an otherwise-legal swap. (4,0)<->(4,1)
// is a legal swap on the won board, so WITHOUT the freeze it would commit and
// move score/budget; the freeze is what keeps them put.
print("== frozen-when-won ==\n");
won_score := bw.score;
won_made := bw.moves_made;
won_remain := bw.moves_remaining();
twf := play_turn(@bw, Cell.{ col = 4, row = 0 }, Cell.{ col = 4, row = 1 });
print("post-win play_turn: accepted {} legal {} status {}\n",
twf.accepted, twf.move.legal, status_name(twf.status));
print("unchanged: score {} moves_made {} moves_remaining {}\n",
bw.score, bw.moves_made, bw.moves_remaining());
t.expect(!twf.accepted, "frozen-won: move rejected");
t.expect(!twf.move.legal, "frozen-won: no swap committed");
t.expect(twf.status == .won, "frozen-won: turn still reports won");
t.expect(bw.score == won_score, "frozen-won: score unchanged");
t.expect(bw.moves_made == won_made, "frozen-won: no move spent");
t.expect(bw.moves_remaining() == won_remain, "frozen-won: budget unchanged");
t.expect(level_status(@bw) == .won, "frozen-won: board still won");
// ── LOSS transition: the move budget runs out short of the goal ────────── // ── LOSS transition: the move budget runs out short of the goal ──────────
// Same legal swap (award 30) but a 1-move budget and an unreachable goal: // Same legal swap (award 30) but a 1-move budget and an unreachable goal:
// after the move moves_remaining hits 0 with score < goal, flipping // after the move moves_remaining hits 0 with score < goal, flipping
@@ -129,6 +152,27 @@ main :: () -> s32 {
t.expect(level_status(@bl) == .lost, "lose: board reads lost"); t.expect(level_status(@bl) == .lost, "lose: board reads lost");
t.expect(!tl.reshuffled, "lose: no reshuffle once lost"); t.expect(!tl.reshuffled, "lose: no reshuffle once lost");
// ── Frozen when LOST: a finished level rejects further moves ─────────────
// Symmetric to the won case. (4,0)<->(4,1) is a legal swap on the lost board,
// so the REJECTION — not the spent budget — is what holds score and moves
// put: `commit_swap` itself does not refuse a swap once the budget is gone.
print("== frozen-when-lost ==\n");
lost_score := bl.score;
lost_made := bl.moves_made;
lost_remain := bl.moves_remaining();
tlf := play_turn(@bl, Cell.{ col = 4, row = 0 }, Cell.{ col = 4, row = 1 });
print("post-lose play_turn: accepted {} legal {} status {}\n",
tlf.accepted, tlf.move.legal, status_name(tlf.status));
print("unchanged: score {} moves_made {} moves_remaining {}\n",
bl.score, bl.moves_made, bl.moves_remaining());
t.expect(!tlf.accepted, "frozen-lost: move rejected");
t.expect(!tlf.move.legal, "frozen-lost: no swap committed");
t.expect(tlf.status == .lost, "frozen-lost: turn still reports lost");
t.expect(bl.score == lost_score, "frozen-lost: score unchanged");
t.expect(bl.moves_made == lost_made, "frozen-lost: no move spent");
t.expect(bl.moves_remaining() == lost_remain, "frozen-lost: budget unchanged");
t.expect(level_status(@bl) == .lost, "frozen-lost: board still lost");
// ── No-legal-moves rule: RESHUFFLE ────────────────────────────────────── // ── No-legal-moves rule: RESHUFFLE ──────────────────────────────────────
// A provably deadlocked board: gem(col,row) = (col-row) mod 6, a diagonal // A provably deadlocked board: gem(col,row) = (col-row) mod 6, a diagonal
// Latin square. Equal gems lie only on slope-1 diagonals, so no row/column // Latin square. Equal gems lie only on slope-1 diagonals, so no row/column
@@ -191,6 +235,20 @@ main :: () -> s32 {
t.expect(level_status(@br) == .in_progress, "restart: status in_progress"); t.expect(level_status(@br) == .in_progress, "restart: status in_progress");
t.expect(boards_equal(@br, @ref), "restart: same seed reproduces starting board"); t.expect(boards_equal(@br, @ref), "restart: same seed reproduces starting board");
// restart RE-ENABLES play: the fresh in-progress level accepts moves again
// (it was frozen only while terminal). A legal swap on the restored board now
// commits — accepted, one move spent — proving the entry point is live again.
rsw := legal_swaps(@br);
t.expect(rsw.len > 0, "restart: fresh board has a legal move");
rmove := rsw.items[0];
tr := play_turn(@br, rmove.a, rmove.b);
print("re-enabled: ({},{})-({},{}) accepted {} legal {} moves_made {}\n",
rmove.a.col, rmove.a.row, rmove.b.col, rmove.b.row,
tr.accepted, tr.move.legal, br.moves_made);
t.expect(tr.accepted, "restart: play_turn accepted after restart");
t.expect(tr.move.legal, "restart: legal swap committed after restart");
t.expect(br.moves_made == 1, "restart: a move spent after restart");
print("ok: level / turn-goal state machine\n"); print("ok: level / turn-goal state machine\n");
return 0; return 0;
} }