// Package casino implements the in-game casino: chip/credit wallets, table // games (blackjack, baccarat), slot machines, and their shared state machine. package casino import ( "fmt" "math/rand" ) // Blackjack uses the shared table phases; these aliases keep the rule // vocabulary readable at the call sites. const ( blackjackWaiting = phaseWaiting blackjackBetting = phaseBetting blackjackInsurance = phaseInsurance blackjackPlaying = phasePlaying ) type blackjackHand struct { cards []card bet int // stakes are the wallet handles of the wager records committed to this // hand (the opening bet plus each double or split wager). The hand's // settlement returns their total; the linkage lets a refund skip them. stakes []string fromSplit bool doubled bool stood bool bust bool surrendered bool } func (h *blackjackHand) total() (int, bool) { total, aces := 0, 0 for _, c := range h.cards { total += c.blackjackValue() if c.rank == "A" { aces++ } } for total > 21 && aces > 0 { total -= 10 aces-- } soft := aces > 0 return total, soft } func (h *blackjackHand) blackjack(rules BlackjackConfig) bool { total, _ := h.total() return (len(h.cards) == 2 && !h.fromSplit && total == 21) || (rules.CountsDoubleSplitBlackjack() && h.fromSplit && h.doubled && total == 21) } type blackjackPlayer struct { Participant hands []*blackjackHand insurance int insuranceStakeID string declinedInsurance bool lastBet int } func (p *blackjackPlayer) participantPtr() *Participant { return &p.Participant } type blackjackTable struct { tableCore[*blackjackPlayer] Rules BlackjackConfig timer countdown ceiling countdown activePlayer int activeHand int // turnName/turnHand identify the current turn so the absolute turn // ceiling is restarted only when the turn actually changes. A third party // joining or leaving mid-round re-prompts the same turn and must not // extend a hung bettor's deadline. turnName string turnHand int dealer blackjackHand shoe *shoe } // loneTurnGrace multiplies the action window into an absolute per-turn // ceiling. A lone connected player is deliberately never timed out by the // action timer (a solo player's think time must not end the hand), but // "never" cannot mean forever: a hung-but-connected client would hold the // hand, the seat, and the stake open with no automatic resolution, since // "leave" refuses mid-round. After this many ticks on one turn the table // stands the hand for the player and plays on. const loneTurnGrace = 10 func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTable { cfg = cfg.Normalize() rules := cfg.Blackjack.Normalize() return &blackjackTable{ tableCore: newTableCore[*blackjackPlayer](&tableBase{RoomID: roomID, Config: cfg}), Rules: rules, shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), turnHand: -1, } } func (t *blackjackTable) menuEvent(name string) Event { if p := t.players.get(name); p != nil && p.HasBet { if t.phase == blackjackPlaying && t.activeName() == name { return t.turnMenuLocked(name) } if t.phase == blackjackInsurance && !p.declinedInsurance && p.insurance == 0 { return t.insuranceMenuLocked(p) } return t.committedMenu(name) } if t.phase == blackjackPlaying || t.phase == blackjackInsurance { return t.private(name, "Round in progress. You’ll join the next hand; table shows the current action.") } amount, desc := t.Config.MinBet, "Table minimum" if p := t.players.get(name); p != nil && p.lastBet > 0 { amount, desc = p.lastBet, "Repeat wager" } e := t.betMenuEvent(name, []MenuCommand{ {Command: "bet", Desc: fmt.Sprintf("%s: %d credits (also: rebet)", desc, amount)}, {Command: "bet ", Desc: "Change wager; min/max accepted"}, {Command: "stop", Desc: "Leave; during a round, leave afterward"}, {Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"}, }) if p := t.players.get(name); p != nil { e.Menu.Repeat = p.lastBet > 0 } return e } func (t *blackjackTable) 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 blackjack table is full.")} } events := []Event{t.addSeatLocked(name, &blackjackPlayer{Participant: Participant{Name: name, Connected: true}})} events = append(events, t.private(name, t.Config.BlackjackRulesText()), t.menuEvent(name)) return events } func (t *blackjackTable) 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. Non-bettor removal reuses the disconnect path, // which fixes up the active seat while a round is in flight. if p.HasBet { return []Event{t.private(name, "You're in the middle of a blackjack round!")} } events := t.markDisconnectedLocked(name) if len(events) == 0 { return nil } return append(events, t.private(name, "You stand up.")) } func (t *blackjackTable) 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 blackjack.")} } if spot != "" { return []Event{t.private(name, "Blackjack bets don't take a bet target.")} } if t.phase != blackjackWaiting && t.phase != blackjackBetting { return []Event{t.private(name, "That blackjack table isn't accepting bets.")} } 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("Blackjack 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.SittingOut = false p.HasBet, p.Wager, p.Wallet, p.lastBet = true, wager, wallet, amount events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))} events = t.openBettingLocked(events) if allBet(t.players.participants()) { events = append(events, t.startRound()...) } return events } func (t *blackjackTable) tick() []Event { t.mu.Lock() defer t.mu.Unlock() switch t.phase { case blackjackBetting: return t.advanceBettingLocked(t.startRound, t.resetRound) case blackjackInsurance: if t.insuranceSettled() { return t.finishInsurance() } t.timer.tick() if !t.timer.expired() { var events []Event if t.timer.warn() { for _, p := range t.players.ordered() { if p.HasBet && p.insurance == 0 && !p.declinedInsurance && p.Connected { events = append(events, t.warning(p.Name, fmt.Sprintf("Insurance closes in %d ticks. No response means decline.", t.timer.remaining()))) } } } return events } return t.finishInsurance() case blackjackPlaying: name := t.activeName() if name == "" { return nil } active := t.players.get(name) if active == nil { // Unreachable while mid-round leaves are blocked; keep the game moving. t.activePlayer++ return t.finishHand() } // The absolute turn ceiling bounds every turn, including a lone // connected player's: the action timer below deliberately never // expires for a solo player, so without this a hung-but-connected // client would hold the stake and the phase open forever. t.ceiling.tick() if t.ceiling.expired() { active.hands[t.activeHand].stood = true events := []Event{t.warning(name, "You've been idle too long; standing.")} // Restart the action timer so the next hand gets a full window. t.timer.start(t.Rules.ActionTicks) return append(events, t.finishHand()...) } if active.Connected && t.bettorCount() <= 1 { // A lone connected player is never timed out. A disconnected // active player always is, so the table can never stall. if t.ceiling.warn() { return []Event{t.warning(name, fmt.Sprintf("%d ticks remaining. No response means stand.", t.ceiling.remaining()))} } return nil } t.timer.tick() if !t.timer.expired() { if t.timer.warn() { return []Event{t.warning(name, fmt.Sprintf("%d ticks remaining. No response means stand.", t.timer.remaining()))} } return nil } active.hands[t.activeHand].stood = true events := []Event{t.warning(name, "Your action timed out; standing.")} // Restart the timer so the next active hand gets a full action window // instead of timing out on the following tick. t.timer.start(t.Rules.ActionTicks) events = append(events, t.finishHand()...) return events default: return nil } } func (t *blackjackTable) action(name, action string) []Event { t.mu.Lock() defer t.mu.Unlock() if events, handled := t.controlLocked(name, action); handled { if t.phase == blackjackBetting && allBet(t.players.participants()) { return append(events, t.startRound()...) } return events } if t.phase == blackjackInsurance { return t.insuranceAction(name, action) } if t.phase != blackjackPlaying || t.activeName() != name { return []Event{t.private(name, "It's not your turn.")} } switch action { case "h": action = "hit" case "st": action = "stand" case "dd": action = "double" case "sp": action = "split" } p := t.players.get(name) hand := p.hands[t.activeHand] // The action window only restarts when a new turn is actually prompted // (see prompt()); a rejected command can no longer stall the table. switch action { case "hit": if fromSplitAces(hand) { return []Event{t.private(name, "Split aces cannot be hit.")} } hand.cards = append(hand.cards, t.shoe.draw()) total, _ := hand.total() if total > 21 { hand.bust = true } e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1])) e.Snapshot = t.snapshot(false) e.Detail = true events := []Event{e} if total >= 21 { events = append(events, t.finishHand()...) } else { events = append(events, t.prompt()...) } return events case "stand": hand.stood = true return t.finishHand() case "double": if len(hand.cards) != 2 || hand.doubled || hand.surrendered || fromSplitAces(hand) || (hand.fromSplit && !t.Rules.AllowsDoubleAfterSplit()) { return []Event{t.private(name, "You can only double a qualifying hand.")} } if wager, err := p.Wallet.Wager(hand.bet); err != nil { return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits to double that hand."))} } else { hand.stakes = append(hand.stakes, wager.ID) } hand.bet *= 2 hand.doubled = true hand.stood = true hand.cards = append(hand.cards, t.shoe.draw()) if total, _ := hand.total(); total > 21 { hand.bust = true } e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.") e.Snapshot = t.snapshot(false) e.Detail = true return append([]Event{e}, t.finishHand()...) case "split": return t.splitHand(name, p, hand) case "surrender": if !t.Rules.AllowsSurrender() || hand.fromSplit || len(hand.cards) != 2 { return []Event{t.private(name, "Surrender is not available for this hand.")} } hand.surrendered = true return t.finishHand() default: return []Event{t.private(name, "Unknown blackjack action.")} } } // insuranceAction handles commands during the insurance offer window: // "insurance" buys cover for half the base bet; "decline" explicitly declines. func (t *blackjackTable) insuranceAction(name, action string) []Event { p := t.players.get(name) if p == nil || !p.HasBet { return []Event{t.private(name, "Insurance is only offered to players with a bet this round.")} } if p.insurance > 0 || p.declinedInsurance { return []Event{t.private(name, "You have already decided about insurance.")} } var events []Event if action != "insurance" && action != "decline" { return []Event{t.private(name, "Insurance decision first: insurance to buy, decline to continue. No card action has been taken.")} } if action == "insurance" { amount := p.hands[0].bet / 2 if amount <= 0 { return []Event{t.private(name, "Your bet is too small to insure.")} } if wager, err := p.Wallet.Wager(amount); err != nil { return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits for insurance."))} } else { p.insuranceStakeID = wager.ID } p.insurance = amount events = append(events, t.playerAction(name, fmt.Sprintf("%s takes insurance.", name), fmt.Sprintf("You place %d credits on insurance.", amount))) } else { p.declinedInsurance = true events = append(events, t.private(name, "You decline insurance.")) } if t.insuranceSettled() { events = append(events, t.finishInsurance()...) } return events } // insuranceSettled reports whether every connected bettor has taken or // declined insurance. func (t *blackjackTable) insuranceSettled() bool { for _, p := range t.players.ordered() { if !p.HasBet || !p.Connected { continue } if p.insurance == 0 && !p.declinedInsurance { return false } } return true } // finishInsurance resolves the insurance window: a dealer blackjack settles // the round immediately, otherwise play begins. func (t *blackjackTable) finishInsurance() []Event { if t.dealerBlackjack() { return t.settle() } t.beginPlay() return t.finishHand() } func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackjackHand) []Event { if len(hand.cards) != 2 || len(p.hands) >= t.Rules.MaxSplitHands || !splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) { return []Event{t.private(name, "That hand cannot be split.")} } if hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces() { return []Event{t.private(name, "Aces cannot be re-split at this table.")} } wager, err := p.Wallet.Wager(hand.bet) if err != nil { return []Event{t.private(name, walletErrorMessage(err, "You don't have enough chips and credits to split that hand."))} } left := &blackjackHand{cards: []card{hand.cards[0]}, bet: hand.bet, fromSplit: true, stakes: hand.stakes} right := &blackjackHand{cards: []card{hand.cards[1]}, bet: hand.bet, fromSplit: true, stakes: []string{wager.ID}} left.cards = append(left.cards, t.shoe.draw()) right.cards = append(right.cards, t.shoe.draw()) index := t.activeHand p.hands = append(p.hands[:index], append([]*blackjackHand{left, right}, p.hands[index+1:]...)...) e := t.playerAction(name, fmt.Sprintf("%s splits the hand.", name), "You split the hand.") e.Snapshot = t.snapshot(false) events := []Event{e} if left.cards[0].rank == "A" { // Split aces receive one card each; they only stay live when a // re-split of aces is allowed and the new card is itself an ace. left.stood = !(t.Rules.AllowsResplitAces() && left.cards[1].rank == "A") right.stood = !(t.Rules.AllowsResplitAces() && right.cards[1].rank == "A") } // Route through finishHand, not a raw prompt: a split draw can already // total 21 (e.g. K,K splits into A+K), and prompting a complete hand // offered hit/double on an unplayable 21. events = append(events, t.finishHand()...) return events } func splitCompatible(a, b card, unlikeTens bool) bool { if a.rank == b.rank { return true } return unlikeTens && a.blackjackValue() == 10 && b.blackjackValue() == 10 } func (t *blackjackTable) startRound() []Event { // The cut-card reshuffle and a best-effort round-coverage reshuffle // happen at this boundary, before a single card of the round is dealt, // so a boundary reshuffle cannot redeal a card the round already holds. // draw() can still rebuild an exhausted shoe mid-round (a deliberate // shuffle-through), so this is a heuristic, not a guarantee. t.shoe.shuffleIfDue() t.shoe.ensureForRound(t.roundCardCeiling()) t.dealer = blackjackHand{cards: []card{t.shoe.draw(), t.shoe.draw()}} for _, p := range t.players.ordered() { if !p.HasBet { continue } p.hands = []*blackjackHand{{cards: []card{t.shoe.draw(), t.shoe.draw()}, bet: p.Wager.Total, stakes: []string{p.Wager.ID}}} if p.hands[0].bet/2 <= 0 { // A half-bet that rounds to zero cannot be insured. p.declinedInsurance = true } } roundEvent := t.event("New blackjack hand!", true) roundEvent.Snapshot = t.snapshot(false) events := []Event{roundEvent} if t.dealer.cards[1].rank == "A" { t.phase = blackjackInsurance t.timer.start(t.Rules.ActionTicks) events = append(events, t.event("The dealer shows an ace. Insurance is on offer.", true)) for _, p := range t.players.ordered() { if !p.HasBet || !p.Connected || p.declinedInsurance { continue } events = append(events, t.insuranceMenuLocked(p)) } if t.insuranceSettled() { events = append(events, t.finishInsurance()...) } return events } if t.dealerBlackjack() { events = append(events, t.settle()...) return events } t.beginPlay() t.skipCompletedHands() events[0].Snapshot = t.snapshot(false) return append(events, t.finishHand()...) } func (t *blackjackTable) beginPlay() { t.phase = blackjackPlaying t.timer.start(t.Rules.ActionTicks) t.activePlayer, t.activeHand = 0, 0 t.turnName, t.turnHand = "", -1 } // roundCardCeiling is the heuristic number of cards this table's round is // assumed to consume (see blackjackRoundCards): the dealer's hand plus every // seat's maximum number of split hands. startRound uses it to decide whether // to reshuffle at the round boundary. It is NOT a certified maximum (an // all-ace hand can exceed the per-hand estimate), so the shoe is not // guaranteed to hold it and draw() may still rebuild an exhausted shoe // mid-round (a deliberate shuffle-through). func (t *blackjackTable) roundCardCeiling() int { // Size the boundary reshuffle from the seats actually in the round, not // the configured maximum: using MaxPlayers made ensureForRound dominate // the cut card, so shuffle_at never fired on a table whose maximum seats // implied more cards than the cut card allowed. return blackjackRoundCards(t.bettorCount(), t.Rules.MaxSplitHands) } func (t *blackjackTable) prompt() []Event { // Every freshly-prompted turn gets a full action window; rejected inputs // and timeouts can never extend the deadline. t.timer.start(t.Rules.ActionTicks) name := t.activeName() if name == "" { return t.settle() } changed := name != t.turnName || t.activeHand != t.turnHand // The absolute turn ceiling belongs to the turn, not the prompt: restart // it only when the active (player, hand) changes. A seat event that // re-prompts the same turn must not reset a hung bettor's deadline. if name != t.turnName || t.activeHand != t.turnHand { t.ceiling.start(t.Rules.ActionTicks * loneTurnGrace) t.turnName = name t.turnHand = t.activeHand } events := []Event{t.turnMenuLocked(name)} if changed { for _, p := range t.players.ordered() { if p.Name == name || !p.Connected { continue } text := name + "’s turn." if p.HasBet && t.players.indexOf(p.Name) > t.activePlayer { text += " You’re waiting for your turn." } events = append(events, t.private(p.Name, text)) } } return events } func (t *blackjackTable) turnMenuLocked(name string) Event { p := t.players.get(name) hand := p.hands[t.activeHand] actions := t.availableActions(p, hand) // The table snapshot already shows the active hand's cards, score, wager, // and "Your turn" status; the menu only needs the deadline and actions. e := t.private(name, "Your turn.") e.Color = "casino_menu" deadline := t.timer.remaining() if p.Connected && t.bettorCount() <= 1 { deadline = t.ceiling.remaining() } e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn | %d ticks remaining", deadline), Commands: blackjackActionCommands(actions)} for i := range e.Menu.Commands { c := &e.Menu.Commands[i] if c.Command == "double" || c.Command == "split" { c.Cost = hand.bet c.Desc += fmt.Sprintf(" (+%d credits)", hand.bet) } } return e } func blackjackActionCommands(actions []string) []MenuCommand { descriptions := map[string]string{ "hit": "[h] Take another card", "stand": "[st] Hold your total", "double": "[dd] Double your bet and take one card", "split": "[sp] Split your pair into two hands", "surrender": "Forfeit half your bet", } commands := make([]MenuCommand, 0, len(actions)) for _, action := range actions { commands = append(commands, MenuCommand{Command: action, Desc: descriptions[action]}) } return commands } func (t *blackjackTable) availableActions(p *blackjackPlayer, hand *blackjackHand) []string { if fromSplitAces(hand) { // Split aces receive one card each and may only be re-split. actions := []string{"stand"} if len(hand.cards) == 2 && len(p.hands) < t.Rules.MaxSplitHands && t.Rules.AllowsResplitAces() && splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) { actions = append(actions, "split") } return actions } actions := []string{"hit", "stand"} if len(hand.cards) == 2 && !hand.doubled && (!hand.fromSplit || t.Rules.AllowsDoubleAfterSplit()) { actions = append(actions, "double") } if len(hand.cards) == 2 && len(p.hands) < t.Rules.MaxSplitHands && splitCompatible(hand.cards[0], hand.cards[1], t.Rules.AllowsUnlikeTenSplit()) && !(hand.cards[0].rank == "A" && hand.fromSplit && !t.Rules.AllowsResplitAces()) { actions = append(actions, "split") } if t.Rules.AllowsSurrender() && !hand.fromSplit && len(hand.cards) == 2 { actions = append(actions, "surrender") } return actions } func fromSplitAces(hand *blackjackHand) bool { return hand.fromSplit && len(hand.cards) > 0 && hand.cards[0].rank == "A" } func (t *blackjackTable) finishHand() []Event { t.skipCompletedHands() if t.activeName() == "" { return t.settle() } return t.prompt() } func (t *blackjackTable) skipCompletedHands() { for t.activePlayer < t.players.count() { p := t.players.get(t.players.nameAt(t.activePlayer)) if p == nil || !p.HasBet { t.activePlayer++ t.activeHand = 0 continue } for t.activeHand < len(p.hands) { h := p.hands[t.activeHand] total, _ := h.total() if h.stood || h.bust || h.surrendered || total >= 21 || h.blackjack(t.Rules) { t.activeHand++ continue } return } t.activePlayer++ t.activeHand = 0 } } func (t *blackjackTable) activeName() string { return t.players.nameAt(t.activePlayer) } func (t *blackjackTable) dealerBlackjack() bool { return t.dealer.blackjack(t.Rules) } func (t *blackjackTable) settle() []Event { for { total, soft := t.dealer.total() if total > 17 || total == 17 && (!soft || !t.Rules.DealerHitsSoft17()) { break } t.dealer.cards = append(t.dealer.cards, t.shoe.draw()) } dealerTotal, _ := t.dealer.total() dealerEvent := t.event("", true) dealerEvent.Snapshot = t.snapshot(true) events := []Event{dealerEvent} dealerBlackjack := t.dealerBlackjack() for _, p := range t.players.ordered() { if !p.HasBet { continue } needsBalance := p.LeaveAfter || p.SitOutAfter || !p.Connected lines := make([]settlementLine, 0, len(p.hands)+1) for i, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) // One shared settlement path for every game: pay before // announcing, and leave the stakes pending on any failure so a // refund can never be paid on top of a payout that was not durable. if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { events = append(events, t.playerAction(p.Name, p.Name+"’s settlement is pending recovery.", payoutFailedMessage())) continue } label := "" if len(p.hands) > 1 { label = fmt.Sprintf("Hand %d/%d", i+1, len(p.hands)) } lines = append(lines, settlementLine{Label: label, Result: result, Stake: hand.bet, Returned: payout}) } if p.insurance > 0 { insurancePayout := 0 var insuranceErr error if dealerBlackjack { insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) { events = append(events, t.private(p.Name, payoutFailedMessage())) } else { lines = append(lines, settlementLine{Label: "Insurance", Result: "insurance", Stake: p.insurance, Returned: insurancePayout}) } } // One settlement per player reports the round's net once and then // names every hand (and insurance) that produced it. A single hand // keeps the headline only; splits and insurance get the itemized list. if len(lines) > 0 { settlement := t.playerAction(p.Name, blackjackPublicResult(p.Name, lines), settlementText(lines)) settlement.ShowBalance = needsBalance settlement.Color = settlementTag(lines) events = append(events, settlement) } // Each hand's stakes were already consumed per-handle by // settleStakeOutcome (a payout removes them in PayCredits; a loss // resolves them), and the insurance stake likewise. 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 (see the Wallet contract). } 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 settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rules BlackjackConfig) (string, int, error) { total, _ := hand.total() if hand.surrendered { return "surrender", hand.bet / 2, nil } if hand.bust { return "bust", 0, nil } playerBlackjack := hand.blackjack(rules) if playerBlackjack && dealerBlackjack { return "push", hand.bet, nil } if dealerBlackjack { return "dealer blackjack", 0, nil } if playerBlackjack { // Winnings round down to whole credits; never bet less than two. blackjackWin, err := MulPayout(hand.bet, rules.BlackjackPayout) if err != nil { return "blackjack", 0, err } return "blackjack", hand.bet + blackjackWin, nil } if total > 21 || dealerTotal > total && dealerTotal <= 21 { return "loss", 0, nil } if dealerTotal == total { return "push", hand.bet, nil } return "win", hand.bet * 2, nil } func (t *blackjackTable) resetRound() { t.phase = blackjackWaiting t.window.close() t.timer.stop() t.ceiling.stop() t.turnName, t.turnHand = "", -1 for _, p := range t.players.ordered() { p.HasBet, p.Wager, p.hands, p.insurance, p.insuranceStakeID, p.declinedInsurance = false, Wager{}, nil, 0, "", false } } // blackjackPublicResult summarizes a player's round for the room: the net is // not named, only the outcome. func blackjackPublicResult(name string, lines []settlementLine) string { if len(lines) == 1 && lines[0].Result == "blackjack" { return name + " gets blackjack!" } switch { case settlementNet(lines) > 0: return name + " wins!" case settlementNet(lines) < 0: return name + " loses." default: return name + " pushes." } } func (t *blackjackTable) 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.lastBet } return "", t.Config.MinBet } func (t *blackjackTable) bettorCount() int { count := 0 for _, p := range t.players.ordered() { if p.HasBet { count++ } } return count } func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot { snapshot := &BlackjackSnapshot{} for i, card := range t.dealer.cards { if i == 0 && !revealDealer { snapshot.Dealer = append(snapshot.Dealer, CardView{Rank: "?", Hidden: true}) continue } snapshot.Dealer = append(snapshot.Dealer, CardView{Rank: card.rank, Suit: card.suit}) } for _, p := range t.players.ordered() { if !p.HasBet { continue } for i, hand := range p.hands { status := "Waiting" switch { case hand.bust: status = "Busted" case hand.surrendered: status = "Surrendered" case hand.stood: status = "Standing" case hand.blackjack(t.Rules): status = "Blackjack" } active := t.phase == blackjackPlaying && t.activeName() == p.Name && t.activeHand == i && !revealDealer if active { status = "Your turn" } view := BlackjackPlayerView{Name: p.Name, Score: handScore(hand), Hand: i + 1, Hands: len(p.hands), Bet: hand.bet, Active: active, Status: status} for _, card := range hand.cards { view.Cards = append(view.Cards, CardView{Rank: card.rank, Suit: card.suit}) } snapshot.Players = append(snapshot.Players, view) } } if len(t.dealer.cards) > 1 { snapshot.DealerShownScore = handScore(&blackjackHand{cards: []card{t.dealer.cards[1]}}) } snapshot.DealerScore = handScore(&t.dealer) snapshot.DealerRevealed = revealDealer return snapshot } func handScore(hand *blackjackHand) string { total, soft := hand.total() if !soft || total == 21 { return fmt.Sprintf("%d", total) } return fmt.Sprintf("Soft %d", total) } // markDisconnected removes the player from the table. A wager is forfeited // only when a hand is actually in flight (cards are face up, so a free // refund would hand back information the round carries); a stake placed in // the betting window has revealed nothing yet and is REFUNDED, so a // transient disconnect cannot destroy an undelt wager. Removing a seat // shifts the seat indices the active turn points into, so mid-round play is // fixed up and handed to the next bettor (or settled) immediately. func (t *blackjackTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() return t.markDisconnectedLocked(name) } func (t *blackjackTable) markDisconnectedLocked(name string) []Event { index := t.players.indexOf(name) if index < 0 { return nil } refundFailed := false if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { if t.phase == blackjackPlaying || t.phase == blackjackInsurance { // A disconnected player forfeits the round in flight: consume // ONLY this round's stakes, by handle, so a stake left pending by // an earlier failed payout stays refundable. var ids []string for _, hand := range p.hands { ids = append(ids, hand.stakes...) } ids = append(ids, p.insuranceStakeID) resolveStakeIDs(p.Wallet, ids) } else { // No card has been dealt for this stake yet, so refund it: nothing // the bettor paid for has been revealed or played. if _, _, err := p.Wallet.RefundOutstanding(); err != nil { refundFailed = true } } } t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} events = t.closeIfEmptyLocked(events) if refundFailed { 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.")) } if t.phase != blackjackPlaying { // Betting and insurance resolve against the remaining seats on the // next tick; waiting has no round in flight. return events } switch { case index < t.activePlayer: // The same player stays active, one seat earlier. prompt() sees the // unchanged turn and keeps the existing ceiling. t.activePlayer-- case index == t.activePlayer: // The next bettor slides into the active seat. t.activeHand = 0 } t.timer.start(t.Rules.ActionTicks) return append(events, t.finishHand()...) } // refundAll refunds every committed wager at this table (graceful // shutdown/eviction): the wallet returns every pending stake — opening // bet, doubles, splits, and insurance — 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 *blackjackTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() events := t.refundAllLocked() t.resetRound() return events }