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 // mainSpot is the spot of the committed main wager (player, banker or // tie). Once the round ends it is what a bare "bet" repeats. mainSpot string // sides holds this round's optional side-bet stakes by spot. sides map[string]Wager // lastBets remembers the last stake placed on each spot so a bare "bet" // can repeat the side bets and the main wager together. lastBets map[string]int } func (p *baccaratPlayer) participantPtr() *Participant { return &p.Participant } // totalStaked is every credit this seat has committed to the current round. func (p *baccaratPlayer) totalStaked() int { total := 0 if p.HasBet { total += p.Wager.Total } for _, w := range p.sides { total += w.Total } return total } // sideSpots lists committed side spots in the table's own order so menus and // settlements never depend on map iteration. func (p *baccaratPlayer) sideSpots(rules BaccaratConfig) []string { var spots []string for _, spot := range rules.spots() { if baccaratIsMainSpot(spot) || p.sides[spot].Total == 0 { continue } spots = append(spots, spot) } return spots } // wagers lists the main wager first, then every side bet, as one settlement // and snapshot unit each. func (p *baccaratPlayer) wagers(rules BaccaratConfig) []baccaratWager { var out []baccaratWager if p.HasBet { out = append(out, baccaratWager{spot: p.mainSpot, wager: p.Wager}) } for _, spot := range p.sideSpots(rules) { out = append(out, baccaratWager{spot: spot, wager: p.sides[spot]}) } return out } func (p *baccaratPlayer) hasLastBet() bool { for _, amount := range p.lastBets { if amount > 0 { return true } } return false } // baccaratWager pairs a committed stake with the spot it rides on. type baccaratWager struct { spot string wager Wager } func baccaratIsMainSpot(spot string) bool { return spot == baccaratSpotPlayer || spot == baccaratSpotBanker || spot == baccaratSpotTie } 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 { p := t.players.get(name) if p != nil && p.HasBet { return t.committedMenu(name) } commands := []MenuCommand{} if p != nil && p.hasLastBet() { commands = append(commands, MenuCommand{Command: "bet", Desc: "Repeat your last bets"}) } if p != nil && len(p.sides) > 0 { commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back your side bets"}) } commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)}, MenuCommand{Command: "bet on "}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"}) commands = withTableHints(commands, t.wagerRows()) e := t.betMenuEvent(name, commands) if p != nil { e.Menu.Repeat = p.hasLastBet() } return e } 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}})} // The rules board is a rendered felt (presentation layer); the engine // only ships the configuration. rules := t.private(name, "") rules.Snapshot = &BaccaratRulesView{Config: t.Config} events = append(events, rules, 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.totalStaked() > 0 { return []Event{t.private(name, "You're in the middle of a baccarat round!")} } var events []Event events = append(events, t.refundSidesLocked(p)...) t.removeSeatLocked(name) events = append(events, t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")) return t.closeIfEmptyLocked(events) } // bet commits one wager: a main bet (player, banker or tie) or a side bet. // Side bets may only be placed before the main wager, so the main bet remains // the single trigger that ends the betting window and deals the cards. 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.mainSpot } 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()+".")} } events, ok := t.placeBetLocked(p, spot, amount, wallet) if !ok { return events } return t.finishBetLocked(events) } // placeBetLocked validates and commits a single wager without deciding // whether the round should start. It reports false with a refusal event when // the wager is not allowed. func (t *baccaratTable) placeBetLocked(p *baccaratPlayer, spot string, amount int, wallet Wallet) ([]Event, bool) { isMain := baccaratIsMainSpot(spot) if isMain && p.HasBet { return []Event{t.private(p.Name, "You have already bet a main wager this round.")}, false } if !isMain { if p.sides[spot].Total > 0 { return []Event{t.private(p.Name, "You have already bet that side wager this round.")}, false } if p.HasBet { return []Event{t.private(p.Name, "Place side bets before your main wager.")}, false } } if amount < t.Config.MinBet || amount > t.Config.MaxBet { return []Event{t.private(p.Name, fmt.Sprintf("Baccarat bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))}, false } if wallet == nil { return []Event{t.private(p.Name, "Wallet unavailable.")}, false } stake, err := wallet.Wager(amount) if err != nil { return []Event{t.private(p.Name, walletErrorMessage(err))}, false } p.Wallet = wallet p.SittingOut = false if p.lastBets == nil { p.lastBets = map[string]int{} } p.lastBets[spot] = amount if isMain { p.HasBet, p.Wager, p.mainSpot = true, stake, spot } else { if p.sides == nil { p.sides = map[string]Wager{} } p.sides[spot] = stake } return []Event{t.playerAction(p.Name, fmt.Sprintf("%s bets %d credits on %s.", p.Name, amount, spot), fmt.Sprintf("You bet %d credits on %s.", amount, spot))}, true } // finishBetLocked opens the shared betting window and deals the moment every // active seat has a main wager. Side bets on their own never deal. func (t *baccaratTable) finishBetLocked(events []Event) []Event { events = t.openBettingLocked(events) if allBet(t.players.participants()) { events = append(events, t.deal()...) } return events } // rebetAll repeats every remembered side bet and then the main wager in one // command. It is the baccarat meaning of a bare "bet". func (t *baccaratTable) rebetAll(name string, 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 p.HasBet { return []Event{t.private(name, "You have already bet a main wager this round.")} } if !p.hasLastBet() { return []Event{t.private(name, "Bet on what? Try bet on "+t.Rules.betChoices()+".")} } var events []Event for _, spot := range t.Rules.spots() { if baccaratIsMainSpot(spot) { continue } if amount := p.lastBets[spot]; amount > 0 { ev, ok := t.placeBetLocked(p, spot, amount, wallet) events = append(events, ev...) if !ok { return events } } } if p.mainSpot != "" { if amount := p.lastBets[p.mainSpot]; amount > 0 { ev, ok := t.placeBetLocked(p, p.mainSpot, amount, wallet) events = append(events, ev...) if !ok { return events } } } return t.finishBetLocked(events) } func (t *baccaratTable) tick() []Event { t.mu.Lock() defer t.mu.Unlock() if t.phase != baccaratBetting { return nil } sitOuts, goRound, pending := t.window.advance(t.players.participants()) if pending { if !t.window.timer.warn() { return nil } var warnings []Event for _, p := range t.players.participants() { if p.Connected && !p.HasBet && !p.SittingOut { warnings = append(warnings, t.warning(p.Name, fmt.Sprintf("Betting closes in %d ticks. Bet now or sit out this hand.", t.window.timer.remaining()))) } } return warnings } var events []Event // A seat with side bets but no main wager is not in the round; return the // side stakes rather than letting them ride on a hand their owner skipped. for _, p := range t.players.ordered() { if !p.HasBet && len(p.sides) > 0 { events = append(events, t.refundSidesLocked(p)...) } } for _, name := range sitOuts { events = append(events, t.event(fmt.Sprintf("%s sits out this baccarat round.", name), true)) } if goRound { return append(events, t.deal()...) } t.resetRound() return events } func (t *baccaratTable) action(name, action string) []Event { t.mu.Lock() defer t.mu.Unlock() if action == "unbet" { return t.unbetAllLocked(name) } if events, handled := t.controlLocked(name, action); handled { // A seat that sits out or leaves before committing a main wager is not // in the round; return any side stakes it already placed. if p := t.players.get(name); p != nil && !p.HasBet && len(p.sides) > 0 { events = append(events, t.refundSidesLocked(p)...) } if t.phase == baccaratBetting && allBet(t.players.participants()) { return append(events, t.deal()...) } return events } return []Event{t.private(name, "That game has no player actions.")} } // unbetAllLocked returns every stake the seat has committed this round, main // wager and side bets alike. func (t *baccaratTable) unbetAllLocked(name string) []Event { p := t.players.get(name) if p == nil { return []Event{t.private(name, "You aren't seated at this table.")} } if t.phase != baccaratWaiting && t.phase != baccaratBetting { return []Event{t.private(name, "You can only take back bets before the round starts.")} } total := 0 var ids []string if p.HasBet { total += p.Wager.Total if p.Wager.ID != "" { ids = append(ids, p.Wager.ID) } } for _, w := range p.sides { total += w.Total if w.ID != "" { ids = append(ids, w.ID) } } if total == 0 { return []Event{t.private(name, "Nothing to take back.")} } if p.Wallet != nil { if err := p.Wallet.PayCredits(total, ids...); err != nil { return []Event{t.private(name, payoutFailedMessage())} } } p.HasBet, p.Wager = false, Wager{} p.sides = nil return []Event{t.private(name, fmt.Sprintf("You take back %d credits.", total))} } // refundSidesLocked returns every side stake on a seat that never committed a // main wager. func (t *baccaratTable) refundSidesLocked(p *baccaratPlayer) []Event { total := 0 var ids []string for _, spot := range p.sideSpots(t.Rules) { w := p.sides[spot] total += w.Total if w.ID != "" { ids = append(ids, w.ID) } } if total == 0 { return nil } if p.Wallet != nil { if err := p.Wallet.PayCredits(total, ids...); err != nil { return []Event{t.private(p.Name, "Your side bets could not be returned just now; the stake stays pending and will be refunded after the next server restart.")} } } p.sides = nil return []Event{t.private(p.Name, fmt.Sprintf("Your %d in side bets is returned; no main wager was placed.", total))} } func (t *baccaratTable) rebet(name string) (string, int) { t.mu.Lock() defer t.mu.Unlock() p := t.players.get(name) if p == nil { return "", t.Config.MinBet } if p.mainSpot != "" && p.lastBets[p.mainSpot] > 0 { return p.mainSpot, p.lastBets[p.mainSpot] } 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 } wagers := p.wagers(t.Rules) lines := make([]settlementLine, 0, len(wagers)) var ids []string totalStake, totalReturn := 0, 0 var payoutErr error for _, w := range wagers { result, payout, err := settleBaccaratHand(w.spot, w.wager.Total, player, banker, t.Rules) if err != nil && payoutErr == nil { payoutErr = err } if w.wager.ID != "" { ids = append(ids, w.wager.ID) } totalStake += w.wager.Total totalReturn += payout line := settlementLine{Label: w.spot, Odds: baccaratSettlementOdds(w.spot, player, banker, t.Rules), Result: result, Stake: w.wager.Total, Returned: payout} if result == "win" && w.spot == baccaratSpotTie { line.Tag = "casino_jackpot_win" } lines = append(lines, line) } // The shared settlement path pays before announcing and leaves the // stakes pending on any failure, so a refund can never be paid on top // of a payout that was not durable. payoutFailed := settleStakeOutcome(p.Wallet, totalReturn, ids, payoutErr) publicResult, _ := baccaratResultMessages(p.Name, returnResult(totalStake, totalReturn), totalReturn) privateResult := settlementText(lines) if payoutFailed { privateResult = payoutFailedMessage() publicResult = p.Name + "’s settlement is pending recovery." } settlement := t.playerAction(p.Name, publicResult, privateResult) // Returning players get their balance in the next bet menu; only // departures/sit-outs need it attached to the settlement. settlement.ShowBalance = !payoutFailed && (p.LeaveAfter || p.SitOutAfter || !p.Connected) if !payoutFailed { settlement.Settlement = lines switch { case len(lines) == 1 && lines[0].Tag != "": settlement.Color = lines[0].Tag case totalReturn > totalStake: settlement.Color = "casino_result_win" case totalReturn == totalStake: settlement.Color = "casino_result_push" default: settlement.Color = "casino_result_loss" } } events = append(events, settlement) // settleStakeOutcome already consumed these stakes by handle (a payout // removed them, a push/loss resolved them). 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() events = append(events, t.finishControlsLocked()...) for _, p := range t.players.ordered() { if p.Connected && !p.SittingOut { 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() { for _, w := range p.wagers(t.Rules) { snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: p.Name, Spot: w.spot, Amount: w.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.sides = nil } } // 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.totalStaked() > 0 && 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 }