package casino import ( "errors" "fmt" "math" "sort" "strings" ) // Validate checks raw configuration before Normalize supplies omitted (zero) // defaults. Invalid explicit values must not silently become house rules. func (c MachineConfig) Validate() error { if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil { return err } if c.Game != GameSlots { return fmt.Errorf("unsupported machine game %q", c.Game) } if c.Variant != "" { if _, ok := slotVariants[c.Variant]; !ok { return fmt.Errorf("unsupported slots variant %q", c.Variant) } } if c.SpinTicks < 0 || c.SpinTicks > maxSpinTicks { return fmt.Errorf("spin_ticks must be between 0 and %d", maxSpinTicks) } if !finiteNonnegative(c.Payout) || c.Payout > 1 { return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)") } // The machine's effective profile is the built-in one scaled to the // configured RTP; it must be a geometry the runtime evaluator can // actually play. return machineSlotProfile(c).Validate() } func (c TableConfig) Validate() error { if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil { return err } if c.MaxPlayers < 0 || c.MaxPlayers > maxTablePlayers { return fmt.Errorf("max_players must be between 0 and %d", maxTablePlayers) } if c.BettingTicks < 0 || c.BettingTicks > maxBettingTicks { return fmt.Errorf("betting_ticks must be between 0 and %d", maxBettingTicks) } def, ok := tableGameDefFor(c.Game) if !ok { return fmt.Errorf("unsupported table game %q", c.Game) } if foreign := foreignRuleBlocks(c); len(foreign) > 0 { return fmt.Errorf("table game %q has a rule block for a different game (%s); remove it or set game to match", c.Game, strings.Join(foreign, ", ")) } return def.validate(c) } // foreignRuleBlocks names every game-specific rule block present on c that // does not belong to c.Game, in a deterministic order. Each game registers its // own block in tableGames, so a new game widens this automatically instead of // needing another hand-maintained switch. func foreignRuleBlocks(c TableConfig) []string { var foreign []string for game, def := range tableGames { if game == c.Game || def.hasRuleBlock == nil || def.ruleBlockName == "" { continue } if def.hasRuleBlock(c) { foreign = append(foreign, def.ruleBlockName) } } sort.Strings(foreign) return foreign } // normalizeBlackjackTable applies the blackjack rule defaults. It is the // game's Normalize entry in tableGames. func normalizeBlackjackTable(c TableConfig) TableConfig { c.Blackjack = c.Blackjack.Normalize() return c } // normalizeBaccaratTable applies the baccarat rule defaults. It is the game's // Normalize entry in tableGames. func normalizeBaccaratTable(c TableConfig) TableConfig { c.Baccarat = c.Baccarat.Normalize() return c } func validateBlackjackTable(c TableConfig) error { b := c.Blackjack if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { return err } if b.MaxSplitHands < 0 || b.ActionTicks < 0 || b.ActionTicks > maxActionTicks { return fmt.Errorf("max_split_hands must not be negative and action_ticks must be between 0 and %d", maxActionTicks) } 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) } // Shoe coverage is NOT a configuration floor. blackjackRoundCards is // only a boundary heuristic (an all-ace hand can hold more than // eleven cards), so a hard floor would reject tables that play fine // and, because a room is refused whole on any error, would darken // every casino game in it. shoe.ensureForRound reshuffles at the // round boundary and draw()'s exhausted-shoe rebuild is a deliberate // shuffle-through instead. return nil } func validateBaccaratTable(c TableConfig) error { b := c.Baccarat if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { return err } 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 } func validateIdentityAndBets(id string, minBet, maxBet int) error { if id == "" || strings.TrimSpace(id) != id { return fmt.Errorf("id must be nonempty with no surrounding whitespace") } if minBet < 0 || maxBet < 0 { return fmt.Errorf("min_bet and max_bet must not be negative") } if minBet > 0 && maxBet == 0 && minBet > DefaultMaxBet { return fmt.Errorf("min_bet (%d) exceeds the default maximum bet (%d); set a larger max_bet", minBet, DefaultMaxBet) } if maxBet > 0 && maxBet < max(1, minBet) { return fmt.Errorf("max_bet must be at least min_bet") } return nil } func finiteNonnegative(n float64) bool { return !math.IsNaN(n) && !math.IsInf(n, 0) && n >= 0 } // hasBlackjackRuleBlock reports whether c carries any explicit blackjack rule. // Used by foreignRuleBlocks to reject a block authored for another game. func hasBlackjackRuleBlock(c TableConfig) bool { b := c.Blackjack return b.Decks != 0 || b.ShuffleAt != 0 || b.BlackjackPayout != 0 || b.DealerHitSoft17 != nil || b.MaxSplitHands != 0 || b.ResplitAces != nil || b.DoubleAfterSplit != nil || b.DoubleSplitBlackjack != nil || b.InsurancePayout != 0 || b.Surrender != nil || b.ActionTicks != 0 || b.SplitUnlikeTenValues != nil } func hasBaccaratRuleBlock(c TableConfig) bool { b := c.Baccarat return b.Decks != 0 || b.ShuffleAt != 0 || b.TiePayout != 0 || b.BankerPayout != 0 } func validateShoe(decks int, shuffleAt float64) error { // A generous resource limit prevents malformed content from allocating an // unbounded shoe. Zero retains the existing eight-deck default. if decks < 0 || decks > 64 { return fmt.Errorf("decks must be between 0 and 64 (0 uses the default)") } if !finiteNonnegative(shuffleAt) || shuffleAt >= 1 { return fmt.Errorf("shuffle_at must be finite and in [0, 1)") } return nil } // minimumSurrenderBet is the smallest wager a surrendered hand can return // half of: half of a one-credit bet truncates to zero, which would consume // 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 ) // Authoring limits for tick-based windows. A typo such as betting_ticks: // 2000000 would otherwise publish a table that holds its betting window open // for days; while the window is open a committed stake cannot be withdrawn, so // the bettor is trapped. These are generous relative to normal play (a tick is // 600ms). const ( maxTablePlayers = 64 maxBettingTicks = 3600 maxActionTicks = 3600 maxSpinTicks = 3600 ) // 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 // hand plus every seat's maximum number of split hands). It is NOT a // certified maximum — an all-ace hand can exceed eleven cards — so it only // sizes the best-effort shoe.ensureForRound boundary reshuffle and must not // be turned back into a hard configuration floor. func blackjackRoundCards(maxPlayers, maxSplitHands int) int { return 11 * (1 + maxPlayers*maxSplitHands) } // baccaratRoundCards is the maximum number of cards one baccarat coup can // consume: three per hand (baccarat's fixed tableau never draws more). const baccaratRoundCards = 6 // Validate checks a slot profile the machine can actually play. The runtime // evaluator (generateSlotSpinWithProfile, matchingLength, stoppedScatters and // evaluateSlot) is pinned to the SlotSpin grid of three rows by five reels; // CalculateRTP also assumes that geometry (fixed 3..5 symbol runs and a // five-reel no-match term). A profile with another geometry would be // certified by the RTP gate and then played as a different game. Rejecting // the geometry here keeps that gate meaningful. func (p SlotProfile) Validate() error { profile := p.Normalize() if len(profile.Symbols) == 0 { return fmt.Errorf("slot profile has no symbols") } if profile.Rows != slotGridRows || profile.Reels != slotGridReels { 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 != "" { found := false positiveWeight := false for _, symbol := range profile.Symbols { if symbol.ID == profile.FreeSpinTrigger { found = true positiveWeight = symbol.Weight > 0 break } } if !found { return fmt.Errorf("free_spin_trigger %q names no symbol in symbols", profile.FreeSpinTrigger) } if !positiveWeight { return fmt.Errorf("free_spin_trigger %q names a symbol with no reel weight, so the feature can never trigger", profile.FreeSpinTrigger) } } // A paytable must not have a gap: evaluateSlot pays only the longest run, // so Payout{3: 0.5} with no Payout[4] makes every 4-run pay zero — less // than the 3-run — while the RTP model agrees and nothing else flags it. // A symbol whose shorter runs also pay zero (e.g. only a 5-run pays) is a // legitimate shape and is not flagged. for _, symbol := range profile.Symbols { for length := 4; length <= 5; length++ { if symbol.Payout[length] <= 0 && symbol.Payout[length-1] > 0 { return fmt.Errorf("symbol %q pays for a run of %d but not %d: a longer run would pay zero", symbol.ID, length-1, length) } } } return nil } // machineSlotProfile builds the profile a machine configured by c plays: the // built-in paytable, scaled to the configured RTP. One source of truth for // validation and for newMachine. func machineSlotProfile(c MachineConfig) SlotProfile { profile := DefaultHuffAndPuffProfile() if factory, ok := slotVariants[c.Variant]; ok { profile = factory() } if c.Payout > 0 { profile = ScaleToRTP(profile, c.Payout) } return profile.Normalize() } // ValidateRoomConfigs checks the shared room-local ID namespace as well as // individual definitions. // // It also enforces one table/machine per game per room: a room is addressed // by game name ("play blackjack"), so a second entry for the same game (or // the same machine variant) could never be reached. func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { seen := make(map[string]bool) var errs []error check := func(kind, id string, err error) { if seen[id] { errs = append(errs, fmt.Errorf("duplicate casino id %q", id)) } seen[id] = true if err != nil { errs = append(errs, fmt.Errorf("%s %q: %w", kind, id, err)) } } addressable := make(map[string]string) checkAddressable := func(key, id string) { if owner, ok := addressable[key]; ok { errs = append(errs, fmt.Errorf("duplicate casino game %q in one room (%q and %q): only one table or machine per game name is addressable", key, owner, id)) return } addressable[key] = id } for _, c := range tables { check("table", c.ID, c.Validate()) checkAddressable(strings.ToLower(string(c.Game)), c.ID) } // A machine addresses by its variant when one is declared; a bare game // name is addressable only when exactly one machine in the room declares // it, so a room may host several variants of one game (and the validator // no longer rejects the second variant as a duplicate game). machineGameCount := make(map[string]int, len(machines)) for _, c := range machines { machineGameCount[strings.ToLower(string(c.Game))]++ } for _, c := range machines { check("machine", c.ID, c.Validate()) if c.Variant != "" { checkAddressable(strings.ToLower(c.Variant), c.ID) } // A variant-less machine is addressed by its game name, so two of them // collide; a variant-ful machine only claims the bare game name when it // is the sole machine that declares it. if c.Variant == "" || machineGameCount[strings.ToLower(string(c.Game))] == 1 { checkAddressable(strings.ToLower(string(c.Game)), c.ID) } } return errors.Join(errs...) }