From b5a3b16651afcfcc2a53b00c6bc30444115629f6 Mon Sep 17 00:00:00 2001 From: swipelab Date: Thu, 4 Jun 2026 20:21:12 +0300 Subject: [PATCH] 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}. --- board.sx | 43 ++++++++++ tests/collapse.sx | 146 +++++++++++++++++++++++++++++++++ tests/expected/collapse.exit | 1 + tests/expected/collapse.stdout | 78 ++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 tests/collapse.sx create mode 100644 tests/expected/collapse.exit create mode 100644 tests/expected/collapse.stdout 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