P3.1: base match scoring by run length (pure sx)
Add a run-enumeration path and base scoring to the pure-sx model, scoring one resolution round's clears purely by maximal-run length. - find_runs(board) enumerates each maximal H/V run (len >= 3) with its length, parallel to find_matches/MatchMask (left untouched so every clear/cascade caller is unaffected). - run_score(len): length 3 -> 30, 4 -> 60, 5+ -> 100 (named constants). - score_round(board): sum of run_score over the current runs (read-only, must be called before the round's clear). L/T rule: each maximal run scores independently by its own length, so an overlapping L/T scores horizontal + vertical (shared corner counts toward both runs). - Board gains a running `score` field (init zeroes it) and add_round_score accumulates a round's base points into it; the cross-round combo multiplier off Cascade.depth is left for P3.2. - tests/score.sx golden over hand-crafted single-round boards asserts exact points for len-3/4/5 runs, disjoint runs (sum), an overlapping L/T, and a no-match board (0).
This commit is contained in:
125
board.sx
125
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 `<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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user