diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/config_validate.go | 119 | ||||
| -rw-r--r-- | internal/casino/config_validate_test.go | 124 | ||||
| -rw-r--r-- | internal/casino/machine.go | 12 | ||||
| -rw-r--r-- | internal/casino/machine_ownership_test.go | 24 | ||||
| -rw-r--r-- | internal/casino/manager.go | 22 | ||||
| -rw-r--r-- | internal/casino/slots.go | 7 |
6 files changed, 298 insertions, 10 deletions
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go new file mode 100644 index 0000000..13229bc --- /dev/null +++ b/internal/casino/config_validate.go @@ -0,0 +1,119 @@ +package casino + +import ( + "errors" + "fmt" + "math" + "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 != "" && c.Variant != "huff_and_even_more_puff" { + return fmt.Errorf("unsupported slots variant %q", c.Variant) + } + if c.SpinTicks < 0 { + return fmt.Errorf("spin_ticks must not be negative") + } + 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 +} + +func (c TableConfig) Validate() error { + if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil { + return err + } + 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") + } + 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") + } + default: + return fmt.Errorf("unsupported table game %q", c.Game) + } + 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 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 +} + +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 +} + +// ValidateRoomConfigs checks the shared room-local ID namespace as well as +// individual definitions. Legacy slots under casino_tables remain supported. +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)) + } + } + for _, c := range tables { + if c.Game == GameSlots { + 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()) + } + } + for _, c := range machines { + check("machine", c.ID, c.Validate()) + } + return errors.Join(errs...) +} diff --git a/internal/casino/config_validate_test.go b/internal/casino/config_validate_test.go new file mode 100644 index 0000000..f0f87f5 --- /dev/null +++ b/internal/casino/config_validate_test.go @@ -0,0 +1,124 @@ +package casino + +import ( + "fmt" + "math" + "reflect" + "sync" + "testing" +) + +func TestMachineConfigValidation(t *testing.T) { + valid := MachineConfig{ID: "slots", Game: GameSlots} + if err := valid.Validate(); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*MachineConfig){ + "missing ID": func(c *MachineConfig) { c.ID = "" }, + "ID whitespace": func(c *MachineConfig) { c.ID = " slots " }, + "unknown game": func(c *MachineConfig) { c.Game = "roulette" }, + "unknown variant": func(c *MachineConfig) { c.Variant = "typo" }, + "negative bet": func(c *MachineConfig) { c.MinBet = -1 }, + "negative max": func(c *MachineConfig) { c.MaxBet = -1 }, + "inverted bets": func(c *MachineConfig) { c.MinBet, c.MaxBet = 10, 5 }, + "negative timer": func(c *MachineConfig) { c.SpinTicks = -1 }, + "negative payout": func(c *MachineConfig) { c.Payout = -1 }, + "excess payout": func(c *MachineConfig) { c.Payout = 1.01 }, + "NaN payout": func(c *MachineConfig) { c.Payout = math.NaN() }, + "infinite payout": func(c *MachineConfig) { c.Payout = math.Inf(1) }, + } { + t.Run(name, func(t *testing.T) { + c := valid + change(&c) + if err := c.Validate(); err == nil { + t.Fatalf("accepted invalid config: %+v", c) + } + m := NewManager(1) + if m.EnsureMachine(1, c) != nil { + t.Fatal("invalid machine instantiated") + } + if events := m.JoinMachine(1, c, "alice"); len(events) != 1 || !contains(events[0].Message, "not available") { + t.Fatalf("invalid machine join: %+v", events) + } + }) + } +} + +func TestTableConfigValidation(t *testing.T) { + for _, game := range []GameName{GameBlackjack, GameBaccarat} { + if err := (TableConfig{ID: "table", Game: game}).Validate(); err != nil { + t.Fatal(err) + } + } + for name, c := range map[string]TableConfig{ + "unknown game": {ID: "table", Game: "roulette"}, + "missing ID": {Game: GameBlackjack}, + "negative timer": {ID: "table", Game: GameBlackjack, BettingTicks: -1}, + "negative seats": {ID: "table", Game: GameBlackjack, MaxPlayers: -1}, + "inverted bets": {ID: "table", Game: GameBlackjack, MinBet: 10, MaxBet: 5}, + "negative decks": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{Decks: -1}}, + "huge shoe": {ID: "table", Game: GameBaccarat, Baccarat: BaccaratConfig{Decks: 1000000000}}, + "NaN shuffle": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{ShuffleAt: math.NaN()}}, + "shuffle endpoint": {ID: "table", Game: GameBaccarat, Baccarat: BaccaratConfig{ShuffleAt: 1}}, + "infinite payout": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{BlackjackPayout: math.Inf(1)}}, + "negative insurance": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{InsurancePayout: -1}}, + "NaN tie": {ID: "table", Game: GameBaccarat, Baccarat: BaccaratConfig{TiePayout: math.NaN()}}, + "negative banker": {ID: "table", Game: GameBaccarat, Baccarat: BaccaratConfig{BankerPayout: -1}}, + "negative splits": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{MaxSplitHands: -1}}, + "negative action timer": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: -1}}, + } { + t.Run(name, func(t *testing.T) { + if c.Validate() == nil || NewManager(1).EnsureTable(1, c) != nil { + t.Fatalf("accepted invalid table: %+v", c) + } + }) + } +} + +func TestRoomCasinoIDsAndLegacySlots(t *testing.T) { + tables := []TableConfig{{ID: "slots", Game: GameSlots}} + machines := []MachineConfig{{ID: "slots", Game: GameSlots}} + if err := ValidateRoomConfigs(tables, nil); err != nil { + t.Fatalf("legacy slots rejected: %v", err) + } + if ValidateRoomConfigs(tables, machines) == nil || ValidateRoomConfigs(nil, append(machines, machines...)) == nil { + t.Fatal("duplicate casino IDs accepted") + } +} + +func TestManagerGamesHaveIndependentRandomStreams(t *testing.T) { + m := NewManager(123) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + // Creation/shuffling can overlap spins on already-created games. + id := fmt.Sprintf("slots%d", i) + machine := m.EnsureMachine(1, MachineConfig{ID: id, Game: GameSlots}) + m.EnsureTable(1, TableConfig{ID: fmt.Sprintf("cards%d", i), Game: GameBlackjack}) + machine.join(id) + wallet := &testWallet{credits: 100} + for j := 0; j < 50; j++ { + machine.setBet(id, 1, wallet) + for machine.isBusy(id) { + machine.tick() + } + } + }(i) + } + wg.Wait() +} + +func TestScaleToRTPDoesNotChangeSourceProfile(t *testing.T) { + source := DefaultHuffAndPuffProfile() + want := DefaultHuffAndPuffProfile() + first := ScaleToRTP(source, .9) + second := ScaleToRTP(source, .97) + if !reflect.DeepEqual(source, want) { + t.Fatal("scaling mutated the source profile") + } + if math.Abs(CalculateRTP(first)-.9) > 1e-10 || math.Abs(CalculateRTP(second)-.97) > 1e-10 { + t.Fatal("profiles share mutable paytables") + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 15c612d..64dcfd4 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -63,7 +63,7 @@ func (m *Machine) setBet(name string, amount int, wallet Wallet) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { - return m.private("You aren't playing at a slot machine.") + return m.privateTo(name, "You aren't playing at a slot machine.") } if m.autoBet { return m.private("Autobet is active. Type \"stop\" to stop playing.") @@ -83,7 +83,7 @@ func (m *Machine) start(name string, wallet Wallet) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { - return m.private("You aren't playing at a slot machine.") + return m.privateTo(name, "You aren't playing at a slot machine.") } if m.autoBet { return m.private("Autobet is active. Type \"stop\" to stop playing.") @@ -116,7 +116,7 @@ func (m *Machine) enableAutoBet(name string, wallet Wallet) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { - return m.private("You aren't playing at a slot machine.") + return m.privateTo(name, "You aren't playing at a slot machine.") } if m.autoBet { return m.private("Autobet is already active.") @@ -319,7 +319,11 @@ func (m *Machine) isBusy(name string) bool { func (m *Machine) owns(name string) bool { return m.player != nil && m.player.Name == name } func (m *Machine) private(message string) []Event { - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: m.player.Name, Message: message, Color: "casino_action"}} + return m.privateTo(m.player.Name, message) +} + +func (m *Machine) privateTo(name, message string) []Event { + return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Message: message, Color: "casino_action"}} } func (m *Machine) public(message string) []Event { diff --git a/internal/casino/machine_ownership_test.go b/internal/casino/machine_ownership_test.go new file mode 100644 index 0000000..47e068a --- /dev/null +++ b/internal/casino/machine_ownership_test.go @@ -0,0 +1,24 @@ +package casino + +import "testing" + +func TestMachineRejectsStaleAndNonOwnerCommands(t *testing.T) { + m := NewManager(1) + cfg := MachineConfig{ID: "slots", Game: GameSlots} + m.JoinMachine(1, cfg, "alice") + m.MarkDisconnected("alice") + for _, occupied := range []bool{false, true} { + if occupied { + m.JoinMachine(1, cfg, "bob") + } + for _, events := range [][]Event{ + m.SetMachineBet(1, "slots", "alice", 10, nil), + m.StartMachine(1, "slots", "alice", nil), + m.EnableMachineAutoBet(1, "slots", "alice", nil), + } { + if len(events) != 1 || events[0].PrivateTo != "alice" || events[0].Public { + t.Fatalf("rejection misrouted (occupied=%v): %+v", occupied, events) + } + } + } +} diff --git a/internal/casino/manager.go b/internal/casino/manager.go index a9bab92..38b754f 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -26,7 +26,11 @@ func tableKey(roomID int, tableID string) string { return fmt.Sprintf("%d:%s", roomID, tableID) } +// EnsureMachine returns nil for an invalid or unsupported configuration. func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine { + if cfg.Validate() != nil { + return nil + } cfg = cfg.Normalize() key := tableKey(roomID, cfg.ID) m.mu.Lock() @@ -34,14 +38,18 @@ func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine { if machine := m.machines[key]; machine != nil { return machine } - machine := newMachine(roomID, cfg, m.rng) + // Only the manager consumes its RNG, under m.mu. Each game owns an + // independent stream protected by that game's lock. + machine := newMachine(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63()))) m.machines[key] = machine return machine } -// EnsureTable returns the table for cfg, or nil when cfg.Game has no -// multiplayer table implementation. +// EnsureTable returns nil for an invalid or unsupported configuration. func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table { + if cfg.Validate() != nil { + return nil + } cfg = cfg.Normalize() key := tableKey(roomID, cfg.ID) m.mu.Lock() @@ -49,7 +57,7 @@ func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table { if table := m.tables[key]; table != nil { return table } - table := newTable(roomID, cfg, m.rng) + table := newTable(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63()))) if table == nil { return nil } @@ -122,7 +130,11 @@ func (m *Manager) withMachine(roomID int, machineID string, fn func(*Machine) [] } func (m *Manager) JoinMachine(roomID int, cfg MachineConfig, name string) []Event { - return m.EnsureMachine(roomID, cfg).join(name) + machine := m.EnsureMachine(roomID, cfg) + if machine == nil { + return []Event{{RoomID: roomID, TableID: cfg.ID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}} + } + return machine.join(name) } func (m *Manager) SetMachineBet(roomID int, machineID, name string, amount int, wallet Wallet) []Event { diff --git a/internal/casino/slots.go b/internal/casino/slots.go index aba8f91..c519293 100644 --- a/internal/casino/slots.go +++ b/internal/casino/slots.go @@ -234,10 +234,15 @@ func ScaleToRTP(profile SlotProfile, target float64) SlotProfile { return profile } scale := target / actual + // SlotProfile is a value API, but its slice and paytable maps are shared + // references. Clone both so tuning one machine cannot retune another. + profile.Symbols = append([]SlotSymbol(nil), profile.Symbols...) for i := range profile.Symbols { + payouts := make(map[int]float64, len(profile.Symbols[i].Payout)) for length, payout := range profile.Symbols[i].Payout { - profile.Symbols[i].Payout[length] = payout * scale + payouts[length] = payout * scale } + profile.Symbols[i].Payout = payouts } profile.TargetRTP = target return profile |
