diff --git a/board.sx b/board.sx index 1007c48..e35fe9f 100644 --- a/board.sx +++ b/board.sx @@ -81,6 +81,13 @@ Board :: struct { // Row-major: cell (col, row) lives at row*BOARD_COLS + col. cells: [BOARD_CELLS]Gem; + // The board's own deterministic RNG. `init` seeds it, then every later draw + // — refill (P2.3) and the cascade beyond — advances THIS state, so the whole + // gem stream for a seed is reproducible and successive refills continue the + // sequence instead of reseeding. A hand-built board (one made without `init`) + // must seed this before any draw. + rng: Rng; + idx :: (col: s64, row: s64) -> s64 { row * BOARD_COLS + col } @@ -100,10 +107,10 @@ Board :: struct { // 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); + self.rng = rng_seeded(seed); for 0..BOARD_ROWS: (row) { for 0..BOARD_COLS: (col) { - self.set(col, row, pick_gem(self, @rng, col, row)); + self.set(col, row, pick_gem(self, @self.rng, col, row)); } } } @@ -421,3 +428,30 @@ collapse :: (board: *Board) -> bool { } moved } + +// ── Refill (P2.3) ────────────────────────────────────────────────────────────── +// Final step of the resolution pipeline: drop a fresh gem into every hole. Each +// `.empty` cell is replaced by a gem drawn from the board's OWN seeded RNG, so a +// given seed always produces the same refill and successive refills continue the +// stream rather than repeating — the state threads through `init`, clears and +// prior refills, never reseeding. Holes are filled wherever they sit, in +// row-major order, so refill does not assume `collapse` ran first. +// +// Unlike `init`, refill makes NO attempt to avoid matches: a refilled gem may +// complete a new run, which is exactly what drives the P2.4 cascade. `next_range` +// only ever yields ordinals 0..GEM_COUNT, so a hole is never refilled with +// `.empty`; afterwards the board has no holes left. Returns the number of cells +// filled (0 on a board that had none). +refill :: (board: *Board) -> s64 { + rng := @board.rng; + filled : s64 = 0; + for 0..BOARD_ROWS: (row) { + for 0..BOARD_COLS: (col) { + if board.at(col, row) == .empty { + board.set(col, row, cast(Gem) rng.next_range(GEM_COUNT)); + filled += 1; + } + } + } + filled +} diff --git a/tests/expected/refill.exit b/tests/expected/refill.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/refill.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/refill.stdout b/tests/expected/refill.stdout new file mode 100644 index 0000000..8dd101c --- /dev/null +++ b/tests/expected/refill.stdout @@ -0,0 +1,39 @@ +== refill (seeded) == +start: +OGOGOGOG +RRROGOGO +OGOGOGOG +GOGOGOGO +OGOGOGYG +GOPPPOYO +OGOGOGYG +GOGOGOGO +after clear: +OGOGOGOG +...OGOGO +OGOGOGOG +GOGOGOGO +OGOGOG.G +GO...O.O +OGOGOG.G +GOGOGOGO +after collapse: +.....G.G +OG.GOO.O +OGOOGG.G +GOOGOOOO +OGGOGGGG +GOOGOOOO +OGOGOGGG +GOGOGOGO +after refill: +RROPYGGG +OGRGOOGO +OGOOGGPG +GOOGOOOO +OGGOGGGG +GOOGOOOO +OGOGOGGG +GOGOGOGO +filled 9 holes +ok: refill fills every hole from the seeded stream diff --git a/tests/refill.sx b/tests/refill.sx new file mode 100644 index 0000000..a64ca92 --- /dev/null +++ b/tests/refill.sx @@ -0,0 +1,145 @@ +// Refill golden: run the full resolution pipeline clear -> collapse -> refill +// over a HAND-CRAFTED seeded board and snapshot every stage. The starting board +// is the run-free O/G checkerboard from match_detect carrying three disjoint +// matches (RRR row 1, PPP row 5, YYY col 6); clearing punches 9 holes, gravity +// floats them to the top of their columns, and refill drops fresh gems in. +// +// Determinism is shown three independent ways, all locked by the snapshot and by +// asserts: +// * stream-continuation — each refilled hole holds exactly the NEXT draw of the +// board's own RNG, replayed from the pre-refill state. A reseed-from-scratch +// or a constant fill both fail this. +// * reproducibility — the same start + seed refills to a byte-identical board. +// * threading across refills — re-opening the just-filled holes and refilling +// again yields DIFFERENT gems, proving the RNG advances rather than reseeding. +#import "modules/std.sx"; +#import "board.sx"; +t :: #import "test.sx"; + +SEED :: 1337; + +// Inverse of `gem_char`: map a board character back to its Gem so the starting +// board can be written as a human-readable grid. The hole glyph maps to `.empty`. +char_to_gem :: (c: u8) -> Gem { + if c == EMPTY_CHAR { return .empty; } + for 0..GEM_COUNT: (i) { + if GEM_CHARS[i] == c { return cast(Gem) i; } + } + .red +} + +// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars). +// The RNG is left unseeded — callers seed it before drawing. +load_board :: (rows: []string) -> Board { + b : Board = ---; + for 0..BOARD_ROWS: (row) { + line := rows[row]; + for 0..BOARD_COLS: (col) { + b.set(col, row, char_to_gem(line[col])); + } + } + b +} + +count_empties :: (b: *Board) -> s64 { + n : s64 = 0; + for 0..BOARD_CELLS: (i) { if b.cells[i] == .empty { n += 1; } } + n +} + +boards_equal :: (a: *Board, b: *Board) -> bool { + for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } } + true +} + +// A fresh starting board with its RNG seeded from SEED. Because the RNG lives on +// the board, the refill it later performs is reproducible for SEED. The board is +// a run-free checkerboard carrying three disjoint matches — a horizontal RRR run +// (row 1, cols 0-2), a horizontal PPP run (row 5, cols 2-4) and a vertical YYY +// run (col 6, rows 4-6) — so clearing them punches 9 holes across six columns. +fresh_board :: () -> Board { + b := load_board(.[ + "OGOGOGOG", + "RRROGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGYG", + "GOPPPOYO", + "OGOGOGYG", + "GOGOGOGO", + ]); + b.rng = rng_seeded(SEED); + b +} + +main :: () -> s32 { + print("== refill (seeded) ==\n"); + + // Pipeline, snapshotting each stage. + b := fresh_board(); + out("start:\n"); out(board_dump(@b)); + clear_matches(@b); + out("after clear:\n"); out(board_dump(@b)); + collapse(@b); + out("after collapse:\n"); out(board_dump(@b)); + + // Snapshot the holed board (cells + RNG state) BEFORE refill, so we can both + // check which cells refill touched and replay the stream from the same state. + pre := b; + filled := refill(@b); + out("after refill:\n"); out(board_dump(@b)); + print("filled {} holes\n", filled); + + // (1) No empty cells remain. + t.expect(count_empties(@b) == 0, "refill: board has zero empty cells"); + + // (2) Stream continuation + not-a-constant: every refilled cell holds exactly + // the next draw of the board's RNG, taken row-major from the pre-refill state, + // and the drawn gems are not all identical. + v := Rng.{ state = pre.rng.state }; + stream_ok := true; + distinct := false; + have_first := false; + first : Gem = .empty; + for 0..BOARD_CELLS: (i) { + if pre.cells[i] == .empty { + want := cast(Gem) v.next_range(GEM_COUNT); + if b.cells[i] != want { stream_ok = false; } + if !have_first { first = b.cells[i]; have_first = true; } + else if b.cells[i] != first { distinct = true; } + } + } + t.expect(stream_ok, "refill: each hole holds the next seeded-stream gem"); + t.expect(distinct, "refill: drawn gems vary (not a fixed constant)"); + + // (3) Reproducibility: same start + seed refills to a byte-identical board. + b2 := fresh_board(); + clear_matches(@b2); + collapse(@b2); + refill(@b2); + t.expect(boards_equal(@b, @b2), "refill: same start + seed -> identical board"); + + // (4) Threading across refills: re-open exactly the cells the first refill + // filled, then refill again. The board's RNG has advanced past the first + // fill, so the second fill draws new gems — proof it does NOT reseed per call. + holes_n := 0; + hole_idx : [BOARD_CELLS]s64 = ---; + fill1 : [BOARD_CELLS]Gem = ---; + for 0..BOARD_CELLS: (i) { + if pre.cells[i] == .empty { + hole_idx[holes_n] = i; + fill1[holes_n] = b.cells[i]; + holes_n += 1; + } + } + for 0..holes_n: (k) { b.cells[hole_idx[k]] = .empty; } + refill(@b); + differs := false; + for 0..holes_n: (k) { + if b.cells[hole_idx[k]] != fill1[k] { differs = true; } + } + t.expect(differs, "refill: a second refill of the same holes draws new gems (RNG threads, no reseed)"); + + print("ok: refill fills every hole from the seeded stream\n"); + return 0; +}