// 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; 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) { rng := rng_seeded(seed); for 0..BOARD_ROWS: (row) { for 0..BOARD_COLS: (col) { self.set(col, row, pick_gem(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) }