Skip to content

skull_raiders: free-spins totalWin = freegame_wins (feature-only) - #116

Closed
mdicillo wants to merge 19 commits into
engineio:mainfrom
mdicillo:freespins-totalwin-feature-only
Closed

skull_raiders: free-spins totalWin = freegame_wins (feature-only)#116
mdicillo wants to merge 19 commits into
engineio:mainfrom
mdicillo:freespins-totalwin-feature-only

Conversation

@mdicillo

@mdicillo mdicillo commented Sep 3, 2026

Copy link
Copy Markdown

What

game_override.py::update_freespin annotated each updateFreeSpin event's live totalWin with the round-scoped running_bet_win (base + feature). When a base spin paid a line and triggered the feature, the free-spins counter therefore opened on that base win instead of 0 — the base win leaked into the feature tally, and the on-screen TOTAL WIN dropped back at the outro.

Fix: use the feature-scoped freegame_wins, matching the fake-math reference model and the freeSpinEnd outro (which already reports freegame_wins).

Blast radius — presentation only

finalWin, per-book payouts, RTP and the optimized lookup tables are unchanged — only this one event field's bytes differ. The sims are seeded, so regenerating produced identical outcomes with the corrected annotation. Books regenerated via make run GAME=skull_raiders; SHA-256 + payout hashes verified, 100k entries/mode.

Verification

In the game repo (castle-raid), npm run parity against the regenerated books:

  • key/shape parity: OK (all event types)
  • value invariant "first free-spin totalWin == 0": was violated in thousands of books, now 0 violations across all 7 modes
  • spot-check: a book with a $3 base trigger win now opens the FS counter at 0; finalWin still equals base + feature.

Pairs with the castle-raid PR that reverts the interim client-side workaround and adds the parity value-check guard.

🤖 Generated with Claude Code

