diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat.go | 45 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 56 | ||||
| -rw-r--r-- | internal/casino/credits.go | 84 | ||||
| -rw-r--r-- | internal/casino/credits_test.go | 74 | ||||
| -rw-r--r-- | internal/casino/machine.go | 48 | ||||
| -rw-r--r-- | internal/casino/machine_test.go | 4 | ||||
| -rw-r--r-- | internal/casino/manager.go | 122 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 13 | ||||
| -rw-r--r-- | internal/casino/reconfig_test.go | 84 | ||||
| -rw-r--r-- | internal/casino/table.go | 29 | ||||
| -rw-r--r-- | internal/casino/types.go | 19 |
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 |
