diff --git a/board.sx b/board.sx index 975ab60..049df13 100644 --- a/board.sx +++ b/board.sx @@ -77,6 +77,13 @@ BOARD_COLS :: 8; BOARD_ROWS :: 8; BOARD_CELLS :: BOARD_COLS * BOARD_ROWS; +// Default per-game move budget (P3.3). `init` seeds `Board.move_limit` with +// this; `moves_remaining` counts down from it as committed swaps spend moves. +// The turn/goal loop (P7) owns enforcing the budget — the model only TRACKS it, +// so `moves_remaining` may legitimately reach 0 (or below, if a caller keeps +// committing) without `commit_swap` refusing. +DEFAULT_MOVE_LIMIT :: 30; + Board :: struct { // Row-major: cell (col, row) lives at row*BOARD_COLS + col. cells: [BOARD_CELLS]Gem; @@ -94,10 +101,28 @@ Board :: struct { // field. A hand-built board must zero this before accumulating. score: s64; + // Turn accounting (P3.3). `moves_made` counts the swaps actually COMMITTED — + // only a legal swap (one that resolved into >=1 match) via `commit_swap` + // increments it; an illegal, reverted swap does not. `move_limit` is the + // game's move budget (`init` sets it to DEFAULT_MOVE_LIMIT); `moves_remaining` + // is derived from the two, so there is a single source of truth and the + // counters can never drift apart. A hand-built board must set both before + // committing swaps. + moves_made: s64; + move_limit: s64; + idx :: (col: s64, row: s64) -> s64 { row * BOARD_COLS + col } + // Moves still available against the budget: `move_limit - moves_made`. Goes + // to 0 when the budget is spent (and below it only if a caller keeps + // committing past the budget — see DEFAULT_MOVE_LIMIT). The turn/goal loop + // (P7) reads this to decide when the game ends. + moves_remaining :: (self: *Board) -> s64 { + self.move_limit - self.moves_made + } + at :: (self: *Board, col: s64, row: s64) -> Gem { self.cells[Board.idx(col, row)] } @@ -115,6 +140,8 @@ Board :: struct { init :: (self: *Board, seed: s64) { self.rng = rng_seeded(seed); self.score = 0; + self.moves_made = 0; + self.move_limit = DEFAULT_MOVE_LIMIT; for 0..BOARD_ROWS: (row) { for 0..BOARD_COLS: (col) { self.set(col, row, pick_gem(self, @self.rng, col, row)); @@ -478,10 +505,27 @@ refill :: (board: *Board) -> s64 { // `Board.score`: the sum over rounds of `score_round * combo_multiplier(round)` // (P3.2), so the HUD (P4.4) and turn accounting (P3.3) can read a swap's payout // without re-deriving it. A depth-0 (already-stable) board awards 0. +// +// `len4` / `len5_plus` tally the special-match runs cleared across the WHOLE +// settle (summed over rounds): the number of maximal runs of length exactly 4, +// and of length 5 or more (P3.3 special-match flagging). They are a HOOK for +// future special gems — nothing here creates or alters a gem; the tallies only +// make "did this settle clear a 4 / 5+ run" observable. `had_len4` / +// `had_len5_plus` are the boolean view of the same counts. Cascade :: struct { depth: s64; cleared: List(s64); awarded: s64; + len4: s64; + len5_plus: s64; + + had_len4 :: (self: *Cascade) -> bool { + self.len4 > 0 + } + + had_len5_plus :: (self: *Cascade) -> bool { + self.len5_plus > 0 + } } // One resolution round: detect matches and, if any, clear them, collapse under @@ -503,11 +547,13 @@ resolve_step :: (board: *Board) -> s64 { // Each round adds `score_round * combo_multiplier(round)` (round 1-based) to // `Board.score`; an already-stable board returns depth 0, awards 0, untouched. resolve :: (board: *Board) -> Cascade { - result := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0 }; + result := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 }; while true { - // Read the round's base points while its runs are still on the board: - // `resolve_step` clears them, so the score has to be taken first. + // Read the round's base points AND its special-match tally while the runs + // are still on the board: `resolve_step` clears them, so both have to be + // taken first. A no-match round scores 0 and tallies nothing, then breaks. base := score_round(board); + sp := count_specials(board); n := resolve_step(board); if n == 0 { break; } result.depth += 1; @@ -515,6 +561,8 @@ resolve :: (board: *Board) -> Cascade { board.score += points; result.awarded += points; result.cleared.append(n); + result.len4 += sp.len4; + result.len5_plus += sp.len5_plus; } result } @@ -637,6 +685,43 @@ combo_multiplier :: (round: s64) -> s64 { round } +// ── Special-match flagging (P3.3) ────────────────────────────────────────── +// A HOOK for future special gems: surface, per detection round, how many of the +// board's maximal runs are long enough to (later) spawn one — a run of length +// exactly 4, and a run of length 5 or more. This is detection ONLY: nothing here +// creates, marks, or alters a gem; later work reads these counts to decide what +// special gem (if any) a clear produces. Length 3 runs are ordinary and counted +// by neither tier. + +// Per-round tally of special-length runs: `len4` is the number of maximal runs +// of length exactly 4, `len5_plus` the number of length 5 or more. Boolean +// "did any occur" lives on `Cascade` (`had_len4` / `had_len5_plus`) for the +// whole settle; a single round reads these counts directly. +SpecialCounts :: struct { + len4: s64; + len5_plus: s64; +} + +// Count the board's currently-matched runs that hit a special length, by the +// same `find_runs` enumeration scoring uses (so an L/T's horizontal and vertical +// runs are counted independently by their own lengths). Pure and read-only — it +// inspects the board but changes nothing, so it must be called BEFORE the round's +// clear, while the runs are still present. A board with no run (or only length-3 +// runs) tallies zero in both tiers. +count_specials :: (board: *Board) -> SpecialCounts { + runs := find_runs(board); + counts := SpecialCounts.{ len4 = 0, len5_plus = 0 }; + for 0..runs.len: (i) { + len := runs.items[i].len; + if len == 4 { + counts.len4 += 1; + } else if len >= 5 { + counts.len5_plus += 1; + } + } + counts +} + // Deterministic textual dump of an enumerated run list, in `find_runs` order: a // count header, then one run per line as ` len at fixed start ` // where axis is H (horizontal) or V (vertical). An empty list dumps as just @@ -650,3 +735,42 @@ dump_runs :: (runs: *List(Run)) -> string { } result } + +// ── Player move (P3.3) ───────────────────────────────────────────────────── +// The single model-level entry point a player's swap goes through — what the +// swipe input (P5) and turn/goal loop (P7) will call. It ties legality, the +// swap, the cascade settle, and turn accounting together so callers don't +// re-implement the sequence. + +// Outcome of attempting one player swap via `commit_swap`. `legal` says whether +// the swap resolved into at least one match and was therefore COMMITTED: when +// false the board is untouched, no move was spent, and `cascade` is the empty +// (depth-0) settle; when true the swap was applied, the board resolved (scoring +// accrued into `Board.score`) and exactly one move was spent. `cascade` carries +// the settle's full outcome — depth, per-round cleared counts, awarded points, +// and the special-match (len4 / len5+) tallies. `moves_remaining` snapshots the +// board's remaining budget AFTER the move, so a caller has it without re-reading. +PlayerMove :: struct { + legal: bool; + cascade: Cascade; + moves_remaining: s64; +} + +// Attempt the player's intended swap of two adjacent cells. If the swap is legal +// (`swap_legal`: adjacent AND it forms a match), apply it, `resolve` the cascade +// — which accrues score into `Board.score` and reports the awarded points and +// special-match flags — then spend one move (`moves_made += 1`). If it is illegal +// (non-adjacent, or forms no match) the board is left exactly as it was — no swap, +// no resolve, no move spent — and an empty depth-0 cascade is returned. Move +// accounting only TRACKS the budget; it does not refuse a swap when the budget is +// spent (that is the P7 turn-loop's call) — see DEFAULT_MOVE_LIMIT. +commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove { + if !swap_legal(board, a, b) { + empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 }; + return PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() }; + } + swap(board, a, b); + cascade := resolve(board); + board.moves_made += 1; + PlayerMove.{ legal = true, cascade = cascade, moves_remaining = board.moves_remaining() } +} diff --git a/tests/expected/turn.exit b/tests/expected/turn.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/turn.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/turn.stdout b/tests/expected/turn.stdout new file mode 100644 index 0000000..eb81416 --- /dev/null +++ b/tests/expected/turn.stdout @@ -0,0 +1,98 @@ +== turn (accounting + special-match flagging) == +== flag-len3-none == +OGOGOGOG +GOGOGOGO +OGOGOGOG +GORRROGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +len4 0 len5_plus 0 +had_len4 false had_len5_plus false +== flag-len4 == +OGOGOGOG +GOGOGOGO +OGOGOGOG +GORRRRGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +len4 1 len5_plus 0 +had_len4 true had_len5_plus false +== flag-len5 == +OGOGOGOG +GRRRRRGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +len4 0 len5_plus 1 +had_len4 false had_len5_plus true +== flag-len4-and-len5 == +RRRGOGOG +GOGOGOGO +GOBBBBOG +OGOGOGOG +GPPPPPGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +len4 1 len5_plus 1 +had_len4 true had_len5_plus true +== commit-legal-len3 == +RROGOGOG +GGROGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +before: score 0 moves_made 0 moves_remaining 5 +after: legal true depth 1 awarded 30 len4 0 len5_plus 0 +after: score 30 moves_made 1 moves_remaining 4 +RBRGOGOG +GGOOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +== commit-illegal == +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +before: score 0 moves_made 0 moves_remaining 5 +after: legal false depth 0 awarded 0 len4 0 len5_plus 0 +after: score 0 moves_made 0 moves_remaining 5 +== commit-legal-len4 == +RROROGOG +GGROGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +after: legal true depth 1 awarded 60 len4 1 len5_plus 0 +after: had_len4 true had_len5_plus false +after: score 60 moves_made 1 moves_remaining 4 +RBRROGOG +GGOOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +ok: turn accounting + special-match flagging diff --git a/tests/turn.sx b/tests/turn.sx new file mode 100644 index 0000000..1d6af04 --- /dev/null +++ b/tests/turn.sx @@ -0,0 +1,216 @@ +// Turn accounting + special-match flagging golden (P3.3). +// +// Two things are proven over crafted, deterministic boards: +// +// 1. Special-match FLAGGING (pure, no RNG): `count_specials` tallies a single +// detection round's maximal runs by special length — runs of length exactly +// 4 (`len4`) and of length 5 or more (`len5_plus`). A round with only +// length-3 runs tallies neither; the tiers are independent and length-3 runs +// never count. This is a HOOK for future special gems — detection only. +// +// 2. Turn ACCOUNTING via `commit_swap`, the single player-move entry point P5 +// (input) and P7 (turn loop) will call. A legal swap (one that forms a +// match) is committed — applied, resolved (scoring accrues), and it spends +// exactly one move; the returned `PlayerMove` reports the settle's payout +// and special flags. An illegal swap is rejected — board reverted, no move +// spent, no score change. +// +// Every counter/flag is asserted independently of the dump AND printed so the +// golden is self-explanatory and deterministic. +#import "modules/std.sx"; +#import "board.sx"; +t :: #import "test.sx"; + +SEED :: 7; +LIMIT :: 5; + +// 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, and the turn counters reset to a fresh game +// (no moves made, the given move budget). +load_board :: (rows: []string, seed: s64, move_limit: 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 +} + +boards_equal :: (a: *Board, b: *Board) -> bool { + for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } } + true +} + +// One flag scene: snapshot the board, then count its single round's special +// runs and assert the tallies (and the boolean flags derived from them) are +// exactly the documented values. No RNG, no clear — pure detection. +flag_scene :: (name: string, rows: []string, want_len4: s64, want_len5_plus: s64) { + print("== {} ==\n", name); + b := load_board(rows, 0, LIMIT); + out(board_dump(@b)); + sp := count_specials(@b); + print("len4 {} len5_plus {}\n", sp.len4, sp.len5_plus); + print("had_len4 {} had_len5_plus {}\n", sp.len4 > 0, sp.len5_plus > 0); + t.expect(sp.len4 == want_len4, concat(name, ": len4 count exact")); + t.expect(sp.len5_plus == want_len5_plus, concat(name, ": len5_plus count exact")); +} + +main :: () -> s32 { + print("== turn (accounting + special-match flagging) ==\n"); + + // ── Special-match flagging (single round, no RNG) ────────────────────── + // Only length-3 runs: a lone horizontal RRR -> neither tier flags. + flag_scene("flag-len3-none", .[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GORRROGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 0, 0); + + // A length-4 run -> len4 flags, len5_plus does not. + flag_scene("flag-len4", .[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GORRRRGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 1, 0); + + // A length-5 run -> len5_plus flags, len4 does not. + flag_scene("flag-len5", .[ + "OGOGOGOG", + "GRRRRRGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 0, 1); + + // A length-3, a length-4 and a length-5 run together: the length-3 is ignored + // and the two special tiers each count their own run -> len4 1, len5_plus 1. + flag_scene("flag-len4-and-len5", .[ + "RRRGOGOG", + "GOGOGOGO", + "GOBBBBOG", + "OGOGOGOG", + "GPPPPPGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + ], 1, 1); + + // ── Turn accounting via commit_swap ──────────────────────────────────── + // A legal swap that forms a length-3 run. Swapping (2,0)<->(2,1) drops a red + // into row 0 to complete RRR at cols 0-2 (the donor row 1 is shaped so the + // displaced gem makes no second run). The swap is committed: applied, + // resolved (score accrues), and it spends exactly one move. + print("== commit-legal-len3 ==\n"); + bl := load_board(.[ + "RROGOGOG", + "GGROGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], SEED, LIMIT); + out(board_dump(@bl)); + score_before := bl.score; + made_before := bl.moves_made; + rem_before := bl.moves_remaining(); + print("before: score {} moves_made {} moves_remaining {}\n", score_before, made_before, rem_before); + mv := commit_swap(@bl, Cell.{ col = 2, row = 0 }, Cell.{ col = 2, row = 1 }); + print("after: legal {} depth {} awarded {} len4 {} len5_plus {}\n", + mv.legal, mv.cascade.depth, mv.cascade.awarded, mv.cascade.len4, mv.cascade.len5_plus); + print("after: score {} moves_made {} moves_remaining {}\n", bl.score, bl.moves_made, mv.moves_remaining); + out(board_dump(@bl)); + t.expect(mv.legal, "legal-len3: swap committed"); + t.expect(bl.moves_made == made_before + 1, "legal-len3: moves_made +1"); + t.expect(mv.moves_remaining == rem_before - 1, "legal-len3: reported moves_remaining -1"); + t.expect(bl.moves_remaining() == rem_before - 1, "legal-len3: board moves_remaining -1"); + t.expect(bl.score > score_before, "legal-len3: score accrued"); + t.expect(mv.cascade.awarded == bl.score - score_before, "legal-len3: awarded equals score delta"); + t.expect(mv.cascade.depth >= 1, "legal-len3: settled at least one round"); + + // An illegal swap on a run-free checkerboard: adjacent but forms no match. + // It must be rejected outright — board reverted, no move spent, no score. + print("== commit-illegal ==\n"); + bi := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], SEED, LIMIT); + before := bi; + out(board_dump(@bi)); + print("before: score {} moves_made {} moves_remaining {}\n", bi.score, bi.moves_made, bi.moves_remaining()); + miv := commit_swap(@bi, Cell.{ col = 0, row = 0 }, Cell.{ col = 1, row = 0 }); + print("after: legal {} depth {} awarded {} len4 {} len5_plus {}\n", + miv.legal, miv.cascade.depth, miv.cascade.awarded, miv.cascade.len4, miv.cascade.len5_plus); + print("after: score {} moves_made {} moves_remaining {}\n", bi.score, bi.moves_made, miv.moves_remaining); + t.expect(!miv.legal, "illegal: swap rejected"); + t.expect(miv.cascade.depth == 0, "illegal: no cascade"); + t.expect(miv.cascade.awarded == 0, "illegal: no points"); + t.expect(bi.moves_made == 0, "illegal: no move spent"); + t.expect(miv.moves_remaining == LIMIT, "illegal: full budget remains"); + t.expect(bi.score == 0, "illegal: score unchanged"); + t.expect(boards_equal(@before, @bi), "illegal: board reverted"); + + // A legal swap that forms a length-4 run (RRRR at cols 0-3): proves the + // special-match flag rides through the whole settle onto the PlayerMove. + print("== commit-legal-len4 ==\n"); + b4 := load_board(.[ + "RROROGOG", + "GGROGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], SEED, LIMIT); + out(board_dump(@b4)); + m4 := commit_swap(@b4, Cell.{ col = 2, row = 0 }, Cell.{ col = 2, row = 1 }); + print("after: legal {} depth {} awarded {} len4 {} len5_plus {}\n", + m4.legal, m4.cascade.depth, m4.cascade.awarded, m4.cascade.len4, m4.cascade.len5_plus); + print("after: had_len4 {} had_len5_plus {}\n", m4.cascade.had_len4(), m4.cascade.had_len5_plus()); + print("after: score {} moves_made {} moves_remaining {}\n", b4.score, b4.moves_made, m4.moves_remaining); + out(board_dump(@b4)); + t.expect(m4.legal, "legal-len4: swap committed"); + t.expect(m4.cascade.had_len4(), "legal-len4: settle flags a length-4 run"); + t.expect(b4.moves_made == 1, "legal-len4: one move spent"); + t.expect(b4.score > 0, "legal-len4: score accrued"); + + print("ok: turn accounting + special-match flagging\n"); + return 0; +}