P2.2: gravity collapse (pure sx)

Add collapse(board): per-column gravity that packs gems contiguously at
the bottom (preserving top-to-bottom order) and bubbles holes to the top,
with no horizontal movement. Returns whether any gem fell, for the P2.4
cascade. Does not refill (that is P2.3).

tests/collapse.sx snapshots gravity over hand-crafted boards exercising
holes in the middle / at the bottom, a full column of holes, a column
with none, a lone gem, an alternating stack, and an already-settled board
(idempotency). Asserts, independently of the dump, that each column's gems
end packed at the bottom in original order with holes above, plus the
exact moved flag. Golden locked in tests/expected/collapse.{stdout,exit}.
This commit is contained in:
swipelab
2026-06-04 20:21:12 +03:00
parent e5adc39cec
commit b5a3b16651
4 changed files with 268 additions and 0 deletions

View File

@@ -378,3 +378,46 @@ clear_matches :: (board: *Board) -> s64 {
m := find_matches(board);
clear_cells(board, @m)
}
// ── Gravity / collapse (P2.2) ─────────────────────────────────────────────────
// Second step of the resolution pipeline: let the gems fall into the holes a
// clear left behind. Within EACH column independently, every gem slides straight
// down past any holes below it, and the holes bubble to the TOP of the column
// (the smaller row index, since row 0 is the top of the dump). Columns never
// exchange gems — there is no horizontal movement. The surviving gems keep their
// original top-to-bottom order, now packed contiguously at the bottom with all
// holes contiguous above them. Refilling the freed top holes with fresh gems is
// P2.3; this step only moves what is already on the board.
//
// Returns true iff at least one gem changed row (i.e. some hole had a gem above
// it). A column that is already settled — or all holes, or all gems — moves
// nothing, so a fully-settled board returns false; the cascade loop (P2.4) reads
// this to know when gravity has stopped.
collapse :: (board: *Board) -> bool {
moved := false;
for 0..BOARD_COLS: (col) {
// Pack this column's gems toward the bottom: scan it bottom-to-top and
// write each gem at the falling cursor `w`, which also descends from the
// bottom. A gem whose source row differs from `w` actually fell. `w`
// never overtakes the read cursor, so writes only land on rows already
// read — safe to pack in place.
w := BOARD_ROWS - 1;
r := BOARD_ROWS - 1;
while r >= 0 {
g := board.at(col, r);
if g != .empty {
if r != w { moved = true; }
board.set(col, w, g);
w -= 1;
}
r -= 1;
}
// Every row above the packed gems is now a hole.
fill := 0;
while fill <= w {
board.set(col, fill, .empty);
fill += 1;
}
}
moved
}

146
tests/collapse.sx Normal file
View File

