Files
m3te/board.sx
swipelab 6f7d2f4db2 lang migration: rename signed integer types sN -> iN
Mechanical sweep of all .sx sources, plan docs, and tests/expected
snapshots for the sx language rename (s8/s16/s32/s64 -> i8/i16/i32/i64).
Verified: tools/run_tests.sh 23/23.

Note: the ios-sim build has 2 pre-existing 'restart' dot-call errors
from the sx opt-in UFCS change (sx a47ea14) — independent of this
rename (present pre-sweep); migrated in the follow-up commit.
2026-06-12 09:36:51 +03:00

941 lines
41 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// m3te core model — pure, headless match-3 board (Phase 1).
//
// Everything here is deterministic and rendering-free: a fixed seed always
// produces the same board. Later phases build on these primitives —
// P1.2 (match detection), P1.3 (swap legality), P2 (clear/cascade/refill) —
// so the layout favours plain index access (`at` / `idx`) over anything
// rendering-specific.
#import "modules/std.sx";
// ── Gem ──────────────────────────────────────────────────────────────────
// Six distinct gem types plus an `empty` hole sentinel. The ordinal of a real
// gem (0..5) IS its gem index, so it casts cleanly to/from the integers the RNG
// and the textual dump work in; `empty` (ordinal 6) sits outside that range and
// is never drawn by the RNG.
GEM_COUNT :: 6;
Gem :: enum {
red;
orange;
yellow;
green;
blue;
purple;
// A hole: a cell with no gem, left behind when a match is cleared (P2.1).
// Distinct from all six gem types and never produced by init/pick_gem
// (which only draw ordinals 0..GEM_COUNT), so gravity/refill (P2.2/P2.3) can
// test a cell for `== .empty` to find holes. Outside GEM_CHARS, so it dumps
// via the dedicated EMPTY_CHAR rather than the gem alphabet.
empty;
}
// One stable character per gem type, indexed by ordinal — the alphabet the
// board dump (and its golden) is written in.
GEM_CHARS :: "ROYGBP";
// Hole glyph for the board dump: an empty cell renders as this instead of a gem
// character. Distinct from every gem in GEM_CHARS.
EMPTY_CHAR :: 46; // '.'
gem_char :: (g: Gem) -> u8 {
if g == .empty { return EMPTY_CHAR; }
GEM_CHARS[cast(i64) g]
}
// ── Deterministic RNG ─────────────────────────────────────────────────────
// A 32-bit linear congruential generator (Numerical Recipes constants),
// carried in an i64 and masked back to 32 bits after every step so the
// stream is identical regardless of host integer width. The state*MUL+ADD
// product stays well under i64 range, so no intermediate overflow. Any seed
// (including 0) yields a valid stream — an LCG has no forbidden state.
RNG_MASK32 :: 0xFFFFFFFF;
RNG_MUL :: 1664525;
RNG_ADD :: 1013904223;
Rng :: struct {
state: i64;
// Advance and return the next 32-bit value.
next_u32 :: (self: *Rng) -> i64 {
self.state = (self.state * RNG_MUL + RNG_ADD) & RNG_MASK32;
self.state
}
// Uniform-ish value in [0, n). Uses the high bits, whose period is far
// longer than the low bits of an LCG.
next_range :: (self: *Rng, n: i64) -> i64 {
(self.next_u32() >> 16) % n
}
}
rng_seeded :: (seed: i64) -> Rng {
Rng.{ state = seed & RNG_MASK32 }
}
// ── Board ─────────────────────────────────────────────────────────────────
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;
// 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;
// The board's own deterministic RNG. `init` seeds it, then every later draw
// — refill (P2.3) and the cascade beyond — advances THIS state, so the whole
// gem stream for a seed is reproducible and successive refills continue the
// sequence instead of reseeding. A hand-built board (one made without `init`)
// must seed this before any draw.
rng: Rng;
// Running score total. `init` zeroes it; `add_round_score` adds a single
// round's base points (see `score_round`), and `resolve` adds each cascade
// round's base scaled by `combo_multiplier` (P3.2). The HUD (P4.4) reads this
// field. A hand-built board must zero this before accumulating.
score: i64;
// 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: i64;
move_limit: i64;
// 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: i64;
idx :: (col: i64, row: i64) -> i64 {
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) -> i64 {
self.move_limit - self.moves_made
}
at :: (self: *Board, col: i64, row: i64) -> Gem {
self.cells[Board.idx(col, row)]
}
set :: (self: *Board, col: i64, row: i64, g: Gem) {
self.cells[Board.idx(col, row)] = g;
}
// Fill every cell from `seed` so that NO horizontal or vertical run of
// three same-type gems exists. Cells are placed in row-major order; when
// placing one, any gem type that would complete a 3-in-a-row with the two
// already-placed cells to its left or above is excluded, and the gem is
// drawn from the remaining allowed types. At most two types are ever
// excluded, so a choice always remains.
init :: (self: *Board, seed: i64) {
self.rng = rng_seeded(seed);
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));
}
}
}
}
// Choose a gem for (col, row) that can't extend an existing run leftward or
// upward. Pure given the board's already-placed prefix and the RNG state.
pick_gem :: (board: *Board, rng: *Rng, col: i64, row: i64) -> Gem {
forbidden : [GEM_COUNT]bool = ---;
for 0..GEM_COUNT (t) { forbidden[t] = false; }
// Two same gems immediately to the left → a third of that type matches.
if col >= 2 {
left := board.at(col - 1, row);
if left == board.at(col - 2, row) {
forbidden[cast(i64) left] = true;
}
}
// Two same gems immediately above → a third of that type matches.
if row >= 2 {
up := board.at(col, row - 1);
if up == board.at(col, row - 2) {
forbidden[cast(i64) up] = true;
}
}
allowed := 0;
for 0..GEM_COUNT (t) { if !forbidden[t] { allowed += 1; } }
// Pick the k-th still-allowed type; single RNG draw, always terminates.
k := rng.next_range(allowed);
for 0..GEM_COUNT (t) {
if !forbidden[t] {
if k == 0 { return cast(Gem) t; }
k -= 1;
}
}
.red // unreachable: `allowed` >= GEM_COUNT-2 >= 4, so k is always consumed
}
// Deterministic textual dump: one row per line, top (row 0) to bottom, a
// single gem character per cell. Suitable for snapshotting.
board_dump :: (self: *Board) -> string {
line_w := BOARD_COLS + 1; // 8 gem chars + newline
buf := cstring(BOARD_ROWS * line_w);
for 0..BOARD_ROWS (row) {
base := row * line_w;
for 0..BOARD_COLS (col) {
buf[base + col] = gem_char(self.at(col, row));
}
buf[base + BOARD_COLS] = 10; // '\n'
}
buf
}
// ── Match detection ────────────────────────────────────────────────────────
// Per-cell membership over the board: cell (col, row) is `true` iff it takes
// part in some horizontal or vertical run of three or more same-type gems.
// This mask IS the matched-cell SET — overlapping shapes (an L or a T where a
// horizontal and a vertical run share a cell) collapse to a single `true`, so
// the union is automatic. The layout mirrors Board.cells exactly so the
// clear/cascade phase can consume it without translation.
MatchMask :: struct {
cells: [BOARD_CELLS]bool;
at :: (self: *MatchMask, col: i64, row: i64) -> bool {
self.cells[Board.idx(col, row)]
}
count :: (self: *MatchMask) -> i64 {
n : i64 = 0;
for 0..BOARD_CELLS (i) { if self.cells[i] { n += 1; } }
n
}
}
// Mark a closed span of cells along one axis. `vertical` picks the axis; `fixed`
// is the constant coordinate (the row for a horizontal span, the column for a
// vertical one) and the span covers `start..end` of the moving coordinate.
mark_run :: (m: *MatchMask, vertical: bool, fixed: i64, start: i64, end: i64) {
for start..end (i) {
if vertical {
m.cells[Board.idx(fixed, i)] = true;
} else {
m.cells[Board.idx(i, fixed)] = true;
}
}
}
// Detect every maximal horizontal and vertical run of length >= 3 and mark all
// participating cells. Each row and column is scanned once, extending a run
// while the gem type holds; a maximal run of length >= 3 marks its whole span,
// so length-4 / length-5 runs are simply longer spans of the same walk. A cell
// shared by an intersecting horizontal and vertical run is marked once per
// axis into the same slot — idempotent, so the union counts it once.
//
// Only runs of an actual gem match: `.empty` holes are never matchable, so a
// line of 3+ holes (left behind by a prior clear) is not a match. Holes also
// break runs of real gems, since a hole differs from every gem type.
find_matches :: (b: *Board) -> MatchMask {
m : MatchMask = ---;
for 0..BOARD_CELLS (i) { m.cells[i] = false; }
// Horizontal: walk each row left-to-right in maximal same-type spans.
for 0..BOARD_ROWS (row) {
col := 0;
while col < BOARD_COLS {
g := b.at(col, row);
run_end := col + 1;
while run_end < BOARD_COLS and b.at(run_end, row) == g {
run_end += 1;
}
if g != .empty and run_end - col >= 3 { mark_run(@m, false, row, col, run_end); }
col = run_end;
}
}
// Vertical: walk each column top-to-bottom in maximal same-type spans.
for 0..BOARD_COLS (col) {
row := 0;
while row < BOARD_ROWS {
g := b.at(col, row);
run_end := row + 1;
while run_end < BOARD_ROWS and b.at(col, run_end) == g {
run_end += 1;
}
if g != .empty and run_end - row >= 3 { mark_run(@m, true, col, row, run_end); }
row = run_end;
}
}
m
}
// Deterministic textual dump of a matched-cell SET, in the same row-major grid
// shape as `board_dump`: a matched cell shows its gem character, an unmatched
// cell shows '.'. A board with no matches dumps as an all-'.' grid, which reads
// unambiguously as the empty set. Suitable for snapshotting.
dump_matches :: (b: *Board, m: *MatchMask) -> string {
line_w := BOARD_COLS + 1; // 8 cells + newline
buf := cstring(BOARD_ROWS * line_w);
for 0..BOARD_ROWS (row) {
base := row * line_w;
for 0..BOARD_COLS (col) {
if m.at(col, row) {
buf[base + col] = gem_char(b.at(col, row));
} else {
buf[base + col] = 46; // '.'
}
}
buf[base + BOARD_COLS] = 10; // '\n'
}
buf
}
// ── Swap & legality ──────────────────────────────────────────────────────────
// A board cell address. Kept separate from the row-major index so swap callers
// and the move enumeration speak in (col, row) like the rest of the model.
Cell :: struct {
col: i64;
row: i64;
}
// Exchange the gems of two cells, in place. `swap` is its own inverse: calling
// it again with the same two cells restores the board, so a caller can trial a
// swap, inspect the result, then swap back to revert.
swap :: (board: *Board, a: Cell, b: Cell) {
ai := Board.idx(a.col, a.row);
bi := Board.idx(b.col, b.row);
tmp := board.cells[ai];
board.cells[ai] = board.cells[bi];
board.cells[bi] = tmp;
}
// Two cells are orthogonally adjacent iff they differ by exactly one step along
// a single axis. The same cell, a diagonal, or any longer gap is not adjacent.
adjacent :: (a: Cell, b: Cell) -> bool {
if a.row == b.row { return a.col == b.col + 1 or a.col == b.col - 1; }
if a.col == b.col { return a.row == b.row + 1 or a.row == b.row - 1; }
false
}
// Legality of swapping two cells: legal iff they are orthogonally adjacent AND,
// after the swap, at least one of the two swapped cells takes part in a 3+ match
// (via `find_matches`). A swap that only completes a run for the OTHER moved gem
// still counts — either swapped position participating is enough. Non-adjacent
// or diagonal pairs are rejected outright, before any match check. The board is
// left UNCHANGED: the trial swap is reverted before returning.
swap_legal :: (board: *Board, a: Cell, b: Cell) -> bool {
if !adjacent(a, b) { return false; }
swap(board, a, b);
m := find_matches(board);
legal := m.at(a.col, a.row) or m.at(b.col, b.row);
swap(board, a, b); // revert the trial swap
legal
}
// One legal move: an unordered pair of adjacent cells. By construction `a` is
// the top-left cell of the pair and `b` is its right (same row) or down (same
// col) neighbour, so each adjacency is represented once — never as both (a, b)
// and (b, a).
Swap :: struct {
a: Cell;
b: Cell;
}
// Enumerate every currently-legal swap in a stable order: row-major over the
// top-left cell of each pair, and for each cell its right neighbour before its
// down neighbour. This visits each orthogonal adjacency exactly once. The order
// is fixed (independent of board contents), so later hint / no-moves logic and
// the snapshot can depend on it.
legal_swaps :: (board: *Board) -> List(Swap) {
result := List(Swap).{};
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) {
result.append(Swap.{ a = here, b = right });
}
}
if row + 1 < BOARD_ROWS {
down := Cell.{ col = col, row = row + 1 };
if swap_legal(board, here, down) {
result.append(Swap.{ a = here, b = down });
}
}
}
}
result
}
// Deterministic textual dump of an enumerated swap list, in list order: a count
// header, then one swap per line as its unordered cell pair `(col,row)-(col,row)`
// with the canonical top-left cell first. An empty list (no legal moves) dumps
// as just "0 legal swaps", which reads unambiguously. Suitable for snapshotting.
dump_swaps :: (swaps: *List(Swap)) -> string {
result := format("{} legal swaps\n", swaps.len);
for 0..swaps.len (i) {
s := swaps.items[i];
result = concat(result, format("({},{})-({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row));
}
result
}
// ── Clear (P2.1) ─────────────────────────────────────────────────────────────
// First step of the resolution pipeline: turn matched cells into holes. No
// gravity or refill here (P2.2 / P2.3) — clearing only writes `.empty` into the
// matched cells and leaves every other cell exactly as it was.
// Set every cell flagged in `mask` to a hole, leaving all unflagged cells
// unchanged. Returns the number of cells cleared. `mask` is the matched-cell SET
// from find_matches, so overlapping L/T shapes (already unioned into a single
// `true` per shared cell) clear as one set with no double-counting.
clear_cells :: (board: *Board, mask: *MatchMask) -> i64 {
cleared : i64 = 0;
for 0..BOARD_CELLS (i) {
if mask.cells[i] {
board.cells[i] = .empty;
cleared += 1;
}
}
cleared
}
// Detect matches on `board` and clear them in one step. Returns the number of
// cells cleared — 0 when there are no matches, in which case the board is left
// unchanged. The count drives later cascade/scoring (P2.2+): a non-zero result
// means the board changed and the resolution loop should continue.
clear_matches :: (board: *Board) -> i64 {
m := find_matches(board);
clear_cells(board, @m)
}
// ── Gravity / collapse (P2.2) ─────────────────────────────────────────────────
// Second step of the resolution pipeline: let the gems fall into the holes a
// clear left behind. Within EACH column independently, every gem slides straight
// down past any holes below it, and the holes bubble to the TOP of the column
// (the smaller row index, since row 0 is the top of the dump). Columns never
// exchange gems — there is no horizontal movement. The surviving gems keep their
// original top-to-bottom order, now packed contiguously at the bottom with all
// holes contiguous above them. Refilling the freed top holes with fresh gems is
// P2.3; this step only moves what is already on the board.
//
// Returns true iff at least one gem changed row (i.e. some hole had a gem above
// it). A column that is already settled — or all holes, or all gems — moves
// nothing, so a fully-settled board returns false; the cascade loop (P2.4) reads
// this to know when gravity has stopped.
collapse :: (board: *Board) -> bool {
moved := false;
for 0..BOARD_COLS (col) {
// Pack this column's gems toward the bottom: scan it bottom-to-top and
// write each gem at the falling cursor `w`, which also descends from the
// bottom. A gem whose source row differs from `w` actually fell. `w`
// never overtakes the read cursor, so writes only land on rows already
// read — safe to pack in place.
w := BOARD_ROWS - 1;
r := BOARD_ROWS - 1;
while r >= 0 {
g := board.at(col, r);
if g != .empty {
if r != w { moved = true; }
board.set(col, w, g);
w -= 1;
}
r -= 1;
}
// Every row above the packed gems is now a hole.
fill := 0;
while fill <= w {
board.set(col, fill, .empty);
fill += 1;
}
}
moved
}
// ── Refill (P2.3) ──────────────────────────────────────────────────────────────
// Final step of the resolution pipeline: drop a fresh gem into every hole. Each
// `.empty` cell is replaced by a gem drawn from the board's OWN seeded RNG, so a
// given seed always produces the same refill and successive refills continue the
// stream rather than repeating — the state threads through `init`, clears and
// prior refills, never reseeding. Holes are filled wherever they sit, in
// row-major order, so refill does not assume `collapse` ran first.
//
// Unlike `init`, refill makes NO attempt to avoid matches: a refilled gem may
// complete a new run, which is exactly what drives the P2.4 cascade. `next_range`
// only ever yields ordinals 0..GEM_COUNT, so a hole is never refilled with
// `.empty`; afterwards the board has no holes left. Returns the number of cells
// filled (0 on a board that had none).
refill :: (board: *Board) -> i64 {
rng := @board.rng;
filled : i64 = 0;
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
if board.at(col, row) == .empty {
board.set(col, row, cast(Gem) rng.next_range(GEM_COUNT));
filled += 1;
}
}
}
filled
}
// ── Cascade resolution (P2.4) ──────────────────────────────────────────────
// The settle loop a swap triggers: keep resolving matches until the board is
// stable. One round is detect → clear → collapse → refill; the loop repeats
// while a round still finds a match. Gravity can align falling survivors into a
// fresh run and a seeded refill can complete one, so a single clear chains into
// more — the cascade. Termination is reached the first round that detects no
// match; for a fixed seed the whole sequence is deterministic.
// Outcome of resolving a board to a stable state. `depth` is the number of
// rounds that found and cleared at least one match (0 for an already-stable
// board). `cleared` holds those rounds' cleared-cell counts in round order, so
// `cleared.len == depth`. `awarded` is the total points this settle added to
// `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: i64;
cleared: List(i64);
awarded: i64;
len4: i64;
len5_plus: i64;
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
// gravity, then refill the holes from the board's seeded RNG. Returns the
// number of cells cleared this round — 0 iff the board was already stable, in
// which case nothing moves and no gem is drawn. `resolve` repeats this until it
// returns 0.
resolve_step :: (board: *Board) -> i64 {
cleared := clear_matches(board);
if cleared == 0 { return 0; }
collapse(board);
refill(board);
cleared
}
// Resolve the board to a stable state, running rounds until one finds no match,
// scoring each round with the cascade combo multiplier (P3.2). Returns the
// cascade: its depth, per-round cleared-cell counts, and total `awarded` points.
// 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(i64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
while true {
// 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;
points := base * combo_multiplier(result.depth);
board.score += points;
result.awarded += points;
result.cleared.append(n);
result.len4 += sp.len4;
result.len5_plus += sp.len5_plus;
}
result
}
// ── Scoring (P3.1) ───────────────────────────────────────────────────────────
// Base match scoring: value a round's clears purely by RUN LENGTH — longer runs
// are worth more. The scheme is fixed and documented by these named constants:
// a maximal run of length 3 → 30, length 4 → 60, length 5 or more → 100.
//
// Scoring needs each maximal run's LENGTH, not just the unioned matched-cell set
// (`find_matches`/`MatchMask`, which collapses overlaps to a single `true`). So
// this is a separate enumeration path — `find_matches` and every clear/cascade
// caller are untouched. L/T rule: each maximal run is scored INDEPENDENTLY by
// its own length, so an L (a horizontal run meeting a vertical run at a shared
// corner) scores horizontal + vertical — the corner counts toward both runs'
// lengths, unlike the cleared-cell set which unions it once.
//
// One round only: the cross-round combo MULTIPLIER is `combo_multiplier` (P3.2),
// applied by `resolve`; this base scheme is unscaled.
SCORE_RUN_3 :: 30;
SCORE_RUN_4 :: 60;
SCORE_RUN_5_PLUS :: 100;
// One maximal same-type run of length >= 3. `vertical` picks the axis; `fixed`
// is the constant coordinate (the row for a horizontal run, the column for a
// vertical one) and the run covers `start..start+len` of the moving coordinate.
Run :: struct {
vertical: bool;
fixed: i64;
start: i64;
len: i64;
}
// Base points for a single maximal run, by length. Runs are always length >= 3
// (shorter spans are not enumerated), so 3 is the floor; 5 and longer all score
// the top tier.
run_score :: (len: i64) -> i64 {
if len <= 3 { return SCORE_RUN_3; }
if len == 4 { return SCORE_RUN_4; }
SCORE_RUN_5_PLUS
}
// Enumerate every maximal horizontal and vertical run of length >= 3 with its
// length, in a stable order: all horizontal runs row-major (top-to-bottom, each
// row left-to-right), then all vertical runs column-major. The scan mirrors
// `find_matches` exactly — same maximal-span walk, same `.empty` exclusion (holes
// never run) — but records each run's length instead of marking a shared mask, so
// an intersecting L/T yields the horizontal run AND the vertical run as two
// separate entries rather than one unioned cell set.
find_runs :: (b: *Board) -> List(Run) {
runs := List(Run).{};
for 0..BOARD_ROWS (row) {
col := 0;
while col < BOARD_COLS {
g := b.at(col, row);
run_end := col + 1;
while run_end < BOARD_COLS and b.at(run_end, row) == g {
run_end += 1;
}
if g != .empty and run_end - col >= 3 {
runs.append(Run.{ vertical = false, fixed = row, start = col, len = run_end - col });
}
col = run_end;
}
}
for 0..BOARD_COLS (col) {
row := 0;
while row < BOARD_ROWS {
g := b.at(col, row);
run_end := row + 1;
while run_end < BOARD_ROWS and b.at(col, run_end) == g {
run_end += 1;
}
if g != .empty and run_end - row >= 3 {
runs.append(Run.{ vertical = true, fixed = col, start = row, len = run_end - row });
}
row = run_end;
}
}
runs
}
// Base points for clearing the board's currently-matched runs THIS round: the
// sum of `run_score` over every maximal run from `find_runs`. 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 on the board. A board with
// no run scores 0.
score_round :: (board: *Board) -> i64 {
runs := find_runs(board);
total : i64 = 0;
for 0..runs.len (i) {
total += run_score(runs.items[i].len);
}
total
}
// Add this round's base points (×1, no combo multiplier) to the board's running
// `score` total and return them. The single-round accumulation primitive; the
// cascade loop (`resolve`) instead scales each round by `combo_multiplier`
// (P3.2). Neither path changes `score_round`.
add_round_score :: (board: *Board) -> i64 {
points := score_round(board);
board.score += points;
points
}
// ── Combo multiplier (P3.2) ────────────────────────────────────────────────
// Across one swap's cascade, each resolution round's base points (`score_round`)
// are scaled by a multiplier that grows with chain depth, so deeper chains pay
// out more. The scheme: the 1-based round index IS the multiplier — round 1 ×1,
// round 2 ×2, round 3 ×3, … A single-round settle (depth 1) therefore scores
// exactly its base (×1, no bonus); every round past the first is amplified, so a
// multi-round chain strictly beats the same clears scored flat. `resolve`
// accumulates `score_round * combo_multiplier(round)` per round into `Board.score`
// and reports the sum as `Cascade.awarded`.
combo_multiplier :: (round: i64) -> i64 {
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: i64;
len5_plus: i64;
}
// 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 `<axis> len <n> at fixed <f> start <s>`
// where axis is H (horizontal) or V (vertical). An empty list dumps as just
// "0 runs". Suitable for snapshotting.
dump_runs :: (runs: *List(Run)) -> string {
result := format("{} runs\n", runs.len);
for 0..runs.len (i) {
r := runs.items[i];
axis := if r.vertical then "V" else "H";
result = concat(result, format("{} len {} at fixed {} start {}\n", axis, r.len, r.fixed, r.start));
}
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: i64;
}
// 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(i64).{}, 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() }
}
// ── 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
}
// After a committed move's cascade has settled, recover a deadlocked board so the
// player is never stranded: if the level is still in progress yet no legal swap
// remains, `reshuffle` the gems in place. A reshuffle is NOT a move and never runs
// on a finished (won/lost) level, so win/lose and turn accounting are untouched.
// Returns whether a reshuffle ran. BOTH the headless turn loop (`play_turn`) and
// the animated UI commit (`plan_and_commit`) call this, so the rendered game obeys
// the identical no-moves rule — neither path can leave the board stuck.
reshuffle_if_deadlocked :: (board: *Board) -> bool {
if level_status(board) == .in_progress and !has_legal_swap(board) {
return reshuffle(board);
}
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: i64) {
board.init(seed);
}
// Outcome of one turn through the goal loop: whether the turn was `accepted`
// (false only when a finished level rejected the move), the underlying
// `PlayerMove`, the level `status` AFTER it, and whether a deadlock `reshuffle`
// ran (so P7.2 can flash a "shuffled" note). When `accepted` is false the move
// is a no-op (illegal, depth-0 cascade) and `status` is the terminal status that
// caused the rejection. The status is recomputed from the model, never stored.
TurnResult :: struct {
accepted: bool;
move: PlayerMove;
status: Status;
reshuffled: bool;
}
// Play one turn. A FINISHED level is frozen: once `level_status` is won or lost
// the move is REJECTED (`accepted = false`) — no swap, no move spent, no score
// change, status unchanged — until `restart` reseeds a fresh level. P7.2 reads
// `accepted` to tell the player the input was ignored because the level is over.
// While in progress the swap is attempted via `commit_swap` (an illegal swap
// changes nothing and spends no move); then — only if still in progress — the
// board reshuffles if it 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 whether the turn was accepted, the move
// outcome, the resulting status, and whether a reshuffle ran.
play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult {
status := level_status(board);
if status != .in_progress {
empty := Cascade.{ depth = 0, cleared = List(i64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
frozen := PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
return TurnResult.{ accepted = false, move = frozen, status = status, reshuffled = false };
}
move := commit_swap(board, a, b);
reshuffled := reshuffle_if_deadlocked(board);
TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled }
}