aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/config_validate.go119
-rw-r--r--internal/casino/config_validate_test.go124
-rw-r--r--internal/casino/machine.go12
-rw-r--r--internal/casino/machine_ownership_test.go24
-rw-r--r--internal/casino/manager.go22
-rw-r--r--internal/casino/slots.go7
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