aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 18:38:35 -0400
committerhistoria <[not public]>2026-09-18 18:38:35 -0400
commit1f118d79e319da4e765502e9ca5b661772de94f1 (patch)
treebca8a3fd1e538f1c302480511e6ba872c92e10b8 /internal/casino
parent5ad42a51df4dcc5ccc3aa6a6d902085ce2ee4239 (diff)
downloadthehouseoficarus-1f118d79e319da4e765502e9ca5b661772de94f1.tar.gz
refactor: casino table core; fix: stake/migration/validation gaps
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go106
-rw-r--r--internal/casino/blackjack.go102
-rw-r--r--internal/casino/config_validate.go13
-rw-r--r--internal/casino/core.go137
-rw-r--r--internal/casino/machine.go30
-rw-r--r--internal/casino/manager.go40
-rw-r--r--internal/casino/manager_test.go34
-rw-r--r--internal/casino/money_regression_test.go37
-rw-r--r--internal/casino/slots.go7
-rw-r--r--internal/casino/table.go62
-rw-r--r--internal/casino/types.go7
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