find_matches walked maximal same-type spans without excluding `.empty`, so a line of 3+ holes (left by a prior clear) was reported as a match. After any vertical 3-clear or L/T clear the board carries such a line, so find_matches / clear_matches returned non-zero on a board with no real gem match — which would prevent the P2.4 cascade from ever stabilising. Fix at the source: a run is only a match if its gem type is not `.empty`. Holes already break runs of real gems (a hole differs from every gem), so this is the only change needed and every caller (P1.3 legality, P2.4 cascade) is now correct. Regression in tests/clear.sx: a holes-only board yields zero matches and clear_matches 0, and re-clearing a holed board returns 0. Other goldens are unchanged (no board without holes is affected).
381 lines
15 KiB
Plaintext
381 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.
|
|
//
|
|
// 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)
|
|
}
|