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

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

View File

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