diff options
Diffstat (limited to 'internal/casino/baccarat.go')
| -rw-r--r-- | internal/casino/baccarat.go | 363 |
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 |
