P2.3: seeded refill (pure sx)
Add refill to the board model: every .empty hole is filled with a fresh gem drawn from the board's OWN seeded RNG, so refills are fully reproducible for a seed and continue the stream rather than reseeding. - Board now owns its RNG state (rng: Rng); init seeds and draws from it, so draws after init/clears thread deterministically. The init draw sequence is unchanged, so board_init's golden is byte-identical. - refill(board) fills all holes in row-major order wherever they sit (does not assume collapse ran) and makes no attempt to avoid matches — a refill may create new runs, which drives the P2.4 cascade. - tests/refill.sx (fixed seed) runs clear -> collapse -> refill, locks the staged dump as a golden, and asserts: zero empties after refill; each hole holds the next seeded-stream gem (replayed from the pre-refill state); drawn gems vary (not a constant); same start+seed -> identical board; a second refill of the same holes draws new gems (RNG threads, no reseed).
This commit is contained in:
38
board.sx
38
board.sx
@@ -81,6 +81,13 @@ Board :: struct {
|
|||||||
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
|
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
|
||||||
cells: [BOARD_CELLS]Gem;
|
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 {
|
idx :: (col: s64, row: s64) -> s64 {
|
||||||
row * BOARD_COLS + col
|
row * BOARD_COLS + col
|
||||||
}
|
}
|
||||||
@@ -100,10 +107,10 @@ Board :: struct {
|
|||||||
// drawn from the remaining allowed types. At most two types are ever
|
// drawn from the remaining allowed types. At most two types are ever
|
||||||
// excluded, so a choice always remains.
|
// excluded, so a choice always remains.
|
||||||
init :: (self: *Board, seed: s64) {
|
init :: (self: *Board, seed: s64) {
|
||||||
rng := rng_seeded(seed);
|
self.rng = rng_seeded(seed);
|
||||||
for 0..BOARD_ROWS: (row) {
|
for 0..BOARD_ROWS: (row) {
|
||||||
for 0..BOARD_COLS: (col) {
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
1
tests/expected/refill.exit
Normal file
1
tests/expected/refill.exit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
39
tests/expected/refill.stdout
Normal file
39
tests/expected/refill.stdout
Normal file
@@ -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
|
||||||
145
tests/refill.sx
Normal file
145
tests/refill.sx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user