diff --git a/board.sx b/board.sx new file mode 100644 index 0000000..04cde55 --- /dev/null +++ b/board.sx @@ -0,0 +1,147 @@ +// 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 +} diff --git a/tests/board_init.sx b/tests/board_init.sx new file mode 100644 index 0000000..e67349d --- /dev/null +++ b/tests/board_init.sx @@ -0,0 +1,39 @@ +// Board-state golden: seed the board deterministically, dump it, and assert +// the no-pre-existing-match invariant (zero horizontal/vertical 3-in-a-rows). +// The dump is locked as a snapshot so the seeded board state can't drift. +#import "modules/std.sx"; +#import "board.sx"; +t :: #import "test.sx"; + +SEED :: 1337; + +// Count every horizontal or vertical window of three consecutive same-type +// gems. A correctly initialized board has zero. This walks the finished board +// independently of the placement logic, so it's a real check, not a tautology. +count_three_runs :: (b: *Board) -> s32 { + runs : s32 = 0; + for 0..BOARD_ROWS: (row) { + for 0..(BOARD_COLS - 2): (col) { + g := b.at(col, row); + if g == b.at(col + 1, row) and g == b.at(col + 2, row) { runs += 1; } + } + } + for 0..(BOARD_ROWS - 2): (row) { + for 0..BOARD_COLS: (col) { + g := b.at(col, row); + if g == b.at(col, row + 1) and g == b.at(col, row + 2) { runs += 1; } + } + } + runs +} + +main :: () -> s32 { + board : Board = ---; + board.init(SEED); + + out(board_dump(@board)); + + t.expect(count_three_runs(@board) == 0, "seeded board has no 3-in-a-row runs"); + print("ok: board_init no-match invariant holds\n"); + return 0; +} diff --git a/tests/expected/board_init.exit b/tests/expected/board_init.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/board_init.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/board_init.stdout b/tests/expected/board_init.stdout new file mode 100644 index 0000000..e1249b9 --- /dev/null +++ b/tests/expected/board_init.stdout @@ -0,0 +1,9 @@ +RRPPOGRG +PGPOPRRO +YYBBYRYB +GBYYRGGP +OGBRRORY +BYRRPRBG +YOYYROBB +OROBPPRB +ok: board_init no-match invariant holds