From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- internal/casino/config_validate.go | 89 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 4 deletions(-) (limited to 'internal/casino/config_validate.go') diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 8e5e7b5..ee8e8c9 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -25,7 +25,10 @@ func (c MachineConfig) Validate() error { if !finiteNonnegative(c.Payout) || c.Payout > 1 { return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)") } - return nil + // The machine's effective profile is the built-in one scaled to the + // configured RTP; ValidateRTP certifies that profile, so it must be a + // geometry the runtime evaluator can actually play. + return machineSlotProfile(c).Validate() } func (c TableConfig) Validate() error { @@ -47,6 +50,18 @@ 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") } + 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 case GameBaccarat: b := c.Baccarat if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { @@ -55,10 +70,11 @@ 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") } + // Non-blocking for the same reason as blackjack above. + return nil default: return fmt.Errorf("unsupported table game %q", c.Game) } - return nil } func validateIdentityAndBets(id string, minBet, maxBet int) error { @@ -93,8 +109,58 @@ func validateShoe(decks int, shuffleAt float64) error { 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 + +// 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/ValidateRTP are generic over Rows/Reels/Ways, so 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 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) + } + 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 c.Payout > 0 { + profile = ScaleToRTP(profile, c.Payout) + } + return profile.Normalize() +} + // ValidateRoomConfigs checks the shared room-local ID namespace as well as // individual definitions. Legacy slots under casino_tables remain supported. +// +// 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 @@ -107,16 +173,31 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { 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 { if c.Game == GameSlots { + // A slots "table" is instantiated as the room's slots machine. legacy := MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet} check("legacy slots", c.ID, legacy.Validate()) - } else { - check("table", c.ID, c.Validate()) + checkAddressable(strings.ToLower(string(GameSlots)), c.ID) + continue } + check("table", c.ID, c.Validate()) + checkAddressable(strings.ToLower(string(c.Game)), c.ID) } for _, c := range machines { check("machine", c.ID, c.Validate()) + checkAddressable(strings.ToLower(string(c.Game)), c.ID) + if c.Variant != "" { + checkAddressable(strings.ToLower(c.Variant), c.ID) + } } return errors.Join(errs...) } -- cgit v1.2.3