diff --git a/board.sx b/board.sx index 4e139ba..9508cfc 100644 --- a/board.sx +++ b/board.sx @@ -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 ` len at fixed start ` +// 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 +} diff --git a/tests/expected/score.exit b/tests/expected/score.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/score.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/score.stdout b/tests/expected/score.stdout new file mode 100644 index 0000000..7aa1d86 --- /dev/null +++ b/tests/expected/score.stdout @@ -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 diff --git a/tests/score.sx b/tests/score.sx new file mode 100644 index 0000000..a3626a2 --- /dev/null +++ b/tests/score.sx @@ -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; +}