From c7368a82f180c176ea901cc39f6a0e08895a3eea Mon Sep 17 00:00:00 2001 From: swipelab Date: Thu, 4 Jun 2026 21:17:08 +0300 Subject: [PATCH] P3.2: cascade combo multiplier (pure sx) Scale each cascade round's base points by combo_multiplier(round) = the 1-based round index (round 1 x1, round 2 x2, ...), so deeper chains pay out more. resolve now reads score_round before each clear, accumulates score_round * combo_multiplier(round) into Board.score, and reports the settle's payout as the new Cascade.awarded field. A depth-1 settle scores exactly its base (x1, no bonus); any multi-round chain strictly exceeds the same clears scored flat. resolve_step keeps its signature (no scoring), so cascade.sx and its golden are unchanged; score_round/add_round_score are untouched, so score.sx is unchanged. New tests/combo.sx golden locks exact cumulative scores for a single-round clear (30), the P2.4 cascade board (flat 60 -> mult 90), and a controlled 3-round chain (flat 90 -> mult 180), printing per-round base/multiplier/points so the golden self-explains. --- board.sx | 57 ++++++++++----- tests/combo.sx | 140 ++++++++++++++++++++++++++++++++++++ tests/expected/combo.exit | 1 + tests/expected/combo.stdout | 41 +++++++++++ 4 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 tests/combo.sx create mode 100644 tests/expected/combo.exit create mode 100644 tests/expected/combo.stdout diff --git a/board.sx b/board.sx index 9508cfc..975ab60 100644 --- a/board.sx +++ b/board.sx @@ -88,10 +88,10 @@ 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. + // Running score total. `init` zeroes it; `add_round_score` adds a single + // round's base points (see `score_round`), and `resolve` adds each cascade + // round's base scaled by `combo_multiplier` (P3.2). The HUD (P4.4) reads this + // field. A hand-built board must zero this before accumulating. score: s64; idx :: (col: s64, row: s64) -> s64 { @@ -474,11 +474,14 @@ refill :: (board: *Board) -> s64 { // Outcome of resolving a board to a stable state. `depth` is the number of // rounds that found and cleared at least one match (0 for an already-stable // board). `cleared` holds those rounds' cleared-cell counts in round order, so -// `cleared.len == depth`; P3 scores each round off this list and reads the -// combo multiplier from the depth. +// `cleared.len == depth`. `awarded` is the total points this settle added to +// `Board.score`: the sum over rounds of `score_round * combo_multiplier(round)` +// (P3.2), so the HUD (P4.4) and turn accounting (P3.3) can read a swap's payout +// without re-deriving it. A depth-0 (already-stable) board awards 0. Cascade :: struct { depth: s64; cleared: List(s64); + awarded: s64; } // One resolution round: detect matches and, if any, clear them, collapse under @@ -494,16 +497,24 @@ resolve_step :: (board: *Board) -> s64 { cleared } -// Resolve the board to a stable state, running rounds until one finds no match. -// Returns the cascade: its depth and per-round cleared-cell counts. An -// already-stable board returns depth 0 with an empty `cleared` list, untouched. +// Resolve the board to a stable state, running rounds until one finds no match, +// scoring each round with the cascade combo multiplier (P3.2). Returns the +// cascade: its depth, per-round cleared-cell counts, and total `awarded` points. +// Each round adds `score_round * combo_multiplier(round)` (round 1-based) to +// `Board.score`; an already-stable board returns depth 0, awards 0, untouched. resolve :: (board: *Board) -> Cascade { - result := Cascade.{ depth = 0, cleared = List(s64).{} }; + result := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0 }; while true { + // Read the round's base points while its runs are still on the board: + // `resolve_step` clears them, so the score has to be taken first. + base := score_round(board); n := resolve_step(board); if n == 0 { break; } - result.cleared.append(n); result.depth += 1; + points := base * combo_multiplier(result.depth); + board.score += points; + result.awarded += points; + result.cleared.append(n); } result } @@ -521,7 +532,8 @@ resolve :: (board: *Board) -> Cascade { // 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. +// One round only: the cross-round combo MULTIPLIER is `combo_multiplier` (P3.2), +// applied by `resolve`; this base scheme is unscaled. SCORE_RUN_3 :: 30; SCORE_RUN_4 :: 60; SCORE_RUN_5_PLUS :: 100; @@ -602,16 +614,29 @@ score_round :: (board: *Board) -> s64 { 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 this round's base points (×1, no combo multiplier) to the board's running +// `score` total and return them. The single-round accumulation primitive; the +// cascade loop (`resolve`) instead scales each round by `combo_multiplier` +// (P3.2). Neither path changes `score_round`. add_round_score :: (board: *Board) -> s64 { points := score_round(board); board.score += points; points } +// ── Combo multiplier (P3.2) ──────────────────────────────────────────────── +// Across one swap's cascade, each resolution round's base points (`score_round`) +// are scaled by a multiplier that grows with chain depth, so deeper chains pay +// out more. The scheme: the 1-based round index IS the multiplier — round 1 ×1, +// round 2 ×2, round 3 ×3, … A single-round settle (depth 1) therefore scores +// exactly its base (×1, no bonus); every round past the first is amplified, so a +// multi-round chain strictly beats the same clears scored flat. `resolve` +// accumulates `score_round * combo_multiplier(round)` per round into `Board.score` +// and reports the sum as `Cascade.awarded`. +combo_multiplier :: (round: s64) -> s64 { + round +} + // 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 diff --git a/tests/combo.sx b/tests/combo.sx new file mode 100644 index 0000000..ab08176 --- /dev/null +++ b/tests/combo.sx @@ -0,0 +1,140 @@ +// Combo-multiplier golden (P3.2): drive seeded boards through the full cascade +// settle and snapshot the per-round scoring. Each round's base points +// (`score_round`) are scaled by `combo_multiplier(round)` = the 1-based round +// index, so round 1 ×1, round 2 ×2, round 3 ×3, …; `resolve` accumulates the +// multiplied sum into `Board.score` and reports it as `Cascade.awarded`. +// +// Three scenes prove the rule end to end: +// - single-depth1: one clear, depth 1 → base ×1 = base exactly (NO bonus). +// - cascade-depth2: the P2.4 cascade board (seed 7) → a real 2-round chain +// whose multiplied total (90) strictly beats the flat sum (60). +// - chain-depth3: the same crafted board at seed 10 → a 3-round chain, +// 30×1 + 30×2 + 30×3 = 180, well above the flat 90. +// +// For each scene the starting board and every round's (cleared, base, multiplier, +// round points) are printed so the golden is self-explanatory, the flat and +// multiplied totals are printed side by side, and `resolve` on a fresh identical +// board is asserted to award EXACTLY the multiplied total into `Board.score`. +#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), +// seeded RNG, running score zeroed so `board.score` ends equal to the payout. +load_board :: (rows: []string, seed: s64) -> 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.rng = rng_seeded(seed); + b.score = 0; + b +} + +// One scene: drive the settle one round at a time so each round is visible in the +// snapshot — score_round BEFORE the clear, multiplier off the 1-based round index, +// mirroring `resolve` exactly. Print per-round (cleared, base, multiplier, round +// points) and the flat-vs-multiplied totals, then assert `resolve` on a fresh +// identical board awards `want_mult` into `Board.score` and reports it as +// `Cascade.awarded` at the same depth. A depth-1 settle must equal the flat sum +// (no bonus); a deeper chain must strictly exceed it. +scene :: (name: string, rows: []string, seed: s64, want_flat: s64, want_mult: s64) { + print("== {} ==\n", name); + b := load_board(rows, seed); + out(board_dump(@b)); + + flat : s64 = 0; + mult : s64 = 0; + depth : s64 = 0; + while true { + base := score_round(@b); + n := resolve_step(@b); + if n == 0 { break; } + depth += 1; + m := combo_multiplier(depth); + print("round {}: cleared {} base {} x{} = {}\n", depth, n, base, m, base * m); + flat += base; + mult += base * m; + } + print("flat sum {}\n", flat); + print("multiplied total {}\n", mult); + + t.expect(flat == want_flat, concat(name, ": flat sum exact")); + t.expect(mult == want_mult, concat(name, ": multiplied total exact")); + if depth >= 2 { + t.expect(mult > flat, concat(name, ": multi-round chain beats flat sum")); + } else { + t.expect(mult == flat, concat(name, ": single round scores flat (no bonus)")); + } + + // The public `resolve` on a fresh identical board reproduces the payout: + // accumulates the multiplied total into `Board.score` and reports it as + // `Cascade.awarded`, at the same depth. + b2 := load_board(rows, seed); + c := resolve(@b2); + t.expect(c.depth == depth, concat(name, ": resolve depth matches manual loop")); + t.expect(c.awarded == want_mult, concat(name, ": resolve awarded equals multiplied total")); + t.expect(b2.score == want_mult, concat(name, ": resolve accumulates into board.score")); +} + +main :: () -> s32 { + print("== combo (cascade multiplier) ==\n"); + + // Single-round clear (seed 0): one RRR clears and the refill makes no new + // match, so the settle stops at depth 1 → base 30 ×1 = 30, exactly the flat + // value. Proves there is no combo bonus on a single round. + scene("single-depth1", .[ + "RRRGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 0, 30, 30); + + // The P2.4 cascade board (seed 7): round 1 clears the horizontal BBB (base 30 + // ×1), round 2 the gravity-formed vertical RRR (base 30 ×2) → 30 + 60 = 90, + // strictly above the flat 30 + 30 = 60. + scene("cascade-depth2", .[ + "OGOGOGOG", + "GOGOGOGO", + "RGOGOGOG", + "BBBOGOGO", + "RGOGOGOG", + "ROGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 7, 60, 90); + + // The same crafted board at seed 10: the refill after round 2 sets up a third + // len-3 clear, a controlled 3-round chain → 30×1 + 30×2 + 30×3 = 180, well + // above the flat 90. + scene("chain-depth3", .[ + "OGOGOGOG", + "GOGOGOGO", + "RGOGOGOG", + "BBBOGOGO", + "RGOGOGOG", + "ROGOGOGO", + "OGOGOGOG", + "GOGOGOGO", + ], 10, 90, 180); + + print("ok: combo multiplier scales cascade rounds\n"); + return 0; +} diff --git a/tests/expected/combo.exit b/tests/expected/combo.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/combo.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/combo.stdout b/tests/expected/combo.stdout new file mode 100644 index 0000000..a008c7a --- /dev/null +++ b/tests/expected/combo.stdout @@ -0,0 +1,41 @@ +== combo (cascade multiplier) == +== single-depth1 == +RRRGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +OGOGOGOG +GOGOGOGO +round 1: cleared 3 base 30 x1 = 30 +flat sum 30 +multiplied total 30 +== cascade-depth2 == +OGOGOGOG +GOGOGOGO +RGOGOGOG +BBBOGOGO +RGOGOGOG +ROGOGOGO +OGOGOGOG +GOGOGOGO +round 1: cleared 3 base 30 x1 = 30 +round 2: cleared 3 base 30 x2 = 60 +flat sum 60 +multiplied total 90 +== chain-depth3 == +OGOGOGOG +GOGOGOGO +RGOGOGOG +BBBOGOGO +RGOGOGOG +ROGOGOGO +OGOGOGOG +GOGOGOGO +round 1: cleared 3 base 30 x1 = 30 +round 2: cleared 3 base 30 x2 = 60 +round 3: cleared 3 base 30 x3 = 90 +flat sum 90 +multiplied total 180 +ok: combo multiplier scales cascade rounds