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:
33
board.sx
33
board.sx
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user