Add collapse(board): per-column gravity that packs gems contiguously at
the bottom (preserving top-to-bottom order) and bubbles holes to the top,
with no horizontal movement. Returns whether any gem fell, for the P2.4
cascade. Does not refill (that is P2.3).
tests/collapse.sx snapshots gravity over hand-crafted boards exercising
holes in the middle / at the bottom, a full column of holes, a column
with none, a lone gem, an alternating stack, and an already-settled board
(idempotency). Asserts, independently of the dump, that each column's gems
end packed at the bottom in original order with holes above, plus the
exact moved flag. Golden locked in tests/expected/collapse.{stdout,exit}.
424 lines
17 KiB
Plaintext
424 lines
17 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)
|
|
}
|
|
|
|
// ── Gravity / collapse (P2.2) ─────────────────────────────────────────────────
|
|
// Second step of the resolution pipeline: let the gems fall into the holes a
|
|
// clear left behind. Within EACH column independently, every gem slides straight
|
|
// down past any holes below it, and the holes bubble to the TOP of the column
|
|
// (the smaller row index, since row 0 is the top of the dump). Columns never
|
|
// exchange gems — there is no horizontal movement. The surviving gems keep their
|
|
// original top-to-bottom order, now packed contiguously at the bottom with all
|
|
// holes contiguous above them. Refilling the freed top holes with fresh gems is
|
|
// P2.3; this step only moves what is already on the board.
|
|
//
|
|
// Returns true iff at least one gem changed row (i.e. some hole had a gem above
|
|
// it). A column that is already settled — or all holes, or all gems — moves
|
|
// nothing, so a fully-settled board returns false; the cascade loop (P2.4) reads
|
|
// this to know when gravity has stopped.
|
|
collapse :: (board: *Board) -> bool {
|
|
moved := false;
|
|
for 0..BOARD_COLS: (col) {
|
|
// Pack this column's gems toward the bottom: scan it bottom-to-top and
|
|
// write each gem at the falling cursor `w`, which also descends from the
|
|
// bottom. A gem whose source row differs from `w` actually fell. `w`
|
|
// never overtakes the read cursor, so writes only land on rows already
|
|
// read — safe to pack in place.
|
|
w := BOARD_ROWS - 1;
|
|
r := BOARD_ROWS - 1;
|
|
while r >= 0 {
|
|
g := board.at(col, r);
|
|
if g != .empty {
|
|
if r != w { moved = true; }
|
|
board.set(col, w, g);
|
|
w -= 1;
|
|
}
|
|
r -= 1;
|
|
}
|
|
// Every row above the packed gems is now a hole.
|
|
fill := 0;
|
|
while fill <= w {
|
|
board.set(col, fill, .empty);
|
|
fill += 1;
|
|
}
|
|
}
|
|
moved
|
|
}
|