Merge branch 'flow/m3te/P2.1' into m3te-plan

This commit is contained in:
swipelab
2026-06-04 20:15:20 +03:00
4 changed files with 370 additions and 4 deletions

View File

@@ -8,8 +8,10 @@
#import "modules/std.sx";
// ── Gem ──────────────────────────────────────────────────────────────────
// Six distinct gem types. The enum's ordinal (0..5) IS the gem index, so it
// casts cleanly to/from the integers the RNG and the textual dump work in.
// Six distinct gem types plus an `empty` hole sentinel. The ordinal of a real
// gem (0..5) IS its gem index, so it casts cleanly to/from the integers the RNG
// and the textual dump work in; `empty` (ordinal 6) sits outside that range and
// is never drawn by the RNG.
GEM_COUNT :: 6;
Gem :: enum {
@@ -19,13 +21,24 @@ Gem :: enum {
green;
blue;
purple;
// A hole: a cell with no gem, left behind when a match is cleared (P2.1).
// Distinct from all six gem types and never produced by init/pick_gem
// (which only draw ordinals 0..GEM_COUNT), so gravity/refill (P2.2/P2.3) can
// test a cell for `== .empty` to find holes. Outside GEM_CHARS, so it dumps
// via the dedicated EMPTY_CHAR rather than the gem alphabet.
empty;
}
// One stable character per gem type, indexed by ordinal — the alphabet the
// board dump (and its golden) is written in.
GEM_CHARS :: "ROYGBP";
// Hole glyph for the board dump: an empty cell renders as this instead of a gem
// character. Distinct from every gem in GEM_CHARS.
EMPTY_CHAR :: 46; // '.'
gem_char :: (g: Gem) -> u8 {
if g == .empty { return EMPTY_CHAR; }
GEM_CHARS[cast(s64) g]
}
@@ -186,6 +199,10 @@ mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) {
// so length-4 / length-5 runs are simply longer spans of the same walk. A cell
// shared by an intersecting horizontal and vertical run is marked once per
// axis into the same slot — idempotent, so the union counts it once.
//
// Only runs of an actual gem match: `.empty` holes are never matchable, so a
// line of 3+ holes (left behind by a prior clear) is not a match. Holes also
// break runs of real gems, since a hole differs from every gem type.
find_matches :: (b: *Board) -> MatchMask {
m : MatchMask = ---;
for 0..BOARD_CELLS: (i) { m.cells[i] = false; }
@@ -199,7 +216,7 @@ find_matches :: (b: *Board) -> MatchMask {
while run_end < BOARD_COLS and b.at(run_end, row) == g {
run_end += 1;
}
if run_end - col >= 3 { mark_run(@m, false, row, col, run_end); }
if g != .empty and run_end - col >= 3 { mark_run(@m, false, row, col, run_end); }
col = run_end;
}
}
@@ -213,7 +230,7 @@ find_matches :: (b: *Board) -> MatchMask {
while run_end < BOARD_ROWS and b.at(col, run_end) == g {
run_end += 1;
}
if run_end - row >= 3 { mark_run(@m, true, col, row, run_end); }
if g != .empty and run_end - row >= 3 { mark_run(@m, true, col, row, run_end); }
row = run_end;
}
}
@@ -332,3 +349,32 @@ dump_swaps :: (swaps: *List(Swap)) -> string {
}
result
}
// ── Clear (P2.1) ─────────────────────────────────────────────────────────────
// First step of the resolution pipeline: turn matched cells into holes. No
// gravity or refill here (P2.2 / P2.3) — clearing only writes `.empty` into the
// matched cells and leaves every other cell exactly as it was.
// Set every cell flagged in `mask` to a hole, leaving all unflagged cells
// unchanged. Returns the number of cells cleared. `mask` is the matched-cell SET
// from find_matches, so overlapping L/T shapes (already unioned into a single
// `true` per shared cell) clear as one set with no double-counting.
clear_cells :: (board: *Board, mask: *MatchMask) -> s64 {
cleared : s64 = 0;
for 0..BOARD_CELLS: (i) {
if mask.cells[i] {
board.cells[i] = .empty;
cleared += 1;
}
}
cleared
}
// Detect matches on `board` and clear them in one step. Returns the number of
// cells cleared — 0 when there are no matches, in which case the board is left
// unchanged. The count drives later cascade/scoring (P2.2+): a non-zero result
// means the board changed and the resolution loop should continue.
clear_matches :: (board: *Board) -> s64 {
m := find_matches(board);
clear_cells(board, @m)
}

203
tests/clear.sx Normal file
View File

