P7.1: turn / goal state machine (pure sx)
Add a thin, deterministic level loop over the headless model in board.sx:
- target_score: per-level score goal (DEFAULT_TARGET_SCORE=1500), seeded by init.
- Status (in_progress/won/lost) derived purely from score/target/move budget via
level_status; won is checked before lost so meeting the goal on the final move
wins. status_name for the HUD/snapshots.
- has_legal_swap: allocation-free deadlock probe (first legal pair short-circuits).
- reshuffle: Fisher-Yates over existing gems via the board's seeded RNG until the
arrangement has no immediate match and at least one legal move; consumes no
move, terminates via MAX_RESHUFFLE_TRIES.
- restart: reseed a fresh, reproducible level (resets cells/score/moves/goal).
- play_turn / TurnResult: commit_swap then reshuffle on deadlock while in
progress, reporting the resulting status.
tests/level.sx (golden tests/expected/level.{stdout,exit}) asserts: start
in_progress; one legal swap crossing a low goal -> won transition; budget
exhausted below an unreachable goal -> lost transition; a provably deadlocked
diagonal Latin-square board ((col-row) mod 6) reshuffles to >=1 legal move with
no immediate match and no move spent; restart resets progress and a fixed seed
reproduces the same starting board.
Gate: sx build --target ios-sim main.sx (exit 0); bash tools/run_tests.sh
(17 passed, 0 failed).
This commit is contained in:
140
board.sx
140
board.sx
@@ -84,6 +84,13 @@ BOARD_CELLS :: BOARD_COLS * BOARD_ROWS;
|
|||||||
// committing) without `commit_swap` refusing.
|
// committing) without `commit_swap` refusing.
|
||||||
DEFAULT_MOVE_LIMIT :: 30;
|
DEFAULT_MOVE_LIMIT :: 30;
|
||||||
|
|
||||||
|
// Default per-level score goal (P7.1). `init` seeds `Board.target_score` with
|
||||||
|
// this; `level_status` wins the moment `score` reaches it. Sized to be reachable
|
||||||
|
// within the DEFAULT_MOVE_LIMIT budget with focused play but not by idle
|
||||||
|
// swapping — a single legal swap pays tens of points, a deep cascade a couple
|
||||||
|
// hundred. A level may override `target_score` for a harder or easier goal.
|
||||||
|
DEFAULT_TARGET_SCORE :: 1500;
|
||||||
|
|
||||||
Board :: struct {
|
Board :: struct {
|
||||||
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
|
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
|
||||||
cells: [BOARD_CELLS]Gem;
|
cells: [BOARD_CELLS]Gem;
|
||||||
@@ -111,6 +118,11 @@ Board :: struct {
|
|||||||
moves_made: s64;
|
moves_made: s64;
|
||||||
move_limit: s64;
|
move_limit: s64;
|
||||||
|
|
||||||
|
// Per-level score goal (P7.1). `init` sets it to DEFAULT_TARGET_SCORE;
|
||||||
|
// `level_status` reads it to decide a win (`score >= target_score`). A
|
||||||
|
// hand-built board must set this before its status is read.
|
||||||
|
target_score: s64;
|
||||||
|
|
||||||
idx :: (col: s64, row: s64) -> s64 {
|
idx :: (col: s64, row: s64) -> s64 {
|
||||||
row * BOARD_COLS + col
|
row * BOARD_COLS + col
|
||||||
}
|
}
|
||||||
@@ -142,6 +154,7 @@ Board :: struct {
|
|||||||
self.score = 0;
|
self.score = 0;
|
||||||
self.moves_made = 0;
|
self.moves_made = 0;
|
||||||
self.move_limit = DEFAULT_MOVE_LIMIT;
|
self.move_limit = DEFAULT_MOVE_LIMIT;
|
||||||
|
self.target_score = DEFAULT_TARGET_SCORE;
|
||||||
for 0..BOARD_ROWS: (row) {
|
for 0..BOARD_ROWS: (row) {
|
||||||
for 0..BOARD_COLS: (col) {
|
for 0..BOARD_COLS: (col) {
|
||||||
self.set(col, row, pick_gem(self, @self.rng, col, row));
|
self.set(col, row, pick_gem(self, @self.rng, col, row));
|
||||||
@@ -774,3 +787,130 @@ commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove {
|
|||||||
board.moves_made += 1;
|
board.moves_made += 1;
|
||||||
PlayerMove.{ legal = true, cascade = cascade, moves_remaining = board.moves_remaining() }
|
PlayerMove.{ legal = true, cascade = cascade, moves_remaining = board.moves_remaining() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Turn / goal loop (P7.1) ────────────────────────────────────────────────
|
||||||
|
// A thin, pure level loop over the model: a per-level score GOAL
|
||||||
|
// (`Board.target_score`) to reach within the move budget (`Board.move_limit`),
|
||||||
|
// the win / lose / in-progress STATUS derived from the two, a deadlock
|
||||||
|
// RESHUFFLE so the player is never stuck, and a RESTART that reseeds a fresh
|
||||||
|
// level. All deterministic and rendering-free; P7.2 reads `level_status` to draw
|
||||||
|
// the goal HUD, the win/lose banner and the restart button.
|
||||||
|
|
||||||
|
// Where a level stands. Derived purely from `Board.score`, `Board.target_score`
|
||||||
|
// and the move budget — there is no stored status to keep in sync, so it can
|
||||||
|
// never drift from the model. `won` is tested before `lost`, so meeting the goal
|
||||||
|
// on the final move wins even though no moves remain.
|
||||||
|
Status :: enum {
|
||||||
|
in_progress;
|
||||||
|
won;
|
||||||
|
lost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One stable name per status, for snapshots and the HUD.
|
||||||
|
status_name :: (s: Status) -> string {
|
||||||
|
if s == .won { return "won"; }
|
||||||
|
if s == .lost { return "lost"; }
|
||||||
|
"in_progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The level's current standing. WON as soon as `score` reaches `target_score`
|
||||||
|
// (even with the budget exhausted); otherwise LOST once the move budget is spent
|
||||||
|
// (`moves_remaining() <= 0`) short of the goal; otherwise still in progress.
|
||||||
|
// `<= 0` (not `== 0`) so a board pushed past its budget still reads lost.
|
||||||
|
level_status :: (board: *Board) -> Status {
|
||||||
|
if board.score >= board.target_score { return .won; }
|
||||||
|
if board.moves_remaining() <= 0 { return .lost; }
|
||||||
|
.in_progress
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the board has at least one legal swap — the cheap deadlock probe.
|
||||||
|
// Same enumeration as `legal_swaps`, but it stops at the first legal pair and
|
||||||
|
// allocates nothing, so the reshuffle loop and P7.2's deadlock check don't build
|
||||||
|
// a throwaway list each call. The trial swaps inside `swap_legal` are reverted,
|
||||||
|
// so the board is left unchanged.
|
||||||
|
has_legal_swap :: (board: *Board) -> bool {
|
||||||
|
for 0..BOARD_ROWS: (row) {
|
||||||
|
for 0..BOARD_COLS: (col) {
|
||||||
|
here := Cell.{ col = col, row = row };
|
||||||
|
if col + 1 < BOARD_COLS {
|
||||||
|
right := Cell.{ col = col + 1, row = row };
|
||||||
|
if swap_legal(board, here, right) { return true; }
|
||||||
|
}
|
||||||
|
if row + 1 < BOARD_ROWS {
|
||||||
|
down := Cell.{ col = col, row = row + 1 };
|
||||||
|
if swap_legal(board, here, down) { return true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upper bound on shuffle attempts before `reshuffle` gives up. A gather-and-
|
||||||
|
// permute lands on a no-immediate-match, has-a-legal-move arrangement within a
|
||||||
|
// handful of tries for a real (multi-colour) board, so this cap only guarantees
|
||||||
|
// termination; exhausting it (astronomically unlikely) leaves the board in its
|
||||||
|
// last shuffled state and returns false, which the turn loop treats as an
|
||||||
|
// unbreakable deadlock.
|
||||||
|
MAX_RESHUFFLE_TRIES :: 200;
|
||||||
|
|
||||||
|
// Re-arrange the board's existing gems in place until the player has a move
|
||||||
|
// again: no cell is part of an immediate match AND at least one legal swap
|
||||||
|
// exists. The board's own seeded RNG drives a Fisher–Yates permutation, so a
|
||||||
|
// given state always reshuffles the same way; each invalid arrangement is simply
|
||||||
|
// re-permuted (the RNG keeps advancing) until one is valid or the attempt cap is
|
||||||
|
// hit. A reshuffle is NOT a move: `score`, `moves_made` and `move_limit` are
|
||||||
|
// untouched. Returns true once a valid arrangement is reached, false if the cap
|
||||||
|
// was exhausted.
|
||||||
|
reshuffle :: (board: *Board) -> bool {
|
||||||
|
rng := @board.rng;
|
||||||
|
tries := 0;
|
||||||
|
while tries < MAX_RESHUFFLE_TRIES {
|
||||||
|
// Fisher–Yates over all 64 cells, in place. Short loops — in-body locals
|
||||||
|
// here are fine (issue 0001 only bites loops of ~1M+ iterations).
|
||||||
|
i := BOARD_CELLS - 1;
|
||||||
|
while i > 0 {
|
||||||
|
j := rng.next_range(i + 1);
|
||||||
|
tmp := board.cells[i];
|
||||||
|
board.cells[i] = board.cells[j];
|
||||||
|
board.cells[j] = tmp;
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
m := find_matches(board);
|
||||||
|
if m.count() == 0 and has_legal_swap(board) { return true; }
|
||||||
|
tries += 1;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to a fresh, reproducible level: `init(seed)` reseeds the board (same
|
||||||
|
// seed → identical starting layout), zeroes `score` and `moves_made`, and
|
||||||
|
// restores the default move budget and score goal, so `level_status` reads
|
||||||
|
// `in_progress` again. The entry point P7.2's restart button calls.
|
||||||
|
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.
|
||||||
|
TurnResult :: struct {
|
||||||
|
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
|
||||||
|
// 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.
|
||||||
|
play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
1
tests/expected/level.exit
Normal file
1
tests/expected/level.exit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
74
tests/expected/level.stdout
Normal file
74
tests/expected/level.stdout
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
== level (turn / goal state machine) ==
|
||||||
|
== start-in-progress ==
|
||||||
|
RRPPOGRG
|
||||||
|
PGPOPRRO
|
||||||
|
YYBBYRYB
|
||||||
|
GBYYRGGP
|
||||||
|
OGBRRORY
|
||||||
|
BYRRPRBG
|
||||||
|
YOYYROBB
|
||||||
|
OROBPPRB
|
||||||
|
score 0 target 1500 moves_remaining 30 status in_progress
|
||||||
|
== win-transition ==
|
||||||
|
RROGOGOG
|
||||||
|
GGROGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
before: score 0 target 30 moves_remaining 5 status in_progress
|
||||||
|
after: legal true awarded 30 reshuffled false
|
||||||
|
after: score 30 moves_remaining 4 status won
|
||||||
|
RBRGOGOG
|
||||||
|
GGOOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
== lose-transition ==
|
||||||
|
RROGOGOG
|
||||||
|
GGROGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
OGOGOGOG
|
||||||
|
GOGOGOGO
|
||||||
|
before: score 0 target 1000000 moves_remaining 1 status in_progress
|
||||||
|
after: legal true score 30 moves_remaining 0 status lost
|
||||||
|
== deadlock-reshuffle ==
|
||||||
|
ROYGBPRO
|
||||||
|
PROYGBPR
|
||||||
|
BPROYGBP
|
||||||
|
GBPROYGB
|
||||||
|
YGBPROYG
|
||||||
|
OYGBPROY
|
||||||
|
ROYGBPRO
|
||||||
|
PROYGBPR
|
||||||
|
before: matches 0 legal_swaps 0 has_legal_swap false
|
||||||
|
reshuffled true matches 0 legal_swaps 9
|
||||||
|
after: score 0 moves_made 0
|
||||||
|
BGGYORYR
|
||||||
|
RRYGOPBY
|
||||||
|
YRYBPRGB
|
||||||
|
OOBGBPRG
|
||||||
|
RPRPYRPO
|
||||||
|
OBBPOOPG
|
||||||
|
OBGGOPGY
|
||||||
|
YPRYBORP
|
||||||
|
== restart ==
|
||||||
|
dirty: score 500 moves_made 7 status in_progress
|
||||||
|
after restart: score 0 moves_made 0 moves_remaining 30 status in_progress
|
||||||
|
RRPPOGRG
|
||||||
|
PGPOPRRO
|
||||||
|
YYBBYRYB
|
||||||
|
GBYYRGGP
|
||||||
|
OGBRRORY
|
||||||
|
BYRRPRBG
|
||||||
|
YOYYROBB
|
||||||
|
OROBPPRB
|
||||||
|
ok: level / turn-goal state machine
|
||||||
196
tests/level.sx
Normal file
196
tests/level.sx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// Level / turn-goal state-machine golden (P7.1).
|
||||||
|
//
|
||||||
|
// Proves the pure level loop layered over the model in board.sx:
|
||||||
|
//
|
||||||
|
// * `level_status` derives in_progress / won / lost from `Board.score`,
|
||||||
|
// `Board.target_score` and the move budget — won the moment the goal is met,
|
||||||
|
// lost when the budget is spent short of it.
|
||||||
|
// * the win and loss TRANSITIONS across a committed move (`play_turn`).
|
||||||
|
// * the deadlock RESHUFFLE rule: a provably stuck board (no legal swap, no
|
||||||
|
// immediate match) reshuffles — via the seeded RNG — into a board with >=1
|
||||||
|
// legal move and still no immediate match, consuming no move.
|
||||||
|
// * `restart` resets score/moves/status and, for a fixed seed, reproduces the
|
||||||
|
// exact same starting board.
|
||||||
|
//
|
||||||
|
// Deterministic: fixed seeds and crafted boards, every transition asserted AND
|
||||||
|
// printed so the golden is self-explanatory.
|
||||||
|
#import "modules/std.sx";
|
||||||
|
#import "board.sx";
|
||||||
|
t :: #import "test.sx";
|
||||||
|
|
||||||
|
START_SEED :: 1337;
|
||||||
|
RESHUFFLE_SEED :: 1337;
|
||||||
|
|
||||||
|
// Inverse of `gem_char`: map a board character back to its Gem so each board can
|
||||||
|
// be written as a human-readable grid. The hole glyph maps to `.empty`.
|
||||||
|
char_to_gem :: (c: u8) -> Gem {
|
||||||
|
if c == EMPTY_CHAR { return .empty; }
|
||||||
|
for 0..GEM_COUNT: (i) {
|
||||||
|
if GEM_CHARS[i] == c { return cast(Gem) i; }
|
||||||
|
}
|
||||||
|
.red
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
|
||||||
|
// seeded RNG, running score zeroed, the turn counters reset to a fresh game, and
|
||||||
|
// the per-level goal set.
|
||||||
|
load_board :: (rows: []string, seed: s64, move_limit: s64, target_score: s64) -> Board {
|
||||||
|
b : Board = ---;
|
||||||
|
for 0..BOARD_ROWS: (row) {
|
||||||
|
line := rows[row];
|
||||||
|
for 0..BOARD_COLS: (col) {
|
||||||
|
b.set(col, row, char_to_gem(line[col]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.rng = rng_seeded(seed);
|
||||||
|
b.score = 0;
|
||||||
|
b.moves_made = 0;
|
||||||
|
b.move_limit = move_limit;
|
||||||
|
b.target_score = target_score;
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
boards_equal :: (a: *Board, b: *Board) -> bool {
|
||||||
|
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
main :: () -> s32 {
|
||||||
|
print("== level (turn / goal state machine) ==\n");
|
||||||
|
|
||||||
|
// ── Start: a fresh seeded board reads in_progress with the default goal ──
|
||||||
|
print("== start-in-progress ==\n");
|
||||||
|
b0 : Board = ---;
|
||||||
|
b0.init(START_SEED);
|
||||||
|
out(board_dump(@b0));
|
||||||
|
print("score {} target {} moves_remaining {} status {}\n",
|
||||||
|
b0.score, b0.target_score, b0.moves_remaining(), status_name(level_status(@b0)));
|
||||||
|
t.expect(level_status(@b0) == .in_progress, "start: status in_progress");
|
||||||
|
t.expect(b0.target_score == DEFAULT_TARGET_SCORE, "start: default goal");
|
||||||
|
|
||||||
|
// ── WIN transition: one legal swap crosses a low goal ───────────────────
|
||||||
|
// The commit-legal-len3 board: swapping (2,0)<->(2,1) completes RRR for an
|
||||||
|
// award of 30. With the goal set to 30, that single move flips in_progress
|
||||||
|
// -> won, and wins with moves still in the budget (win triggers on the goal,
|
||||||
|
// not on the budget running out).
|
||||||
|
print("== win-transition ==\n");
|
||||||
|
bw := load_board(.[
|
||||||
|
"RROGOGOG",
|
||||||
|
"GGROGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
], 7, 5, 30);
|
||||||
|
out(board_dump(@bw));
|
||||||
|
print("before: score {} target {} moves_remaining {} status {}\n",
|
||||||
|
bw.score, bw.target_score, bw.moves_remaining(), status_name(level_status(@bw)));
|
||||||
|
t.expect(level_status(@bw) == .in_progress, "win: in_progress before move");
|
||||||
|
tw := play_turn(@bw, Cell.{ col = 2, row = 0 }, Cell.{ col = 2, row = 1 });
|
||||||
|
print("after: legal {} awarded {} reshuffled {}\n", tw.move.legal, tw.move.cascade.awarded, tw.reshuffled);
|
||||||
|
print("after: score {} moves_remaining {} status {}\n",
|
||||||
|
bw.score, bw.moves_remaining(), status_name(tw.status));
|
||||||
|
out(board_dump(@bw));
|
||||||
|
t.expect(tw.move.legal, "win: swap committed");
|
||||||
|
t.expect(bw.score >= bw.target_score, "win: goal reached");
|
||||||
|
t.expect(tw.status == .won, "win: turn reports won");
|
||||||
|
t.expect(level_status(@bw) == .won, "win: board reads won");
|
||||||
|
t.expect(bw.moves_remaining() > 0, "win: won with moves to spare");
|
||||||
|
t.expect(!tw.reshuffled, "win: no reshuffle once 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
|
||||||
|
// in_progress -> lost.
|
||||||
|
print("== lose-transition ==\n");
|
||||||
|
bl := load_board(.[
|
||||||
|
"RROGOGOG",
|
||||||
|
"GGROGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
"OGOGOGOG",
|
||||||
|
"GOGOGOGO",
|
||||||
|
], 7, 1, 1000000);
|
||||||
|
out(board_dump(@bl));
|
||||||
|
print("before: score {} target {} moves_remaining {} status {}\n",
|
||||||
|
bl.score, bl.target_score, bl.moves_remaining(), status_name(level_status(@bl)));
|
||||||
|
t.expect(level_status(@bl) == .in_progress, "lose: in_progress before move");
|
||||||
|
tl := play_turn(@bl, Cell.{ col = 2, row = 0 }, Cell.{ col = 2, row = 1 });
|
||||||
|
print("after: legal {} score {} moves_remaining {} status {}\n",
|
||||||
|
tl.move.legal, bl.score, bl.moves_remaining(), status_name(tl.status));
|
||||||
|
t.expect(tl.move.legal, "lose: swap committed");
|
||||||
|
t.expect(bl.moves_remaining() == 0, "lose: budget spent");
|
||||||
|
t.expect(bl.score < bl.target_score, "lose: short of goal");
|
||||||
|
t.expect(tl.status == .lost, "lose: turn reports lost");
|
||||||
|
t.expect(level_status(@bl) == .lost, "lose: board reads lost");
|
||||||
|
t.expect(!tl.reshuffled, "lose: no reshuffle once 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
|
||||||
|
// holds two of a kind within reach — there is no immediate match, and no
|
||||||
|
// orthogonal swap can line up three. `reshuffle` re-arranges the SAME gems
|
||||||
|
// (seeded RNG) into a board with a legal move and no immediate match, and
|
||||||
|
// spends no move.
|
||||||
|
print("== deadlock-reshuffle ==\n");
|
||||||
|
bd := load_board(.[
|
||||||
|
"ROYGBPRO",
|
||||||
|
"PROYGBPR",
|
||||||
|
"BPROYGBP",
|
||||||
|
"GBPROYGB",
|
||||||
|
"YGBPROYG",
|
||||||
|
"OYGBPROY",
|
||||||
|
"ROYGBPRO",
|
||||||
|
"PROYGBPR",
|
||||||
|
], RESHUFFLE_SEED, 10, 1500);
|
||||||
|
out(board_dump(@bd));
|
||||||
|
pre_m := find_matches(@bd);
|
||||||
|
pre_sw := legal_swaps(@bd);
|
||||||
|
print("before: matches {} legal_swaps {} has_legal_swap {}\n",
|
||||||
|
pre_m.count(), pre_sw.len, has_legal_swap(@bd));
|
||||||
|
t.expect(pre_m.count() == 0, "deadlock: no immediate match before");
|
||||||
|
t.expect(pre_sw.len == 0, "deadlock: no legal swap before");
|
||||||
|
t.expect(!has_legal_swap(@bd), "deadlock: has_legal_swap false before");
|
||||||
|
score_before := bd.score;
|
||||||
|
made_before := bd.moves_made;
|
||||||
|
ok := reshuffle(@bd);
|
||||||
|
post_m := find_matches(@bd);
|
||||||
|
post_sw := legal_swaps(@bd);
|
||||||
|
print("reshuffled {} matches {} legal_swaps {}\n", ok, post_m.count(), post_sw.len);
|
||||||
|
print("after: score {} moves_made {}\n", bd.score, bd.moves_made);
|
||||||
|
out(board_dump(@bd));
|
||||||
|
t.expect(ok, "deadlock: reshuffle succeeded");
|
||||||
|
t.expect(post_m.count() == 0, "deadlock: no immediate match after reshuffle");
|
||||||
|
t.expect(post_sw.len > 0, "deadlock: >=1 legal move after reshuffle");
|
||||||
|
t.expect(bd.score == score_before, "deadlock: reshuffle spent no score");
|
||||||
|
t.expect(bd.moves_made == made_before, "deadlock: reshuffle spent no move");
|
||||||
|
|
||||||
|
// ── Restart: reset progress, reproduce the seeded starting board ─────────
|
||||||
|
print("== restart ==\n");
|
||||||
|
br : Board = ---;
|
||||||
|
br.init(START_SEED);
|
||||||
|
// Dirty the state as a partial game would: spend moves, accrue score, and
|
||||||
|
// mutate a cell so the layout differs from a fresh board.
|
||||||
|
br.score = 500;
|
||||||
|
br.moves_made = 7;
|
||||||
|
br.set(0, 0, .empty);
|
||||||
|
print("dirty: score {} moves_made {} status {}\n",
|
||||||
|
br.score, br.moves_made, status_name(level_status(@br)));
|
||||||
|
restart(@br, START_SEED);
|
||||||
|
ref : Board = ---;
|
||||||
|
ref.init(START_SEED);
|
||||||
|
print("after restart: score {} moves_made {} moves_remaining {} status {}\n",
|
||||||
|
br.score, br.moves_made, br.moves_remaining(), status_name(level_status(@br)));
|
||||||
|
out(board_dump(@br));
|
||||||
|
t.expect(br.score == 0, "restart: score reset");
|
||||||
|
t.expect(br.moves_made == 0, "restart: moves reset");
|
||||||
|
t.expect(level_status(@br) == .in_progress, "restart: status in_progress");
|
||||||
|
t.expect(boards_equal(@br, @ref), "restart: same seed reproduces starting board");
|
||||||
|
|
||||||
|
print("ok: level / turn-goal state machine\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user