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 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 } // 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 // PayStakeCover BEFORE announcing the win (that one save both credits the // payout and removes the stakes it returns), 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 payout was never // durable, so it can never be paid on top of the refund. 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 { if err := w.PayStakeCover(payout, stakeIDs); err != nil { return true } } for _, id := range stakeIDs { if id != "" { w.ResolveStake(id) } } 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:] }