aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 01:50:17 -0400
committerhistoria <[not public]>2026-09-18 01:50:17 -0400
commit87d08f0151e643951769e323995f1c5ed89ee200 (patch)
treedc42a0b30ebb5a00dd7ba505cc4eaa44f4f308bc /internal/casino
parent32a3c59cebf2a16277e54d715a67adfdbe00b456 (diff)
downloadthehouseoficarus-87d08f0151e643951769e323995f1c5ed89ee200.tar.gz
fix: harden economy recovery, shutdown concurrency, world validation, and inventory-loss paths
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/config_validate.go3
-rw-r--r--internal/casino/machine.go8
-rw-r--r--internal/casino/manager.go12
-rw-r--r--internal/casino/minbet_regression_test.go25
-rw-r--r--internal/casino/types.go9
5 files changed, 53 insertions, 4 deletions
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go
index 13229bc..8e5e7b5 100644
--- a/internal/casino/config_validate.go
+++ b/internal/casino/config_validate.go
@@ -68,6 +68,9 @@ func validateIdentityAndBets(id string, minBet, maxBet int) error {
if minBet < 0 || maxBet < 0 {
return fmt.Errorf("min_bet and max_bet must not be negative")
}
+ if minBet > 0 && maxBet == 0 && minBet > DefaultMaxBet {
+ return fmt.Errorf("min_bet (%d) exceeds the default maximum bet (%d); set a larger max_bet", minBet, DefaultMaxBet)
+ }
if maxBet > 0 && maxBet < max(1, minBet) {
return fmt.Errorf("max_bet must be at least min_bet")
}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 0f137ec..844c5e9 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -228,8 +228,12 @@ func (m *Machine) finishSpin() []Event {
events := []Event{}
result := m.spin.Payout + m.payoutRemainder
// Checked conversion: a runaway profile or overflow must clamp at the
- // economy cap instead of wrapping through a machine-sized int.
- if result > MaxCredits || result != result || result < 0 {
+ // economy cap instead of wrapping through a machine-sized int. A NaN or
+ // negative result is a corrupt computation, not a jackpot: clamp it to
+ // zero (fail closed) rather than up to the economy ceiling (fail open).
+ if math.IsNaN(result) || result < 0 {
+ result = 0
+ } else if result > MaxCredits {
result = MaxCredits
}
credits := int(math.Floor(result))
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index 78e26b0..4798217 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -3,6 +3,7 @@ package casino
import (
"fmt"
"math/rand"
+ "reflect"
"sort"
"strings"
"sync"
@@ -41,7 +42,10 @@ func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine {
m.mu.Lock()
defer m.mu.Unlock()
if machine := m.machines[key]; machine != nil {
- if machine.Config == cfg {
+ // DeepEqual (not ==) so the *bool rule fields compare by value;
+ // == would treat every freshly parsed config as different and
+ // discard an idle machine on every room load.
+ if reflect.DeepEqual(machine.Config, cfg) {
return machine
}
if machine.idle() {
@@ -74,7 +78,7 @@ func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table {
m.mu.Lock()
defer m.mu.Unlock()
if table := m.tables[key]; table != nil {
- if table.Config == cfg {
+ if reflect.DeepEqual(table.Config, cfg) {
return table
}
if table.idle() {
@@ -308,6 +312,10 @@ func (m *Manager) RefundAll() []Event {
// (oldID -> newID): both the manager's map keys and each game's RoomID.
// Game state (wagers, rounds) is preserved — the games continue in the
// renamed room. Uses delete-all-then-set-all so a swap cannot clobber.
+//
+// Callers MUST run on the owner goroutine: each game's RoomID is mutated
+// without taking that game's own lock, which is safe only because Tick and
+// every command are owner-serialized too.
func (m *Manager) RekeyRooms(idMap map[int]int) {
if len(idMap) == 0 {
return
diff --git a/internal/casino/minbet_regression_test.go b/internal/casino/minbet_regression_test.go
new file mode 100644
index 0000000..578c251
--- /dev/null
+++ b/internal/casino/minbet_regression_test.go
@@ -0,0 +1,25 @@
+package casino
+
+import "testing"
+
+// TestMinBetAboveDefaultMaxRejected guards the config/validate + Normalize
+// interaction: an unset max_bet defaults to DefaultMaxBet, so a larger min_bet
+// must be rejected (and Normalize must never leave MaxBet below MinBet), or the
+// game would accept a table every wager is rejected from.
+func TestMinBetAboveDefaultMaxRejected(t *testing.T) {
+ if err := (TableConfig{ID: "bj", Game: GameBlackjack, MinBet: DefaultMaxBet + 1}).Validate(); err == nil {
+ t.Fatal("table accepted min_bet above the default max")
+ }
+ if err := (MachineConfig{ID: "slots", Game: GameSlots, MinBet: DefaultMaxBet + 1}).Validate(); err == nil {
+ t.Fatal("machine accepted min_bet above the default max")
+ }
+
+ m := MachineConfig{ID: "slots", Game: GameSlots, MinBet: DefaultMaxBet + 5}.Normalize()
+ if m.MaxBet < m.MinBet {
+ t.Fatalf("normalized machine left max %d below min %d", m.MaxBet, m.MinBet)
+ }
+ tc := TableConfig{ID: "bj", Game: GameBlackjack, MinBet: DefaultMaxBet + 5}.Normalize()
+ if tc.MaxBet < tc.MinBet {
+ t.Fatalf("normalized table left max %d below min %d", tc.MaxBet, tc.MinBet)
+ }
+}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 283aa43..49e2cc4 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -128,6 +128,12 @@ func (c MachineConfig) Normalize() MachineConfig {
if c.MaxBet <= 0 {
c.MaxBet = DefaultMaxBet
}
+ // Never leave a normalized config self-contradictory: a min_bet above
+ // the default cap must widen the effective maximum rather than produce
+ // a game every wager is rejected from.
+ if c.MaxBet < c.MinBet {
+ c.MaxBet = c.MinBet
+ }
if c.SpinTicks <= 0 {
c.SpinTicks = 2
}
@@ -153,6 +159,9 @@ func (c TableConfig) Normalize() TableConfig {
if c.MaxBet <= 0 {
c.MaxBet = DefaultMaxBet
}
+ if c.MaxBet < c.MinBet {
+ c.MaxBet = c.MinBet
+ }
return c
}