// 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;

// 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: s64;

    // Turn accounting (P3.3). `moves_made` counts the swaps actually COMMITTED —
    // only a legal swap (one that resolved into >=1 match) via `commit_swap`
    // increments it; an illegal, reverted swap does not. `move_limit` is the
    // game's move budget (`init` sets it to DEFAULT_MOVE_LIMIT); `moves_remaining`
    // is derived from the two, so there is a single source of truth and the
    // counters can never drift apart. A hand-built board must set both before
    // committing swaps.
    moves_made: s64;
    move_limit: s64;

    // 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: s64;

    idx :: (col: s64, row: s64) -> s64 {
        row * BOARD_COLS + col
    }

    // Moves still available against the budget: `move_limit - moves_made`. Goes
    // to 0 when the budget is spent (and below it only if a caller keeps
    // committing past the budget — see DEFAULT_MOVE_LIMIT). The turn/goal loop
    // (P7) reads this to decide when the game ends.
    moves_remaining :: (self: *Board) -> s64 {
        self.move_limit - self.moves_made
    }

    at :: (self: *Board, col: s64, row: s64) -> Gem {
        self.cells[Board.idx(col, row)]
    }

    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;
        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: 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.
//
// `len4` / `len5_plus` tally the special-match runs cleared across the WHOLE
// settle (summed over rounds): the number of maximal runs of length exactly 4,
// and of length 5 or more (P3.3 special-match flagging). They are a HOOK for
// future special gems — nothing here creates or alters a gem; the tallies only
// make "did this settle clear a 4 / 5+ run" observable. `had_len4` /
// `had_len5_plus` are the boolean view of the same counts.
Cascade :: struct {
    depth: s64;
    cleared: List(s64);
    awarded: s64;
    len4: s64;
    len5_plus: s64;

    had_len4 :: (self: *Cascade) -> bool {
        self.len4 > 0
    }

    had_len5_plus :: (self: *Cascade) -> bool {
        self.len5_plus > 0
    }
}

// One resolution round: detect matches and, if any, clear them, collapse under
// 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, 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: 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
}

// ── Special-match flagging (P3.3) ──────────────────────────────────────────
// A HOOK for future special gems: surface, per detection round, how many of the
// board's maximal runs are long enough to (later) spawn one — a run of length
// exactly 4, and a run of length 5 or more. This is detection ONLY: nothing here
// creates, marks, or alters a gem; later work reads these counts to decide what
// special gem (if any) a clear produces. Length 3 runs are ordinary and counted
// by neither tier.

// Per-round tally of special-length runs: `len4` is the number of maximal runs
// of length exactly 4, `len5_plus` the number of length 5 or more. Boolean
// "did any occur" lives on `Cascade` (`had_len4` / `had_len5_plus`) for the
// whole settle; a single round reads these counts directly.
SpecialCounts :: struct {
    len4: s64;
    len5_plus: s64;
}

// Count the board's currently-matched runs that hit a special length, by the
// same `find_runs` enumeration scoring uses (so an L/T's horizontal and vertical
// runs are counted independently by their own lengths). Pure and read-only — it
// inspects the board but changes nothing, so it must be called BEFORE the round's
// clear, while the runs are still present. A board with no run (or only length-3
// runs) tallies zero in both tiers.
count_specials :: (board: *Board) -> SpecialCounts {
    runs := find_runs(board);
    counts := SpecialCounts.{ len4 = 0, len5_plus = 0 };
    for 0..runs.len: (i) {
        len := runs.items[i].len;
        if len == 4 {
            counts.len4 += 1;
        } else if len >= 5 {
            counts.len5_plus += 1;
        }
    }
    counts
}

// Deterministic textual dump of an enumerated run list, in `find_runs` order: a
// count header, then one run per line as `<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: s64;
}

// Attempt the player's intended swap of two adjacent cells. If the swap is legal
// (`swap_legal`: adjacent AND it forms a match), apply it, `resolve` the cascade
// — which accrues score into `Board.score` and reports the awarded points and
// special-match flags — then spend one move (`moves_made += 1`). If it is illegal
// (non-adjacent, or forms no match) the board is left exactly as it was — no swap,
// no resolve, no move spent — and an empty depth-0 cascade is returned. Move
// accounting only TRACKS the budget; it does not refuse a swap when the budget is
// spent (that is the P7 turn-loop's call) — see DEFAULT_MOVE_LIMIT.
commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove {
    if !swap_legal(board, a, b) {
        empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
        return PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
    }
    swap(board, a, b);
    cascade := resolve(board);
    board.moves_made += 1;
    PlayerMove.{ legal = true, cascade = cascade, moves_remaining = board.moves_remaining() }
}

// ── 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
}

// 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: s64) {
    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(s64).{}, 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 := false;
    if level_status(board) == .in_progress and !has_legal_swap(board) {
        reshuffled = reshuffle(board);
    }
    TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled }
}
