Files
m3te/board.sx
swipelab c7368a82f1 P3.2: cascade combo multiplier (pure sx)
Scale each cascade round's base points by combo_multiplier(round) = the
1-based round index (round 1 x1, round 2 x2, ...), so deeper chains pay
out more. resolve now reads score_round before each clear, accumulates
score_round * combo_multiplier(round) into Board.score, and reports the
settle's payout as the new Cascade.awarded field. A depth-1 settle scores
exactly its base (x1, no bonus); any multi-round chain strictly exceeds
the same clears scored flat.

resolve_step keeps its signature (no scoring), so cascade.sx and its
golden are unchanged; score_round/add_round_score are untouched, so
score.sx is unchanged. New tests/combo.sx golden locks exact cumulative
scores for a single-round clear (30), the P2.4 cascade board (flat 60 ->
mult 90), and a controlled 3-round chain (flat 90 -> mult 180), printing
per-round base/multiplier/points so the golden self-explains.
2026-06-04 21:17:08 +03:00

653 lines
27 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(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. `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: 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`. `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.
Cascade :: struct {
depth: s64;
cleared: List(s64);
awarded: 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,
// 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(s64).{}, awarded = 0 };
while true {
// Read the round's base points while its runs are still on the board:
// `resolve_step` clears them, so the score has to be taken first.
base := score_round(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
}
// ── 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: 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 (×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) -> s64 {
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: s64) -> s64 {
round
}
// 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
}