From 194164a77eb4f2479b94c19e6f0e1278ab608781 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 16:13:49 -0400 Subject: fix: more casino, economy, gameplay, security, and world data hardening gaps --- internal/casino/config_validate.go | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) (limited to 'internal/casino/config_validate.go') diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 38d426d..a03cfbf 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -50,6 +50,12 @@ func (c TableConfig) Validate() error { if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) { return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative") } + if b.BlackjackPayout > maxBlackjackPayout { + return fmt.Errorf("blackjack_payout %v exceeds the %d:1 cap", b.BlackjackPayout, maxBlackjackPayout) + } + if b.InsurancePayout > maxInsurancePayout { + return fmt.Errorf("insurance_payout %v exceeds the %d:1 cap", b.InsurancePayout, maxInsurancePayout) + } rules := b.Normalize() if rules.AllowsSurrender() && c.Normalize().MinBet < minimumSurrenderBet { return fmt.Errorf("surrender needs min_bet >= %d: a surrendered hand returns half the bet, which rounds to zero below that", minimumSurrenderBet) @@ -70,6 +76,12 @@ func (c TableConfig) Validate() error { if !finiteNonnegative(b.TiePayout) || !finiteNonnegative(b.BankerPayout) { return fmt.Errorf("tie_payout and banker_payout must be finite and nonnegative") } + if b.TiePayout > maxTiePayout { + return fmt.Errorf("tie_payout %v exceeds the %d:1 cap", b.TiePayout, maxTiePayout) + } + if b.BankerPayout > maxBankerPayout { + return fmt.Errorf("banker_payout %v exceeds the %v:1 cap", b.BankerPayout, maxBankerPayout) + } // Non-blocking for the same reason as blackjack above. return nil default: @@ -114,6 +126,22 @@ func validateShoe(decks int, shuffleAt float64) error { // the whole stake. const minimumSurrenderBet = 2 +// Payout-multiplier caps. Validation bounds authored multipliers so a typo +// (e.g. blackjack_payout: 15) cannot silently create an absurd table and so +// MulPayout's overflow backstop is not the only thing standing between a +// configuration error and a MaxCredits payout. +const ( + maxBlackjackPayout = 5 + maxInsurancePayout = 5 + maxTiePayout = 10 + maxBankerPayout = 0.99 +) + +// maxSlotSymbolLen is the fixed width of one reel cell in the renderer +// (" " + symbol + padding + " "); a longer id would make the renderer's +// width padding negative and panic. +const maxSlotSymbolLen = 7 + // blackjackRoundCards is the heuristic per-round card budget the shoe uses // to decide whether to reshuffle before a round: an estimated eleven cards // per hand times the most hands the table can have in play (the dealer's @@ -141,6 +169,31 @@ func (p SlotProfile) Validate() error { return fmt.Errorf("unsupported slot geometry %dx%d: the runtime evaluator supports %dx%d only", profile.Rows, profile.Reels, slotGridRows, slotGridReels) } + // Symbol IDs are looked up by exact string and rendered in fixed-width + // reel cells, so a duplicate ID silently pays that win twice, an over-long + // ID panics the renderer, and a zero total weight makes weightedSymbol + // unable to pick anything. + seenSymbols := make(map[string]bool, len(profile.Symbols)) + totalWeight := 0 + for _, symbol := range profile.Symbols { + if symbol.ID == "" { + return fmt.Errorf("slot symbol with an empty id") + } + if len(symbol.ID) > maxSlotSymbolLen { + return fmt.Errorf("slot symbol id %q exceeds the %d-character reel width", symbol.ID, maxSlotSymbolLen) + } + if seenSymbols[symbol.ID] { + return fmt.Errorf("duplicate slot symbol id %q", symbol.ID) + } + seenSymbols[symbol.ID] = true + if symbol.Weight < 0 { + return fmt.Errorf("slot symbol %q has negative reel weight", symbol.ID) + } + totalWeight += symbol.Weight + } + if totalWeight <= 0 { + return fmt.Errorf("slot symbols have no total reel weight, so no symbol can ever appear") + } // A named trigger must exist, or the feature silently never fires while // the RTP model also computes a zero trigger probability and reports green. if profile.FreeSpinTrigger != "" { -- cgit v1.2.3