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.
|
||||
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 {
|
||||
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
|
||||
cells: [BOARD_CELLS]Gem;
|
||||
@@ -111,6 +118,11 @@ Board :: struct {
|
||||
moves_made: 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 {
|
||||
row * BOARD_COLS + col
|
||||
}
|
||||
@@ -142,6 +154,7 @@ Board :: struct {
|
||||
self.score = 0;
|
||||
self.moves_made = 0;
|
||||
self.move_limit = DEFAULT_MOVE_LIMIT;
|
||||
self.target_score = DEFAULT_TARGET_SCORE;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
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;
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user