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