diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/baccarat.go | 106 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 102 | ||||
| -rw-r--r-- | internal/casino/config_validate.go | 13 | ||||
| -rw-r--r-- | internal/casino/core.go | 137 | ||||
| -rw-r--r-- | internal/casino/machine.go | 30 | ||||
| -rw-r--r-- | internal/casino/manager.go | 40 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 34 | ||||
| -rw-r--r-- | internal/casino/money_regression_test.go | 37 | ||||
| -rw-r--r-- | internal/casino/slots.go | 7 | ||||
| -rw-r--r-- | internal/casino/table.go | 62 | ||||
| -rw-r--r-- | internal/casino/types.go | 7 |
11 files changed, 338 insertions, 237 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 164d0ff..b762b6f 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -3,14 +3,13 @@ package casino import ( "fmt" "math/rand" - "sync" ) -type baccaratPhase string - +// Baccarat uses the shared table phases; these aliases keep the call sites +// readable. const ( - baccaratWaiting baccaratPhase = "waiting" - baccaratBetting baccaratPhase = "betting" + baccaratWaiting = phaseWaiting + baccaratBetting = phaseBetting ) // baccaratSpots are the outcomes a participant may wager on. @@ -36,38 +35,27 @@ type baccaratPlayer struct { func (p *baccaratPlayer) participantPtr() *Participant { return &p.Participant } type baccaratTable struct { - mu sync.Mutex - tableBase - Rules BaccaratConfig - players seatSet[*baccaratPlayer] - phase baccaratPhase - window bettingWindow - shoe *shoe + tableCore[*baccaratPlayer] + Rules BaccaratConfig + shoe *shoe } func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTable { cfg = cfg.Normalize() rules := cfg.Baccarat.Normalize() return &baccaratTable{ - tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules, - players: newSeatSet[*baccaratPlayer](), phase: baccaratWaiting, - shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), + tableCore: newTableCore[*baccaratPlayer](&tableBase{RoomID: roomID, Config: cfg}), + Rules: rules, + shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), } } func (t *baccaratTable) menuEvent(name string) Event { - e := t.private(name, t.menu()) - e.Color = "casino_menu" - e.Menu = &MenuView{Kind: MenuBaccaratBet, Commands: []MenuCommand{ + return t.betMenuEvent(name, MenuBaccaratBet, []MenuCommand{ {Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"}, {Command: "bet <amount> on player|banker|tie", Desc: "Wager on a specific outcome"}, {Command: "stop", Desc: "Leave the table"}, - }} - return e -} - -func (t *baccaratTable) menu() string { - return "New baccarat hand! Place your bet." + }) } func (t *baccaratTable) join(name string) []Event { @@ -79,8 +67,7 @@ func (t *baccaratTable) join(name string) []Event { if t.players.count() >= t.Config.MaxPlayers { return []Event{t.private(name, "That baccarat table is full.")} } - t.players.add(name, &baccaratPlayer{Participant: Participant{Name: name, Connected: true}}) - events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")} + events := []Event{t.addSeatLocked(name, &baccaratPlayer{Participant: Participant{Name: name, Connected: true}})} events = append(events, t.menuEvent(name)) return events } @@ -97,12 +84,9 @@ func (t *baccaratTable) leave(name string) []Event { if p.HasBet { return []Event{t.private(name, "You're in the middle of a baccarat round!")} } - t.players.remove(name) + t.removeSeatLocked(name) events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")} - if t.players.count() == 0 { - events = append(events, t.event("The baccarat table closes.", true)) - } - return events + return t.closeIfEmptyLocked(events) } func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Event { @@ -135,11 +119,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits on %s.", name, amount, spot), fmt.Sprintf("You bet %d credits on %s.", amount, spot))} - if t.players.count() > 1 && t.phase == baccaratWaiting { - t.phase = baccaratBetting - t.window.open(t.Config.BettingTicks) - events = append(events, t.event(fmt.Sprintf("Baccarat betting is open for %d ticks.", t.Config.BettingTicks), true)) - } + events = t.openBettingLocked(events) if allBet(t.players.participants()) { events = append(events, t.deal()...) } @@ -152,20 +132,7 @@ func (t *baccaratTable) tick() []Event { if t.phase != baccaratBetting { return nil } - sitOuts, resolve, pending := t.window.advance(t.players.participants()) - if pending { - return nil - } - var events []Event - for _, name := range sitOuts { - events = append(events, t.event(fmt.Sprintf("%s sits out this baccarat round.", name), true)) - } - if resolve { - events = append(events, t.deal()...) - } else { - t.resetRound() - } - return events + return t.advanceBettingLocked(t.deal, t.resetRound) } func (t *baccaratTable) action(name, action string) []Event { @@ -291,18 +258,9 @@ func (t *baccaratTable) markDisconnected(name string) []Event { if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { p.Wallet.RefundOutstanding() } - t.players.remove(name) + t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} - if t.players.count() == 0 { - events = append(events, t.event("The baccarat table closes.", true)) - } - return events -} - -func (t *baccaratTable) hasParticipant(name string) bool { - t.mu.Lock() - defer t.mu.Unlock() - return t.players.has(name) + return t.closeIfEmptyLocked(events) } // baccaratTotal scores a hand modulo 10. @@ -431,31 +389,7 @@ func baccaratResultMessages(name, result string, payout int) (string, string) { 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 := p.Name - chips, credits, err := p.Wallet.RefundOutstanding() - refund := chips + credits - if err != nil || refund <= 0 { - if 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 as the casino closes.", name, refund), - fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund))) - } + events := t.refundAllLocked() t.resetRound() 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 fd0572b..fe212f7 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -6,16 +6,15 @@ import ( "fmt" "math/rand" "strings" - "sync" ) -type blackjackPhase string - +// Blackjack uses the shared table phases; these aliases keep the rule +// vocabulary readable at the call sites. const ( - blackjackWaiting blackjackPhase = "waiting" - blackjackBetting blackjackPhase = "betting" - blackjackInsurance blackjackPhase = "insurance" - blackjackPlaying blackjackPhase = "playing" + blackjackWaiting = phaseWaiting + blackjackBetting = phaseBetting + blackjackInsurance = phaseInsurance + blackjackPlaying = phasePlaying ) type blackjackHand struct { @@ -50,7 +49,8 @@ func (h *blackjackHand) total() (int, bool) { func (h *blackjackHand) blackjack(rules BlackjackConfig) bool { total, _ := h.total() - return len(h.cards) == 2 && !h.fromSplit && total == 21 || rules.CountsDoubleSplitBlackjack() && h.fromSplit && h.doubled && total == 21 + return (len(h.cards) == 2 && !h.fromSplit && total == 21) || + (rules.CountsDoubleSplitBlackjack() && h.fromSplit && h.doubled && total == 21) } type blackjackPlayer struct { @@ -65,12 +65,8 @@ type blackjackPlayer struct { func (p *blackjackPlayer) participantPtr() *Participant { return &p.Participant } type blackjackTable struct { - mu sync.Mutex - tableBase + tableCore[*blackjackPlayer] Rules BlackjackConfig - players seatSet[*blackjackPlayer] - phase blackjackPhase - window bettingWindow timer countdown ceiling countdown activePlayer int @@ -98,21 +94,19 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa cfg = cfg.Normalize() rules := cfg.Blackjack.Normalize() return &blackjackTable{ - tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules, - players: newSeatSet[*blackjackPlayer](), phase: blackjackWaiting, - shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), turnHand: -1, + tableCore: newTableCore[*blackjackPlayer](&tableBase{RoomID: roomID, Config: cfg}), + Rules: rules, + shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), + turnHand: -1, } } func (t *blackjackTable) menuEvent(name string) Event { - e := t.private(name, t.menu()) - e.Color = "casino_menu" - e.Menu = &MenuView{Kind: MenuBlackjackBet, Commands: []MenuCommand{ + return t.betMenuEvent(name, MenuBlackjackBet, []MenuCommand{ {Command: "bet", Desc: "Place a wager (min/max accepted)"}, {Command: "bet <amount>", Desc: "Place a specific wager"}, {Command: "stop", Desc: "Leave the table"}, - }} - return e + }) } func (t *blackjackTable) join(name string) []Event { @@ -124,8 +118,7 @@ func (t *blackjackTable) join(name string) []Event { if t.players.count() >= t.Config.MaxPlayers { return []Event{t.private(name, "That blackjack table is full.")} } - t.players.add(name, &blackjackPlayer{Participant: Participant{Name: name, Connected: true}}) - events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")} + events := []Event{t.addSeatLocked(name, &blackjackPlayer{Participant: Participant{Name: name, Connected: true}})} events = append(events, t.menuEvent(name)) return events } @@ -175,11 +168,7 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve } 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))} - if t.players.count() > 1 && t.phase == blackjackWaiting { - t.phase = blackjackBetting - t.window.open(t.Config.BettingTicks) - events = append(events, t.event(fmt.Sprintf("Blackjack betting is open for %d ticks.", t.Config.BettingTicks), true)) - } + events = t.openBettingLocked(events) if allBet(t.players.participants()) { events = append(events, t.startRound()...) } @@ -191,20 +180,7 @@ func (t *blackjackTable) tick() []Event { defer t.mu.Unlock() switch t.phase { case blackjackBetting: - sitOuts, resolve, pending := t.window.advance(t.players.participants()) - if pending { - return nil - } - var events []Event - for _, name := range sitOuts { - events = append(events, t.event(fmt.Sprintf("%s sits out this blackjack round.", name), true)) - } - if resolve { - events = append(events, t.startRound()...) - } else { - t.resetRound() - } - return events + return t.advanceBettingLocked(t.startRound, t.resetRound) case blackjackInsurance: if t.insuranceSettled() { return t.finishInsurance() @@ -807,10 +783,6 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot { return snapshot } -func (t *blackjackTable) menu() string { - return "New blackjack hand! Place your bet." -} - func handScore(hand *blackjackHand) string { total, soft := hand.total() if !soft || total == 21 { @@ -859,11 +831,9 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { p.Wallet.RefundOutstanding() } } - t.players.remove(name) + t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} - if t.players.count() == 0 { - events = append(events, t.event("The blackjack table closes.", true)) - } + events = t.closeIfEmptyLocked(events) if t.phase != blackjackPlaying { // Betting and insurance resolve against the remaining seats on the // next tick; waiting has no round in flight. @@ -882,12 +852,6 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { return append(events, t.finishHand()...) } -func (t *blackjackTable) hasParticipant(name string) bool { - t.mu.Lock() - defer t.mu.Unlock() - return t.players.has(name) -} - // refundAll refunds every committed wager at this table (graceful // shutdown/eviction): the wallet returns every outstanding stake — opening // bet, doubles, splits, and insurance — exactly once. It then RESETS the @@ -897,31 +861,7 @@ func (t *blackjackTable) hasParticipant(name string) bool { 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 := p.Name - chips, credits, err := p.Wallet.RefundOutstanding() - refund := chips + credits - if err != nil || refund <= 0 { - if 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 as the casino closes.", name, refund), - fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund))) - } + events := t.refundAllLocked() t.resetRound() 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/config_validate.go b/internal/casino/config_validate.go index a03cfbf..9b9759c 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -26,8 +26,8 @@ func (c MachineConfig) Validate() error { return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)") } // The machine's effective profile is the built-in one scaled to the - // configured RTP; ValidateRTP certifies that profile, so it must be a - // geometry the runtime evaluator can actually play. + // configured RTP; it must be a geometry the runtime evaluator can + // actually play. return machineSlotProfile(c).Validate() } @@ -160,9 +160,10 @@ const baccaratRoundCards = 6 // Validate checks a slot profile the machine can actually play. The runtime // evaluator (generateSlotSpinWithProfile, matchingLength, stoppedScatters and // evaluateSlot) is pinned to the SlotSpin grid of three rows by five reels; -// CalculateRTP/ValidateRTP are generic over Rows/Reels/Ways, so a profile -// with another geometry would be certified by the RTP gate and then played as -// a different game. Rejecting the geometry here keeps that gate meaningful. +// CalculateRTP also assumes that geometry (fixed 3..5 symbol runs and a +// five-reel no-match term). A profile with another geometry would be +// certified by the RTP gate and then played as a different game. Rejecting +// the geometry here keeps that gate meaningful. func (p SlotProfile) Validate() error { profile := p.Normalize() if profile.Rows != slotGridRows || profile.Reels != slotGridReels { @@ -268,7 +269,7 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { for _, c := range tables { if c.Game == GameSlots { // A slots "table" is instantiated as the room's slots machine. - legacy := MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet} + legacy := LegacySlotMachine(c) check("legacy slots", c.ID, legacy.Validate()) checkAddressable(strings.ToLower(string(GameSlots)), c.ID) continue diff --git a/internal/casino/core.go b/internal/casino/core.go new file mode 100644 index 0000000..85a5e28 --- /dev/null +++ b/internal/casino/core.go @@ -0,0 +1,137 @@ +package casino + +import ( + "fmt" + "strings" + "sync" +) + +// tablePhase is the shared lifecycle phase of a multiplayer table game. +// Blackjack adds insurance and playing; baccarat uses only waiting/betting. +type tablePhase string + +const ( + phaseWaiting tablePhase = "waiting" + phaseBetting tablePhase = "betting" + phaseInsurance tablePhase = "insurance" + phasePlaying tablePhase = "playing" +) + +// tableCore is the plumbing every multiplayer table game shares: the seat set, +// the phase/betting window, and the table lock. A game embeds a +// tableCore[itsPlayerType] and keeps only its own rules and round state. +// +// Methods suffixed Locked require the caller to hold c.mu; the others take it. +// A game's methods take the same promoted lock, so a game must never call a +// locking core method while already holding c.mu. +type tableCore[P seatedPlayer] struct { + *tableBase + mu sync.Mutex + players seatSet[P] + phase tablePhase + window bettingWindow +} + +func newTableCore[P seatedPlayer](base *tableBase) tableCore[P] { + return tableCore[P]{tableBase: base, players: newSeatSet[P](), phase: phaseWaiting} +} + +// base exposes the shared table identity to the non-generic Table handle. +func (c *tableCore[P]) base() *tableBase { return c.tableBase } + +// idle reports whether no player is seated (or mid-round). +func (c *tableCore[P]) idle() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.players.count() == 0 +} + +func (c *tableCore[P]) hasParticipant(name string) bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.players.has(name) +} + +// addSeatLocked seats a player and returns the shared public "sits down" +// event. The caller has already checked capacity. +func (c *tableCore[P]) addSeatLocked(name string, p P) Event { + c.players.add(name, p) + return c.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.") +} + +// removeSeatLocked frees a player's seat. The caller owns the surrounding +// fixups (active-seat indices, refunds) and announcements. +func (c *tableCore[P]) removeSeatLocked(name string) { c.players.remove(name) } + +// closeIfEmptyLocked appends the shared "table closes" event once the last +// seat has been removed. +func (c *tableCore[P]) closeIfEmptyLocked(events []Event) []Event { + if c.players.count() == 0 { + events = append(events, c.event(fmt.Sprintf("The %s table closes.", c.Config.Game), true)) + } + return events +} + +// openBettingLocked opens the timed betting window once a second player has a +// wager down, and announces it. +func (c *tableCore[P]) openBettingLocked(events []Event) []Event { + if c.players.count() > 1 && c.phase == phaseWaiting { + c.phase = phaseBetting + c.window.open(c.Config.BettingTicks) + events = append(events, c.event(fmt.Sprintf("%s betting is open for %d ticks.", gameTitle(c.Config.Game), c.Config.BettingTicks), true)) + } + return events +} + +// advanceBettingLocked moves the betting window one tick. pending leaves the +// window open; otherwise it announces the players who never bet and calls +// resolve (wagers are down) or reset (nobody bet). +func (c *tableCore[P]) advanceBettingLocked(resolve func() []Event, reset func()) []Event { + sitOuts, goRound, pending := c.window.advance(c.players.participants()) + if pending { + return nil + } + var events []Event + for _, name := range sitOuts { + events = append(events, c.event(fmt.Sprintf("%s sits out this %s round.", name, c.Config.Game), true)) + } + if goRound { + return append(events, resolve()...) + } + reset() + return events +} + +// refundAllLocked refunds every committed wager through its wallet exactly +// once and returns the events. The caller resets its own round state +// afterwards, so a refunded round can never settle and pay again. +func (c *tableCore[P]) refundAllLocked() []Event { + var events []Event + for _, p := range c.players.participants() { + if !p.HasBet || p.Wallet == nil { + continue + } + name := p.Name + chips, credits, err := p.Wallet.RefundOutstanding() + refund := chips + credits + if err != nil || refund <= 0 { + if err != nil { + events = append(events, c.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery.")) + } + continue + } + events = append(events, c.playerAction(name, + fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), + fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund))) + } + return events +} + +// gameTitle renders a configured game name as a message heading. +func gameTitle(game GameName) string { + name := string(game) + if name == "" { + return "Table" + } + return strings.ToUpper(name[:1]) + name[1:] +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 83e018f..f285518 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -30,6 +30,11 @@ type Machine struct { inFreeSpins bool spinBet int payoutRemainder float64 + // stakeID is the wallet handle of the wager committed to the current + // round (the triggering wager for a free-spin feature). Resolving by + // handle — not the player-wide ResolveStakes — keeps an older + // failed-payout stake open as recovery evidence when a new round starts. + stakeID string // featureBank holds the winnings of a paid round whose free-spin feature // is still running. They are paid as ONE settlement when the feature // ends: see finishSpin for why no payout may be journaled while the @@ -105,11 +110,13 @@ func (m *Machine) start(name string, wallet Wallet) []Event { } func (m *Machine) beginSpinLocked(amount int, wallet Wallet, publicMessage, privateMessage string) []Event { - if _, err := wallet.Wager(amount); err != nil { + wager, err := wallet.Wager(amount) + if err != nil { return m.private(walletErrorMessage(err, "You don't have enough chips and credits for that bet.")) } m.bet = amount m.spinBet = amount + m.stakeID = wager.ID m.player.Wallet = wallet m.stakeFailed = false m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) @@ -209,9 +216,11 @@ func (m *Machine) autoBetTick() []Event { m.autoBet = false return m.private("Autobet stopped: no wager is set.") } - if _, err := m.player.Wallet.Wager(m.bet); err != nil { + if wager, err := m.player.Wallet.Wager(m.bet); err != nil { m.autoBet = false return m.private(walletErrorMessage(err, "Autobet stopped: you don't have enough chips and credits for that bet.")) + } else { + m.stakeID = wager.ID } m.spinBet = m.bet m.stakeFailed = false @@ -328,13 +337,15 @@ func (m *Machine) finishSpin() []Event { m.inFreeSpins = false if !payFailed && m.player.Wallet != nil { // The paid round fully resolved (the feature either never - // started or just completed; free spins wager nothing): the - // stake is no longer outstanding. - m.player.Wallet.ResolveStakes() + // started or just completed; free spins wager nothing): resolve + // ONLY this round's stake, so an older failed-payout stake stays + // open as recovery evidence. + m.player.Wallet.ResolveStake(m.stakeID) + m.stakeID = "" m.stakeFailed = false } if !m.autoBet { - events = append(events, m.menuEvents("")...) + events = append(events, m.privateMenu("")...) } } return events @@ -362,12 +373,13 @@ func (m *Machine) leave(name string) []Event { // unresolved for recovery (or the shutdown refund) to complete. func (m *Machine) resetSeat() { if m.player != nil && m.player.Wallet != nil && !m.stakeFailed { - m.player.Wallet.ResolveStakes() + m.player.Wallet.ResolveStake(m.stakeID) } m.player = nil m.bet = 0 m.spin = nil m.spinBet = 0 + m.stakeID = "" m.phase = machineReady m.reel, m.clock, m.suspense = 0, 0, false m.freeSpins = 0 @@ -496,10 +508,6 @@ func (m *Machine) privateMenu(prefix string) []Event { return events } -func (m *Machine) menuEvents(prefix string) []Event { - return m.privateMenu(prefix) -} - // refundAll refunds any outstanding stake at graceful shutdown/eviction and // then DISCARDS the round (the machine closes with the process). The wallet // resolves every outstanding stake exactly once; a completed spin has no diff --git a/internal/casino/manager.go b/internal/casino/manager.go index b1281b2..b71e5df 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -7,6 +7,7 @@ import ( "math/rand" "reflect" "sort" + "strconv" "strings" "sync" ) @@ -361,26 +362,32 @@ func (m *Manager) RekeyRooms(idMap map[int]int) { } 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 + // rekey parses a room:table key and returns the remapped key and room ID. + // strings.Cut/strconv is used instead of fmt.Sscanf because a table or + // machine ID may legally contain whitespace (validation rejects only + // surrounding whitespace) and %s would stop at the first space, rebuilding + // a corrupted key and orphaning the game. + rekey := func(key string) (string, int, bool) { + roomStr, tableID, found := strings.Cut(key, ":") + if !found { + return "", 0, false + } + roomID, err := strconv.Atoi(roomStr) + if err != nil { + return "", 0, false } newID, ok := idMap[roomID] if !ok || newID == roomID { - return "", false + return "", 0, false } - return fmt.Sprintf("%d:%s", newID, tableID), true + return fmt.Sprintf("%d:%s", newID, tableID), newID, 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) + if newKey, newRoomID, ok := rekey(key); ok { + // The Table handle and the game share one *tableBase, so this + // single assignment updates event routing for both. + table.RoomID = newRoomID movedTables[newKey] = table delete(m.tables, key) } @@ -390,11 +397,8 @@ func (m *Manager) RekeyRooms(idMap map[int]int) { } 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] + if newKey, newRoomID, ok := rekey(key); ok { + machine.RoomID = newRoomID movedMachines[newKey] = machine delete(m.machines, key) } diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go index b037efa..6601b52 100644 --- a/internal/casino/manager_test.go +++ b/internal/casino/manager_test.go @@ -127,3 +127,37 @@ func eventText(events []Event) string { } return "" } + +// TestRekeyRoomsKeepsTableIdentityAndWhitespaceIDs: a room rekey must move +// the manager key and the shared table identity together, and must not +// truncate a table ID that legally contains whitespace. +func TestRekeyRoomsKeepsTableIdentityAndWhitespaceIDs(t *testing.T) { + m := NewManager(1) + const id = "high stakes bj" + cfg := TableConfig{ID: id, Game: GameBlackjack} + m.Join(1, cfg, "alice") + table := m.Table(1, id) + if table == nil { + t.Fatal("table was not created for a valid whitespace-containing ID") + } + m.RekeyRooms(map[int]int{1: 12}) + if m.Table(1, id) != nil { + t.Fatal("table left under the old room key") + } + moved := m.Table(12, id) + if moved != table { + t.Fatalf("rekey lost the table (or truncated its key): Table(12, %q)=%p want %p", id, moved, table) + } + if moved.RoomID != 12 { + t.Fatalf("moved table RoomID = %d, want 12", moved.RoomID) + } + // The game shares the table's base, so events must route to the new room. + events := moved.join("bob") + if len(events) == 0 || events[0].RoomID != 12 { + t.Fatalf("post-rekey events route to the wrong room: %+v", events) + } + // The old, truncated key must not have been created. + if m.Table(12, "high") != nil { + t.Fatal("rekey created a truncated table key from a whitespace ID") + } +} diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go index 79f9b46..31cd0e6 100644 --- a/internal/casino/money_regression_test.go +++ b/internal/casino/money_regression_test.go @@ -249,3 +249,40 @@ func TestSlotsStakeResolvesOnlyAfterFullRound(t *testing.T) { t.Errorf("refundAll double-paid a completed round: paid %d -> %d", paidBefore, wallet.paid) } } + +// TestSlotsSecondSpinDoesNotResolveFailedPayoutStake: a spin whose payout +// could not be persisted leaves its stake OPEN as recovery evidence. The +// next round must resolve only its own stake — a player-wide ResolveStakes +// would silently erase the failed round's evidence and drop the refund. +func TestSlotsSecondSpinDoesNotResolveFailedPayoutStake(t *testing.T) { + machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand()) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + machine.join("alice") + machine.setBet("alice", 10, wallet) + machine.spin = &SlotSpin{Payout: 20} // winning spin the wallet refuses to pay + for i := 0; i < 20 && machine.phase != machineReady; i++ { + machine.tick() + } + if len(wallet.open) != 1 { + t.Fatal("setup: a failed payout must leave the stake open") + } + + // A second, losing round must not resolve the first (still-open) stake. + machine.setBet("alice", 10, wallet) + machine.spin = &SlotSpin{} + for i := 0; i < 20 && machine.phase != machineReady; i++ { + machine.tick() + } + if len(wallet.open) != 1 { + t.Fatalf("the second spin resolved the failed-payout stake; open=%d, want 1", len(wallet.open)) + } + + // The preserved stake is still refundable at shutdown. + machine.refundAll() + if len(wallet.open) != 0 { + t.Fatalf("failed-payout stake was not refunded: open=%+v", wallet.open) + } + if got := wallet.credits + wallet.paid; got != 90 { + t.Fatalf("balance %d, want 90 (first stake refunded, second leg lost)", got) + } +} diff --git a/internal/casino/slots.go b/internal/casino/slots.go index 6be5004..217e9fb 100644 --- a/internal/casino/slots.go +++ b/internal/casino/slots.go @@ -31,9 +31,10 @@ type SlotSpin struct { // The runtime slot evaluator is pinned to this grid: SlotSpin/SlotSnapshot // hold it, generateSlotSpinWithProfile fills it, and matchingLength and -// stoppedScatters evaluate it. CalculateRTP/ValidateRTP are generic over -// SlotProfile.Rows/Reels/Ways, so SlotProfile.Validate rejects any other -// geometry (see config_validate.go) to keep the RTP gate honest. +// stoppedScatters evaluate it. CalculateRTP also assumes this geometry (fixed +// 3..5 symbol runs and a five-reel no-match term), so SlotProfile.Validate +// rejects any other geometry (see config_validate.go) to keep the RTP gate +// honest. const ( slotGridRows = 3 slotGridReels = 5 diff --git a/internal/casino/table.go b/internal/casino/table.go index 09c19de..4be60a0 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -1,6 +1,7 @@ package casino import ( + "fmt" "math/rand" ) @@ -17,55 +18,71 @@ type tableGame interface { markDisconnected(name string) []Event hasParticipant(name string) bool refundAll() []Event - setRoom(roomID int) idle() bool + // base returns the table identity shared with the Table handle. + base() *tableBase } -// tableBase carries the identity every table game needs to build events. +// tableBase is the identity every table game shares with its Table handle. +// The Table and the embedded tableGame hold the SAME *tableBase, so the room +// ID has a single source of truth: a rekey updates it once and every event the +// game builds follows, with no second field to keep in sync. type tableBase struct { RoomID int Config TableConfig } -func (b tableBase) event(message string, public bool) Event { +func (b *tableBase) event(message string, public bool) Event { return Event{RoomID: b.RoomID, TableID: b.Config.ID, Type: EventResult, Message: message, Public: public, Color: "casino_action"} } -func (b tableBase) private(name, message string) Event { +func (b *tableBase) private(name, message string) Event { e := b.event(message, false) e.PrivateTo = name return e } -func (b tableBase) playerAction(name, publicMessage, privateMessage string) Event { +func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Event { e := b.event(publicMessage, true) e.PrivateTo = name e.PrivateMessage = privateMessage return e } +// betMenuEvent builds the shared "place your bet" menu heading for a table +// game; only the menu kind and command list differ per game. +func (b *tableBase) betMenuEvent(name string, kind MenuKind, commands []MenuCommand) Event { + e := b.private(name, fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game)) + e.Color = "casino_menu" + e.Menu = &MenuView{Kind: kind, Commands: commands} + return e +} + // Table is the manager-facing handle for a multiplayer table. It only holds -// routing state; all game logic lives in the tableGame implementation. +// routing state; all game logic lives in the tableGame implementation. It +// shares one *tableBase with the game, so RoomID/Config are a single source of +// truth for both. type Table struct { - RoomID int - Config TableConfig - game tableGame + *tableBase + game tableGame } // newTable builds the table game for cfg.Game, or nil when the game has no // multiplayer table implementation. func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table { cfg = cfg.Normalize() - t := &Table{RoomID: roomID, Config: cfg} + var game tableGame switch cfg.Game { case GameBlackjack: - t.game = newBlackjackTable(roomID, cfg, rng) + game = newBlackjackTable(roomID, cfg, rng) case GameBaccarat: - t.game = newBaccaratTable(roomID, cfg, rng) + game = newBaccaratTable(roomID, cfg, rng) default: return nil } - return t + // The Table and the game share the game's base, so RoomID/Config are a + // single source of truth for both. + return &Table{tableBase: game.base(), game: game} } func (t *Table) join(name string) []Event { return t.game.join(name) } @@ -86,24 +103,5 @@ func (t *Table) hasParticipant(name string) bool { return t.game.hasPartici // 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 578aee1..3d31820 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -129,6 +129,13 @@ type MachineConfig struct { Payout float64 `yaml:"payout,omitempty"` } +// LegacySlotMachine converts a legacy slots TableConfig (older rooms declared +// slots under casino_tables) into the MachineConfig the room actually +// instantiates. Shared by validation and room lookup so the two cannot drift. +func LegacySlotMachine(c TableConfig) MachineConfig { + return MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet} +} + func (c MachineConfig) Normalize() MachineConfig { if c.MinBet <= 0 { c.MinBet = 1 |
