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 { var events []Event if c.window.timer.warn() { for _, p := range c.players.participants() { if p.Connected && !p.HasBet && !p.SittingOut { events = append(events, c.warning(p.Name, fmt.Sprintf("Betting closes in %d ticks. Bet now or sit out this hand.", c.window.timer.remaining()))) } } } return events } 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. // // It deliberately does NOT gate on p.HasBet: a stake whose payout could not // be persisted stays pending after resetRound clears HasBet, and a shutdown // or room eviction must still return it. RefundOutstanding is the player-wide // ledger and a player can only participate in one casino game at a time, so // refunding every seated wallet is safe and returns exactly that player's // outstanding stakes. func (c *tableCore[P]) refundAllLocked() []Event { var events []Event for _, p := range c.players.participants() { if 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 stays pending for the next startup.")) } 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 } // resolveStakeIDs consumes each listed pending stake by wallet handle. It is // the per-handle counterpart to ResolveStakes: use it whenever another stake // may still be pending for the same player (for example an earlier payout // that failed to persist), so consuming one round's stakes cannot destroy an // unrelated refundable stake. func resolveStakeIDs(w Wallet, ids []string) { if w == nil { return } w.ResolveStakesByIDs(ids...) } // settleStakeOutcome applies one wager's outcome through the wallet and // reports whether the settlement could NOT be made durable. It carries the // money-critical ordering shared by every table game (documented in // building_guide/concurrency.md): compute the payout, pay it via PayCredits // BEFORE announcing the win — that one save credits the payout and resolves // the stakes it returns, so a crash can never pay the payout AND refund the // same stake — and only then stand the hand down. On a persistence failure // the stakes stay pending, so a later shutdown refund or startup recovery // returns them exactly once. The caller must not announce a paid win and must // not run the player-wide ResolveStakes. // // payoutErr is a payout-computation error (for example an overflowed // multiplier): it is treated exactly like a persistence failure so a // clamped or absurd amount is never paid. A nil wallet (test scaffolding) // resolves the stakes in memory only. func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error) (failed bool) { if payoutErr != nil { return true } if w == nil { return false } if payout > 0 { return w.PayCredits(payout, stakeIDs...) != nil } // A zero payout is a loss: consume the stakes in one save so recovery can // never refund a round that resolved. if w != nil { w.ResolveStakesByIDs(stakeIDs...) } return false } // 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:] }