aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go45
-rw-r--r--internal/casino/blackjack.go56
-rw-r--r--internal/casino/credits.go84
-rw-r--r--internal/casino/credits_test.go74
-rw-r--r--internal/casino/machine.go48
-rw-r--r--internal/casino/machine_test.go4
-rw-r--r--internal/casino/manager.go122
-rw-r--r--internal/casino/manager_test.go13
-rw-r--r--internal/casino/reconfig_test.go84
-rw-r--r--internal/casino/table.go29
-rw-r--r--internal/casino/types.go19
11 files changed, 540 insertions, 38 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 800b5e6..d415901 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -2,7 +2,6 @@ package casino
import (
"fmt"
- "math"
"math/rand"
"sync"
)
@@ -125,9 +124,9 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
if amount < t.Config.MinBet || amount > t.Config.MaxBet {
return []Event{t.private(name, fmt.Sprintf("Baccarat bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))}
}
- wager, ok := wallet.Wager(amount)
- if !ok {
- return []Event{t.private(name, "You don't have enough chips and credits for that bet.")}
+ wager, err := wallet.Wager(amount)
+ if err != nil {
+ return []Event{t.private(name, walletErrorMessage(err))}
}
p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot = true, wager, wallet, amount, spot
events := []Event{t.playerAction(name,
@@ -329,11 +328,13 @@ func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules
}
case bankerTotal > playerTotal:
if spot == baccaratSpotBanker {
- return "win", bet + int(math.Floor(float64(bet)*rules.BankerPayout))
+ bankerWin, _ := MulPayout(bet, rules.BankerPayout)
+ return "win", bet + bankerWin
}
default:
if spot == baccaratSpotTie {
- return "win", bet + int(math.Floor(float64(bet)*rules.TiePayout))
+ tieWin, _ := MulPayout(bet, rules.TiePayout)
+ return "win", bet + tieWin
}
return "push", bet
}
@@ -361,3 +362,35 @@ func baccaratResultMessages(name, result string, payout int) (string, string) {
return name + " loses.", "You lose!"
}
}
+
+// refundAll resolves every committed wager at this table (graceful
+// shutdown): stakes return to their players through the wallet.
+func (t *baccaratTable) refundAll() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ var events []Event
+ for _, p := range t.players.ordered() {
+ if !p.HasBet || p.Wallet == nil {
+ continue
+ }
+ name, refund := p.Name, p.Wager.Total
+ if refund <= 0 {
+ continue
+ }
+ if err := p.Wallet.PayCredits(refund); err != nil {
+ events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
+ continue
+ }
+ events = append(events, t.playerAction(name,
+ fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund),
+ fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund)))
+ }
+ return events
+}
+
+// setRoom updates the event-routing room after a room-ID rekey. Locked.
+func (t *baccaratTable) setRoom(roomID int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.tableBase.setRoom(roomID)
+}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 4901c31..7c01c34 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -2,7 +2,6 @@ package casino
import (
"fmt"
- "math"
"math/rand"
"strings"
"sync"
@@ -147,9 +146,9 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve
if amount < t.Config.MinBet || amount > t.Config.MaxBet {
return []Event{t.private(name, fmt.Sprintf("Blackjack bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))}
}
- wager, ok := wallet.Wager(amount)
- if !ok {
- return []Event{t.private(name, "You don't have enough chips and credits for that bet.")}
+ wager, err := wallet.Wager(amount)
+ if err != nil {
+ return []Event{t.private(name, walletErrorMessage(err))}
}
p.HasBet, p.Wager, p.Wallet, p.lastBet = true, wager, wallet, amount
events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))}
@@ -263,8 +262,8 @@ func (t *blackjackTable) action(name, action string) []Event {
if len(hand.cards) != 2 || hand.doubled || hand.surrendered || fromSplitAces(hand) || (hand.fromSplit && !t.Rules.AllowsDoubleAfterSplit()) {
return []Event{t.private(name, "You can only double a qualifying hand.")}
}
- if _, ok := p.Wallet.Wager(hand.bet); !ok {
- return []Event{t.private(name, "You don't have enough chips and credits to double that hand.")}
+ if _, err := p.Wallet.Wager(hand.bet); err != nil {
+ return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits to double that hand."))}
}
hand.bet *= 2
hand.doubled = true
@@ -305,8 +304,8 @@ func (t *blackjackTable) insuranceAction(name, action string) []Event {
if amount <= 0 {
return []Event{t.private(name, "Your bet is too small to insure.")}
}
- if _, ok := p.Wallet.Wager(amount); !ok {
- return []Event{t.private(name, "You don't have enough chips and credits for insurance.")}
+ if _, err := p.Wallet.Wager(amount); err != nil {
+ return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits for insurance."))}
}
p.insurance = amount
events = append(events, t.playerAction(name, fmt.Sprintf("%s takes insurance.", name), fmt.Sprintf("You place %d credits on insurance.", amount)))
@@ -351,8 +350,8 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj
if hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces() {
return []Event{t.private(name, "Aces cannot be re-split at this table.")}
}
- if _, ok := p.Wallet.Wager(hand.bet); !ok {
- return []Event{t.private(name, "You don't have enough chips and credits to split that hand.")}
+ if _, err := p.Wallet.Wager(hand.bet); err != nil {
+ return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits to split that hand."))}
}
left := &blackjackHand{cards: []card{hand.cards[0]}, bet: hand.bet, fromSplit: true}
right := &blackjackHand{cards: []card{hand.cards[1]}, bet: hand.bet, fromSplit: true}
@@ -560,7 +559,7 @@ func (t *blackjackTable) settle() []Event {
if p.insurance > 0 {
insurancePayout := 0
if dealerBlackjack {
- insurancePayout = int(math.Floor(float64(p.insurance) * (1 + t.Rules.InsurancePayout)))
+ insurancePayout, _ = MulPayout(p.insurance, 1+t.Rules.InsurancePayout)
}
if insurancePayout > 0 {
p.Wallet.PayCredits(insurancePayout)
@@ -594,7 +593,8 @@ func settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rule
}
if playerBlackjack {
// Winnings round down to whole credits; never bet less than two.
- return "blackjack", hand.bet + int(math.Floor(float64(hand.bet)*rules.BlackjackPayout))
+ blackjackWin, _ := MulPayout(hand.bet, rules.BlackjackPayout)
+ return "blackjack", hand.bet + blackjackWin
}
if total > 21 || dealerTotal > total && dealerTotal <= 21 {
return "loss", 0
@@ -728,3 +728,35 @@ func (t *blackjackTable) hasParticipant(name string) bool {
defer t.mu.Unlock()
return t.players.has(name)
}
+
+// refundAll resolves every committed wager at this table (graceful
+// shutdown): stakes return to their players through the wallet.
+func (t *blackjackTable) refundAll() []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ var events []Event
+ for _, p := range t.players.ordered() {
+ if !p.HasBet || p.Wallet == nil {
+ continue
+ }
+ name, refund := p.Name, p.Wager.Total
+ if refund <= 0 {
+ continue
+ }
+ if err := p.Wallet.PayCredits(refund); err != nil {
+ events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
+ continue
+ }
+ events = append(events, t.playerAction(name,
+ fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund),
+ fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund)))
+ }
+ return events
+}
+
+// setRoom updates the event-routing room after a room-ID rekey. Locked.
+func (t *blackjackTable) setRoom(roomID int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.tableBase.setRoom(roomID)
+}
diff --git a/internal/casino/credits.go b/internal/casino/credits.go
new file mode 100644
index 0000000..ce47d41
--- /dev/null
+++ b/internal/casino/credits.go
@@ -0,0 +1,84 @@
+package casino
+
+import (
+ "errors"
+ "fmt"
+ "math"
+)
+
+// Currency limits and checked arithmetic. All balances and payouts flow
+// through these helpers so overflow and unbounded multipliers fail loudly
+// instead of silently wrapping or producing absurd balances.
+const (
+ // MaxCredits is the largest credit balance the economy supports.
+ // Configured bets are additionally bounded by each game's MaxBet.
+ MaxCredits = 1_000_000_000_000 // 1e12
+ // DefaultMaxBet bounds wagers when a configuration does not set one.
+ // The explicit unlimited sentinel (MaxBet = MaxInt) remains accepted
+ // for authored configurations that deliberately want no cap.
+ DefaultMaxBet = 1_000_000
+)
+
+// maxBetUnlimited is the MaxBet sentinel meaning "no wager cap".
+const maxBetUnlimited = int(^uint(0) >> 1)
+
+// Wallet errors. ErrInsufficientFunds is normal gameplay (the table reports
+// it like a rejected bet); ErrWalletPersistence means the wager or payout
+// could not be made durable and MUST NOT be presented as successful play.
+var (
+ ErrInsufficientFunds = errors.New("insufficient funds")
+ ErrWalletPersistence = errors.New("wallet persistence failure")
+)
+
+// AddCredits adds two balances with an explicit cap, reporting overflow.
+// A negative addend is rejected (payouts never decrease a balance).
+func AddCredits(a, b int) (int, error) {
+ if b < 0 {
+ return a, errors.New("negative credit add")
+ }
+ sum := a + b
+ if b > 0 && sum < a || a > 0 && sum < b {
+ return MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, MaxCredits)
+ }
+ if sum > MaxCredits {
+ return MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, MaxCredits)
+ }
+ return sum, nil
+}
+
+// MulPayout scales an amount by a payout multiplier and converts to whole
+// credits (floor, like a house paying in chips). Overflow past MaxCredits
+// is an error, not a silent wrap. A non-finite or negative multiplier is
+// rejected.
+func MulPayout(amount int, multiplier float64) (int, error) {
+ if multiplier < 0 || math.IsInf(multiplier, 0) || math.IsNaN(multiplier) {
+ return 0, fmt.Errorf("invalid payout multiplier %v", multiplier)
+ }
+ if amount <= 0 {
+ return 0, nil
+ }
+ // Bound the product before conversion: amount * multiplier must fit
+ // within MaxCredits (multiplier > 1 may grow the stake).
+ if float64(amount) > float64(MaxCredits)/multiplier {
+ return MaxCredits, fmt.Errorf("payout overflow: %d * %v exceeds %d", amount, multiplier, MaxCredits)
+ }
+ payout := int(math.Floor(float64(amount) * multiplier))
+ if payout > MaxCredits {
+ return MaxCredits, fmt.Errorf("payout overflow: %d * %v exceeds %d", amount, multiplier, MaxCredits)
+ }
+ return payout, nil
+}
+
+// walletErrorMessage renders a wallet error for a player: insufficient
+// funds is normal gameplay; a persistence failure makes clear that the
+// house could not process the wager rather than implying a short balance.
+// context, when non-empty, is the game-specific insufficient-funds wording.
+func walletErrorMessage(err error, context ...string) string {
+ if err == ErrWalletPersistence {
+ return "The house could not process that transaction (persistence failure). Nothing was charged; try again shortly."
+ }
+ if len(context) > 0 && context[0] != "" {
+ return context[0]
+ }
+ return "You don't have enough chips and credits for that bet."
+}
diff --git a/internal/casino/credits_test.go b/internal/casino/credits_test.go
new file mode 100644
index 0000000..4094d72
--- /dev/null
+++ b/internal/casino/credits_test.go
@@ -0,0 +1,74 @@
+package casino
+
+import (
+ "testing"
+)
+
+func TestAddCreditsChecked(t *testing.T) {
+ sum, err := AddCredits(5, 7)
+ if err != nil || sum != 12 {
+ t.Fatalf("AddCredits(5,7) = %d, %v", sum, err)
+ }
+ if sum, err := AddCredits(MaxCredits, 1); err == nil || sum != MaxCredits {
+ t.Fatalf("overflow add: %d, %v", sum, err)
+ }
+ if _, err := AddCredits(MaxCredits, -1); err == nil {
+ t.Fatal("negative add must be rejected")
+ }
+}
+
+func TestMulPayoutBoundsAndConverts(t *testing.T) {
+ payout, err := MulPayout(10, 1.5)
+ if err != nil || payout != 15 {
+ t.Fatalf("MulPayout(10,1.5) = %d, %v", payout, err)
+ }
+ // Floor conversion: 10 * 0.333 = 3.33 -> 3.
+ if payout, _ := MulPayout(10, 0.333); payout != 3 {
+ t.Fatalf("floor conversion: %d", payout)
+ }
+ if payout, err := MulPayout(MaxCredits, 2); err == nil || payout != MaxCredits {
+ t.Fatalf("overflow payout: %d, %v", payout, err)
+ }
+ if payout, err := MulPayout(MaxCredits, 0.5); err != nil || payout != MaxCredits/2 {
+ t.Fatalf("half of max: %d, %v", payout, err)
+ }
+ for _, bad := range []float64{-1, inf(), nan()} {
+ if payout, err := MulPayout(10, bad); err == nil || payout != 0 {
+ t.Fatalf("multiplier %v accepted: %d, %v", bad, payout, err)
+ }
+ }
+ if payout, _ := MulPayout(0, 2); payout != 0 {
+ t.Fatalf("zero-amount payout = %d", payout)
+ }
+}
+
+func TestWalletErrorMessages(t *testing.T) {
+ if got := walletErrorMessage(ErrInsufficientFunds, "Custom short balance."); got != "Custom short balance." {
+ t.Fatalf("insufficient funds rendering: %q", got)
+ }
+ if got := walletErrorMessage(ErrWalletPersistence); !errContains(got, "persistence failure") || errContains(got, "insufficient") {
+ t.Fatalf("persistence rendering must be distinct: %q", got)
+ }
+ if got := walletErrorMessage(ErrInsufficientFunds); got == "" {
+ t.Fatal("default message missing")
+ }
+}
+
+func inf() float64 {
+ var max float64 = 1e308
+ return max * 10
+}
+func nan() float64 { return inf() - inf() }
+
+func errContains(value, fragment string) bool {
+ return len(value) >= len(fragment) && (value == fragment || len(fragment) == 0 || indexOf(value, fragment) >= 0)
+}
+
+func indexOf(haystack, needle string) int {
+ for i := 0; i+len(needle) <= len(haystack); i++ {
+ if haystack[i:i+len(needle)] == needle {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 64dcfd4..6a7b7aa 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -2,6 +2,7 @@ package casino
import (
"fmt"
+ "math"
"math/rand"
"sync"
)
@@ -100,8 +101,8 @@ func (m *Machine) start(name string, wallet Wallet) []Event {
}
func (m *Machine) beginSpinLocked(name string, amount int, wallet Wallet, publicMessage, privateMessage string) []Event {
- if _, ok := wallet.Wager(amount); !ok {
- return m.private("You don't have enough chips and credits for that bet.")
+ if _, err := wallet.Wager(amount); err != nil {
+ return m.private(walletErrorMessage(err, "You don't have enough chips and credits for that bet."))
}
m.bet = amount
m.spinBet = amount
@@ -203,9 +204,9 @@ func (m *Machine) autoBetTick() []Event {
m.autoBet = false
return m.private("Autobet stopped: no wager is set.")
}
- if _, ok := m.player.Wallet.Wager(m.bet); !ok {
+ if _, err := m.player.Wallet.Wager(m.bet); err != nil {
m.autoBet = false
- return m.private("Autobet stopped: you don't have enough chips and credits for that bet.")
+ return m.private(walletErrorMessage(err, "Autobet stopped: you don't have enough chips and credits for that bet."))
}
m.spinBet = m.bet
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
@@ -220,7 +221,12 @@ func (m *Machine) autoBetTick() []Event {
func (m *Machine) finishSpin() []Event {
events := []Event{}
result := m.spin.Payout + m.payoutRemainder
- credits := int(result)
+ // 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 {
+ result = MaxCredits
+ }
+ credits := int(math.Floor(result))
m.payoutRemainder = result - float64(credits)
if credits > 0 && m.player.Wallet != nil {
m.player.Wallet.PayCredits(credits)
@@ -380,3 +386,35 @@ func (m *Machine) privateMenu(prefix string) []Event {
func (m *Machine) menuEvents(prefix string) []Event {
return m.privateMenu(prefix)
}
+
+// refundAll refunds any committed stake at graceful shutdown. Locked.
+func (m *Machine) refundAll() []Event {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.player == nil || m.player.Wallet == nil || m.spinBet <= 0 {
+ return nil
+ }
+ name, refund := m.player.Name, m.spinBet
+ wallet := m.player.Wallet
+ m.spinBet = 0
+ m.bet = 0
+ if err := wallet.PayCredits(refund); err != nil {
+ return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.",
+ Color: "casino_action", Public: false}}
+ }
+ return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ Message: fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund),
+ PrivateMessage: fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund),
+ Color: "casino_action", Public: true}}
+}
+
+// idle reports whether no player is seated: only an idle machine may have
+// its live rules replaced by new room configuration.
+func (m *Machine) idle() bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.player == nil
+}
diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go
index 349a452..73687b2 100644
--- a/internal/casino/machine_test.go
+++ b/internal/casino/machine_test.go
@@ -14,11 +14,11 @@ func TestBetRangeTextFormatsUnlimitedMax(t *testing.T) {
}
}
-func TestMachineSetBetUnlimitedMaxMessage(t *testing.T) {
+func TestMachineSetBetUnsetDefaultMaxMessage(t *testing.T) {
m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, MinBet: 1}, rand.New(rand.NewSource(7)))
m.player = &Participant{Name: "alice", Connected: true}
events := m.setBet("alice", 0, &testWallet{credits: 100})
- if !hasMessage(events, "Slot machine bets must be 1 or more (no maximum).") {
+ if !hasMessage(events, "Slot machine bets must be between 1 and 1000000.") {
t.Fatalf("unexpected bet-range message: %+v", events)
}
}
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index 38b754f..78e26b0 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -4,6 +4,7 @@ import (
"fmt"
"math/rand"
"sort"
+ "strings"
"sync"
)
@@ -27,6 +28,10 @@ func tableKey(roomID int, tableID string) string {
}
// EnsureMachine returns nil for an invalid or unsupported configuration.
+// When a machine already exists for the room/ID, its live rules are
+// replaced by the new configuration ONLY while it is idle (no seated
+// player, so no wager can be discarded); an occupied machine keeps its
+// live rules until it becomes idle again.
func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine {
if cfg.Validate() != nil {
return nil
@@ -36,6 +41,17 @@ 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 {
+ return machine
+ }
+ if machine.idle() {
+ if next := newMachine(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63()))); next != nil {
+ m.machines[key] = next
+ return next
+ }
+ // Fall through to keep the live machine rather than drop a
+ // functioning game for an invalid replacement.
+ }
return machine
}
// Only the manager consumes its RNG, under m.mu. Each game owns an
@@ -46,6 +62,9 @@ func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine {
}
// EnsureTable returns nil for an invalid or unsupported configuration.
+// When a table already exists for the room/ID, its live rules are replaced
+// only while the table is idle (no seated players, so no wagers can be
+// silently discarded); an occupied table keeps its live rules until idle.
func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table {
if cfg.Validate() != nil {
return nil
@@ -55,6 +74,18 @@ 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 {
+ return table
+ }
+ if table.idle() {
+ next := newTable(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63())))
+ if next != nil {
+ m.tables[key] = next
+ return next
+ }
+ // Fall through to keep the live table rather than drop a
+ // functioning game for an invalid replacement.
+ }
return table
}
table := newTable(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63())))
@@ -257,3 +288,94 @@ func (m *Manager) MachineBusy(name string) bool {
}
return false
}
+
+// RefundAll resolves every outstanding wager by refunding committed stakes
+// to their players. Graceful shutdown uses this: rounds cannot settle, so
+// no player may lose a stake to the shutdown itself. Refunds go through the
+// wallet (durable, journaled) and return the refund events for delivery.
+func (m *Manager) RefundAll() []Event {
+ var events []Event
+ for _, table := range m.snapshotTables() {
+ events = append(events, table.refundAll()...)
+ }
+ for _, machine := range m.snapshotMachines() {
+ events = append(events, machine.refundAll()...)
+ }
+ return events
+}
+
+// RekeyRooms remaps every room-keyed table and machine per idMap
+// (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.
+func (m *Manager) RekeyRooms(idMap map[int]int) {
+ if len(idMap) == 0 {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ rekey := func(key string) (string, bool) {
+ var roomID int
+ var tableID string
+ if _, err := fmt.Sscanf(key, "%d:%s", &roomID, &tableID); err != nil {
+ return "", false
+ }
+ newID, ok := idMap[roomID]
+ if !ok || newID == roomID {
+ return "", false
+ }
+ return fmt.Sprintf("%d:%s", newID, tableID), true
+ }
+ movedTables := make(map[string]*Table, len(m.tables))
+ for key, table := range m.tables {
+ if newKey, ok := rekey(key); ok {
+ var roomID int
+ var tableID string
+ fmt.Sscanf(key, "%d:%s", &roomID, &tableID)
+ table.RoomID = idMap[roomID]
+ table.game.setRoom(table.RoomID)
+ movedTables[newKey] = table
+ delete(m.tables, key)
+ }
+ }
+ for newKey, table := range movedTables {
+ m.tables[newKey] = table
+ }
+ movedMachines := make(map[string]*Machine, len(m.machines))
+ for key, machine := range m.machines {
+ if newKey, ok := rekey(key); ok {
+ var roomID int
+ var tableID string
+ fmt.Sscanf(key, "%d:%s", &roomID, &tableID)
+ machine.RoomID = idMap[roomID]
+ movedMachines[newKey] = machine
+ delete(m.machines, key)
+ }
+ }
+ for newKey, machine := range movedMachines {
+ m.machines[newKey] = machine
+ }
+}
+
+// EvictRoom removes every cached table and machine in roomID (graceful
+// shutdown semantics for deleted/renumbered rooms): outstanding wagers are
+// refunded through each game's refundAll before the entries drop.
+func (m *Manager) EvictRoom(roomID int) []Event {
+ prefix := fmt.Sprintf("%d:", roomID)
+ var events []Event
+ m.mu.Lock()
+ for key, table := range m.tables {
+ if strings.HasPrefix(key, prefix) {
+ events = append(events, table.refundAll()...)
+ delete(m.tables, key)
+ }
+ }
+ for key, machine := range m.machines {
+ if strings.HasPrefix(key, prefix) {
+ events = append(events, machine.refundAll()...)
+ delete(m.machines, key)
+ }
+ }
+ m.mu.Unlock()
+ return events
+}
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index 134798a..50cf114 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -6,9 +6,9 @@ type testWallet struct {
chips, credits, paid int
}
-func (w *testWallet) Wager(amount int) (Wager, bool) {
+func (w *testWallet) Wager(amount int) (Wager, error) {
if w.chips+w.credits < amount {
- return Wager{}, false
+ return Wager{}, ErrInsufficientFunds
}
chips := amount
if chips > w.chips {
@@ -17,17 +17,18 @@ func (w *testWallet) Wager(amount int) (Wager, bool) {
w.chips -= chips
credits := amount - chips
w.credits -= credits
- return Wager{Total: amount, Chips: chips, Credits: credits}, true
+ return Wager{Total: amount, Chips: chips, Credits: credits}, nil
}
-func (w *testWallet) PayCredits(amount int) {
+func (w *testWallet) PayCredits(amount int) error {
w.paid += amount
+ return nil
}
func TestMixedWagerUsesChipsFirst(t *testing.T) {
w := &testWallet{chips: 7, credits: 50}
- wager, ok := w.Wager(12)
- if !ok || wager.Chips != 7 || wager.Credits != 5 || w.chips != 0 || w.credits != 45 {
+ wager, err := w.Wager(12)
+ if err != nil || wager.Chips != 7 || wager.Credits != 5 || w.chips != 0 || w.credits != 45 {
t.Fatalf("unexpected wager: %+v wallet=%+v", wager, w)
}
}
diff --git a/internal/casino/reconfig_test.go b/internal/casino/reconfig_test.go
new file mode 100644
index 0000000..37b3a65
--- /dev/null
+++ b/internal/casino/reconfig_test.go
@@ -0,0 +1,84 @@
+package casino
+
+// Reconfiguration contract regressions: live rules replace only when idle;
+// occupied games keep their rules (and wagers); deleted rooms evict their
+// cached games with refunds.
+
+import "testing"
+
+func TestEnsureMachineReplacesIdleRules(t *testing.T) {
+ m := NewManager(1)
+ first := m.EnsureMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 2})
+ if first == nil {
+ t.Fatal("machine not created")
+ }
+ // Same config: same machine.
+ if again := m.EnsureMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 2}); again != first {
+ t.Fatal("same config returned a different machine")
+ }
+ // Changed config while idle: live rules replaced.
+ changed := m.EnsureMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 5})
+ if changed == first {
+ t.Fatal("idle machine did not adopt the new configuration")
+ }
+ if changed.Config.SpinTicks != 5 {
+ t.Fatalf("replacement config spin ticks = %d, want 5", changed.Config.SpinTicks)
+ }
+}
+
+func TestEnsureMachineKeepsOccupiedRules(t *testing.T) {
+ m := NewManager(1)
+ m.EnsureMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 2})
+ m.JoinMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 2}, "alice")
+
+ // Occupied machine: config changes do NOT replace live rules.
+ kept := m.EnsureMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 9})
+ if kept.Config.SpinTicks != 2 {
+ t.Fatalf("occupied machine replaced live rules: spin ticks %d", kept.Config.SpinTicks)
+ }
+}
+
+func TestEnsureTableKeepsOccupiedRules(t *testing.T) {
+ m := NewManager(1)
+ m.Join(1, TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1, BettingTicks: 50}, "alice")
+
+ // Occupied table: changed config does not replace live rules.
+ kept := m.EnsureTable(1, TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1, BettingTicks: 3})
+ if kept.Config.BettingTicks != 50 {
+ t.Fatalf("occupied table replaced live rules: betting ticks %d", kept.Config.BettingTicks)
+ }
+}
+
+func TestEnsureTableReplacesIdleRules(t *testing.T) {
+ m := NewManager(1)
+ m.Join(1, TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1, BettingTicks: 50}, "alice")
+ m.Leave(1, "bj", "alice")
+
+ changed := m.EnsureTable(1, TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1, BettingTicks: 3})
+ if changed.Config.BettingTicks != 3 {
+ t.Fatalf("idle table did not adopt new rules: betting ticks %d", changed.Config.BettingTicks)
+ }
+}
+
+func TestEvictRoomRefundsAndRemovesGames(t *testing.T) {
+ m := NewManager(1)
+ w := &testWallet{credits: 100}
+ m.JoinMachine(1, MachineConfig{ID: "slots", Game: GameSlots, MinBet: 1}, "alice")
+ m.SetMachineBet(1, "slots", "alice", 10, w)
+ if w.credits != 90 {
+ t.Fatalf("wallet before evict = %d, want 90", w.credits)
+ }
+
+ events := m.EvictRoom(1)
+ if w.paid != 10 {
+ t.Fatalf("outstanding wager not refunded on evict: paid %d", w.paid)
+ }
+ if len(events) == 0 {
+ t.Fatal("eviction produced no refund events")
+ }
+ if m.Table(1, "any") != nil {
+ t.Fatal("stale table survived eviction")
+ }
+ // Re-entering the room creates a fresh game.
+ m.JoinMachine(1, MachineConfig{ID: "slots", Game: GameSlots, MinBet: 1}, "alice")
+}
diff --git a/internal/casino/table.go b/internal/casino/table.go
index 80ae451..09c19de 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -16,6 +16,9 @@ type tableGame interface {
rebet(name string) (spot string, amount int)
markDisconnected(name string) []Event
hasParticipant(name string) bool
+ refundAll() []Event
+ setRoom(roomID int)
+ idle() bool
}
// tableBase carries the identity every table game needs to build events.
@@ -78,3 +81,29 @@ func (t *Table) rebet(name string) (string, int) { return t.game.rebet(name)
func (t *Table) markDisconnected(name string) []Event { return t.game.markDisconnected(name) }
func (t *Table) hasParticipant(name string) bool { return t.game.hasParticipant(name) }
+
+// refundAll resolves every committed wager at this table (graceful
+// shutdown): stakes return to their players through the wallet.
+func (t *Table) refundAll() []Event { return t.game.refundAll() }
+
+// setRoom updates the table game's room for event routing after a room-ID
+// rekey. Games keep their state; only their location moves.
+func (b *tableBase) setRoom(roomID int) { b.RoomID = roomID }
+
+// idle reports whether no player is seated (or mid-round): only an idle
+// table may have its live rules replaced by new room configuration.
+func (t *blackjackTable) idle() bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.players.count() == 0
+}
+
+// idle reports whether no player is seated (or mid-round).
+func (t *baccaratTable) idle() bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.players.count() == 0
+}
+
+// idle on the Table wrapper delegates to the game.
+func (t *Table) idle() bool { return t.game.idle() }
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 4d5159a..75d041b 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -2,9 +2,6 @@ package casino
import "fmt"
-// maxBetUnlimited is the MaxBet sentinel meaning "no wager cap".
-const maxBetUnlimited = int(^uint(0) >> 1)
-
// betRangeText describes the accepted wager range; the unlimited sentinel is
// rendered as "no maximum" instead of a machine-sized integer.
func betRangeText(minBet, maxBet int) string {
@@ -126,8 +123,10 @@ func (c MachineConfig) Normalize() MachineConfig {
if c.MinBet <= 0 {
c.MinBet = 1
}
+ // An unset MaxBet defaults to the economy's bounded cap; the explicit
+ // unlimited sentinel is still honored for authored configurations.
if c.MaxBet <= 0 {
- c.MaxBet = maxBetUnlimited
+ c.MaxBet = DefaultMaxBet
}
if c.SpinTicks <= 0 {
c.SpinTicks = 2
@@ -152,7 +151,7 @@ func (c TableConfig) Normalize() TableConfig {
c.MinBet = 1
}
if c.MaxBet <= 0 {
- c.MaxBet = maxBetUnlimited
+ c.MaxBet = DefaultMaxBet
}
return c
}
@@ -164,8 +163,14 @@ type Wager struct {
}
type Wallet interface {
- Wager(amount int) (Wager, bool)
- PayCredits(amount int)
+ // Wager debits the stake durably. ErrInsufficientFunds is a normal
+ // rejected bet; ErrWalletPersistence means the debit could not be made
+ // durable and the game must not proceed as if the bet succeeded.
+ Wager(amount int) (Wager, error)
+ // PayCredits pays a settlement durably, returning an error when the
+ // credit could not be persisted (the game must not report a win that
+ // is not durable).
+ PayCredits(amount int) error
}
type EventType string