diff options
Diffstat (limited to 'internal/casino/pai_gow.go')
| -rw-r--r-- | internal/casino/pai_gow.go | 299 |
1 files changed, 258 insertions, 41 deletions
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index 17d8971..ba3daaa 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -26,6 +26,7 @@ type pgPlayer struct { cards []card split pgSplit ready, disconnected, paid bool + leaveAfter bool payout int result string } @@ -92,18 +93,27 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand { return nil } commands := []MenuCommand{} + faceUp := t.Config.PaiGow.Variant == PaiGowFaceUp switch t.phase { case "waiting", "betting": if p.main.Total == 0 { - commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; opens a timed betting window"}) - } else { + // 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 <amount> fortune", Desc: "Optional Fortune side bet before deal"}) + commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet; place before your main bet"}) + } else { + commands = append(commands, MenuCommand{Command: "unbet fortune", Desc: "Take back your Fortune side bet"}) } - if t.Config.PaiGow.Variant == PaiGowFaceUp && p.pushace.Total == 0 { - commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet (max half the main wager)"}) + if faceUp { + if p.pushace.Total == 0 { + commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet; place before your main bet"}) + } else { + commands = append(commands, MenuCommand{Command: "unbet pushace", Desc: "Take back your Push Ace High side bet"}) + } } - commands = append(commands, MenuCommand{Command: "deal", Desc: "Mark ready; deal when all bettors are ready"}) + commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; deals once every bettor is in"}) + } else { + commands = append(commands, MenuCommand{Command: "unbet main", Desc: "Take back your main wager before the deal"}) } case "setting": if p.main.Total > 0 && !p.ready { @@ -157,14 +167,14 @@ func (t *paiGowTable) statusLocked(name string) []Event { s.Tiebreaker = pgViews(t.tie[:]) } } - e := t.private(name, t.Config.Label()) + 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{Heading: t.Config.Label() + " | " + t.phase, ShowBalance: true, Commands: commands} + e.Menu = &MenuView{ShowBalance: true, Commands: commands} } return []Event{e} } @@ -189,11 +199,24 @@ func (t *paiGowTable) join(name string) []Event { func (t *paiGowTable) leave(name string) []Event { t.mu.Lock() defer t.mu.Unlock() - if p := t.player(name); p != nil && p.main.Total > 0 { - return []Event{t.private(name, "Finish your Pai Gow round before leaving; house sets your hand.")} + 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) - return []Event{t.private(name, "You leave Pai Gow Poker.")} + 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() @@ -226,11 +249,14 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { 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.") } - if spot != "main" && (p.main.Total == 0 || p.ready) { - return fail("Place a main bet first, then side bets before marking ready with deal.") + // 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 == "pushace" && amount > p.main.Total/2 { - return fail("Push Ace High is capped at half the 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 pushace 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)) + ".") @@ -251,20 +277,24 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { p.result = "" p.ready = false p.paid = false - p.fortune = Wager{} - p.pushace = Wager{} } else if spot == "pushace" { p.pushace = stake } else { p.fortune = stake } - if t.phase == "waiting" { + if spot == "main" && t.phase == "waiting" { t.phase = "betting" t.ticks = t.Config.BettingTicks t.dealer = nil t.dealerSplit = pgSplit{} } - return append(fail(fmt.Sprintf("Committed %d on %s. Betting closes in %d ticks; Fortune is optional, then deal.", amount, spot, t.ticks)), t.statusLocked(name)...) + 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)...) } func (t *paiGowTable) allReady() bool { found := false @@ -278,6 +308,34 @@ func (t *paiGowTable) allReady() bool { } 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"}) @@ -291,13 +349,13 @@ func (t *paiGowTable) dealLocked() []Event { } } t.dealer = packets[6] - t.dealerSplit = pgHouse(t.dealer) + 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 && pgDealerPush(t.dealer, PaiGowFaceUp) != "" + 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 { @@ -306,7 +364,7 @@ func (t *paiGowTable) dealLocked() []Event { p.cards = packets[i] p.ready = p.disconnected || autoPush if p.ready { - p.split = pgHouse(p.cards) + p.split = pgHouseRules(p.cards, t.a2345High()) } if !autoPush { events = append(events, t.statusLocked(p.name)...) @@ -331,12 +389,24 @@ func (t *paiGowTable) action(name, action string) []Event { if t.phase == "settling" && action == "settle" { return t.payLocked() } - if action == "deal" && t.phase == "betting" && p.main.Total > 0 { - p.ready = true - if t.allReady() { + 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 strings.HasPrefix(action, "unbet") { + return t.unbetLocked(p, action) + } + if action == "deal" && (t.phase == "waiting" || t.phase == "betting") && p.main.Total > 0 { + if t.allBetLocked() { return t.dealLocked() } - return []Event{t.private(name, "Ready. Waiting for other bettors or the betting timer.")} + 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.")} @@ -346,7 +416,7 @@ func (t *paiGowTable) action(name, action string) []Event { } var split pgSplit if action == "house" || action == "house way" || action == "set house" { - split = pgHouse(p.cards) + split = pgHouseRules(p.cards, t.a2345High()) } else { digits, ok := ParsePaiGowDigits(strings.TrimPrefix(action, "set ")) if !ok { @@ -354,7 +424,7 @@ func (t *paiGowTable) action(name, action string) []Event { } split = pgSet(p.cards, digits) } - if !split.valid() { + 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.")} } p.split = split @@ -370,12 +440,12 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { if p.main.Total == 0 { continue } - result := pgMainResult(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie) + 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 = "Main: " + result if t.Config.PaiGow.Variant == PaiGowFaceUp { rates, _ := t.Config.PaiGow.pushAceRates() - if class := pgPushAceClass(t.dealer, p.cards, rates); class.klass != "none" { + if class := pgPushAceClassRules(t.dealer, p.cards, rates, t.a2345High()); class.klass != "none" { p.payout = p.pushace.Total * (class.rate + 1) p.result += fmt.Sprintf("; Push Ace High: %s (%d:1)", class.desc, class.rate) } else if p.pushace.Total > 0 { @@ -383,7 +453,7 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { } } if p.fortune.Total > 0 { - row, win := pgFortune(p.cards, t.Config.PaiGow.Envy) + row, win := pgFortuneRules(p.cards, t.Config.PaiGow.Envy, t.a2345High()) p.result += "; Fortune: " + row.Hand if win { p.payout += p.fortune.Total * (row.Odds + 1) @@ -392,7 +462,7 @@ func (t *paiGowTable) prepareSettlementLocked() []Event { envy := 0 for _, other := range t.players { if other != p && other.main.Total > 0 { - row, _ := pgFortune(other.cards, true) + row, _ := pgFortuneRules(other.cards, true, t.a2345High()) envy += row.Envy } } @@ -440,8 +510,9 @@ func (t *paiGowTable) payLocked() []Event { for _, p := range t.players { p.main = Wager{} p.fortune = Wager{} + p.pushace = Wager{} p.ready = false - if !p.disconnected { + if !p.disconnected && !p.leaveAfter { kept = append(kept, p) } } @@ -457,17 +528,23 @@ func (t *paiGowTable) tick() []Event { switch t.phase { case "settling": return t.payLocked() - case "betting", "setting": + case "betting": + if t.allBetLocked() { + return t.dealLocked() + } t.ticks-- if t.ticks > 0 { return nil } - if t.phase == "betting" { - return t.dealLocked() + return t.finishBettingLocked() + case "setting": + t.ticks-- + if t.ticks > 0 { + return nil } for _, p := range t.players { if p.main.Total > 0 && !p.ready { - p.split = pgHouse(p.cards) + p.split = pgHouseRules(p.cards, t.a2345High()) p.ready = true } } @@ -482,15 +559,23 @@ func (t *paiGowTable) markDisconnected(name string) []Event { if p == nil { return nil } - if p.main.Total == 0 { + // 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 nil + return t.afterSeatChangeLocked() } p.disconnected = true p.ready = true if t.phase == "setting" { if p.split.high == nil { - p.split = pgHouse(p.cards) + p.split = pgHouseRules(p.cards, t.a2345High()) } if t.allReady() { return t.prepareSettlementLocked() @@ -498,12 +583,144 @@ func (t *paiGowTable) markDisconnected(name string) []Event { } 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, 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 + for _, p := range t.players { + if p.main.Total > 0 { + continue + } + events = append(events, t.refundSideBetsLocked(p)...) + } + if !t.anyMainLocked() { + 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.ready, p.paid, p.result = nil, pgSplit{}, false, false, "" + } +} + +// unbetLocked takes back one or all of a player's wagers before the deal. +// Refunds go through PayCredits with the original stake handle so the ledger +// entry resolves in the same save; wagers are only cleared on success. +func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event { + target := strings.TrimSpace(strings.TrimPrefix(action, "unbet")) + if target == "" { + target = "all" + } + if target != "all" && target != "main" && target != "fortune" && target != "pushace" { + return []Event{t.private(p.name, "Unbet what? Use unbet main, unbet fortune, unbet pushace, or unbet all.")} + } + 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) + } + switch target { + case "main": + take(&p.main) + case "fortune": + take(&p.fortune) + case "pushace": + take(&p.pushace) + default: + 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())} + } + } + switch target { + case "main": + p.main = Wager{} + case "fortune": + p.fortune = Wager{} + case "pushace": + p.pushace = Wager{} + default: + 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)...) +} 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.main.Total == 0 || p.paid { + 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 { |
