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

This commit is contained in:
swipelab
2026-06-04 21:06:22 +03:00
4 changed files with 354 additions and 0 deletions

125
board.sx
View File

@@ -88,6 +88,12 @@ Board :: struct {
// must seed this before any draw.
rng: Rng;
// Running score total (P3.1). `init` zeroes it; `add_round_score` accumulates
// a round's base points (see `score_round`). The cascade-wide combo MULTIPLIER
// off `Cascade.depth` lands in P3.2, and the HUD (P4.4) reads this field. A
// hand-built board must zero this before accumulating.
score: s64;
idx :: (col: s64, row: s64) -> s64 {
row * BOARD_COLS + col
}
@@ -108,6 +114,7 @@ Board :: struct {
// excluded, so a choice always remains.
init :: (self: *Board, seed: s64) {
self.rng = rng_seeded(seed);
self.score = 0;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
self.set(col, row, pick_gem(self, @self.rng, col, row));
@@ -500,3 +507,121 @@ resolve :: (board: *Board) -> Cascade {
}
result
}
// ── Scoring (P3.1) ───────────────────────────────────────────────────────────
// Base match scoring: value a round's clears purely by RUN LENGTH — longer runs
// are worth more. The scheme is fixed and documented by these named constants:
// a maximal run of length 3 → 30, length 4 → 60, length 5 or more → 100.
//
// Scoring needs each maximal run's LENGTH, not just the unioned matched-cell set
// (`find_matches`/`MatchMask`, which collapses overlaps to a single `true`). So
// this is a separate enumeration path — `find_matches` and every clear/cascade
// caller are untouched. L/T rule: each maximal run is scored INDEPENDENTLY by
// its own length, so an L (a horizontal run meeting a vertical run at a shared
// corner) scores horizontal + vertical — the corner counts toward both runs'
// lengths, unlike the cleared-cell set which unions it once.
//
// One round only: the cross-round combo MULTIPLIER (off `Cascade.depth`) is P3.2.
SCORE_RUN_3 :: 30;
SCORE_RUN_4 :: 60;
SCORE_RUN_5_PLUS :: 100;
// One maximal same-type run of length >= 3. `vertical` picks the axis; `fixed`
// is the constant coordinate (the row for a horizontal run, the column for a
// vertical one) and the run covers `start..start+len` of the moving coordinate.
Run :: struct {
vertical: bool;
fixed: s64;
start: s64;
len: s64;
}
// Base points for a single maximal run, by length. Runs are always length >= 3
// (shorter spans are not enumerated), so 3 is the floor; 5 and longer all score
// the top tier.
run_score :: (len: s64) -> s64 {
if len <= 3 { return SCORE_RUN_3; }
if len == 4 { return SCORE_RUN_4; }
SCORE_RUN_5_PLUS
}
// Enumerate every maximal horizontal and vertical run of length >= 3 with its
// length, in a stable order: all horizontal runs row-major (top-to-bottom, each
// row left-to-right), then all vertical runs column-major. The scan mirrors
// `find_matches` exactly — same maximal-span walk, same `.empty` exclusion (holes
// never run) — but records each run's length instead of marking a shared mask, so
// an intersecting L/T yields the horizontal run AND the vertical run as two
// separate entries rather than one unioned cell set.
find_runs :: (b: *Board) -> List(Run) {
runs := List(Run).{};
for 0..BOARD_ROWS: (row) {
col := 0;
while col < BOARD_COLS {
g := b.at(col, row);
run_end := col + 1;
while run_end < BOARD_COLS and b.at(run_end, row) == g {
run_end += 1;
}
if g != .empty and run_end - col >= 3 {
runs.append(Run.{ vertical = false, fixed = row, start = col, len = run_end - col });
}
col = run_end;
}
}
for 0..BOARD_COLS: (col) {
row := 0;
while row < BOARD_ROWS {
g := b.at(col, row);
run_end := row + 1;
while run_end < BOARD_ROWS and b.at(col, run_end) == g {
run_end += 1;
}
if g != .empty and run_end - row >= 3 {
runs.append(Run.{ vertical = true, fixed = col, start = row, len = run_end - row });
}
row = run_end;
}
}
runs
}
// Base points for clearing the board's currently-matched runs THIS round: the
// sum of `run_score` over every maximal run from `find_runs`. Pure and
// read-only — it inspects the board but changes nothing, so it must be called
// BEFORE the round's clear, while the runs are still on the board. A board with
// no run scores 0.
score_round :: (board: *Board) -> s64 {
runs := find_runs(board);
total : s64 = 0;
for 0..runs.len: (i) {
total += run_score(runs.items[i].len);
}
total
}
// Add this round's base points to the board's running `score` total and return
// them. The accumulation primitive the HUD (P4.4) reads and that P3.2 wraps with
// the cascade combo multiplier — P3.2 layers the per-round multiplier here / in
// the resolve loop without changing `score_round`.
add_round_score :: (board: *Board) -> s64 {
points := score_round(board);
board.score += points;
points
}
// Deterministic textual dump of an enumerated run list, in `find_runs` order: a
// count header, then one run per line as `<axis> len <n> at fixed <f> start <s>`
// where axis is H (horizontal) or V (vertical). An empty list dumps as just
// "0 runs". Suitable for snapshotting.
dump_runs :: (runs: *List(Run)) -> string {
result := format("{} runs\n", runs.len);
for 0..runs.len: (i) {
r := runs.items[i];
axis := if r.vertical then "V" else "H";
result = concat(result, format("{} len {} at fixed {} start {}\n", axis, r.len, r.fixed, r.start));
}
result
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,84 @@
== score (base match scoring) ==
== len3-run-30 ==
OGOGOGOG
GOGOGOGO
OGOGOGOG
GORRROGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
--
1 runs
H len 3 at fixed 3 start 2
points 30
== len4-run-60 ==
OGOGOGOG
GOGOGOGO
OGOGOGOG
GORRRRGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
--
1 runs
H len 4 at fixed 3 start 2
points 60
== len5-run-100 ==
OGOGOGOG
GRRRRRGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
--
1 runs
H len 5 at fixed 1 start 1
points 100
== disjoint-30+60+100 ==
RRRGOGOG
GOGOGOGO
GOBBBBOG
OGOGOGOG
GPPPPPGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
--
3 runs
H len 3 at fixed 0 start 0
H len 4 at fixed 2 start 2
H len 5 at fixed 4 start 1
points 190
== overlap-LT-per-run-120 ==
OGOGOGOG
GRRRGOGO
OROGOGOG
GRGOGOGO
OGOGOGOG
GOGYYYGO
OGOGYGOG
GOGOYOGO
--
4 runs
H len 3 at fixed 1 start 1
H len 3 at fixed 5 start 3
V len 3 at fixed 1 start 1
V len 3 at fixed 4 start 5
points 120
== no-match-0 ==
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
--
0 runs
points 0
ok: base scoring over hand-crafted boards

144
tests/score.sx Normal file
View File

@@ -0,0 +1,144 @@
// Base-scoring golden (P3.1): score several HAND-CRAFTED single-round boards by
// RUN LENGTH and snapshot the runs + points. Every board sits on the run-free
// O/G checkerboard (adjacent cells always differ, so zero stray runs) with only
// the runs under test painted in, so the score is purely the painted runs'.
//
// Scheme (named constants in board.sx): run length 3 -> 30, length 4 -> 60,
// length 5+ -> 100. L/T rule: each maximal run scores INDEPENDENTLY by its own
// length, so an overlapping L/T scores horizontal + vertical (the shared corner
// counts toward both runs). The scene names encode the expected points.
//
// For each scene the board, its enumerated runs, and the round's points are
// printed, and two facts are asserted independently of the dump: `score_round`
// equals the documented value, and `add_round_score` adds exactly that into the
// board's running `score` total.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
// Inverse of `gem_char`: map a board character back to its Gem so each board can
// be written as a human-readable grid. The hole glyph maps to `.empty`.
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),
// with the running score zeroed so the accumulation check starts from a known base.
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.score = 0;
b
}
// Score one scene: snapshot board + enumerated runs + points, then assert
// `score_round` is exact and `add_round_score` accumulates it into `board.score`.
scene :: (name: string, rows: []string, want_points: s64) {
b := load_board(rows);
runs := find_runs(@b);
print("== {} ==\n", name);
out(board_dump(@b));
out("--\n");
out(dump_runs(@runs));
print("points {}\n", score_round(@b));
t.expect(score_round(@b) == want_points, concat(name, ": score_round exact"));
added := add_round_score(@b);
t.expect(added == want_points and b.score == want_points,
concat(name, ": add_round_score accumulates into board.score"));
}
main :: () -> s32 {
print("== score (base match scoring) ==\n");
// Single length-3 horizontal run (row 3, cols 2-4) -> SCORE_RUN_3 = 30.
scene("len3-run-30", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GORRROGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 30);
// Single length-4 horizontal run (row 3, cols 2-5) -> SCORE_RUN_4 = 60.
scene("len4-run-60", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GORRRRGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 60);
// Single length-5 horizontal run (row 1, cols 1-5) -> SCORE_RUN_5_PLUS = 100.
scene("len5-run-100", .[
"OGOGOGOG",
"GRRRRRGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 100);
// Multiple disjoint runs, points = sum of per-run values: horizontal R len3
// (row 0, cols 0-2 -> 30), horizontal B len4 (row 2, cols 2-5 -> 60),
// horizontal P len5 (row 4, cols 1-5 -> 100) = 190.
scene("disjoint-30+60+100", .[
"RRRGOGOG",
"GOGOGOGO",
"GOBBBBOG",
"OGOGOGOG",
"GPPPPPGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
], 190);
// Overlapping L and T, scored per-run independently: the L is a horizontal R
// len3 (row 1, cols 1-3) meeting a vertical R len3 (col 1, rows 1-3) at corner
// (1,1) -> 30 + 30; the T is a horizontal Y len3 (row 5, cols 3-5) meeting a
// vertical Y len3 (col 4, rows 5-7) at (4,5) -> 30 + 30. Total 120 (the shared
// corners count toward both runs, unlike the unioned clear set).
scene("overlap-LT-per-run-120", .[
"OGOGOGOG",
"GRRRGOGO",
"OROGOGOG",
"GRGOGOGO",
"OGOGOGOG",
"GOGYYYGO",
"OGOGYGOG",
"GOGOYOGO",
], 120);
// No match: the bare checkerboard has no run -> 0 points.
scene("no-match-0", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 0);
print("ok: base scoring over hand-crafted boards\n");
return 0;
}