diff --git a/board.sx b/board.sx index 98c9d28..665fd29 100644 --- a/board.sx +++ b/board.sx @@ -241,3 +241,94 @@ dump_matches :: (b: *Board, m: *MatchMask) -> string { } buf } + +// ── Swap & legality ────────────────────────────────────────────────────────── +// A board cell address. Kept separate from the row-major index so swap callers +// and the move enumeration speak in (col, row) like the rest of the model. +Cell :: struct { + col: s64; + row: s64; +} + +// Exchange the gems of two cells, in place. `swap` is its own inverse: calling +// it again with the same two cells restores the board, so a caller can trial a +// swap, inspect the result, then swap back to revert. +swap :: (board: *Board, a: Cell, b: Cell) { + ai := Board.idx(a.col, a.row); + bi := Board.idx(b.col, b.row); + tmp := board.cells[ai]; + board.cells[ai] = board.cells[bi]; + board.cells[bi] = tmp; +} + +// Two cells are orthogonally adjacent iff they differ by exactly one step along +// a single axis. The same cell, a diagonal, or any longer gap is not adjacent. +adjacent :: (a: Cell, b: Cell) -> bool { + if a.row == b.row { return a.col == b.col + 1 or a.col == b.col - 1; } + if a.col == b.col { return a.row == b.row + 1 or a.row == b.row - 1; } + false +} + +// Legality of swapping two cells: legal iff they are orthogonally adjacent AND, +// after the swap, at least one of the two swapped cells takes part in a 3+ match +// (via `find_matches`). A swap that only completes a run for the OTHER moved gem +// still counts — either swapped position participating is enough. Non-adjacent +// or diagonal pairs are rejected outright, before any match check. The board is +// left UNCHANGED: the trial swap is reverted before returning. +swap_legal :: (board: *Board, a: Cell, b: Cell) -> bool { + if !adjacent(a, b) { return false; } + swap(board, a, b); + m := find_matches(board); + legal := m.at(a.col, a.row) or m.at(b.col, b.row); + swap(board, a, b); // revert the trial swap + legal +} + +// One legal move: an unordered pair of adjacent cells. By construction `a` is +// the top-left cell of the pair and `b` is its right (same row) or down (same +// col) neighbour, so each adjacency is represented once — never as both (a, b) +// and (b, a). +Swap :: struct { + a: Cell; + b: Cell; +} + +// Enumerate every currently-legal swap in a stable order: row-major over the +// top-left cell of each pair, and for each cell its right neighbour before its +// down neighbour. This visits each orthogonal adjacency exactly once. The order +// is fixed (independent of board contents), so later hint / no-moves logic and +// the snapshot can depend on it. +legal_swaps :: (board: *Board) -> List(Swap) { + result := List(Swap).{}; + for 0..BOARD_ROWS: (row) { + for 0..BOARD_COLS: (col) { + here := Cell.{ col = col, row = row }; + if col + 1 < BOARD_COLS { + right := Cell.{ col = col + 1, row = row }; + if swap_legal(board, here, right) { + result.append(Swap.{ a = here, b = right }); + } + } + if row + 1 < BOARD_ROWS { + down := Cell.{ col = col, row = row + 1 }; + if swap_legal(board, here, down) { + result.append(Swap.{ a = here, b = down }); + } + } + } + } + result +} + +// Deterministic textual dump of an enumerated swap list, in list order: a count +// header, then one swap per line as its unordered cell pair `(col,row)-(col,row)` +// with the canonical top-left cell first. An empty list (no legal moves) dumps +// as just "0 legal swaps", which reads unambiguously. Suitable for snapshotting. +dump_swaps :: (swaps: *List(Swap)) -> string { + result := format("{} legal swaps\n", swaps.len); + for 0..swaps.len: (i) { + s := swaps.items[i]; + result = concat(result, format("({},{})-({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row)); + } + result +} diff --git a/tests/expected/swap_legality.exit b/tests/expected/swap_legality.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/swap_legality.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/swap_legality.stdout b/tests/expected/swap_legality.stdout new file mode 100644 index 0000000..6564019 --- /dev/null +++ b/tests/expected/swap_legality.stdout @@ -0,0 +1,56 @@ +== swap & legality == +== swap revert (non-mutating) == +before: +OGOGOGOG +GOGOGOGO +OGOGOGOG +RRBRGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +after: +OGOGOGOG +GOGOGOGO +OGOGOGOG +RRBRGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +== legal_swaps: seeded 1337 == +RRPPOGRG +PGPOPRRO +YYBBYRYB +GBYYRGGP +OGBRRORY +BYRRPRBG +YOYYROBB +OROBPPRB +-- +24 legal swaps +(3,0)-(3,1) +(4,0)-(4,1) +(5,0)-(6,0) +(1,2)-(1,3) +(2,2)-(2,3) +(4,2)-(5,2) +(4,2)-(4,3) +(5,2)-(6,2) +(1,3)-(2,3) +(3,3)-(4,3) +(4,3)-(5,3) +(2,4)-(2,5) +(4,4)-(4,5) +(5,4)-(6,4) +(5,4)-(5,5) +(1,5)-(1,6) +(3,5)-(4,5) +(4,5)-(5,5) +(4,5)-(4,6) +(6,5)-(7,5) +(0,6)-(1,6) +(1,6)-(1,7) +(3,6)-(4,6) +(6,7)-(7,7) +ok: swap legality over hand-crafted boards diff --git a/tests/swap_legality.sx b/tests/swap_legality.sx new file mode 100644 index 0000000..88813f8 --- /dev/null +++ b/tests/swap_legality.sx @@ -0,0 +1,174 @@ +// Swap-legality golden: exercise swap / adjacency / swap_legal and the +// legal_swaps enumeration. The predicate cases run over HAND-CRAFTED boards and +// are asserted directly; the move enumeration runs over the seeded board and is +// dumped deterministically and locked as a snapshot. +// +// Every hand-crafted 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 cells under test overridden — so any match observed is +// purely the trial swap's doing, never a pre-existing run. +#import "modules/std.sx"; +#import "board.sx"; +t :: #import "test.sx"; + +SEED :: 1337; + +// 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 +} + +// Whole-board equality, cell by cell — used to prove a trial swap leaves the +// board untouched. +boards_equal :: (x: *Board, y: *Board) -> bool { + for 0..BOARD_CELLS: (i) { + if !(x.cells[i] == y.cells[i]) { return false; } + } + true +} + +cell :: (col: s64, row: s64) -> Cell { + Cell.{ col = col, row = row } +} + +main :: () -> s32 { + print("== swap & legality ==\n"); + + // Board whose ONLY swap-formable match is the adjacent (2,3)<->(3,3) + // exchange: it slides an R into row 3 to complete R,R,R across cols 0-2. The + // B at (2,3) keeps the pre-swap row run-free. Reused by the revert check. + scene_3run : []string = .[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "RRBRGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]; + + // Legal: an adjacent swap that forms a 3-run. Swapping (3,3)<->(2,3) brings + // R to (2,3), completing R,R,R on row 3 — the match lands on the SECOND + // swapped cell, so a one-sided check would miss it. + legal_3run := load_board(scene_3run); + t.expect(swap_legal(@legal_3run, cell(3, 3), cell(2, 3)) == true, + "legal: adjacent swap forms a 3-run"); + + // Illegal: an adjacent swap that forms NO match. The R guards at (3,2) and + // (4,4) break the runs the displaced checkerboard gems would otherwise make. + no_match := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "OGOROGOG", + "GOGOGOGO", + "OGOGRGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]); + t.expect(swap_legal(@no_match, cell(3, 3), cell(4, 3)) == false, + "illegal: adjacent swap forms no match"); + + // Illegal: a NON-adjacent pair, rejected before any match check. The board + // is rigged so that the (0,3)<->(2,3) exchange WOULD complete a B column run + // if it were allowed — proving the rejection is by adjacency, not by an + // absent match. + non_adjacent := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "BGOGOGOG", + "YOBOGOGO", + "BGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]); + t.expect(adjacent(cell(0, 3), cell(2, 3)) == false, + "non-adjacent pair is not adjacent"); + t.expect(swap_legal(@non_adjacent, cell(0, 3), cell(2, 3)) == false, + "illegal: non-adjacent pair rejected without a match check"); + + // Illegal: a DIAGONAL pair, likewise rejected before any match check. The + // (3,3)<->(4,4) exchange WOULD complete a P column run if it were allowed. + diagonal := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "OGOPOGOG", + "GOGYGOGO", + "OGOPPGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]); + t.expect(adjacent(cell(3, 3), cell(4, 4)) == false, + "diagonal pair is not adjacent"); + t.expect(swap_legal(@diagonal, cell(3, 3), cell(4, 4)) == false, + "illegal: diagonal pair rejected without a match check"); + + // Legal because only the OTHER swapped gem matches. Player moves the gem at + // (4,3) to (5,3); the gem that comes back from (5,3) lands at (4,3) and + // completes a B column run there, while the moved gem at (5,3) matches + // nothing. Either swapped cell participating is enough. + other_gem := load_board(.[ + "OGOGOGOG", + "GOGOGOGO", + "OGOGBGOG", + "GOGOYBGO", + "OGOGBGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ]); + t.expect(swap_legal(@other_gem, cell(4, 3), cell(5, 3)) == true, + "legal: only the other swapped gem matches"); + + // Non-mutating: a legality probe and a manual test-then-revert both leave + // the board byte-for-byte identical. `swap` is its own inverse. + print("== swap revert (non-mutating) ==\n"); + revert := load_board(scene_3run); + fresh := load_board(scene_3run); + out("before:\n"); + out(board_dump(@revert)); + + probe := swap_legal(@revert, cell(3, 3), cell(2, 3)); // trials + reverts + swap(@revert, cell(3, 3), cell(2, 3)); // mutate + swap(@revert, cell(3, 3), cell(2, 3)); // revert (self-inverse) + + out("after:\n"); + out(board_dump(@revert)); + t.expect(probe == true, "probe swap was indeed legal"); + t.expect(boards_equal(@revert, @fresh), + "board unchanged after probe + test-then-revert"); + + // Enumeration over the seeded board: a fixed, deterministic list locked as a + // snapshot. Order is row-major over the top-left cell, right neighbour + // before down neighbour. + print("== legal_swaps: seeded {} ==\n", SEED); + seeded : Board = ---; + seeded.init(SEED); + out(board_dump(@seeded)); + out("--\n"); + moves := legal_swaps(@seeded); + out(dump_swaps(@moves)); + + print("ok: swap legality over hand-crafted boards\n"); + return 0; +}