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:
swipelab
2026-06-05 08:25:08 +03:00
parent 7e82c34a1f
commit a40a994ae1
4 changed files with 411 additions and 0 deletions

140
board.sx
View File

@@ -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 FisherYates 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 {
// FisherYates 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 }
}