// 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 ` len at fixed start ` // 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 } }