Files
m3te/board.sx
swipelab 98752fe8ec P3.1: base match scoring by run length (pure sx)
Add a run-enumeration path and base scoring to the pure-sx model, scoring
one resolution round's clears purely by maximal-run length.

- find_runs(board) enumerates each maximal H/V run (len >= 3) with its
  length, parallel to find_matches/MatchMask (left untouched so every
  clear/cascade caller is unaffected).
- run_score(len): length 3 -> 30, 4 -> 60, 5+ -> 100 (named constants).
- score_round(board): sum of run_score over the current runs (read-only,
  must be called before the round's clear). L/T rule: each maximal run
  scores independently by its own length, so an overlapping L/T scores
  horizontal + vertical (shared corner counts toward both runs).
- Board gains a running `score` field (init zeroes it) and add_round_score
  accumulates a round's base points into it; the cross-round combo
  multiplier off Cascade.depth is left for P3.2.
- tests/score.sx golden over hand-crafted single-round boards asserts exact
  points for len-3/4/5 runs, disjoint runs (sum), an overlapping L/T, and a
  no-match board (0).
2026-06-04 21:01:01 +03:00

628 lines
26 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(s64) g]
}
// ── Deterministic RNG ─────────────────────────────────────────────────────
// A 32-bit linear congruential generator (Numerical Recipes constants),
// carried in an s64 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 s64 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: s64;
// Advance and return the next 32-bit value.
next_u32 :: (self: *Rng) -> s64 {
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: s64) -> s64 {
(self.next_u32() >> 16) % n
}
}
rng_seeded :: (seed: s64) -> Rng {
Rng.{ state = seed & RNG_MASK32 }
}
// ── Board ─────────────────────────────────────────────────────────────────
BOARD_COLS :: 8;
BOARD_ROWS :: 8;
BOARD_CELLS :: BOARD_COLS * BOARD_ROWS;
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 (P3.1). `init` zeroes it; `add_round_score` accumulates
// a round's base points (see `score_round`). The cascade-wide combo MULTIPLIER
// off `Cascade.depth` lands in P3.2, and the HUD (P4.4) reads this field. A
// hand-built board must zero this before accumulating.
score: s64;
idx :: (col: s64, row: s64) -> s64 {
row * BOARD_COLS + col
}
at :: (self: *Board, col: s64, row: s64) -> Gem {
self.cells[Board.idx(col, row)]
}
set :: (self: *Board, col: s64, row: s64, 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: s64) {
self.rng = rng_seeded(seed);
self.score = 0;
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: s64, row: s64) -> 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(s64) 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(s64) 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: s64, row: s64) -> bool {
self.cells[Board.idx(col, row)]
}
count :: (self: *MatchMask) -> s64 {
n : s64 = 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: s64, start: s64, end: s64) {
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: s64;
row: s64;
}
// 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) -> s64 {
cleared : s64 = 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) -> s64 {
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) -> s64 {
rng := @board.rng;
filled : s64 = 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`; P3 scores each round off this list and reads the
// combo multiplier from the depth.
Cascade :: struct {
depth: s64;
cleared: List(s64);
}
// 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) -> s64 {
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.
// Returns the cascade: its depth and per-round cleared-cell counts. An
// already-stable board returns depth 0 with an empty `cleared` list, untouched.
resolve :: (board: *Board) -> Cascade {
result := Cascade.{ depth = 0, cleared = List(s64).{} };
while true {
n := resolve_step(board);
if n == 0 { break; }
result.cleared.append(n);
result.depth += 1;
}
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 (off `Cascade.depth`) is P3.2.
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: s64;
start: s64;
len: s64;
}
// 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: s64) -> s64 {
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) -> s64 {
runs := find_runs(board);
total : s64 = 0;
for 0..runs.len: (i) {
total += run_score(runs.items[i].len);
}
total
}
// Add this round's base points to the board's running `score` total and return
// them. The accumulation primitive the HUD (P4.4) reads and that P3.2 wraps with
// the cascade combo multiplier — P3.2 layers the per-round multiplier here / in
// the resolve loop without changing `score_round`.
add_round_score :: (board: *Board) -> s64 {
points := score_round(board);
board.score += points;
points
}
// 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
}