@@ -0,0 +1,203 @@
// Clear golden: run detect→clear over several HAND-CRAFTED boards and snapshot
// the post-clear board. Each 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 runs under test painted in — so any hole in the result
// is purely the cleared match's doing. For each scene the before/after boards
// are printed, and three facts are asserted independently of the dump: matched
// cells became holes, non-matched cells are byte-identical, and the cleared
// count is exact. The boards (and their match counts) mirror match_detect.sx.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
// Inverse of `gem_char`: map a gem character back to its Gem so each board can
// be written as a human-readable grid. The hole glyph maps to `.empty`, so a
// board can be hand-written with pre-existing holes (cells left by a prior
// clear) for the holes-never-match regression.
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 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
}
// Detect→clear one scene, snapshot before/after, and assert the three clear
// invariants against the matched-cell set: every flagged cell is now a hole,
// every unflagged cell is unchanged, and the returned count is exact.
scene :: (name: string, rows: []string, want_cleared: s64) {
b := load_board(rows);
orig := load_board(rows); // pristine copy for the unchanged check
m := find_matches(@b);
cleared := clear_cells(@b, @m);
print("== {} ==\n", name);
out("before:\n");
out(board_dump(@orig));
out("after:\n");
out(board_dump(@b));
cleared_holes := true; // every matched cell is now a hole
others_intact := true; // every other cell is byte-identical
for 0..BOARD_CELLS: (i) {
if m.cells[i] {
if !(b.cells[i] == .empty) { cleared_holes = false; }
} else {
if !(b.cells[i] == orig.cells[i]) { others_intact = false; }
}
}
t.expect(cleared_holes, concat(name, ": cleared cells are holes"));
t.expect(others_intact, concat(name, ": non-matched cells unchanged"));
t.expect(cleared == want_cleared, concat(name, ": cleared count exact"));
}
main :: () -> s32 {
print("== clear (detect -> clear) ==\n");
// Single horizontal 3-run (row 3, cols 2-4) → three holes there only.
scene("horizontal-3", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GORRROGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 3);
// Single vertical 3-run (col 5, rows 2-4).
scene("vertical-3", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOBOG",
"GOGOGBGO",
"OGOGOBOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 3);
// Disjoint runs: horizontal R (row 1), horizontal P (row 5), vertical Y
// (col 6) — three separate hole clusters, 9 cells total.
scene("disjoint-runs", .[
"OGOGOGOG",
"RRROGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGYG",
"GOPPPOYO",
"OGOGOGYG",
"GOGOGOGO",
], 9);
// Overlapping L and T: a horizontal run and a vertical run share a cell (the
// L's corner (1,1), the T's stem-top (4,5)). The mask already unions the
// shared cell, so clear removes the whole union as one set — 10 holes, not
// 11 — exercising the overlapping-clear acceptance case.
scene("L-and-T", .[
"OGOGOGOG",
"GRRRGOGO",
"OROGOGOG",
"GRGOGOGO",
"OGOGOGOG",
"GOGYYYGO",
"OGOGYGOG",
"GOGOYOGO",
], 10);
// No matches: the bare checkerboard is left completely unchanged (0 holes),
// so its before/after dumps are identical.
scene("no-matches", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 0);
// Holes never match: a checkerboard carrying a horizontal 3-run of holes
// (row 3, cols 2-4) and a vertical 3-run of holes (col 1, rows 5-7), left by
// earlier clears. A line of 3+ holes is NOT a match, so detect finds nothing,
// clear removes nothing, and before/after are identical. Without this, a
// post-clear board would keep re-"matching" its own holes and the P2.4
// cascade would never stabilise.
scene("holes-no-match", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GO...OGO",
"OGOGOGOG",
"G.GOGOGO",
"O.OGOGOG",
"G.GOGOGO",
], 0);
// clear_matches: the one-call detect+clear returns the same cleared count
// and punches the holes itself.
cm := load_board(.[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GORRROGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
]);
t.expect(clear_matches(@cm) == 3, "clear_matches: detect+clear returns count");
t.expect(cm.at(2, 3) == .empty and cm.at(3, 3) == .empty and cm.at(4, 3) == .empty,
"clear_matches: matched run is now holes");
// Holes are never matchable: a board whose only equal-adjacent runs are
// holes yields an empty match set, and clear_matches reports 0 (no change).
holes := load_board(.[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GO...OGO",
"OGOGOGOG",
"G.GOGOGO",
"O.OGOGOG",
"G.GOGOGO",
]);
hm := find_matches(@holes);
t.expect(hm.count() == 0, "holes: a line of 3+ holes is not a match");
t.expect(clear_matches(@holes) == 0, "holes: clear_matches returns 0 on a holes-only board");
// Cascade base case: after a real clear punches a 3-in-a-line into holes,
// re-detecting on the cleared board must find nothing — otherwise the P2.4
// cascade loop would re-match its own holes and never terminate.
casc := load_board(.[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOBOG",
"GOGOGBGO",
"OGOGOBOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
]);
t.expect(clear_matches(@casc) == 3, "cascade: first clear removes the vertical 3-run");
t.expect(clear_matches(@casc) == 0, "cascade: re-clear on the holed board returns 0");
print("ok: clear over hand-crafted boards\n");
return 0;
}

View File

@@ -0,0 +1 @@
0

116
tests/expected/clear.stdout Normal file
View File

@@ -0,0 +1,116 @@
== clear (detect -> clear) ==
== horizontal-3 ==
before:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GORRROGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
after:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GO...OGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
== vertical-3 ==
before:
OGOGOGOG
GOGOGOGO
OGOGOBOG
GOGOGBGO
OGOGOBOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
after:
OGOGOGOG
GOGOGOGO
OGOGO.OG
GOGOG.GO
OGOGO.OG
GOGOGOGO
OGOGOGOG
GOGOGOGO
== disjoint-runs ==
before:
OGOGOGOG
RRROGOGO
OGOGOGOG
GOGOGOGO
OGOGOGYG
GOPPPOYO
OGOGOGYG
GOGOGOGO
after:
OGOGOGOG
...OGOGO
OGOGOGOG
GOGOGOGO
OGOGOG.G
GO...O.O
OGOGOG.G
GOGOGOGO
== L-and-T ==
before:
OGOGOGOG
GRRRGOGO
OROGOGOG
GRGOGOGO
OGOGOGOG
GOGYYYGO
OGOGYGOG
GOGOYOGO
after:
OGOGOGOG
G...GOGO
O.OGOGOG
G.GOGOGO
OGOGOGOG
GOG...GO
OGOG.GOG
GOGO.OGO
== no-matches ==
before:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
after:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
== holes-no-match ==
before:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GO...OGO
OGOGOGOG
G.GOGOGO
O.OGOGOG
G.GOGOGO
after:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GO...OGO
OGOGOGOG
G.GOGOGO
O.OGOGOG
G.GOGOGO
ok: clear over hand-crafted boards