aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/baccarat.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-02 01:54:37 -0400
committerhistoria <[not public]>2026-10-02 01:54:37 -0400
commitd3a305d1d7962f82af2397760cf8008758bddd1d (patch)
treef91f0deb342c20f2993e5366f772c3432648f426 /internal/casino/baccarat.go
parent8eea0e4e06a4aaa6a18edabd9897d3bea1ea2ca0 (diff)
downloadthehouseoficarus-d3a305d1d7962f82af2397760cf8008758bddd1d.tar.gz
feat: better 'list' bet table, multiple bets in baccarat
Diffstat (limited to 'internal/casino/baccarat.go')
-rw-r--r--internal/casino/baccarat.go363
1 files changed, 314 insertions, 49 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 1e7ab23..e30de5b 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -21,15 +21,75 @@ const (
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
+ // 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
@@ -47,18 +107,26 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl
}
func (t *baccaratTable) menuEvent(name string) Event {
- if p := t.players.get(name); p != nil && p.HasBet {
+ p := t.players.get(name)
+ if p != nil && p.HasBet {
return t.committedMenu(name)
}
commands := []MenuCommand{}
- if p := t.players.get(name); p != nil && p.lastBet > 0 {
- commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot})
+ 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), Desc: "Choose banker (b), player (p), or tie (t)"}, MenuCommand{Command: "bet <amount> on <target>", Desc: "One wager per round; targets: list"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"})
+ commands = append(commands,
+ MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)},
+ MenuCommand{Command: "bet <amount> on <bet>"},
+ 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 := t.players.get(name); p != nil {
- e.Menu.Repeat = p.lastBet > 0
+ if p != nil {
+ e.Menu.Repeat = p.hasLastBet()
}
return e
}
@@ -90,14 +158,19 @@ func (t *baccaratTable) leave(name string) []Event {
}
// A wager is committed for the round in flight; everyone else may stand
// up whenever they like.
- if p.HasBet {
+ 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 := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")}
+ 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()
@@ -106,7 +179,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
return []Event{t.private(name, "You aren't playing baccarat.")}
}
if spot == "" {
- spot = p.lastSpot
+ spot = p.mainSpot
}
if spot == "" {
return []Event{t.private(name, "Bet on what? Try bet <amount> on "+t.Rules.betChoices()+".")}
@@ -114,21 +187,61 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
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.")}
+ 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(name, fmt.Sprintf("Baccarat bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))}
+ return []Event{t.private(p.Name, fmt.Sprintf("Baccarat bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))}, false
}
- wager, err := wallet.Wager(amount)
+ if wallet == nil {
+ return []Event{t.private(p.Name, "Wallet unavailable.")}, false
+ }
+ stake, err := wallet.Wager(amount)
if err != nil {
- return []Event{t.private(name, walletErrorMessage(err))}
+ return []Event{t.private(p.Name, walletErrorMessage(err))}, false
}
+ p.Wallet = wallet
p.SittingOut = false
- 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))}
+ 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()...)
@@ -136,23 +249,95 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
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 <amount> 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
}
- return t.advanceBettingLocked(t.deal, t.resetRound)
+ 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" {
- events, _ := t.unbetLocked(name)
- return events
+ 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()...)
}
@@ -161,11 +346,76 @@ func (t *baccaratTable) action(name, action string) []Event {
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()
- if p := t.players.get(name); p != nil && p.lastBet > 0 {
- return p.lastSpot, p.lastBet
+ 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
}
@@ -192,17 +442,33 @@ func (t *baccaratTable) deal() []Event {
if !p.HasBet {
continue
}
- result, payout, settleErr := settleBaccaratHand(p.lastSpot, p.Wager.Total, player, banker, t.Rules)
+ 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
- // stake pending on any failure, so a refund can never be paid on top
+ // 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, payout, []string{p.stakeID}, settleErr)
- publicResult, _ := baccaratResultMessages(p.Name, result, payout)
- line := settlementLine{Label: p.lastSpot, Odds: baccaratSettlementOdds(p.lastSpot, player, banker, t.Rules), Result: result, Stake: p.Wager.Total, Returned: payout}
- if result == "win" && p.lastSpot == baccaratSpotTie {
- line.Tag = "casino_jackpot_win"
- }
- privateResult := settlementText([]settlementLine{line})
+ 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."
@@ -212,21 +478,21 @@ func (t *baccaratTable) deal() []Event {
// departures/sit-outs need it attached to the settlement.
settlement.ShowBalance = !payoutFailed && (p.LeaveAfter || p.SitOutAfter || !p.Connected)
if !payoutFailed {
- settlement.Settlement = []SettlementLine{line}
+ settlement.Settlement = lines
switch {
- case line.Tag != "":
- settlement.Color = line.Tag
- case result == "win":
+ case len(lines) == 1 && lines[0].Tag != "":
+ settlement.Color = lines[0].Tag
+ case totalReturn > totalStake:
settlement.Color = "casino_result_win"
- case result == "push":
+ case totalReturn == totalStake:
settlement.Color = "casino_result_push"
default:
settlement.Color = "casino_result_loss"
}
}
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
+ // 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.
}
@@ -252,10 +518,9 @@ func (t *baccaratTable) snapshot(player, banker []card) *BaccaratSnapshot {
snapshot.Banker = append(snapshot.Banker, CardView{Rank: c.rank, Suit: c.suit})
}
for _, p := range t.players.ordered() {
- if !p.HasBet {
- continue
+ for _, w := range p.wagers(t.Rules) {
+ snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: p.Name, Spot: w.spot, Amount: w.wager.Total})
}
- snapshot.Bets = append(snapshot.Bets, BaccaratBetView{Name: p.Name, Spot: p.lastSpot, Amount: p.Wager.Total})
}
return snapshot
}
@@ -265,7 +530,7 @@ func (t *baccaratTable) resetRound() {
t.window.close()
clearBets(t.players.participants())
for _, p := range t.players.ordered() {
- p.stakeID = ""
+ p.sides = nil
}
}
@@ -281,7 +546,7 @@ func (t *baccaratTable) markDisconnected(name string) []Event {
if !t.players.has(name) {
return nil
}
- if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != 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