@@ -0,0 +1,146 @@
// Collapse golden: run gravity over several HAND-CRAFTED boards and snapshot the
// post-collapse board. Unlike the clear/match goldens these boards need not be
// run-free — `collapse` never inspects matches, it only moves gems past holes —
// so each column is painted to exercise a distinct case (holes in the middle, at
// the bottom, a full column of holes, a column with none, a lone gem, an
// alternating stack). Distinct gem letters are stacked vertically so the
// top-to-bottom order is observable in the dump.
//
// For each scene the before/after boards are printed, and two facts are asserted
// independently of the dump: every column ends with its original gems (same
// top-to-bottom order) packed at the BOTTOM and all holes contiguous above, and
// the returned `moved` flag is exact.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
// Inverse of `gem_char`: map a board character back to its Gem. The hole glyph
// maps to `.empty`, so a board can be hand-written with holes in any position.
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; }
}
.red
}
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars).
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
}
// The collapse invariant, checked column by column against the original board:
// each column's gems (its non-hole cells, in top-to-bottom order) must reappear
// packed contiguously at the BOTTOM in that same order, with every cell above
// them a hole. This single check covers holes-bubble-to-top, gems-settle-to-
// bottom, order-preservation, and the all-holes / no-holes edge columns at once.
check_collapsed :: (orig: *Board, b: *Board) -> bool {
for 0..BOARD_COLS: (col) {
gems : [BOARD_ROWS]Gem = ---;
n := 0;
for 0..BOARD_ROWS: (row) {
g := orig.at(col, row);
if g != .empty { gems[n] = g; n += 1; }
}
boundary := BOARD_ROWS - n; // first row that must hold a gem
for 0..BOARD_ROWS: (row) {
if row < boundary {
if b.at(col, row) != .empty { return false; }
} else {
if b.at(col, row) != gems[row - boundary] { return false; }
}
}
}
true
}
// Collapse one scene, snapshot before/after, and assert the collapse invariant
// plus the exact `moved` flag.
scene :: (name: string, rows: []string, want_moved: bool) {
b := load_board(rows);
orig := load_board(rows); // pristine copy for the invariant check
moved := collapse(@b);
print("== {} ==\n", name);
out("before:\n");
out(board_dump(@orig));
out("after:\n");
out(board_dump(@b));
t.expect(check_collapsed(@orig, @b), concat(name, ": gems packed bottom, holes top, order preserved"));
t.expect(moved == want_moved, concat(name, ": moved flag exact"));
}
main :: () -> s32 {
print("== collapse (gravity) ==\n");
// Eight independent columns, one case each (top-to-bottom):
// col0 holes in the MIDDLE: R O Y G B straddle three holes -> all fall.
// col1 holes at the BOTTOM: R O Y sit on top -> fall to the floor.
// col2 a FULL column of holes -> stays all holes.
// col3 NO holes (eight gems) -> unchanged.
// col4 already settled (holes already at the top) -> unchanged.
// col5 a LONE gem at the very top -> drops to the floor.
// col6 an ALTERNATING gem/hole stack -> gems pack, order preserved.
// col7 three gems already resting on the floor -> unchanged.
scene("varied", .[
"RR.R.RR.",
".O.O....",
".Y.Y..O.",
"O..GR...",
"Y..BO.Y.",
"...RY..R",
"G..OG.GO",
"B..YB..Y",
], true);
// No holes anywhere: gravity has nothing to do, board is left byte-identical.
scene("no-holes", .[
"ROYGBPRO",
"YGBPROYG",
"BPROYGBP",
"ROYGBPRO",
"YGBPROYG",
"BPROYGBP",
"ROYGBPRO",
"YGBPROYG",
], false);
// Every cell a hole: an empty board collapses to itself, nothing moves.
scene("all-holes", .[
"........",
"........",
"........",
"........",
"........",
"........",
"........",
"........",
], false);
// Already settled: every column has its holes contiguous at the top and its
// gems contiguous at the bottom (this IS the post-collapse form of "varied").
// Re-collapsing must move nothing and leave the board unchanged — the
// idempotency the P2.4 cascade relies on to detect that gravity has stopped.
scene("settled", .[
"...R....",
"...O....",
"...Y....",
"R..GR...",
"O..BO.R.",
"YR.RY.OR",
"GO.OG.YO",
"BY.YBRGY",
], false);
print("ok: collapse over hand-crafted boards\n");
return 0;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,78 @@
== collapse (gravity) ==
== varied ==
before:
RR.R.RR.
.O.O....
.Y.Y..O.
O..GR...
Y..BO.Y.
...RY..R
G..OG.GO
B..YB..Y
after:
...R....
...O....
...Y....
R..GR...
O..BO.R.
YR.RY.OR
GO.OG.YO
BY.YBRGY
== no-holes ==
before:
ROYGBPRO
YGBPROYG
BPROYGBP
ROYGBPRO
YGBPROYG
BPROYGBP
ROYGBPRO
YGBPROYG
after:
ROYGBPRO
YGBPROYG
BPROYGBP
ROYGBPRO
YGBPROYG
BPROYGBP
ROYGBPRO
YGBPROYG
== all-holes ==
before:
........
........
........
........
........
........
........
........
after:
........
........
........
........
........
........
........
........
== settled ==
before:
...R....
...O....
...Y....
R..GR...
O..BO.R.
YR.RY.OR
GO.OG.YO
BY.YBRGY
after:
...R....
...O....
...Y....
R..GR...
O..BO.R.
YR.RY.OR
GO.OG.YO
BY.YBRGY
ok: collapse over hand-crafted boards