P1.3: adjacent-swap legality (pure sx)
Add swap + legality to the board model: - swap(board, a, b): in-place, self-inverse cell exchange (trial then revert). - adjacent(a, b): orthogonal-adjacency predicate (diagonal/gap = false). - swap_legal(board, a, b): legal iff adjacent AND, after the trial swap, either swapped cell participates in a 3+ match (reuses find_matches); leaves the board unchanged. Non-adjacent/diagonal rejected before any match check. - Cell/Swap structs + legal_swaps(board): all currently-legal swaps in a stable row-major, right-before-down order; dump_swaps for deterministic snapshotting. tests/swap_legality.sx asserts the predicate over hand-crafted boards (legal 3-run, no-match, non-adjacent, diagonal, only-the-other-gem-matches) and the non-mutating revert; locks legal_swaps over the seeded board as a golden.
This commit is contained in:
91
board.sx
91
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
|
||||
}
|
||||
|
||||
1
tests/expected/swap_legality.exit
Normal file
1
tests/expected/swap_legality.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
56
tests/expected/swap_legality.stdout
Normal file
56
tests/expected/swap_legality.stdout
Normal file
@@ -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
|
||||
174
tests/swap_legality.sx
Normal file
174
tests/swap_legality.sx
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user