diff --git a/board.sx b/board.sx index c4d6148..1007c48 100644 --- a/board.sx +++ b/board.sx @@ -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 +} diff --git a/tests/collapse.sx b/tests/collapse.sx new file mode 100644 index 0000000..3166574 --- /dev/null +++ b/tests/collapse.sx @@ -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; +} diff --git a/tests/expected/collapse.exit b/tests/expected/collapse.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/collapse.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/collapse.stdout b/tests/expected/collapse.stdout new file mode 100644 index 0000000..1fb3310 --- /dev/null +++ b/tests/expected/collapse.stdout @@ -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