diff --git a/board.sx b/board.sx index 04cde55..98c9d28 100644 --- a/board.sx +++ b/board.sx @@ -145,3 +145,99 @@ board_dump :: (self: *Board) -> string { } buf } + +// ── Match detection ──────────────────────────────────────────────────────── +// Per-cell membership over the board: cell (col, row) is `true` iff it takes +// part in some horizontal or vertical run of three or more same-type gems. +// This mask IS the matched-cell SET — overlapping shapes (an L or a T where a +// horizontal and a vertical run share a cell) collapse to a single `true`, so +// the union is automatic. The layout mirrors Board.cells exactly so the +// clear/cascade phase can consume it without translation. +MatchMask :: struct { + cells: [BOARD_CELLS]bool; + + at :: (self: *MatchMask, col: s64, row: s64) -> bool { + self.cells[Board.idx(col, row)] + } + + count :: (self: *MatchMask) -> s64 { + n : s64 = 0; + for 0..BOARD_CELLS: (i) { if self.cells[i] { n += 1; } } + n + } +} + +// Mark a closed span of cells along one axis. `vertical` picks the axis; `fixed` +// is the constant coordinate (the row for a horizontal span, the column for a +// vertical one) and the span covers `start..end` of the moving coordinate. +mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) { + for start..end: (i) { + if vertical { + m.cells[Board.idx(fixed, i)] = true; + } else { + m.cells[Board.idx(i, fixed)] = true; + } + } +} + +// Detect every maximal horizontal and vertical run of length >= 3 and mark all +// participating cells. Each row and column is scanned once, extending a run +// while the gem type holds; a maximal run of length >= 3 marks its whole span, +// 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. +find_matches :: (b: *Board) -> MatchMask { + m : MatchMask = ---; + for 0..BOARD_CELLS: (i) { m.cells[i] = false; } + + // Horizontal: walk each row left-to-right in maximal same-type spans. + for 0..BOARD_ROWS: (row) { + col := 0; + while col < BOARD_COLS { + g := b.at(col, row); + run_end := col + 1; + 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); } + col = run_end; + } + } + + // Vertical: walk each column top-to-bottom in maximal same-type spans. + for 0..BOARD_COLS: (col) { + row := 0; + while row < BOARD_ROWS { + g := b.at(col, row); + run_end := row + 1; + 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); } + row = run_end; + } + } + + m +} + +// Deterministic textual dump of a matched-cell SET, in the same row-major grid +// shape as `board_dump`: a matched cell shows its gem character, an unmatched +// cell shows '.'. A board with no matches dumps as an all-'.' grid, which reads +// unambiguously as the empty set. Suitable for snapshotting. +dump_matches :: (b: *Board, m: *MatchMask) -> string { + line_w := BOARD_COLS + 1; // 8 cells + newline + buf := cstring(BOARD_ROWS * line_w); + for 0..BOARD_ROWS: (row) { + base := row * line_w; + for 0..BOARD_COLS: (col) { + if m.at(col, row) { + buf[base + col] = gem_char(b.at(col, row)); + } else { + buf[base + col] = 46; // '.' + } + } + buf[base + BOARD_COLS] = 10; // '\n' + } + buf +} diff --git a/tests/expected/match_detect.exit b/tests/expected/match_detect.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/match_detect.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/match_detect.stdout b/tests/expected/match_detect.stdout new file mode 100644 index 0000000..7d1bf3f --- /dev/null +++ b/tests/expected/match_detect.stdout @@ -0,0 +1,109 @@ +== horizontal-3 == +OGOGOGOG +GOGOGOGO +OGOGOGOG +GORRROGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +-- +........ +........ +........ +..RRR... +........ +........ +........ +........ +== vertical-3 == +OGOGOGOG +GOGOGOGO +OGOGOBOG +GOGOGBGO +OGOGOBOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +-- +........ +........ +.....B.. +.....B.. +.....B.. +........ +........ +........ +== disjoint-runs == +OGOGOGOG +RRROGOGO +OGOGOGOG +GOGOGOGO +OGOGOGYG +GOPPPOYO +OGOGOGYG +GOGOGOGO +-- +........ +RRR..... +........ +........ +......Y. +..PPP.Y. +......Y. +........ +== len4-and-len5 == +OGOGOGOG +GRRRRRGO +OGOGOGOB +GOGOGOGB +OGOGOGOB +GOGOGOGB +OGOGOGOG +GOGOGOGO +-- +........ +.RRRRR.. +.......B +.......B +.......B +.......B +........ +........ +== L-and-T == +OGOGOGOG +GRRRGOGO +OROGOGOG +GRGOGOGO +OGOGOGOG +GOGYYYGO +OGOGYGOG +GOGOYOGO +-- +........ +.RRR.... +.R...... +.R...... +........ +...YYY.. +....Y... +....Y... +== no-matches == +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +-- +........ +........ +........ +........ +........ +........ +........ +........ +ok: match detection over hand-crafted boards diff --git a/tests/match_detect.sx b/tests/match_detect.sx new file mode 100644 index 0000000..e3dd996 --- /dev/null +++ b/tests/match_detect.sx @@ -0,0 +1,121 @@ +// Match-detection golden: run `find_matches` over several HAND-CRAFTED boards +// and snapshot the matched-cell set for each. Every board is built explicitly +// (no seeded init) on a checkerboard O/G background — which is itself run-free, +// since adjacent cells always differ — with the runs under test painted in the +// other gem colours. For each scene the board and its matched-cell dump are +// printed, and the matched-cell count is asserted independently of the dump. +#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 +} + +// Build a scene: load an 8x8 board from `rows` (top row first, each exactly +// BOARD_COLS gem characters), detect matches, print board + matched dump, and +// assert the matched-cell count. +scene :: (name: string, rows: []string, want_count: s64) { + b : Board = ---; + for 0..BOARD_ROWS: (row) { + line := rows[row]; + for 0..BOARD_COLS: (col) { + b.set(col, row, char_to_gem(line[col])); + } + } + + m := find_matches(@b); + print("== {} ==\n", name); + out(board_dump(@b)); + out("--\n"); + out(dump_matches(@b, @m)); + + t.expect(m.count() == want_count, name); +} + +main :: () -> s32 { + // Single horizontal 3-run (row 3, cols 2-4). + 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); + + // Multiple disjoint runs: horizontal R (row 1, cols 0-2), horizontal P + // (row 5, cols 2-4), vertical Y (col 6, rows 4-6). + scene("disjoint-runs", .[ + "OGOGOGOG", + "RRROGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGYG", + "GOPPPOYO", + "OGOGOGYG", + "GOGOGOGO", + ], 9); + + // A length-5 horizontal run (row 1, cols 1-5) and a length-4 vertical run + // (col 7, rows 2-5). + scene("len4-and-len5", .[ + "OGOGOGOG", + "GRRRRRGO", + "OGOGOGOB", + "GOGOGOGB", + "OGOGOGOB", + "GOGOGOGB", + "OGOGOGOG", + "GOGOGOGO", + ], 9); + + // Intersecting shapes, shared cell counted once: an L (R horizontal row 1 + // cols 1-3 meeting R vertical col 1 rows 1-3 at the corner (1,1)) and a T + // (Y horizontal row 5 cols 3-5 meeting Y vertical col 4 rows 5-7 at the + // mid/top cell (4,5)). + scene("L-and-T", .[ + "OGOGOGOG", + "GRRRGOGO", + "OROGOGOG", + "GRGOGOGO", + "OGOGOGOG", + "GOGYYYGO", + "OGOGYGOG", + "GOGOYOGO", + ], 10); + + // No matches: the bare checkerboard, every adjacent pair differs. + scene("no-matches", .[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 0); + + print("ok: match detection over hand-crafted boards\n"); + return 0; +}