diff options
| author | historia <[not public]> | 2026-09-19 01:55:34 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-19 01:55:34 -0400 |
| commit | cc33072eced3d5460512c09c42ec4531c807d7af (patch) | |
| tree | b1b451fa87fa389cb8343a3645372f3b43f5523e /internal/casino | |
| parent | 2c38386484cdb507c4687caceb37e0b963e33594 (diff) | |
| download | thehouseoficarus-cc33072eced3d5460512c09c42ec4531c807d7af.tar.gz | |
fix: casino stake preservation, world state reconciliation, casino game registration
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat.go | 8 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 26 | ||||
| -rw-r--r-- | internal/casino/config_validate.go | 137 | ||||
| -rw-r--r-- | internal/casino/core.go | 25 | ||||
| -rw-r--r-- | internal/casino/fix_regressions_test.go | 121 | ||||
| -rw-r--r-- | internal/casino/machine.go | 15 | ||||
| -rw-r--r-- | internal/casino/registry.go | 59 | ||||
| -rw-r--r-- | internal/casino/table.go | 13 | ||||
| -rw-r--r-- | internal/casino/types.go | 10 |
9 files changed, 334 insertions, 80 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 2b2b060..62a83a5 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -185,10 +185,10 @@ func (t *baccaratTable) deal() []Event { } } events = append(events, settlement) - if p.Wallet != nil && !payoutFailed { - // The round resolved; the stakes are no longer outstanding. - p.Wallet.ResolveStakes() - } + // settleStakeOutcome already consumed this stake by handle (a payout + // removed it, a push/loss resolved it). Deliberately do NOT call the + // player-wide ResolveStakes here: it would also consume a stake left + // pending by an earlier failed payout, which must stay refundable. } t.resetRound() for _, p := range t.players.ordered() { diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 3b08ba5..cb71034 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -592,14 +592,12 @@ func (t *blackjackTable) settle() []Event { if !p.HasBet { continue } - payoutFailed := false for _, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) // One shared settlement path for every game: pay before // announcing, and leave the stakes pending on any failure so a // refund can never be paid on top of a payout that was not durable. if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { - payoutFailed = true publicResult, _ := blackjackResultMessages(p.Name, result, payout) settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) colorizeBlackjackSettlement(&settlement, result) @@ -618,7 +616,6 @@ func (t *blackjackTable) settle() []Event { insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) { - payoutFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) } else if insurancePayout > 0 { events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) @@ -626,11 +623,12 @@ func (t *blackjackTable) settle() []Event { events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) } } - if p.Wallet != nil && !payoutFailed { - // The round resolved; the stakes are no longer pending and must - // never be refunded. - p.Wallet.ResolveStakes() - } + // Each hand's stakes were already consumed per-handle by + // settleStakeOutcome (a payout removes them in PayCredits; a loss + // resolves them), and the insurance stake likewise. Deliberately do + // NOT call the player-wide ResolveStakes here: it would also consume + // a stake left pending by an earlier failed payout, which must stay + // refundable (see the Wallet contract). } t.resetRound() for _, p := range t.players.ordered() { @@ -781,9 +779,15 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { if t.phase == blackjackPlaying || t.phase == blackjackInsurance { - // A disconnected player forfeits the round in flight: consume the - // stakes now so a later refund cannot return them. - p.Wallet.ResolveStakes() + // A disconnected player forfeits the round in flight: consume + // ONLY this round's stakes, by handle, so a stake left pending by + // an earlier failed payout stays refundable. + var ids []string + for _, hand := range p.hands { + ids = append(ids, hand.stakes...) + } + ids = append(ids, p.insuranceStakeID) + resolveStakeIDs(p.Wallet, ids) } else { // No card has been dealt for this stake yet, so refund it: nothing // the bettor paid for has been revealed or played. diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 9b9759c..6181883 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -16,8 +16,10 @@ func (c MachineConfig) Validate() error { if c.Game != GameSlots { return fmt.Errorf("unsupported machine game %q", c.Game) } - if c.Variant != "" && c.Variant != "huff_and_even_more_puff" { - return fmt.Errorf("unsupported slots variant %q", c.Variant) + if c.Variant != "" { + if _, ok := slotVariants[c.Variant]; !ok { + return fmt.Errorf("unsupported slots variant %q", c.Variant) + } } if c.SpinTicks < 0 { return fmt.Errorf("spin_ticks must not be negative") @@ -38,55 +40,74 @@ func (c TableConfig) Validate() error { if c.MaxPlayers < 0 || c.BettingTicks < 0 { return fmt.Errorf("max_players and betting_ticks must not be negative") } - switch c.Game { - case GameBlackjack: - b := c.Blackjack - if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { - return err - } - if b.MaxSplitHands < 0 || b.ActionTicks < 0 { - return fmt.Errorf("max_split_hands and action_ticks must not be negative") - } - 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 - case GameBaccarat: - 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 - default: + def, ok := tableGameDefFor(c.Game) + if !ok { return fmt.Errorf("unsupported table game %q", c.Game) } + return def.validate(c) +} + +// 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 { + return fmt.Errorf("max_split_hands and action_ticks must not be negative") + } + 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 { @@ -234,6 +255,9 @@ func (p SlotProfile) Validate() error { // 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) } @@ -277,12 +301,25 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { 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()) - checkAddressable(strings.ToLower(string(c.Game)), c.ID) 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...) } diff --git a/internal/casino/core.go b/internal/casino/core.go index 7ac728f..0b9d0af 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -105,10 +105,17 @@ func (c *tableCore[P]) advanceBettingLocked(resolve func() []Event, reset func() // refundAllLocked refunds every committed wager through its wallet exactly // once and returns the events. The caller resets its own round state // afterwards, so a refunded round can never settle and pay again. +// +// It deliberately does NOT gate on p.HasBet: a stake whose payout could not +// be persisted stays pending after resetRound clears HasBet, and a shutdown +// or room eviction must still return it. RefundOutstanding is the player-wide +// ledger and a player can only participate in one casino game at a time, so +// refunding every seated wallet is safe and returns exactly that player's +// outstanding stakes. func (c *tableCore[P]) refundAllLocked() []Event { var events []Event for _, p := range c.players.participants() { - if !p.HasBet || p.Wallet == nil { + if p.Wallet == nil { continue } name := p.Name @@ -127,6 +134,22 @@ func (c *tableCore[P]) refundAllLocked() []Event { return events } +// resolveStakeIDs consumes each listed pending stake by wallet handle. It is +// the per-handle counterpart to ResolveStakes: use it whenever another stake +// may still be pending for the same player (for example an earlier payout +// that failed to persist), so consuming one round's stakes cannot destroy an +// unrelated refundable stake. +func resolveStakeIDs(w Wallet, ids []string) { + if w == nil { + return + } + for _, id := range ids { + if id != "" { + w.ResolveStake(id) + } + } +} + // settleStakeOutcome applies one wager's outcome through the wallet and // reports whether the settlement could NOT be made durable. It carries the // money-critical ordering shared by every table game (documented in diff --git a/internal/casino/fix_regressions_test.go b/internal/casino/fix_regressions_test.go new file mode 100644 index 0000000..dc615ec --- /dev/null +++ b/internal/casino/fix_regressions_test.go @@ -0,0 +1,121 @@ +package casino + +// Regressions for the money-safety fixes: table settlement must consume only +// its own round's stakes (never the player-wide ledger), a shutdown/eviction +// refund must still return a stake left pending after the round reset, and a +// disconnect during the slot payout window must settle a revealed win. + +import "testing" + +// TestBlackjackSecondRoundDoesNotResolveFailedPayoutStake: round 1's payout +// cannot be persisted, leaving its stake open for recovery. Round 2 then +// loses; the table must resolve only round 2's stake, not call the +// player-wide ResolveStakes and erase round 1's refund evidence. +func TestBlackjackSecondRoundDoesNotResolveFailedPayoutStake(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + // Draw order: round 1 dealer 10,7 (17) vs alice 10,8 (18, win); + // round 2 dealer 10,10 (20) vs alice 10,8 (18, loss). + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "8"}, + card{rank: "10"}, card{rank: "10"}, card{rank: "10"}, card{rank: "8"}, + ) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + table.join("alice") + + table.bet("alice", "", 10, wallet) + table.action("alice", "stand") + if len(wallet.open) != 1 { + t.Fatalf("setup: a failed payout must leave round 1's stake open, open=%+v", wallet.open) + } + firstID := wallet.open[0].ID + + table.bet("alice", "", 10, wallet) + table.action("alice", "stand") + if len(wallet.open) != 1 || wallet.open[0].ID != firstID { + t.Fatalf("round 2 erased the failed-payout stake: open=%+v, want only %q", wallet.open, firstID) + } +} + +// TestBlackjackShutdownRefundsFailedPayoutAfterReset: once the round that +// held the stake has reset (HasBet cleared), a shutdown/eviction refund must +// still return the pending stake rather than skip the seated player. +func TestBlackjackShutdownRefundsFailedPayoutAfterReset(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "8"}, + ) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + table.join("alice") + table.bet("alice", "", 10, wallet) + table.action("alice", "stand") + if table.phase != blackjackWaiting { + t.Fatalf("setup: the round should have reset (phase=%v)", table.phase) + } + if len(wallet.open) != 1 { + t.Fatalf("setup: a failed payout must leave the stake open, open=%+v", wallet.open) + } + + table.refundAll() + if len(wallet.open) != 0 { + t.Fatalf("shutdown refund skipped the reset round's pending stake: open=%+v", wallet.open) + } + // The failed payout means the stake was never consumed, so the refund + // makes the player whole at 100 (the pre-fix code skipped the seated + // player and left them at 90). + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("refund returned balance %d, want 100 (stake returned once)", got) + } +} + +// TestRoomAllowsDistinctSlotVariants: a room may host several variants of one +// game (each addressed by its variant), while two variant-less machines of the +// same game remain an unreachable duplicate. +func TestRoomAllowsDistinctSlotVariants(t *testing.T) { + slotVariants["test_second_variant"] = func() SlotProfile { return DefaultHuffAndPuffProfile() } + defer delete(slotVariants, "test_second_variant") + + err := ValidateRoomConfigs(nil, []MachineConfig{ + {ID: "slots_a", Game: GameSlots, Variant: "huff_and_even_more_puff"}, + {ID: "slots_b", Game: GameSlots, Variant: "test_second_variant"}, + }) + if err != nil { + t.Fatalf("two distinct slot variants rejected: %v", err) + } + + err = ValidateRoomConfigs(nil, []MachineConfig{ + {ID: "slots_a", Game: GameSlots}, + {ID: "slots_b", Game: GameSlots}, + }) + if err == nil { + t.Fatal("two variant-less machines of one game accepted despite being unaddressable") + } +} + +// TestSlotsDisconnectDuringPayoutWindowSettlesRevealedWin: once every reel has +// stopped the result is visible to the player, so a disconnect during the +// cosmetic payout delay must pay the shown win and consume the stake rather +// than forfeit both. +func TestSlotsDisconnectDuringPayoutWindowSettlesRevealedWin(t *testing.T) { + machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand()) + wallet := &testWallet{credits: 100} + machine.join("alice") + machine.setBet("alice", 10, wallet) + machine.spin = &SlotSpin{Payout: 20} // deterministic winning spin + for i := 0; i < 20 && machine.phase != machinePayout; i++ { + machine.tick() + } + if machine.phase != machinePayout { + t.Fatalf("setup: expected the payout window (phase=%v)", machine.phase) + } + + machine.markDisconnected("alice") + if wallet.paid != 20 { + t.Fatalf("revealed win was not paid on disconnect: paid=%d, want 20", wallet.paid) + } + if len(wallet.open) != 0 { + t.Fatalf("resolved round left a stake outstanding: open=%+v", wallet.open) + } + if got := wallet.credits + wallet.paid; got != 110 { + t.Fatalf("balance after payout = %d, want 110", got) + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 97b846d..7821975 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -402,12 +402,23 @@ func (m *Machine) markDisconnected(name string) []Event { if !m.owns(name) { return nil } + // A spin whose reels have all stopped has already been revealed to the + // player, so its result is final. Settle it through the normal path + // before the seat is reset: otherwise a disconnect during the cosmetic + // payout delay forfeits an already-shown win and consumes the stake for a + // spin the player watched land. finishSpin consumes the stake itself on a + // resolved round (or banks a feature win), so resetSeat below neither + // double-pays nor double-consumes. + var events []Event + if m.phase == machinePayout && m.spin != nil { + events = append(events, m.finishSpin()...) + } bank := int(m.featureBank) wallet := m.player.Wallet stakeFailed := m.stakeFailed m.resetSeat() - events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, - Message: fmt.Sprintf("%s stands up from the slots machine.", name), Color: "casino_action", Public: true}} + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, + Message: fmt.Sprintf("%s stands up from the slots machine.", name), Color: "casino_action", Public: true}) if bank <= 0 || wallet == nil { return events } diff --git a/internal/casino/registry.go b/internal/casino/registry.go new file mode 100644 index 0000000..9a973d4 --- /dev/null +++ b/internal/casino/registry.go @@ -0,0 +1,59 @@ +package casino + +import "math/rand" + +// tableGameDef is the single registration point for a multiplayer table game. +// A game's validation, rule defaults, and constructor live together here, so +// adding a game is one entry instead of edits to three independent switches +// (TableConfig.Validate, TableConfig.Normalize, and newTable) that could +// silently drift apart. The rule structs stay on TableConfig; this does not +// introduce a config union. +type tableGameDef struct { + // validate checks a raw (pre-Normalize) table config. + validate func(TableConfig) error + // normalize applies this game's rule defaults to a table config. + normalize func(TableConfig) TableConfig + // new builds the game. It is called only after validate passes and the + // shared table config has been normalized. + new func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame +} + +// tableGames is populated in init, not in a var initializer: the constructors +// transitively call TableConfig.Normalize, which consults tableGames, and Go +// rejects that as an initialization cycle when the map is a literal. +var tableGames map[GameName]tableGameDef + +func init() { + tableGames = map[GameName]tableGameDef{ + GameBlackjack: { + validate: validateBlackjackTable, + normalize: normalizeBlackjackTable, + new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { + return newBlackjackTable(roomID, cfg, rng) + }, + }, + GameBaccarat: { + validate: validateBaccaratTable, + normalize: normalizeBaccaratTable, + new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { + return newBaccaratTable(roomID, cfg, rng) + }, + }, + } +} + +// tableGameDefFor returns the registration for a game, or false when the game +// has no multiplayer table implementation. +func tableGameDefFor(game GameName) (tableGameDef, bool) { + def, ok := tableGames[game] + return def, ok +} + +// slotVariants maps a machine variant name to the profile that variant plays. +// This is the content seam for a new slot cabinet: a variant is a profile +// lookup, not a new code path through Validate and the machine constructor. +// The map is shared by MachineConfig.Validate and machineSlotProfile so the +// accepted set and the played set cannot disagree. +var slotVariants = map[string]func() SlotProfile{ + "huff_and_even_more_puff": DefaultHuffAndPuffProfile, +} diff --git a/internal/casino/table.go b/internal/casino/table.go index e3104ae..408e5fe 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -72,13 +72,12 @@ type Table struct { // multiplayer table implementation. func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table { cfg = cfg.Normalize() - var game tableGame - switch cfg.Game { - case GameBlackjack: - game = newBlackjackTable(roomID, cfg, rng) - case GameBaccarat: - game = newBaccaratTable(roomID, cfg, rng) - default: + def, ok := tableGameDefFor(cfg.Game) + if !ok { + return nil + } + game := def.new(roomID, cfg, rng) + if game == nil { return nil } // The Table and the game share the game's base, so RoomID/Config are a diff --git a/internal/casino/types.go b/internal/casino/types.go index 0400df4..e057ba6 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -176,11 +176,11 @@ func (c TableConfig) Normalize() TableConfig { if c.MaxBet <= 0 { c.MaxBet = DefaultMaxBet } - // Normalize the nested game rule blocks too: a new game that reads - // t.Config.Blackjack/Baccarat directly (rather than only t.Rules) would - // otherwise see zero values and build an empty shoe. - c.Blackjack = c.Blackjack.Normalize() - c.Baccarat = c.Baccarat.Normalize() + // Normalize the game's own rule block through its registration, so a new + // game gets its defaults without a third switch to remember. + if def, ok := tableGameDefFor(c.Game); ok { + c = def.normalize(c) + } if c.MaxBet < c.MinBet { c.MaxBet = c.MinBet } |
