From 2713a67b2baf6b69d4fa8895485bf87139fe73a8 Mon Sep 17 00:00:00 2001 From: swipelab Date: Thu, 4 Jun 2026 19:57:08 +0300 Subject: [PATCH 1/2] P2.1: clear matched cells (pure sx) Add the first resolution-pipeline step to the headless board model. - Introduce an `empty` hole sentinel on the Gem enum (ordinal 6, outside GEM_COUNT so the RNG/pick_gem never draw it). board_dump renders holes as EMPTY_CHAR ('.') via a single branch in gem_char, leaving boards without holes byte-identical to before (existing goldens unchanged). - clear_cells(board, mask): set every matched cell to `.empty`, leave all others untouched, return the count cleared. - clear_matches(board): detect+clear in one call; returns 0 (board unchanged) when there are no matches. No gravity or refill yet (P2.2 / P2.3). tests/clear.sx applies detect->clear to hand-crafted boards (single horizontal/vertical runs, disjoint runs, an overlapping L/T whose shared cell clears once, and a no-match checkerboard), snapshots before/after, and asserts matched cells became holes, non-matched cells are unchanged, and the cleared count is exact. Locked as tests/expected/clear.{stdout,exit}. --- board.sx | 46 ++++++++++- tests/clear.sx | 151 ++++++++++++++++++++++++++++++++++++ tests/expected/clear.exit | 1 + tests/expected/clear.stdout | 97 +++++++++++++++++++++++ 4 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 tests/clear.sx create mode 100644 tests/expected/clear.exit create mode 100644 tests/expected/clear.stdout diff --git a/board.sx b/board.sx index 665fd29..e1459d8 100644 --- a/board.sx +++ b/board.sx @@ -8,8 +8,10 @@ #import "modules/std.sx"; // ── Gem ────────────────────────────────────────────────────────────────── -// Six distinct gem types. The enum's ordinal (0..5) IS the gem index, so it -// casts cleanly to/from the integers the RNG and the textual dump work in. +// 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 { @@ -19,13 +21,24 @@ Gem :: enum { 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] } @@ -332,3 +345,32 @@ dump_swaps :: (swaps: *List(Swap)) -> string { } 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) +} diff --git a/tests/clear.sx b/tests/clear.sx new file mode 100644 index 0000000..5f1f508 --- /dev/null +++ b/tests/clear.sx @@ -0,0 +1,151 @@ +// Clear golden: run detect→clear over several HAND-CRAFTED boards and snapshot +// the post-clear board. Each board sits on the run-free O/G checkerboard from +// match_detect (adjacent cells always differ, so it has zero pre-existing +// matches) with only the runs under test painted in — so any hole in the result +// is purely the cleared match's doing. For each scene the before/after boards +// are printed, and three facts are asserted independently of the dump: matched +// cells became holes, non-matched cells are byte-identical, and the cleared +// count is exact. The boards (and their match counts) mirror match_detect.sx. +#import "modules/std.sx"; +#import "board.sx"; +t :: #import "test.sx"; + +// Inverse of `gem_char`: map a gem character back to its Gem so each board can +// be written as a human-readable grid of GEM_CHARS. +char_to_gem :: (c: u8) -> Gem { + 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 gem +// characters). +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 +} + +// Detect→clear one scene, snapshot before/after, and assert the three clear +// invariants against the matched-cell set: every flagged cell is now a hole, +// every unflagged cell is unchanged, and the returned count is exact. +scene :: (name: string, rows: []string, want_cleared: s64) { + b := load_board(rows); + orig := load_board(rows); // pristine copy for the unchanged check + + m := find_matches(@b); + cleared := clear_cells(@b, @m); + + print("== {} ==\n", name); + out("before:\n"); + out(board_dump(@orig)); + out("after:\n"); + out(board_dump(@b)); + + cleared_holes := true; // every matched cell is now a hole + others_intact := true; // every other cell is byte-identical + for 0..BOARD_CELLS: (i) { + if m.cells[i] { + if !(b.cells[i] == .empty) { cleared_holes = false; } + } else { + if !(b.cells[i] == orig.cells[i]) { others_intact = false; } + } + } + t.expect(cleared_holes, concat(name, ": cleared cells are holes")); + t.expect(others_intact, concat(name, ": non-matched cells unchanged")); + t.expect(cleared == want_cleared, concat(name, ": cleared count exact")); +} + +main :: () -> s32 { + print("== clear (detect -> clear) ==\n"); + + // Single horizontal 3-run (row 3, cols 2-4) → three holes there only. + scene("horizontal-3", .[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GORRROGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 3); + + // Single vertical 3-run (col 5, rows 2-4). + scene("vertical-3", .[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOBOG", + "GOGOGBGO", + "OGOGOBOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 3); + + // Disjoint runs: horizontal R (row 1), horizontal P (row 5), vertical Y + // (col 6) — three separate hole clusters, 9 cells total. + scene("disjoint-runs", .[ + "OGOGOGOG", + "RRROGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGYG", + "GOPPPOYO", + "OGOGOGYG", + "GOGOGOGO", + ], 9); + + // Overlapping L and T: a horizontal run and a vertical run share a cell (the + // L's corner (1,1), the T's stem-top (4,5)). The mask already unions the + // shared cell, so clear removes the whole union as one set — 10 holes, not + // 11 — exercising the overlapping-clear acceptance case. + scene("L-and-T", .[ + "OGOGOGOG", + "GRRRGOGO", + "OROGOGOG", + "GRGOGOGO", + "OGOGOGOG", + "GOGYYYGO", + "OGOGYGOG", + "GOGOYOGO", + ], 10); + + // No matches: the bare checkerboard is left completely unchanged (0 holes), + // so its before/after dumps are identical. + scene("no-matches", .[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 0); + + // clear_matches: the one-call detect+clear returns the same cleared count + // and punches the holes itself. + cm := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GORRROGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]); + t.expect(clear_matches(@cm) == 3, "clear_matches: detect+clear returns count"); + t.expect(cm.at(2, 3) == .empty and cm.at(3, 3) == .empty and cm.at(4, 3) == .empty, + "clear_matches: matched run is now holes"); + + print("ok: clear over hand-crafted boards\n"); + return 0; +} diff --git a/tests/expected/clear.exit b/tests/expected/clear.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/clear.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/clear.stdout b/tests/expected/clear.stdout new file mode 100644 index 0000000..fef56e0 --- /dev/null +++ b/tests/expected/clear.stdout @@ -0,0 +1,97 @@ +== clear (detect -> clear) == +== horizontal-3 == +before: +OGOGOGOG +GOGOGOGO +OGOGOGOG +GORRROGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +after: +OGOGOGOG +GOGOGOGO +OGOGOGOG +GO...OGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +== vertical-3 == +before: +OGOGOGOG +GOGOGOGO +OGOGOBOG +GOGOGBGO +OGOGOBOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +after: +OGOGOGOG +GOGOGOGO +OGOGO.OG +GOGOG.GO +OGOGO.OG +GOGOGOGO +OGOGOGOG +GOGOGOGO +== disjoint-runs == +before: +OGOGOGOG +RRROGOGO +OGOGOGOG +GOGOGOGO +OGOGOGYG +GOPPPOYO +OGOGOGYG +GOGOGOGO +after: +OGOGOGOG +...OGOGO +OGOGOGOG +GOGOGOGO +OGOGOG.G +GO...O.O +OGOGOG.G +GOGOGOGO +== L-and-T == +before: +OGOGOGOG +GRRRGOGO +OROGOGOG +GRGOGOGO +OGOGOGOG +GOGYYYGO +OGOGYGOG +GOGOYOGO +after: +OGOGOGOG +G...GOGO +O.OGOGOG +G.GOGOGO +OGOGOGOG +GOG...GO +OGOG.GOG +GOGO.OGO +== no-matches == +before: +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +after: +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +ok: clear over hand-crafted boards From 6e2a57f3a2b449d4863ebce5ca5b663c0a9b8cc8 Mon Sep 17 00:00:00 2001 From: swipelab Date: Thu, 4 Jun 2026 20:08:26 +0300 Subject: [PATCH 2/2] P2.1: holes never match in find_matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- board.sx | 8 ++++-- tests/clear.sx | 54 ++++++++++++++++++++++++++++++++++++- tests/expected/clear.stdout | 19 +++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/board.sx b/board.sx index e1459d8..c4d6148 100644 --- a/board.sx +++ b/board.sx @@ -199,6 +199,10 @@ mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) { // 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; } @@ -212,7 +216,7 @@ find_matches :: (b: *Board) -> MatchMask { while run_end < BOARD_COLS and b.at(run_end, row) == g { run_end += 1; } - if run_end - col >= 3 { mark_run(@m, false, row, col, run_end); } + if g != .empty and run_end - col >= 3 { mark_run(@m, false, row, col, run_end); } col = run_end; } } @@ -226,7 +230,7 @@ find_matches :: (b: *Board) -> MatchMask { while run_end < BOARD_ROWS and b.at(col, run_end) == g { run_end += 1; } - if run_end - row >= 3 { mark_run(@m, true, col, row, run_end); } + if g != .empty and run_end - row >= 3 { mark_run(@m, true, col, row, run_end); } row = run_end; } } diff --git a/tests/clear.sx b/tests/clear.sx index 5f1f508..d9c65d4 100644 --- a/tests/clear.sx +++ b/tests/clear.sx @@ -11,8 +11,11 @@ t :: #import "test.sx"; // Inverse of `gem_char`: map a gem character back to its Gem so each board can -// be written as a human-readable grid of GEM_CHARS. +// be written as a human-readable grid. The hole glyph maps to `.empty`, so a +// board can be hand-written with pre-existing holes (cells left by a prior +// clear) for the holes-never-match regression. 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; } } @@ -130,6 +133,23 @@ main :: () -> s32 { "GOGOGOGO", ], 0); + // Holes never match: a checkerboard carrying a horizontal 3-run of holes + // (row 3, cols 2-4) and a vertical 3-run of holes (col 1, rows 5-7), left by + // earlier clears. A line of 3+ holes is NOT a match, so detect finds nothing, + // clear removes nothing, and before/after are identical. Without this, a + // post-clear board would keep re-"matching" its own holes and the P2.4 + // cascade would never stabilise. + scene("holes-no-match", .[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GO...OGO", + "OGOGOGOG", + "G.GOGOGO", + "O.OGOGOG", + "G.GOGOGO", + ], 0); + // clear_matches: the one-call detect+clear returns the same cleared count // and punches the holes itself. cm := load_board(.[ @@ -146,6 +166,38 @@ main :: () -> s32 { t.expect(cm.at(2, 3) == .empty and cm.at(3, 3) == .empty and cm.at(4, 3) == .empty, "clear_matches: matched run is now holes"); + // Holes are never matchable: a board whose only equal-adjacent runs are + // holes yields an empty match set, and clear_matches reports 0 (no change). + holes := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GO...OGO", + "OGOGOGOG", + "G.GOGOGO", + "O.OGOGOG", + "G.GOGOGO", + ]); + hm := find_matches(@holes); + t.expect(hm.count() == 0, "holes: a line of 3+ holes is not a match"); + t.expect(clear_matches(@holes) == 0, "holes: clear_matches returns 0 on a holes-only board"); + + // Cascade base case: after a real clear punches a 3-in-a-line into holes, + // re-detecting on the cleared board must find nothing — otherwise the P2.4 + // cascade loop would re-match its own holes and never terminate. + casc := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOBOG", + "GOGOGBGO", + "OGOGOBOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]); + t.expect(clear_matches(@casc) == 3, "cascade: first clear removes the vertical 3-run"); + t.expect(clear_matches(@casc) == 0, "cascade: re-clear on the holed board returns 0"); + print("ok: clear over hand-crafted boards\n"); return 0; } diff --git a/tests/expected/clear.stdout b/tests/expected/clear.stdout index fef56e0..52e0480 100644 --- a/tests/expected/clear.stdout +++ b/tests/expected/clear.stdout @@ -94,4 +94,23 @@ OGOGOGOG GOGOGOGO OGOGOGOG GOGOGOGO +== holes-no-match == +before: +OGOGOGOG +GOGOGOGO +OGOGOGOG +GO...OGO +OGOGOGOG +G.GOGOGO +O.OGOGOG +G.GOGOGO +after: +OGOGOGOG +GOGOGOGO +OGOGOGOG +GO...OGO +OGOGOGOG +G.GOGOGO +O.OGOGOG +G.GOGOGO ok: clear over hand-crafted boards