sx 1d17b0a reserves 'cstring' as the C-boundary string type and renames std's cstring(size) allocator to alloc_string; std getenv is now (cstring) -> ?cstring, so the local conflicting binding (caught by the new same-symbol diagnostic) and its strlen/copy loop collapse into a process.env delegation. iOS-sim build + 22/22 snapshots green.
941 lines
42 KiB
Plaintext
941 lines
42 KiB
Plaintext
// 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 := alloc_string(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 := alloc_string(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 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
|
||
}
|
||
|
||
// 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 :: ufcs (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 }
|
||
}
|