From a40a994ae1a55a10dc33c974e92ae4e111fa4641 Mon Sep 17 00:00:00 2001 From: swipelab Date: Fri, 5 Jun 2026 08:25:08 +0300 Subject: [PATCH] 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). --- board.sx | 140 ++++++++++++++++++++++++++ tests/expected/level.exit | 1 + tests/expected/level.stdout | 74 ++++++++++++++ tests/level.sx | 196 ++++++++++++++++++++++++++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 tests/expected/level.exit create mode 100644 tests/expected/level.stdout create mode 100644 tests/level.sx diff --git a/board.sx b/board.sx index 049df13..0aa8eb2 100644 --- a/board.sx +++ b/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 } +} diff --git a/tests/expected/level.exit b/tests/expected/level.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/level.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/level.stdout b/tests/expected/level.stdout new file mode 100644 index 0000000..7a402e3 --- /dev/null +++ b/tests/expected/level.stdout @@ -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 diff --git a/tests/level.sx b/tests/level.sx new file mode 100644 index 0000000..b415e5d --- /dev/null +++ b/tests/level.sx @@ -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; +}