Mike Dicillo and others added 19 commits July 13, 2026 15:28
Full math-SDK port of Camp Deadwater: 5x5 lines game, merit-badge wild
multiplier, additive Helping Hands tumble, 3 tiers, 2 antes, 3 buys, Dig
Deeper tier upgrade. All 6 bet modes optimized to 96.00% RTP; wincap 5000x;
buys 100/250/500. 3-star volatility verified (platform gates cost-normalized
per mode). Generated library/ is gitignored (regenerate via `make run`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the emitted book a superset of the client's internal event vocabulary so
the TypeScript adapter (fromStakeBook) is a clean 1:1 map with no fidelity loss.
Math is unchanged — only extra event fields are added; RTP holds at 0.96 on all
6 modes, SHA-256 + payout hash verify OK.

- reveal: attach this spin's merit badge as `wildMultiplier` on every reveal
  (base + free, incl. losing/no-wild spins). Roll the badge before draw_board so
  it's set at reveal time (gamestate), attach in a draw_board override.
- winInfo: attach `hasWildWin` so the client sets its callout multiplier exactly
  when a wild is in a winning line (meta.globalMult collapses 1x-wild and no-wild).
- handClear: emit the `cleared` cells the hands pulled (unpadded board coords).
- freeSpinTrigger: level, count, baseLevel (Dig Deeper upgrade), maxHands, tier
  floor multiplier — everything bonusTrigger + bonusStart need.
- freeSpinRetrigger: level, added, capped (SDK never enforces bonus_max, so capped
  stays False — faithful to the certified math; do not wire the cap without re-cert).
- updateFreeSpin: cumulative feature `totalWin`; freeSpinEnd: level + maxWin flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regenerated BR0 / BR_ante2 / BR_ante3 from the updated TS model
(npm run reels:export): highs 30->34, lows 70->68, base WILD 3.97->3.90.
Feature strips (FR0 / FR_tier2 / FR_tier3 / FRWCAP) are byte-identical —
the free-spins H/L densities are frozen, so feature EV and every buy mode
stay put.

100k cert: all 6 modes optimize to 0.9600 RTP, SHA-256 + payout hashes OK,
5000x wincap preserved, all cost-normalized volatility gates pass
(CVaR/cost < 800, ETL/cost < 0.9). run.py kept at 10k dev default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
camp_deadwater: A-minus base-reel reweight (100k certified)
Sync the SDK game to the dev model's mid-juice + first-aid work
(camp-deadwater #143/#144/#145) and add the mystery reveal.

- Paytable: de-top-heavied to match SYMBOLS (WILD 5-OAK 60->45; H1
  3/12/55->3/14/40; low ranks regrouped 1-3-1).
- FIRSTAID mystery symbol on BR0/ANTE reels (re-exported), revealed
  before scoring: reveal_mystery() ports the TS "match & extend"
  (best-completing left-anchored run; cosmetic left-neighbour then a
  real-symbol fallback, so a kit never reveals as itself/wild/scatter).
  Registered via a create_symbol_map override (framework only
  auto-registers paytable + special symbols).
- Buys draw a FIRSTAID-free base reel (BR0_buy) so a bought bonus never
  lands an unrevealed kit on its trigger board.
- Reels re-exported (BONUS 1.47, FEATURE_WILD 30.5, ante mults).

Re-cert: 100k, all 6 modes 0.9600 RTP, SHA-256 + payout hash OK;
cost-normalized volatility within limits (buys 4.3-8.5 < 800,
ante_allout 0.40 < 0.9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
camp_deadwater: first-aid mystery re-cert (base + antes)
Ports the stake-game-two (Jelly Jamboree) TS fake-math into the math-SDK,
following the camp_deadwater milestone pattern. A 5x3 243-ways CASCADING slot.

Milestone A: grid, Rage-Quit paytable, exported reels (WILD->W / SCATTER->S /
MYSTERY->M), 25,000x wincap, and the ways-evaluation + tumble loop in both base
and free spins. The multiplier LADDER is neutralized (global_multiplier held at
1) and the mystery "?" is inert, so base ways + cascade math can be verified
before the multiplier mechanics land.

Verified against 2k debug sims: every win row equals paytable*ways*100 (book
units), globalMult is 1 everywhere, and cascades chain (depth up to 25).

Milestones B (ladder + WILD flat pay), C (wheel + tiers + retrigger), and D
(six bet modes + optimizer + cert) follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Ladder: global_multiplier climbs +1 per winning tumble (opening drop 1x,
  then 2x, 3x, …) via update_global_mult in the base + free tumble loops.
  Reset each base spin; reset each free spin for now (tier persistence is C).
- WILD flat pay: add_wild_pay() awards a flat 5x total bet ONCE when a wild
  sits on all five reels, x the ladder, stacked on the ways wins the wilds
  complete. Not a per-way pay, so it is appended to win_data directly.

Verified: all symbol wins pay paytable*ways*ladder*100; globalMult climbs
1..25 across cascades; max payout now 343x (was 47x). Wild-pay path
unit-tested directly (fires once at 5x*ladder on all-reels; no-ops otherwise)
since a full 5-reel wild is naturally rare with base wilds pushed right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etrigger

- Mystery "?" wheel folded into the tumble loop at the locked ordering: each
  "?" spins first (boosting the ladder), then the drop's win pays at the
  boosted ladder, then +1. Base activates a "?" only on a winning drop; free
  spins always. Multiple "?" stack. Each activated "?" is consumed.
- Wheel results (add 5/10/20/50/100, mult x2) with the +5->Upgrade swap in a
  3-scatter (untilUpgrade) free round, reverting to +5 once an Upgrade lands.
- Three tiers by scatter count (3/4/5 -> t1/t2/t3). Ladder persistence: t2/t3
  carry the ladder across the whole feature; t1 resets each spin until the
  Upgrade flips it persistent. Tier 3 opens with 1 free wheel spin.
- Retrigger awards a flat +5 spins.

Verified against debug sims: wheelSpin from->to math 0 errors (924 spins);
every winning drop with a "?" pays at the boosted ladder (426/426); tier-3
opening spins fire once per tier-3 feature (10/10); start-multiplier sequences
confirm persistence (t2 [1,6,6,22,32,36], t3 [21,31,51,57]) and t1 reset
([1,1,1,...]); 0 persistence bugs. Model is intentionally hot pre-optimizer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the three feature-entry bet modes on top of the base game:
- Bonus (100x): forces 3 scatters -> tier 1 (FR1, untilUpgrade).
- Super Bonus (200x): forces 4 scatters -> tier 2 (FR0, persistent).
- Mystery Bonus (500x): scatter_triggers {3:45,4:45,5:10} natively rolls the
  tier 45/45/10 -> tier 1/2/3 (FR1/FR0/FR3).

Feature reel is tier-driven for natural AND bought features via fs_feature_reel
(get_current_distribution_conditions returns a per-tier copy — the TS provider
draws natural features from the per-tier pool too). A buy LOCKS its tier to the
forced opening scatter count; accumulation during the base cascade only upgrades
a natural trigger, never a buy (TS: level = boughtLevel).

Verified across debug sims: all four modes run clean; tier mixes are
bonus 100% t1, super_bonus 100% t2, mystery_bonus 46/43/11 (~45/45/10). Raw
(pre-optimizer) buy EVs are hot as expected (the optimizer down-weights to
0.967 in the full run).

Remaining for Milestone D: the two boost modes (3X Chance fee, Mystery Chance
stake), their reel exports + a WCAP reel, and the optimizer/cert run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All six SDK bet modes now exist. Adds:
- 3X Chance (chance3x): a fee-priced (cost 3) base spin on a richer-scatter
  pool (BR0_chance3x, scatter 4.0 -> 7.4). Pays stay on the base bet.
- Mystery Chance (mysteryChance): a stake-priced (cost 50) base spin. Priced
  via mode_bet_multiplier=50 — every pay scales x50 and evaluate_wincap scales
  the cap to 25,000x the 50x stake, so the cost cancels out of RTP and the
  thinned pool (BR0_mysteryChance) prices it. A "?" is planted on every base
  opening board (inside draw_board, before the reveal, so the client sees it).

Boost reels were built from the TS model's own buildReelStrips (symbolDensity
preserved) so the SDK opening boards match the fake-math.

Verified across debug sims: Mystery Chance x50 pay scaling (2978/0) and a
forced "?" on 100% of base boards; 3X Chance richer scatter (1.21 vs 0.86
scatters/board); all six modes run clean.

Remaining for Milestone D: WCAP reel + wincap distributions, game_optimization
per-mode targets, the full Rust `make run`, volatility gate, and locking the
certified MODE_RTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the cert pipeline:
- WCAP reel (wild+mystery-rich) to force the 25,000x max-win tail; wired as
  the freegame reel for the wincap criteria via get_current_distribution_conditions.
- wincap criteria added to every mode's distributions, with the stake-scaled
  cap (25,000x the 50x stake for Mystery Chance) matching win_manager's clamp.
- game_optimization.py: per-mode RTP targets — base-style (wincap/0/freegame/
  basegame, per-mode base/feature split) and buy-style (freegame + wincap tail),
  with far-tail suppression to hold the 3-star band.
- run.py: all six modes at 100k (publish set).

First cert result (20k/mode validation): the optimizer converged EVERY mode to
RTP 0.967, and all six PASS the 3-star volatility gate after cost-normalization
(cvar/cost <=229 vs 800; etl40b <=0.69 vs 0.9). Pricing validated end-to-end:
average_win = 0.967*cost for fee (chance3x), stake (mysteryChance, cap 25,000x
the 50x stake), and all buys. SHA-256 + payout hashes OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…6.70%

100k/mode publish run: every bet mode converges to RTP 0.967 and passes the
3-star volatility gate after cost-normalization (cvar/cost <=303 vs 800,
etl40b <=0.69 vs 0.9, etl10k <=0.21 vs 0.8, prob5k <=0.001 vs 0.01). Pricing
validated: avg payout = 0.967*cost for the fee mode (3X Chance), the stake mode
(Mystery Chance, cap 25,000x the 50x stake) and the three buys. Publish files
(books_*.jsonl.zst + lookUpTable_*_0.csv + index.json) generated.

Only remaining item is locking the certified 96.70% into MODE_RTP in the game
repo's gameConfig.ts (player-facing regulated copy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add stake_game_two — certified math-SDK port (96.70%, all six modes)
* skull_raiders: Milestone A — base lines game (Stake port scaffold)

New games/skull_raiders port of the Skull Raiders TS fake-math model. 5x5, 15 paylines, multiplier
WILDs, wincap 10,000x, every mode targets 96%.

Milestone A: grid + 15 paylines + paytable (displayed-x units, WILD pays on 5 only) + symbols + base
(BR0) and feature (FR0, FR_tier3) reelstrips exported from the TS reel model (WILD->W, BONUS->S) +
base LINES evaluation + stock free-spins loop + the base/feature multiplier-wild bags. Multiplier wilds
roll in base too (not just feature); the stock "symbol" strategy already implements our rule (sum only
>=2 wilds within a winning line, floor factor at 1).

Units verified on 10k base sims: 10,472 unmultiplied line wins all exactly paytable*100; multiplied
wins scale by the summed factor (e.g. H4 3-OAK 1x with two x2 wilds -> 200 = 1*100*2). Bonus mode raw
feature EV ~98.5x at cost 100 (optimizer will pull to 96% in F).

The forced-wincap distribution is intentionally deferred to Milestone F (no round can reach 10,000x
until the feature + WCAP reel exist; forcing it now resamples forever).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* skull_raiders: Milestone C — three-tier free spins (clamp + retrigger cap + per-tier reels)

update_freespin_amount override: scatter count 3/4/5 -> tier 1/2/3 (8/12/15 spins). A natural trigger
(base game / ante) clamps to naturalMaxTier=2 so it can never award HIDDEN; a buy runs the exact forced
tier (Mystery can roll tier 3). The feature reel follows (tier, is_buy): tier 3 -> FR3 (wild 22), bought
tier 1/2 -> FRB (wild 14), natural tier 1/2 -> FR0 (wild 13.5), repointed per round in
get_current_distribution_conditions. New FR_buy.csv reelstrip (exported via an added FR_buy branch in the
game repo's export-reels.ts).

update_fs_retrigger_amt override: retrigger adds the landed tier's spins, capped so total never exceeds
bonus_max=30; once capped, further scatters add nothing.

Verified on debug sims: base (natural) yields only tiers 1/2; bonus (buy) yields 1/2/3 with 8/12/15
spins; tot_fs never exceeds 30 (2 capped retriggers observed); tier-3 feature boards draw wild-22
density (6.13 wild/board incl padding) vs wild-14 for tiers 1/2 (~4.16).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* skull_raiders: Milestone D — raid wheel (ATTACK / STEAL custom event)

The wheel is a base-game criteria (force_wheel in run_spin). A wheel round replaces the normal line
spin: draw a land, strip scatters + natural wilds to lows, then ATTACK or STEAL (50/50) builds the whole
win.

ATTACK: redraw N paylines (weighted 1..5) — each with a payout symbol T filling the line and 1..3
wheel-multiplier wilds scattered along it — then score as ordinary line wins (one reel-0 conversion
completes every payline through it, which is where the richness comes from). Land line-wins are broken
first so only the redrawn lines pay; a full-wild fallback guarantees a win.

STEAL: plant a present symbol on 3..5 cells + 1..3 banked wheel-multiplier wilds, then score
position-agnostically (evaluate_steal in game_calculations: sweep every 3+ group, sum them, multiply by
the summed wild factor; wilds are pure multipliers that don't pad counts and aren't stealable).

Custom events wheelSpin / wheelConvert / wheelSteal (game_events.py) plus the standard win-accounting
events so analysis/verification still computes payoutMultiplier.

Verified on debug sims: attack 237/237 winInfo==finalWin; steal 263/263 board recompute match and event
totals internally consistent; zero fs leak on wheel rounds; wheel win mean ~43x, max ~905x. The wheel
criteria (quota 0.05 for now) is the optimizer's lever for the ~22.9% wheel RTP contribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* skull_raiders: Milestone E — all seven bet modes

Define the full published mode set, matching the client's publishedModeName taxonomy (PR engineio#75) exactly:

  base                          1x    normal spin (wheel 0.0055, natural bonus clamped to tier 2)
  base_bonuschance              3x    ante: boosted bonus frequency (natural path, clamped)
  base_wheelchance              5x    ante: boosted wheel frequency
  base_bonuschance_wheelchance  8x    ante: both boosts
  bonus_1                       100x  buy tier 1 (8 FS, FRB pool)
  bonus_2                       150x  buy tier 2 (12 FS, FRB pool)
  bonus_mystery                 300x  buy: tier roll {3:1,4:1,5:2} -> only route to HIDDEN (tier 3, FR3)

Antes reuse the base spin math (same BR0/FR0 reels + wheel); they differ only in cost and in the
per-criteria quotas that oversample the boosted feature (bonus for base_bonuschance, wheel for
base_wheelchance). Buys force the feature and lock/roll the tier via scatter_triggers. Buy PRICES are
the premium for immediacy and are decoupled from RTP — every mode targets 96%, weighted by the optimizer.

Verified: all 7 modes generate books with no errors; index.json names + costs (1/3/5/8/100/150/300)
match the client taxonomy; raw buy EVs ~95x/138x/266x are within optimizer reach of 96%. The forced
wincap tail + per-mode opt targets come in Milestone F.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* skull_raiders: Milestone F — CERTIFIED (all seven modes 96%, 3-star gate)

Feature wincap early-end (run_freespin stops + clamps at 10,000x) + a WILD-rich WCAP reel (43% wild) +
a forced-max-win `wincap` distribution per mode, so every mode reaches exactly 10,000x fast (no resample
hang).

Optimizer targets (game_optimization.py): each mode's 0.96 is split across criteria that are BUCKETED by
search_conditions (per docs/math_docs/optimization_section + force_info.md) — the fix that made the base
modes converge: wincap by win value (first), freegame {"symbol":"scatter"}, wheel {"symbol":"wheel"} (via
a record() tag added in run_wheel_round), "0" by win value, and basegame the searchless REMAINDER placed
LAST (previously it greedily claimed freegame/wheel sims, collapsing them to ~0 and the mode to 0.35).
RTP split follows the TS economics: base line ~30% / bonus feature ~43% / raid wheel ~23%; antes keep the
non-advertised feature at natural rates and boost the advertised one; the combined ante boosts both.

100k-sims/mode cert run: ALL SEVEN MODES optimize to RTP 0.9600 and PASS the cost-normalized 3-star
volatility gate (cvar/cost <=158, etl40b_n <=0.63, prob5k 0, rtp <=0.967) with no tail scaling needed.
SHA-256 + payout hash OK (100000 entries/mode). publish_files + PAR sheet generated. index.json mode set
+ costs match the client publishedModeName taxonomy. The local un-normalized 3-star warnings are expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* skull_raiders: align book event schema with the client adapter (Phase 4/6)

Match the shapes the shipped client adapter (stakeFormat.ts StakeEvent union) reads, so book-player /
production replay of the SDK books is byte-identical to the fake-math path:

- wheelConvert cells: emit `symbol`/`mult` (were `name`/`multiplier`).
- wheelSteal wins: emit `count` (was `kind`).
- freeSpinRetrigger: add `level` and `added` (spins added this retrigger) alongside `capped`.
- updateFreeSpin: attach the running round `totalWin` so the feature's live win total renders (the
  adapter falls back to 0 without it).

Pure event formatting — no RNG consumed, so outcomes/payouts and therefore every mode's optimized RTP
(0.96) and the volatility gate are unchanged; only the book bytes + verification hashes refresh on the
next run. Verified on debug sims: all five event shapes now match the adapter contract exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* skull_raiders: STEAL must not emit winInfo (wilds blacked out on replay)

A STEAL wheel round pays via the wheelSteal event (every 3+ group x the summed wild factor), NOT via
paylines. _score_steal also emitted a winInfo (+ setWin), which the client maps to its ordinary line-win
presentation — lighting only the group cells and leaving the multiplier WILDs unlit ("blacked out"),
diverging from the fake-math path. The win value was already correct (wheelSteal.total includes the
multiplier); only the presentation was wrong.

Emit exactly the fake-math STEAL stream: reveal / wheelSpin / wheelSteal / setTotalWin / finalWin (no
winInfo, no setWin). ATTACK is unchanged — it pays as lines, so its winInfo is correct.

Pure event change (no RNG/payout change) — every mode's RTP stays 0.96; only the book bytes + hashes
refresh. Verified the STEAL sequence now matches fake-math exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ins (#6)

The wilds stayed "blacked out" during a STEAL because the wheelSteal event's `positions` carried only the
planted present-symbol cells (3 of them), but the client's stealReveal derives BOTH the non-wild ladder
AND the WILD x-multiplier phase from `positions` (members = positions.map(...); wild = members.filter(W)).
With no wild cell in positions, the WILD phase never ran and the dimmed wilds were never re-lit.

evaluate_steal now returns the full stolen set — every winning group's cells plus every wild coin — and
_score_steal passes that as the event positions (matching fake-math's evaluateSteal.positions, which
includes the wild cells). Verified end-to-end on the book-player replaying a real SDK steal book: the
WILD lights and the x3 multiplier applies (win 19.50x = 6.50 base x 3), where before it was blacked out.

Pure event change (no RNG/payout change) — every mode's RTP stays 0.96.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…T.md) (#7)

Adds a "Port lessons" note referencing the authoritative write-up in the game repo's docs/STAKE_PORT.md:
read the SDK optimization + force docs first, bucket criteria by search_conditions (basegame remainder
last), gate cert/upload on `npm run parity`, smoke-test the real paths, and never run the 100k cert to
discover a bug. Recorded here so it's discoverable from the port itself on any machine.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…t running_bet_win

update_freespin() annotated each updateFreeSpin event's live totalWin with the
round-scoped running_bet_win (base + feature), so the free-spins counter opened
on the triggering base-spin line win instead of 0 — leaking the base win into
the feature tally. freegame_wins is the feature-only accumulator, matching the
fake-math reference and the freeSpinEnd outro (which already uses it).

Presentation-only: finalWin, payouts, RTP and the lookup tables are unchanged
(only this event field's bytes differ). Books regenerated and re-verified;
game-repo parity now passes its "first free-spin totalWin == 0" invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mdicillo

mdicillo commented Sep 3, 2026

Copy link
Copy Markdown
Author

Opened against the wrong base — this game-specific change belongs in the fork, not upstream. Closing and re-opening within mdicillo/math-sdk.

@mdicillo mdicillo closed this Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant