package casino import ( "fmt" "math/rand" ) // Baccarat uses the shared table phases; these aliases keep the call sites // readable. const ( baccaratWaiting = phaseWaiting baccaratBetting = phaseBetting ) // baccaratSpots are the outcomes a participant may wager on. const ( baccaratSpotPlayer = "player" baccaratSpotBanker = "banker" baccaratSpotTie = "tie" ) type baccaratPlayer struct { Participant lastBet int lastSpot string // stakeID is the wallet handle of the wager record committed this // round; the settlement returns it inside the payout. stakeID string } func (p *baccaratPlayer) participantPtr() *Participant { return &p.Participant } type baccaratTable struct { 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{ tableCore: newTableCore[*baccaratPlayer](&tableBase{RoomID: roomID, Config: cfg}), Rules: rules, shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), } } func (t *baccaratTable) menuEvent(name string) Event { return t.betMenuEvent(name, []MenuCommand{ {Command: "bet ", Desc: "Place a wager (min/max accepted)"}, {Command: "bet on " + t.Rules.betChoices(), Desc: "Wager on a specific outcome (one wager per round)"}, {Command: "stop", Desc: "Leave the table"}, }) } func (t *baccaratTable) join(name string) []Event { t.mu.Lock() defer t.mu.Unlock() if t.players.has(name) { return []Event{t.menuEvent(name)} } if t.players.count() >= t.Config.MaxPlayers { return []Event{t.private(name, "That baccarat table is full.")} } events := []Event{t.addSeatLocked(name, &baccaratPlayer{Participant: Participant{Name: name, Connected: true}})} events = append(events, t.private(name, t.Config.BaccaratRulesText()), t.menuEvent(name)) return events } func (t *baccaratTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.players.get(name) if p == nil { return nil } // A wager is committed for the round in flight; everyone else may stand // up whenever they like. if p.HasBet { return []Event{t.private(name, "You're in the middle of a baccarat round!")} } t.removeSeatLocked(name) events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")} return t.closeIfEmptyLocked(events) } func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.players.get(name) if p == nil { return []Event{t.private(name, "You aren't playing baccarat.")} } if spot == "" { spot = p.lastSpot } if spot == "" { return []Event{t.private(name, "Bet on what? Try bet on "+t.Rules.betChoices()+".")} } if !t.Rules.allowsSpot(spot) { return []Event{t.private(name, "Baccarat bets must be on player, banker, or tie, or an enabled optional bet. Available: "+t.Rules.betChoices()+".")} } if p.HasBet { return []Event{t.private(name, "You have already bet this round.")} } 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, err := wallet.Wager(amount) if err != nil { return []Event{t.private(name, walletErrorMessage(err))} } p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot, p.stakeID = true, wager, wallet, amount, spot, wager.ID 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))} events = t.openBettingLocked(events) if allBet(t.players.participants()) { events = append(events, t.deal()...) } return events } func (t *baccaratTable) tick() []Event { t.mu.Lock() defer t.mu.Unlock() if t.phase != baccaratBetting { return nil } return t.advanceBettingLocked(t.deal, t.resetRound) } func (t *baccaratTable) action(name, action string) []Event { return []Event{t.private(name, "That game has no player actions.")} } func (t *baccaratTable) rebet(name string) (string, int) { t.mu.Lock() defer t.mu.Unlock() if p := t.players.get(name); p != nil && p.lastBet > 0 { return p.lastSpot, p.lastBet } return "", t.Config.MinBet } // deal plays out the hand by the punto banco tableau, settles every wager, // and resets the table for the next round. func (t *baccaratTable) deal() []Event { // The cut-card reshuffle and the round-coverage guarantee both happen at // this boundary, before a single card of the coup is dealt (deal() is // synchronous, so a wager here has never had a card dealt for it): // draw() rebuilds an exhausted shoe in place, which mid-coup could deal // a card the coup already holds. t.shoe.shuffleIfDue() t.shoe.ensureForRound(baccaratRoundCards) player, banker := playTableau(t.shoe) playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker) round := t.event("New baccarat hand!", true) round.Snapshot = t.snapshot(player, banker) events := []Event{round, t.event(baccaratOutcomeMessage(playerTotal, bankerTotal), true)} if explanation := baccaratVariantOutcome(player, banker, t.Rules); explanation != "" { events = append(events, t.event(explanation, true)) } for _, p := range t.players.ordered() { if !p.HasBet { continue } result, payout, settleErr := settleBaccaratHand(p.lastSpot, p.Wager.Total, player, banker, t.Rules) // The shared settlement path pays before announcing and leaves the // stake pending on any failure, so a refund can never be paid on top // of a payout that was not durable. payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr) publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) if payoutFailed { privateResult = payoutFailedMessage() } settlement := t.playerAction(p.Name, publicResult, privateResult) if result == "win" { if p.lastSpot == baccaratSpotTie { settlement.Color = "casino_jackpot_win" } else { settlement.Color = "casino_big_win" } } events = append(events, settlement) // settleStakeOutcome already consumed this stake by handle (a payout // removed it, a push/loss resolved it). Deliberately do NOT call the // player-wide ResolveStakes here: it would also consume a stake left // pending by an earlier failed payout, which must stay refundable. } t.resetRound() for _, p := range t.players.ordered() { if p.Connected { events = append(events, t.menuEvent(p.Name)) } } return events } func (t *baccaratTable) snapshot(player, banker []card) *BaccaratSnapshot { snapshot := &BaccaratSnapshot{ PlayerScore: fmt.Sprintf("%d", baccaratTotal(player)), BankerScore: fmt.Sprintf("%d", baccaratTotal(banker)), } for _, c := range player { snapshot.Player = append(snapshot.Player, CardView{Rank: c.rank, Suit: c.suit}) } for _, c := range banker { snapshot.Banker = append(snapshot.Banker, CardView{Rank: c.rank, Suit: c.suit}) } for _, p := range t.players.ordered() { if !p.HasBet { continue } snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: p.Name, Spot: p.lastSpot, Amount: p.Wager.Total}) } return snapshot } func (t *baccaratTable) resetRound() { t.phase = baccaratWaiting t.window.close() clearBets(t.players.participants()) for _, p := range t.players.ordered() { p.stakeID = "" } } // markDisconnected removes the player from the table. A committed wager is // REFUNDED rather than forfeited: deal() is synchronous, so a seated bettor // whose wager is still outstanding has had no card dealt for it (the coup // only starts once every connected seat has bet) and nothing is revealed by // returning the stake. An open betting window then resolves or resets on the // next tick now that they no longer hold it open. func (t *baccaratTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() if !t.players.has(name) { return nil } if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { if _, _, err := p.Wallet.RefundOutstanding(); err != nil { // The stake stays pending on disk and startup recovery will return // it, but tell the departing player instead of silently swallowing // the failure. t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} events = append(events, t.private(name, "Your stake could not be returned just now; it stays pending and will be refunded after the next server restart.")) return t.closeIfEmptyLocked(events) } } t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} return t.closeIfEmptyLocked(events) } // baccaratTotal scores a hand modulo 10. func baccaratTotal(hand []card) int { total := 0 for _, c := range hand { total += c.baccaratValue() } return total % 10 } // playTableau deals the initial four cards and applies the punto banco // drawing rules, returning the final player and banker hands. func playTableau(sh *shoe) (player, banker []card) { player = []card{sh.draw(), sh.draw()} banker = []card{sh.draw(), sh.draw()} playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker) if playerTotal >= 8 || bankerTotal >= 8 { // A natural 8 or 9 ends the hand immediately. return player, banker } playerThird := -1 if playerTotal <= 5 { c := sh.draw() playerThird = c.baccaratValue() player = append(player, c) } if playerThird < 0 { if bankerTotal <= 5 { banker = append(banker, sh.draw()) } return player, banker } if bankerDraws(bankerTotal, playerThird) { banker = append(banker, sh.draw()) } return player, banker } // bankerDraws applies the banker tableau when the player took a third card. func bankerDraws(bankerTotal, playerThird int) bool { switch bankerTotal { case 0, 1, 2: return true case 3: return playerThird != 8 case 4: return playerThird >= 2 && playerThird <= 7 case 5: return playerThird >= 4 && playerThird <= 7 case 6: return playerThird == 6 || playerThird == 7 default: // 7 return false } } // settleBaccaratBet is the ordinary main-wager payout helper. Gameplay must // use settleBaccaratHand first to apply variant exceptions and optional bets; // totals alone cannot identify a three-card Banker seven. // It returns the result label and total payout (stake plus winnings). // Player and banker bets push on a tie. Winnings // round down to whole credits, so the banker commission effectively rounds // up: never bet less than one commission unit. func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules BaccaratConfig) (string, int, error) { switch { case playerTotal > bankerTotal: if spot == baccaratSpotPlayer { return "win", bet * 2, nil } case bankerTotal > playerTotal: if spot == baccaratSpotBanker { bankerWin, err := MulPayout(bet, rules.BankerPayout) if err != nil { return "win", bet + bankerWin, err } // The banker commission rounds UP, not down: when the wager is // too small for a whole credit of commission, a winning banker // bet returns only the stake. MulPayout's positive-stake floor // would otherwise round the commission to zero and make the // banker bet +EV (it wins more often than it loses, and ties // push), which the "never bet less than one commission unit" // rule exists to prevent. if float64(bet)*rules.BankerPayout < 1 { return "push", bet, nil } return "win", bet + bankerWin, nil } default: if spot == baccaratSpotTie { tieWin, err := MulPayout(bet, rules.TiePayout) if err != nil { return "win", bet + tieWin, err } return "win", bet + tieWin, nil } return "push", bet, nil } return "loss", 0, nil } func baccaratOutcomeMessage(playerTotal, bankerTotal int) string { switch { case playerTotal > bankerTotal: return fmt.Sprintf("Player wins %d over %d.", playerTotal, bankerTotal) case bankerTotal > playerTotal: return fmt.Sprintf("Banker wins %d over %d.", bankerTotal, playerTotal) default: return fmt.Sprintf("Player and banker tie at %d.", playerTotal) } } func baccaratResultMessages(name, result string, payout int) (string, string) { switch result { case "win": return name + " wins!", fmt.Sprintf("You win! +%d credits", payout) case "push": return name + " pushes.", fmt.Sprintf("You push. +%d credits", payout) default: return name + " loses.", "You lose!" } } // refundAll refunds every committed wager at this table (graceful // shutdown/eviction): the wallet returns every outstanding stake exactly // once. It then RESETS the round, so a refunded round can never settle (and // pay) afterwards: calling it twice, or letting the table keep running, // cannot pay the same round twice. func (t *baccaratTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() events := t.refundAllLocked() t.resetRound() return events }