aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/baccarat.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-08-03 16:18:17 -0400
committerhistoria <[not public]>2026-08-03 16:19:25 -0400
commitf2c4a58727169a03b8102704888d1579d26b28c8 (patch)
treea334f9cd37b3c7521c11d266d797b208dac4f3be /internal/casino/baccarat.go
parent612e429de438821ae8e099d6b54f87c11ff6c1b5 (diff)
downloadthehouseoficarus-f2c4a58727169a03b8102704888d1579d26b28c8.tar.gz
feat: multiplayer baccarat game
Diffstat (limited to 'internal/casino/baccarat.go')
-rw-r--r--internal/casino/baccarat.go377
1 files changed, 377 insertions, 0 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
new file mode 100644
index 0000000..729851d
--- /dev/null
+++ b/internal/casino/baccarat.go
@@ -0,0 +1,377 @@
+package casino
+
+import (
+ "fmt"
+ "math"
+ "math/rand"
+ "sync"
+)
+
+type baccaratPhase string
+
+const (
+ baccaratWaiting baccaratPhase = "waiting"
+ baccaratBetting baccaratPhase = "betting"
+)
+
+// baccaratSpots are the outcomes a participant may wager on.
+const (
+ baccaratSpotPlayer = "player"
+ baccaratSpotBanker = "banker"
+ baccaratSpotTie = "tie"
+)
+
+func baccaratSpotValid(spot string) bool {
+ return spot == baccaratSpotPlayer || spot == baccaratSpotBanker || spot == baccaratSpotTie
+}
+
+type baccaratPlayer struct {
+ Participant
+ lastBet int
+ lastSpot string
+}
+
+type baccaratTable struct {
+ mu sync.Mutex
+ tableBase
+ Rules BaccaratConfig
+ participants map[string]*baccaratPlayer
+ order []string
+ phase baccaratPhase
+ timer countdown
+ 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,
+ participants: make(map[string]*baccaratPlayer), phase: baccaratWaiting,
+ shoe: newShoe(rules.Decks, rules.ShuffleAt, rng),
+ }
+}
+
+func (t *baccaratTable) seats() []*Participant {
+ seats := make([]*Participant, 0, len(t.order))
+ for _, name := range t.order {
+ if p := t.participants[name]; p != nil {
+ seats = append(seats, &p.Participant)
+ }
+ }
+ return seats
+}
+
+func (t *baccaratTable) menuEvent(name string) Event {
+ e := t.private(name, t.menu())
+ e.Color = "casino_menu"
+ e.Menu = &MenuView{Kind: MenuBaccaratBet}
+ return e
+}
+
+func (t *baccaratTable) menu() string {
+ return "New hand! You can bet, bet <amount> on player|banker|tie (inc. min/max), or stop."
+}
+
+func (t *baccaratTable) join(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.participants[name]; p != nil {
+ p.Connected = true
+ return []Event{t.menuEvent(name)}
+ }
+ if len(t.participants) >= t.Config.MaxPlayers {
+ return []Event{t.private(name, "That baccarat table is full.")}
+ }
+ t.participants[name] = &baccaratPlayer{Participant: Participant{Name: name, Connected: true}}
+ t.order = append(t.order, name)
+ events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")}
+ events = append(events, t.menuEvent(name))
+ return events
+}
+
+func (t *baccaratTable) leave(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.participants[name]
+ if p == nil {
+ return nil
+ }
+ if t.phase != baccaratWaiting {
+ return []Event{t.private(name, "You're in the middle of a baccarat round!")}
+ }
+ delete(t.participants, name)
+ for i, n := range t.order {
+ if n == name {
+ t.order = append(t.order[:i], t.order[i+1:]...)
+ break
+ }
+ }
+ events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")}
+ if len(t.participants) == 0 {
+ events = append(events, t.event("The baccarat table closes.", true))
+ }
+ return events
+}
+
+func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p := t.participants[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 <amount> on player\", \"bet <amount> on banker\", or \"bet <amount> on tie\".")}
+ }
+ if !baccaratSpotValid(spot) {
+ return []Event{t.private(name, "Baccarat bets must be on player, banker, or tie.")}
+ }
+ 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 between %d and %d.", t.Config.MinBet, t.Config.MaxBet))}
+ }
+ wager, ok := wallet.Wager(amount)
+ if !ok {
+ return []Event{t.private(name, "You don't have enough chips and credits for that bet.")}
+ }
+ p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot = true, wager, wallet, amount, spot
+ 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 len(t.participants) > 1 && t.phase == baccaratWaiting {
+ t.phase = baccaratBetting
+ t.timer.start(t.Config.BettingTicks)
+ events = append(events, t.event(fmt.Sprintf("Baccarat betting is open for %d ticks.", t.Config.BettingTicks), true))
+ }
+ if allBet(t.seats()) {
+ 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
+ }
+ t.timer.tick()
+ if allBet(t.seats()) {
+ return t.deal()
+ }
+ if !t.timer.expired() {
+ return nil
+ }
+ var events []Event
+ for _, name := range sitOutNames(t.seats()) {
+ events = append(events, t.event(fmt.Sprintf("%s sits out this baccarat round.", name), true))
+ }
+ if anyBet(t.seats()) {
+ events = append(events, t.deal()...)
+ } else {
+ t.resetRound()
+ }
+ return events
+}
+
+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) {
+ if p := t.participants[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 {
+ player, banker := playTableau(t.shoe)
+ playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker)
+ round := t.event("New baccarat hand!", true)
+ round.Baccarat = t.snapshot(player, banker)
+ events := []Event{round, t.event(baccaratOutcomeMessage(playerTotal, bankerTotal), true)}
+ for _, name := range t.order {
+ p := t.participants[name]
+ if p == nil || !p.HasBet {
+ continue
+ }
+ result, payout := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules)
+ if payout > 0 && p.Wallet != nil {
+ p.Wallet.PayCredits(payout)
+ }
+ publicResult, privateResult := baccaratResultMessages(name, result, payout)
+ settlement := t.playerAction(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)
+ }
+ t.resetRound()
+ for _, name := range t.order {
+ if p := t.participants[name]; p != nil && p.Connected {
+ events = append(events, t.menuEvent(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 _, name := range t.order {
+ p := t.participants[name]
+ if p == nil || !p.HasBet {
+ continue
+ }
+ snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: name, Spot: p.lastSpot, Amount: p.Wager.Total})
+ }
+ return snapshot
+}
+
+func (t *baccaratTable) resetRound() {
+ t.phase = baccaratWaiting
+ t.timer.stop()
+ clearBets(t.seats())
+}
+
+func (t *baccaratTable) markDisconnected(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.participants[name]; p != nil {
+ p.Connected = false
+ }
+ return nil
+}
+
+func (t *baccaratTable) markConnected(name string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if p := t.participants[name]; p != nil {
+ p.Connected = true
+ }
+}
+
+func (t *baccaratTable) hasParticipant(name string) bool {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.participants[name] != nil
+}
+
+// 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 returns the result label and total payout (stake plus
+// winnings) for one wager. Player and banker bets push on a tie.
+func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules BaccaratConfig) (string, int) {
+ switch {
+ case playerTotal > bankerTotal:
+ if spot == baccaratSpotPlayer {
+ return "win", bet * 2
+ }
+ case bankerTotal > playerTotal:
+ if spot == baccaratSpotBanker {
+ return "win", bet + int(math.Round(float64(bet)*rules.BankerPayout))
+ }
+ default:
+ if spot == baccaratSpotTie {
+ return "win", bet + int(math.Round(float64(bet)*rules.TiePayout))
+ }
+ return "push", bet
+ }
+ return "loss", 0
+}
+
+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!"
+ }
+}