From e77c470546f9e96b0f78f3f77c4f4f2ef01fe076 Mon Sep 17 00:00:00 2001 From: swipelab Date: Fri, 5 Jun 2026 08:37:28 +0300 Subject: [PATCH] =?UTF-8?q?P7.1:=20freeze=20finished=20level=20=E2=80=94?= =?UTF-8?q?=20reject=20moves=20after=20won/lost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- board.sx | 33 ++++++++++++++------- tests/expected/level.stdout | 7 +++++ tests/level.sx | 58 +++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/board.sx b/board.sx index 0aa8eb2..f396fde 100644 --- a/board.sx +++ b/board.sx @@ -890,27 +890,40 @@ restart :: (board: *Board, seed: s64) { board.init(seed); } -// Outcome of one turn through the goal loop: the underlying `PlayerMove`, the -// level `status` AFTER it, and whether a deadlock `reshuffle` ran (so P7.2 can -// flash a "shuffled" note). The status is recomputed from the model, never -// stored. +// Outcome of one turn through the goal loop: whether the turn was `accepted` +// (false only when a finished level rejected the move), the underlying +// `PlayerMove`, the level `status` AFTER it, and whether a deadlock `reshuffle` +// 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 { + accepted: bool; move: PlayerMove; status: Status; reshuffled: bool; } -// Play one turn: attempt the swap via `commit_swap` (an illegal swap changes -// nothing and spends no move), then — only while the level is still in progress — -// reshuffle if the board has deadlocked (no legal swaps left), so the player is +// Play one turn. A FINISHED level is frozen: once `level_status` is won or lost +// the move is REJECTED (`accepted = false`) — no swap, no move spent, no score +// 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 -// reshuffle: the level is over. Returns the move outcome, the resulting status, -// and whether a reshuffle ran. +// reshuffle: the level is over. Returns whether the turn was accepted, the move +// outcome, the resulting status, and whether a reshuffle ran. 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); reshuffled := false; if level_status(board) == .in_progress and !has_legal_swap(board) { reshuffled = reshuffle(board); } - TurnResult.{ move = move, status = level_status(board), reshuffled = reshuffled } + TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled } } diff --git a/tests/expected/level.stdout b/tests/expected/level.stdout index 7a402e3..ad8e5b1 100644 --- a/tests/expected/level.stdout +++ b/tests/expected/level.stdout @@ -29,6 +29,9 @@ OGOGOGOG GOGOGOGO OGOGOGOG 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 == RROGOGOG GGROGOGO @@ -40,6 +43,9 @@ OGOGOGOG GOGOGOGO before: score 0 target 1000000 moves_remaining 1 status in_progress 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 == ROYGBPRO PROYGBPR @@ -71,4 +77,5 @@ OGBRRORY BYRRPRBG YOYYROBB OROBPPRB +re-enabled: (3,0)-(3,1) accepted true legal true moves_made 1 ok: level / turn-goal state machine diff --git a/tests/level.sx b/tests/level.sx index b415e5d..fcdb0aa 100644 --- a/tests/level.sx +++ b/tests/level.sx @@ -100,6 +100,29 @@ main :: () -> s32 { t.expect(bw.moves_remaining() > 0, "win: won with moves to spare"); 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 ────────── // 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 @@ -129,6 +152,27 @@ main :: () -> s32 { t.expect(level_status(@bl) == .lost, "lose: board reads 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 ────────────────────────────────────── // 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 @@ -191,6 +235,20 @@ main :: () -> s32 { t.expect(level_status(@br) == .in_progress, "restart: status in_progress"); 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"); return 0; }