Add the first resolution-pipeline step to the headless board model.
- Introduce an `empty` hole sentinel on the Gem enum (ordinal 6, outside
GEM_COUNT so the RNG/pick_gem never draw it). board_dump renders holes as
EMPTY_CHAR ('.') via a single branch in gem_char, leaving boards without
holes byte-identical to before (existing goldens unchanged).
- clear_cells(board, mask): set every matched cell to `.empty`, leave all
others untouched, return the count cleared.
- clear_matches(board): detect+clear in one call; returns 0 (board unchanged)
when there are no matches.
No gravity or refill yet (P2.2 / P2.3).
tests/clear.sx applies detect->clear to hand-crafted boards (single
horizontal/vertical runs, disjoint runs, an overlapping L/T whose shared cell
clears once, and a no-match checkerboard), snapshots before/after, and asserts
matched cells became holes, non-matched cells are unchanged, and the cleared
count is exact. Locked as tests/expected/clear.{stdout,exit}.
377 lines
15 KiB
Plaintext
377 lines
15 KiB
Plaintext
// 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.
|
|
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
|
|
}
|
|
|
|
// ── 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)
|
|
}
|