// 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. The enum's ordinal (0..5) IS the gem index, so it // casts cleanly to/from the integers the RNG and the textual dump work in. GEM_COUNT :: 6; Gem :: enum { red; orange; yellow; green; blue; purple; } // One stable character per gem type, indexed by ordinal — the alphabet the // board dump (and its golden) is written in. GEM_CHARS :: "ROYGBP"; gem_char :: (g: Gem) -> u8 { 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. 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 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 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 }