package casino import ( "fmt" "math/rand" "sort" "strings" "sync" ) // PaiGowHandView is one hand in the showdown: its label, evaluated // strength, cards and whether it won its comparison. Winning is only // meaningful when HasWin is true (a showdown), so Face Up's mid-round // dealer reveal does not paint the dealer as a winner. type PaiGowHandView struct { Label string Strength string Cards []CardView Winning bool HasWin bool } // PaiGowShowdown carries the comparison and settlement pieces the renderer // turns into the coloured results block. Hands always run player high, // dealer high, player low, dealer low. type PaiGowShowdown struct { Hands []PaiGowHandView Result string Fortune string FortuneWin bool PushAce string PushAceWin bool Envy int ShowEnvy bool Stake int Payout int Paid bool Pending bool } type PaiGowSnapshot struct { Config TableConfig Phase string ShowFelt bool Cards, DealerHigh, DealerLow []CardView Tiebreaker []CardView // Preview carries an arranged-but-unset (or locked) hand: the five-card // high hand followed by the two-card low hand. PreviewHighNums and // PreviewLowNums are the original 1-7 positions under each card. Preview bool PreviewHigh, PreviewLow []CardView PreviewHighNums, PreviewLowNums []int // DealerHighName/DealerLowName label a Face Up reveal that happens before // the showdown. DealerHighName, DealerLowName string Showdown *PaiGowShowdown Result string } func (*PaiGowSnapshot) snapshot() {} type pgPlayer struct { name string wallet Wallet main, fortune Wager pushace Wager last int cards []card split pgSplit // pending holds a legal arrangement the player has previewed but not yet // committed with "set". Autoset players never populate it. pending pgSplit autoset bool ready, disconnected, paid bool leaveAfter bool payout int result string showdown *PaiGowShowdown } type paiGowTable struct { *tableBase mu sync.Mutex rng *rand.Rand players []*pgPlayer // stable seat/deal/settlement order phase string ticks int dealer []card dealerSplit pgSplit tie [2]card } func newPaiGowTable(room int, c TableConfig, rng *rand.Rand) tableGame { return &paiGowTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: "waiting"} } func (t *paiGowTable) base() *tableBase { return t.tableBase } func (t *paiGowTable) player(name string) *pgPlayer { for _, p := range t.players { if p.name == name { return p } } return nil } func (t *paiGowTable) remove(name string) { for i, p := range t.players { if p.name == name { t.players = append(t.players[:i], t.players[i+1:]...) return } } } func (t *paiGowTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return len(t.players) == 0 } func (t *paiGowTable) hasParticipant(name string) bool { t.mu.Lock() defer t.mu.Unlock() return t.player(name) != nil } func (t *paiGowTable) overview() string { t.mu.Lock() defer t.mu.Unlock() return fmt.Sprintf("%s | %d/%d seated", t.Config.Label(), len(t.players), t.Config.MaxPlayers) } func (t *paiGowTable) seats() []string { t.mu.Lock() defer t.mu.Unlock() names := make([]string, 0, len(t.players)) for _, p := range t.players { names = append(names, p.name) } return names } func (t *paiGowTable) state() string { t.mu.Lock() defer t.mu.Unlock() return t.phase } func (t *paiGowTable) hintsLocked(name string) []MenuCommand { p := t.player(name) if p == nil { return nil } commands := []MenuCommand{} faceUp := t.Config.PaiGow.Variant == PaiGowFaceUp switch t.phase { case "waiting", "betting": if p.main.Total == 0 { // Side bets are placed before the main wager: the main bet is what // deals, so there is no later chance to add them. if p.fortune.Total == 0 { commands = append(commands, MenuCommand{Command: "bet fortune", Desc: "Optional Fortune side bet; place before your main bet"}) } if faceUp && p.pushace.Total == 0 { commands = append(commands, MenuCommand{Command: "bet pushace", Desc: "Optional Push Ace High side bet; place before your main bet"}) } commands = append(commands, MenuCommand{Command: "bet ", Desc: "Main wager; deals once every bettor is in"}) } if p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0 { commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back all your wagers before the deal"}) } case "setting": if p.main.Total > 0 && !p.ready { if p.pending.high != nil { commands = append(commands, MenuCommand{Command: "set", Desc: "Lock this arrangement and reveal the dealer"}) commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose a different two-low or five-high arrangement"}) } else { commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose two low cards or five high cards to arrange"}) } commands = append(commands, MenuCommand{Command: "house", Desc: "Arrange your hand the house way"}) if p.autoset { commands = append(commands, MenuCommand{Command: "autoset off", Desc: "Require set before the dealer reveals"}) } else { commands = append(commands, MenuCommand{Command: "autoset on", Desc: "Set a legal arrangement immediately"}) } } case "settling": commands = append(commands, MenuCommand{Command: "settle", Desc: "Retry the same pending payment"}) } return append(commands, MenuCommand{Command: "table", Desc: "View the felt"}, MenuCommand{Command: "rules", Desc: "Learn Pai Gow"}, MenuCommand{Command: "stop", Desc: "Leave after finishing your wager"}) } func (t *paiGowTable) hints(name string) []MenuCommand { t.mu.Lock() defer t.mu.Unlock() return t.hintsLocked(name) } func (t *paiGowTable) wagers(name string) []WagerRow { t.mu.Lock() defer t.mu.Unlock() p := t.player(name) if p == nil || t.phase == "setting" || t.phase == "settling" { return nil } rows := []WagerRow{{Bet: "main", Payout: "See the felt", Win: "Beat both dealer hands"}, {Bet: "fortune", Payout: "See Fortune felt", Win: "Best seven-card bonus; independent of main result"}} if t.Config.PaiGow.Variant == PaiGowFaceUp { rows = append(rows, WagerRow{Bet: "pushace", Payout: "40/15/5:1", Win: "Dealer ace-high pai gow; both 40:1"}) } return rows } func pgViews(cards []card) []CardView { var out []CardView for _, c := range cards { out = append(out, CardView{Rank: c.rank, Suit: c.suit}) } return out } // pgSortHand orders a freshly dealt hand small to large so the numbered // positions read naturally: by rank first, then by suit. The Joker sorts last. func pgSortHand(cards []card) { sort.SliceStable(cards, func(i, j int) bool { ri, rj := pgSortRank(cards[i]), pgSortRank(cards[j]) if ri != rj { return ri < rj } return cards[i].suit < cards[j].suit }) } func pgSortRank(c card) int { if c.rank == "JK" { return 15 } return pokerRank(c) } // pgCardNums maps each card in hand to its 1-based position in all. Dealt // cards are unique, so the match is unambiguous and preserves hand order. func pgCardNums(hand, all []card) []int { used := make([]bool, len(all)) var nums []int for _, h := range hand { for i, c := range all { if !used[i] && c == h { used[i] = true nums = append(nums, i+1) break } } } return nums } func (t *paiGowTable) statusLocked(name string, showFelt bool) []Event { s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase, ShowFelt: showFelt} if p := t.player(name); p != nil { // The dealt hand only appears before the player has arranged anything; // afterwards the board shows the arrangement or the waiting message. if !p.ready && p.pending.high == nil { s.Cards = pgViews(p.cards) } if p.showdown != nil { s.Showdown = p.showdown } // A pending arrangement previews before "set"; a committed one previews // until every seat is in and the showdown replaces it. Autoset players // skip the preview and go straight to the set/waiting state. split := p.split if p.pending.high != nil { split = p.pending } if split.high != nil && !(p.autoset && p.pending.high == nil) { s.Preview = true s.PreviewHigh = pgViews(split.high) s.PreviewLow = pgViews(split.low) s.PreviewHighNums = pgCardNums(split.high, p.cards) s.PreviewLowNums = pgCardNums(split.low, p.cards) } s.Result = p.result if p.ready && t.phase == "setting" && p.showdown == nil { s.Result = "Hand set — waiting for the other players." } if t.phase == "settling" && !p.paid && p.main.Total > 0 { s.Result += "; PAYMENT PENDING (not yet credited)" } } if t.phase == "settling" || t.phase == "waiting" || t.phase == "setting" && t.Config.PaiGow.Variant == PaiGowFaceUp { s.DealerHigh = pgViews(t.dealerSplit.high) s.DealerLow = pgViews(t.dealerSplit.low) if len(t.dealerSplit.high) > 0 { s.DealerHighName = pgRankName(pgEvaluateRules(t.dealerSplit.high, t.a2345High())) } if len(t.dealerSplit.low) > 0 { s.DealerLowName = pgRankName(pgEvaluateRules(t.dealerSplit.low, t.a2345High())) } if len(t.dealer) > 0 && t.Config.PaiGow.Variant == PaiGowNoPush { s.Tiebreaker = pgViews(t.tie[:]) } } e := t.private(name, "") e.Snapshot = s // Context-aware hints are attached as a live menu; the shared board // renderer places them below the felt. Stand-ins (for example a watcher) // get no menu, so they cannot be told how to bet a game they have not joined. if commands := t.hintsLocked(name); len(commands) > 0 { e.Color = "casino_menu" e.Menu = &MenuView{ShowBalance: true, Commands: commands} } return []Event{e} } func (t *paiGowTable) status(name string) []Event { t.mu.Lock() defer t.mu.Unlock() return t.statusLocked(name, false) } func (t *paiGowTable) join(name string) []Event { t.mu.Lock() defer t.mu.Unlock() if p := t.player(name); p != nil { p.disconnected = false return t.statusLocked(name, true) } if len(t.players) >= t.Config.MaxPlayers { return []Event{t.private(name, "This Pai Gow table is full.")} } t.players = append(t.players, &pgPlayer{name: name, autoset: t.Config.PaiGow.Autoset}) return t.statusLocked(name, true) } func (t *paiGowTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.player(name) if p == nil { return nil } // Cards in flight must finish; before the deal the wagers are returned. if t.phase == "setting" || t.phase == "settling" { return []Event{t.private(name, "You're in the middle of a Pai Gow round!")} } if p.wallet != nil && (p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0) { if _, _, err := p.wallet.RefundOutstanding(); err != nil { return []Event{t.private(name, "Your stake could not be returned just now; it stays pending for the next startup.")} } } p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{} t.remove(name) events := []Event{t.private(name, "You leave Pai Gow Poker.")} events = append(events, t.afterSeatChangeLocked()...) return events } func (t *paiGowTable) rebet(name string) (string, int) { t.mu.Lock() defer t.mu.Unlock() if p := t.player(name); p != nil && p.last > 0 { return "main", p.last } return "main", t.Config.MinBet } func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.player(name) if p == nil { return nil } fail := func(s string) []Event { return []Event{t.private(name, s)} } if t.phase != "waiting" && t.phase != "betting" { return fail("Betting is closed.") } if spot == "" { spot = "main" } if spot != "main" && spot != "fortune" && spot != "pushace" { return fail("Bet on main or fortune.") } if spot == "pushace" && t.Config.PaiGow.Variant != PaiGowFaceUp { return fail("Push Ace High is a Face Up side bet only.") } if spot == "main" && p.main.Total > 0 || spot == "fortune" && p.fortune.Total > 0 || spot == "pushace" && p.pushace.Total > 0 { return fail("That wager is already committed.") } // Side bets settle into the round before the main wager, which is what // actually deals. Once the main bet is down the deal can fire at any // moment, so a later side bet would race the cards. if spot != "main" && p.main.Total > 0 { return fail("Place side bets before your main wager.") } if spot == "main" && p.pushace.Total > amount/2 { return fail("Push Ace High is capped at half the main wager; take it back with unbet first.") } if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > pgMaxWager { return fail("Wager must be " + betRangeText(t.Config.MinBet, min(t.Config.MaxBet, pgMaxWager)) + ".") } if w == nil { return fail("Wallet unavailable.") } stake, err := w.Wager(amount) if err != nil { return fail(walletErrorMessage(err)) } p.wallet = w // A fresh wager starts a new round in the seat's view. Without this a side // bet placed before the next main wager would leave the previous round's // cards and showdown on screen. p.cards = nil p.split = pgSplit{} p.pending = pgSplit{} p.showdown = nil p.result = "" p.ready = false p.paid = false if spot == "main" { p.main = stake p.last = amount } else if spot == "pushace" { p.pushace = stake } else { p.fortune = stake } if spot == "main" && t.phase == "waiting" { t.phase = "betting" t.ticks = t.Config.BettingTicks t.dealer = nil t.dealerSplit = pgSplit{} } events := []Event{t.private(name, fmt.Sprintf("Committed %d on %s.", amount, spot))} // The main wager is the commitment point: when every active player has // one, the cards come out immediately rather than waiting on the timer. if spot == "main" && t.allBetLocked() { return append(events, t.dealLocked()...) } return append(events, t.statusLocked(name, false)...) } func (t *paiGowTable) allReady() bool { found := false for _, p := range t.players { if p.main.Total > 0 { found = true if !p.ready { return false } } } return found } // allBetLocked reports whether at least one active player has a main wager // and every active (connected) player has one. This is the point at which the // cards can be dealt without waiting for the betting timer. func (t *paiGowTable) allBetLocked() bool { found := false for _, p := range t.players { if p.disconnected { continue } found = true if p.main.Total == 0 { return false } } return found } func (t *paiGowTable) anyMainLocked() bool { for _, p := range t.players { if p.main.Total > 0 { return true } } return false } func (t *paiGowTable) a2345High() bool { return t.Config.PaiGow.A2345High() } func (t *paiGowTable) dealLocked() []Event { deck := newShoe(1, 0, t.rng) deck.cards = append(deck.cards, card{"JK", "joker"}) t.rng.Shuffle(len(deck.cards), func(i, j int) { deck.cards[i], deck.cards[j] = deck.cards[j], deck.cards[i] }) // Seven packets, including unused seats, leave the published No Push // tiebreakers at cards 50 and 51. Empty packets never qualify for Envy. packets := [7][]card{} for i := range packets { for j := 0; j < 7; j++ { packets[i] = append(packets[i], deck.draw()) } } t.dealer = packets[6] t.dealerSplit = pgHouseRules(t.dealer, t.a2345High()) t.tie = [2]card{deck.draw(), deck.draw()} t.phase = "setting" t.ticks = t.Config.PaiGow.ActionTicks // Face Up ace-high settles immediately: there is no decision to make. // Still assign every player's seven cards for independent Fortune/Envy. autoPush := t.Config.PaiGow.Variant == PaiGowFaceUp && pgDealerPushRules(t.dealer, PaiGowFaceUp, t.a2345High()) != "" var events []Event for i, p := range t.players { if p.main.Total == 0 { continue } p.cards = packets[i] pgSortHand(p.cards) p.ready = p.disconnected || autoPush if p.ready { p.split = pgHouseRules(p.cards, t.a2345High()) } if !autoPush { events = append(events, t.statusLocked(p.name, false)...) } } if t.allReady() { events = append(events, t.prepareSettlementLocked()...) } return events } func (t *paiGowTable) action(name, action string) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.player(name) if p == nil { return nil } action = strings.TrimSpace(strings.ToLower(action)) if action == "paytable" || action == "status" { return t.statusLocked(name, false) } if t.phase == "settling" && action == "settle" { return t.payLocked() } if action == "leave_after" { if t.phase == "setting" || t.phase == "settling" { p.leaveAfter = true return []Event{t.private(name, "You'll leave after this round. Your current hand still needs your actions.")} } return []Event{t.private(name, "You're not in a round.")} } if action == "sitout" { return []Event{t.private(name, "Pai Gow has no sit-out; use stop to leave after the round.")} } if action == "autoset" || strings.HasPrefix(action, "autoset ") { if t.phase != "waiting" && t.phase != "betting" && t.phase != "setting" { return []Event{t.private(name, "Autoset can only change before the showdown.")} } rest := strings.TrimSpace(strings.TrimPrefix(action, "autoset")) switch rest { case "", "toggle": p.autoset = !p.autoset case "on": p.autoset = true case "off": p.autoset = false default: return []Event{t.private(name, "Use autoset on or autoset off.")} } state := "off" if p.autoset { state = "on" } return append([]Event{t.private(name, "Autoset is "+state+".")}, t.statusLocked(name, false)...) } if strings.HasPrefix(action, "unbet") { return t.unbetLocked(p) } if action == "deal" && (t.phase == "waiting" || t.phase == "betting") && p.main.Total > 0 { if t.allBetLocked() { return t.dealLocked() } return []Event{t.private(name, "Waiting for the other bettors; the window closes on the timer.")} } if t.phase != "setting" || p.main.Total == 0 { return []Event{t.private(name, "Place your bets, then deal; set cards only after they are dealt.")} } if p.ready { return []Event{t.private(name, "Your hand is locked; waiting for the other players.")} } // "set" commits a previewed arrangement. Without autoset, the numeric // choices only arrange; the player confirms before the dealer reveals. if action == "set" || action == "set hand" { if p.pending.high == nil { return []Event{t.private(name, "Arrange your hand first with 24 or 13456, or house.")} } p.split = p.pending p.pending = pgSplit{} p.ready = true if t.allReady() { return t.prepareSettlementLocked() } return t.statusLocked(name, false) } var split pgSplit if action == "house" || action == "house way" || action == "set house" { split = pgHouseRules(p.cards, t.a2345High()) } else { digits, ok := ParsePaiGowDigits(strings.TrimPrefix(action, "set ")) if !ok { return []Event{t.private(name, "Enter exactly two or five different numbers from 1-7 (24 or 13456), or house.")} } split = pgSet(p.cards, digits) } if !split.validRules(t.a2345High()) { return []Event{t.private(name, "FOUL HAND: your five-card high hand must be higher than your two-card low hand. Nothing was set; choose again or use house.")} } if p.autoset { p.split = split p.pending = pgSplit{} p.ready = true if t.allReady() { return t.prepareSettlementLocked() } return t.statusLocked(name, false) } p.pending = split return t.statusLocked(name, false) } func (t *paiGowTable) prepareSettlementLocked() []Event { t.phase = "settling" for _, p := range t.players { if p.main.Total == 0 { continue } result := pgMainResultRules(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie, t.a2345High()) p.payout = pgMainPay(p.main.Total, result, t.Config.PaiGow.Variant) p.result = result highRank := pgEvaluateRules(p.split.high, t.a2345High()) lowRank := pgEvaluateRules(p.split.low, t.a2345High()) dealerHighRank := pgEvaluateRules(t.dealerSplit.high, t.a2345High()) dealerLowRank := pgEvaluateRules(t.dealerSplit.low, t.a2345High()) highWin := pgCompare(highRank, dealerHighRank) > 0 lowWin := pgCompare(lowRank, dealerLowRank) > 0 sd := &PaiGowShowdown{ Result: result, Hands: []PaiGowHandView{ {Label: "Your high", Strength: pgRankName(highRank), Cards: pgViews(p.split.high), Winning: highWin, HasWin: true}, {Label: "Dealer high", Strength: pgRankName(dealerHighRank), Cards: pgViews(t.dealerSplit.high), Winning: !highWin, HasWin: true}, {Label: "Your low", Strength: pgRankName(lowRank), Cards: pgViews(p.split.low), Winning: lowWin, HasWin: true}, {Label: "Dealer low", Strength: pgRankName(dealerLowRank), Cards: pgViews(t.dealerSplit.low), Winning: !lowWin, HasWin: true}, }, } if t.Config.PaiGow.Variant == PaiGowFaceUp { rates, _ := t.Config.PaiGow.pushAceRates() if class := pgPushAceClassRules(t.dealer, p.cards, rates, t.a2345High()); class.klass != "none" { p.payout += p.pushace.Total * (class.rate + 1) sd.PushAce = fmt.Sprintf("%s (%d:1)", class.desc, class.rate) sd.PushAceWin = true } else if p.pushace.Total > 0 { sd.PushAce = "dealer not ace-high" } } if p.fortune.Total > 0 { row, win := pgFortuneRules(p.cards, t.Config.PaiGow.Envy, t.a2345High()) sd.Fortune = row.Hand sd.FortuneWin = win if win { p.payout += p.fortune.Total * (row.Odds + 1) } if t.Config.PaiGow.Envy && p.fortune.Total >= 5 { envy, others := 0, 0 for _, other := range t.players { if other != p && other.main.Total > 0 { others++ row, _ := pgFortuneRules(other.cards, true, t.a2345High()) envy += row.Envy } } // Envy only means something with someone else at the table. if others > 0 { sd.ShowEnvy = true sd.Envy = envy p.payout += envy } } } sd.Stake = p.main.Total + p.fortune.Total + p.pushace.Total sd.Payout = p.payout p.showdown = sd } return t.payLocked() } func (t *paiGowTable) payLocked() []Event { var events []Event var newlyPaid []string pending := false for _, p := range t.players { if p.main.Total == 0 || p.paid { continue } ids := []string{p.main.ID} if p.fortune.Total > 0 { ids = append(ids, p.fortune.ID) } if p.pushace.Total > 0 { ids = append(ids, p.pushace.ID) } // One durable transaction settles both wagers and Envy, including zero // returns. Freeze the round on failure; retries never redeal or pay twice. if err := p.wallet.PayCredits(p.payout, ids...); err != nil { pending = true if p.showdown != nil { p.showdown.Pending = true } events = append(events, t.private(p.name, payoutFailedMessage()+" Use settle to retry the same result.")) continue } p.paid = true if p.showdown != nil { p.showdown.Paid = true } newlyPaid = append(newlyPaid, p.name) } if pending { for _, name := range newlyPaid { events = append(events, t.statusLocked(name, false)...) } return events } t.phase = "waiting" kept := t.players[:0] for _, p := range t.players { p.main = Wager{} p.fortune = Wager{} p.pushace = Wager{} p.ready = false if !p.disconnected && !p.leaveAfter { kept = append(kept, p) } } t.players = kept for _, p := range t.players { events = append(events, t.statusLocked(p.name, false)...) } return events } func (t *paiGowTable) tick() []Event { t.mu.Lock() defer t.mu.Unlock() switch t.phase { case "settling": return t.payLocked() case "betting": if t.allBetLocked() { return t.dealLocked() } // A lone player has no time limit; wait for their bet instead. if t.activePlayersLocked() <= 1 { return nil } t.ticks-- if t.ticks > 0 { return nil } return t.finishBettingLocked() case "setting": // A lone player has no time limit; wait for them to set. if t.activePlayersLocked() <= 1 { return nil } t.ticks-- if t.ticks > 0 { return nil } var events []Event for _, p := range t.players { if p.main.Total > 0 && !p.ready { p.split = pgHouseRules(p.cards, t.a2345High()) p.ready = true e := t.private(p.name, "Your hand timed out and was set the house way.") e.Color = "casino_warning" events = append(events, e) } } return append(events, t.prepareSettlementLocked()...) } return nil } func (t *paiGowTable) markDisconnected(name string) []Event { t.mu.Lock() defer t.mu.Unlock() p := t.player(name) if p == nil { return nil } // Before the deal nothing is revealed, so a disconnect returns the // wagers and frees the seat; the remaining bettors may then be dealt. if t.phase == "waiting" || t.phase == "betting" { if p.wallet != nil { // Nothing has been dealt, so the stake is refunded; a failed refund // leaves it pending for startup recovery, but the seat is still freed. _, _, _ = p.wallet.RefundOutstanding() } p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{} t.remove(name) return t.afterSeatChangeLocked() } p.disconnected = true p.ready = true if t.phase == "setting" { if p.split.high == nil { p.split = pgHouseRules(p.cards, t.a2345High()) } if t.allReady() { return t.prepareSettlementLocked() } } return nil } // afterSeatChangeLocked re-evaluates the betting window after a seat leaves // or disconnects: the cards deal when every remaining active player has bet, // and a table with no main wager returns to waiting. func (t *paiGowTable) afterSeatChangeLocked() []Event { if t.phase != "betting" { return nil } if t.allBetLocked() { return t.dealLocked() } if !t.anyMainLocked() { return t.finishBettingLocked() } return nil } // finishBettingLocked closes the betting window on the timer: side bets on // seats that never placed a main wager are returned, every non-bettor is told // the window closed (no silent timer), and the round deals when at least one // main wager is live. With no main wager the table resets. func (t *paiGowTable) finishBettingLocked() []Event { var events []Event deals := t.anyMainLocked() for _, p := range t.players { if p.main.Total > 0 { continue } if side := t.refundSideBetsLocked(p); len(side) > 0 { events = append(events, side...) } else if deals { events = append(events, t.private(p.name, "The betting window closed; you did not place a main wager and sit out this round.")) } } if !deals { t.resetTableLocked() return events } return append(events, t.dealLocked()...) } func (t *paiGowTable) refundSideBetsLocked(p *pgPlayer) []Event { total := p.fortune.Total + p.pushace.Total if total == 0 { return nil } var ids []string if p.fortune.Total > 0 { ids = append(ids, p.fortune.ID) } if p.pushace.Total > 0 { ids = append(ids, p.pushace.ID) } e := t.private(p.name, fmt.Sprintf("Your %d in side bets is returned; no main wager was placed.", total)) if p.wallet != nil { if err := p.wallet.PayCredits(total, ids...); err != nil { e.Message = payoutFailedMessage() } } p.fortune, p.pushace = Wager{}, Wager{} return []Event{e} } // resetTableLocked clears a round that closed without a main wager and // returns the table to waiting. Callers have already refunded any side bets. func (t *paiGowTable) resetTableLocked() { t.phase = "waiting" t.ticks = 0 t.dealer = nil t.dealerSplit = pgSplit{} t.tie = [2]card{} for _, p := range t.players { p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{} p.cards, p.split, p.pending, p.ready, p.paid, p.result = nil, pgSplit{}, pgSplit{}, false, false, "" p.showdown = nil } } // activePlayersLocked counts the connected players with a live main wager. // A lone bettor never runs on a clock, so every timeout path checks this // before decrementing. A seated spectator does not start a timer for them. func (t *paiGowTable) activePlayersLocked() int { n := 0 for _, p := range t.players { if !p.disconnected && p.main.Total > 0 { n++ } } return n } // unbetLocked takes back every wager before the deal. Refunds go through // PayCredits with the original stake handles so the ledger entries resolve in // the same save; wagers are only cleared on success. func (t *paiGowTable) unbetLocked(p *pgPlayer) []Event { if t.phase != "waiting" && t.phase != "betting" { return []Event{t.private(p.name, "You can only take back bets before the cards are dealt.")} } total, ids := 0, []string{} take := func(w *Wager) { if w.Total == 0 { return } total += w.Total ids = append(ids, w.ID) } take(&p.main) take(&p.fortune) take(&p.pushace) if total == 0 { return []Event{t.private(p.name, "Nothing to take back.")} } if p.wallet != nil { if err := p.wallet.PayCredits(total, ids...); err != nil { return []Event{t.private(p.name, payoutFailedMessage())} } } p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{} if !t.anyMainLocked() && t.phase == "betting" { t.phase = "waiting" t.ticks = 0 } return append([]Event{t.private(p.name, fmt.Sprintf("You take back %d credits.", total))}, t.statusLocked(p.name, false)...) } func (t *paiGowTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() var events []Event for _, p := range t.players { if p.wallet == nil || p.paid { continue } if p.main.Total == 0 && p.fortune.Total == 0 && p.pushace.Total == 0 { continue } if _, _, err := p.wallet.RefundOutstanding(); err != nil { events = append(events, t.private(p.name, "Shutdown refund remains pending for startup recovery.")) } else { p.main = Wager{} p.fortune = Wager{} p.pushace = Wager{} } } return events }