P1.1: pure-sx Gem & Board model with seeded, match-free init
Add board.sx, the headless Phase-1 match-3 core: - Gem enum (6 types, ordinal 0..5) + single-char dump alphabet. - Rng: a 32-bit LCG carried in s64, masked to 32 bits each step, so the stream is host-width independent and valid for any seed. - Board (8x8, row-major) with idx/at/set accessors and a seeded init that fills row-major, excluding any gem that would complete a 3-in-a-row with the two cells to the left or above — so the result has zero pre-existing matches. Single RNG draw per cell, always terminates. - board_dump: deterministic one-row-per-line textual snapshot. tests/board_init.sx seeds with a fixed seed, dumps the board, and asserts zero horizontal/vertical 3-in-a-row runs via an independent scan. Output and exit code are locked as goldens. App ios-sim build is unaffected (main.sx does not import the model yet).
This commit is contained in:
147
board.sx
Normal file
147
board.sx
Normal file
@@ -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
|
||||||
|
}
|
||||||
39
tests/board_init.sx
Normal file
39
tests/board_init.sx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
1
tests/expected/board_init.exit
Normal file
1
tests/expected/board_init.exit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
9
tests/expected/board_init.stdout
Normal file
9
tests/expected/board_init.stdout
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
RRPPOGRG
|
||||||
|
PGPOPRRO
|
||||||
|
YYBBYRYB
|
||||||
|
GBYYRGGP
|
||||||
|
OGBRRORY
|
||||||
|
BYRRPRBG
|
||||||
|
YOYYROBB
|
||||||
|
OROBPPRB
|
||||||
|
ok: board_init no-match invariant holds
|
||||||
Reference in New Issue
Block a user