aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go8
-rw-r--r--internal/casino/blackjack.go26
-rw-r--r--internal/casino/config_validate.go137
-rw-r--r--internal/casino/core.go25
-rw-r--r--internal/casino/fix_regressions_test.go121
-rw-r--r--internal/casino/machine.go15
-rw-r--r--internal/casino/registry.go59
-rw-r--r--internal/casino/table.go13
-rw-r--r--internal/casino/types.go10
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
}