From 3e180a121a8da500e4a2a2c8850104e7242f1dbd Mon Sep 17 00:00:00 2001 From: swipelab Date: Thu, 4 Jun 2026 20:48:20 +0300 Subject: [PATCH] P2.4: cascade resolution loop (pure sx) Add the settle loop a swap triggers: resolve(board) runs rounds of detect -> clear -> collapse -> refill until a round finds no match, returning a Cascade { depth, cleared } so P3 can read per-round cleared-cell counts and the combo-driving depth. resolve_step exposes one round. Termination follows from eventually reaching no-match; no artificial round cap. tests/cascade.sx: a fixed-seed hand-crafted board where clearing the initial BBB lets gravity pack col 0 into a fresh vertical RRR, so the loop chains two rounds; snapshots each per-round board and the final depth, asserts the final board is stable and that public resolve reproduces the manual loop, plus a depth-0 control on an unchanged checkerboard. Locked in tests/expected/cascade.{stdout,exit}. --- board.sx | 45 +++++++++++ tests/cascade.sx | 141 ++++++++++++++++++++++++++++++++++ tests/expected/cascade.exit | 1 + tests/expected/cascade.stdout | 30 ++++++++ 4 files changed, 217 insertions(+) create mode 100644 tests/cascade.sx create mode 100644 tests/expected/cascade.exit create mode 100644 tests/expected/cascade.stdout diff --git a/board.sx b/board.sx index e35fe9f..4e139ba 100644 --- a/board.sx +++ b/board.sx @@ -455,3 +455,48 @@ refill :: (board: *Board) -> s64 { } filled } + +// ── Cascade resolution (P2.4) ────────────────────────────────────────────── +// The settle loop a swap triggers: keep resolving matches until the board is +// stable. One round is detect → clear → collapse → refill; the loop repeats +// while a round still finds a match. Gravity can align falling survivors into a +// fresh run and a seeded refill can complete one, so a single clear chains into +// more — the cascade. Termination is reached the first round that detects no +// match; for a fixed seed the whole sequence is deterministic. + +// Outcome of resolving a board to a stable state. `depth` is the number of +// rounds that found and cleared at least one match (0 for an already-stable +// board). `cleared` holds those rounds' cleared-cell counts in round order, so +// `cleared.len == depth`; P3 scores each round off this list and reads the +// combo multiplier from the depth. +Cascade :: struct { + depth: s64; + cleared: List(s64); +} + +// One resolution round: detect matches and, if any, clear them, collapse under +// gravity, then refill the holes from the board's seeded RNG. Returns the +// number of cells cleared this round — 0 iff the board was already stable, in +// which case nothing moves and no gem is drawn. `resolve` repeats this until it +// returns 0. +resolve_step :: (board: *Board) -> s64 { + cleared := clear_matches(board); + if cleared == 0 { return 0; } + collapse(board); + refill(board); + cleared +} + +// Resolve the board to a stable state, running rounds until one finds no match. +// Returns the cascade: its depth and per-round cleared-cell counts. An +// already-stable board returns depth 0 with an empty `cleared` list, untouched. +resolve :: (board: *Board) -> Cascade { + result := Cascade.{ depth = 0, cleared = List(s64).{} }; + while true { + n := resolve_step(board); + if n == 0 { break; } + result.cleared.append(n); + result.depth += 1; + } + result +} diff --git a/tests/cascade.sx b/tests/cascade.sx new file mode 100644 index 0000000..85eab42 --- /dev/null +++ b/tests/cascade.sx @@ -0,0 +1,141 @@ +// Cascade golden: resolve a HAND-CRAFTED seeded board to a stable state and +// snapshot the whole settle loop. The board carries a single horizontal match +// (BBB at row 3, cols 0-2) sitting on the run-free O/G checkerboard, painted so +// that clearing it is NOT the end: col 0 holds R . R R straddling that match's +// cell, so once the B is cleared and gravity packs the column, the three reds +// fall adjacent into a fresh vertical RRR — a SECOND match the first clear set +// up. So the loop runs at least two rounds before it stabilises. The exact +// sequence (per-round boards + final depth) is locked by the snapshot. +// +// Two things are asserted independently of the dump: the final board is stable +// (find_matches empty), and the public `resolve` reproduces the manual loop's +// depth, per-round cleared counts, and final board byte-for-byte. A control +// checkerboard with no initial match resolves at depth 0, untouched. +#import "modules/std.sx"; +#import "board.sx"; +t :: #import "test.sx"; + +SEED :: 7; + +// Number of rounds the crafted cascade runs. Locked alongside the golden. +EXPECTED_DEPTH :: 2; + +// 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 resolving. +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 +} + +boards_equal :: (a: *Board, b: *Board) -> bool { + for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } } + true +} + +// The crafted cascade board: checkerboard everywhere except a horizontal BBB at +// row 3 (cols 0-2) and reds salted down col 0 (rows 2,4,5) around that match's +// cell. Clearing BBB punches the col-0 hole between the reds; gravity then packs +// R,R,R adjacent → a vertical match for round 2. Its RNG is seeded from SEED so +// the refill that follows each clear is reproducible. +cascade_board :: () -> Board { + b := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "RGOGOGOG", + "BBBOGOGO", + "RGOGOGOG", + "ROGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]); + b.rng = rng_seeded(SEED); + b +} + +// A run-free checkerboard with no initial match — the depth-0 control. +checker_board :: () -> Board { + b := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]); + b.rng = rng_seeded(SEED); + b +} + +main :: () -> s32 { + print("== cascade (resolution loop) ==\n"); + + // Drive the loop one round at a time so each post-round board is visible in + // the snapshot, recording the per-round cleared counts and the depth. + b := cascade_board(); + out("start:\n"); + out(board_dump(@b)); + + depth := 0; + counts := List(s64).{}; + while true { + n := resolve_step(@b); + if n == 0 { break; } + depth += 1; + counts.append(n); + print("round {}: cleared {} cells\n", depth, n); + out(board_dump(@b)); + } + print("cascade depth {}\n", depth); + + // The loop reached a stable board. + fm := find_matches(@b); + t.expect(fm.count() == 0, "cascade: final board has no matches"); + + // A genuine multi-round chain at the expected depth. + t.expect(depth == EXPECTED_DEPTH, "cascade: depth equals expected"); + t.expect(depth >= 2, "cascade: chained at least two rounds"); + + // The public `resolve` on a fresh identical board reproduces the manual + // loop exactly: same depth, same per-round cleared counts, same final board. + b2 := cascade_board(); + c := resolve(@b2); + t.expect(c.depth == depth, "cascade: resolve depth matches manual loop"); + same_counts := c.cleared.len == counts.len; + if same_counts { + for 0..counts.len: (i) { + if c.cleared.items[i] != counts.items[i] { same_counts = false; } + } + } + t.expect(same_counts, "cascade: resolve per-round counts match manual loop"); + t.expect(boards_equal(@b, @b2), "cascade: resolve final board matches manual loop"); + + // Control: a checkerboard with no initial match resolves at depth 0 and is + // left untouched (no clear, no collapse, no refill draw). + ctrl := checker_board(); + before := ctrl; + cc := resolve(@ctrl); + t.expect(cc.depth == 0, "control: stable board resolves at depth 0"); + t.expect(cc.cleared.len == 0, "control: depth 0 yields an empty per-round list"); + t.expect(boards_equal(@before, @ctrl), "control: stable board left unchanged"); + + print("ok: cascade resolves to a stable board\n"); + return 0; +} diff --git a/tests/expected/cascade.exit b/tests/expected/cascade.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/cascade.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/cascade.stdout b/tests/expected/cascade.stdout new file mode 100644 index 0000000..e618af7 --- /dev/null +++ b/tests/expected/cascade.stdout @@ -0,0 +1,30 @@ +== cascade (resolution loop) == +start: +OGOGOGOG +GOGOGOGO +RGOGOGOG +BBBOGOGO +RGOGOGOG +ROGOGOGO +OGOGOGOG +GOGOGOGO +round 1: cleared 3 cells +RBRGOGOG +OGOOGOGO +GOGGOGOG +RGOOGOGO +RGOGOGOG +ROGOGOGO +OGOGOGOG +GOGOGOGO +round 2: cleared 3 cells +RBRGOGOG +PGOOGOGO +YOGGOGOG +RGOOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +cascade depth 2 +ok: cascade resolves to a stable board