package casino import ( "fmt" "sort" "strings" "unicode" ) const ( PaiGowClassic = "classic" PaiGowEZ = "ez" PaiGowNoPush = "no_push" PaiGowFaceUp = "face_up" // Two independently funded wagers plus all five Envy bonuses must fit // MaxCredits. 2000 conservatively exceeds the maximum combined multiplier. pgMaxWager = MaxCredits / 2000 ) type PaiGowConfig struct { Variant string `yaml:"variant,omitempty"` Envy bool `yaml:"envy,omitempty"` PushAceHigh string `yaml:"push_ace_high,omitempty"` // Face Up only: 5_15_40 or 5_12_40 // Autoset makes a legal arrangement lock immediately instead of waiting // for the player's "set" confirmation. Players may toggle it per seat with // "autoset on|off"; this field only seeds the default at join. Autoset bool `yaml:"autoset,omitempty"` ActionTicks int `yaml:"action_ticks,omitempty"` // A2345SecondHighest makes the wheel the second-highest straight, below // Broadway and above a six-high straight. It defaults to true; only an // explicit false demotes A2345 to the lowest straight. A2345SecondHighest *bool `yaml:"a2345_second_highest,omitempty"` } func (c PaiGowConfig) A2345High() bool { return ruleBool(c.A2345SecondHighest, true) } func (c PaiGowConfig) Normalize() PaiGowConfig { if c.Variant == "" { c.Variant = PaiGowClassic } if c.ActionTicks == 0 { c.ActionTicks = 60 } return c } // pushAceRates returns the three published rates (plain/joker/both) for a // Face Up table's configured schedule; ok is false on any other value. func (c PaiGowConfig) pushAceRates() ([3]int, bool) { switch c.Normalize().PushAceHigh { case "5_15_40": return [3]int{5, 15, 40}, true case "5_12_40": return [3]int{5, 12, 40}, true } return [3]int{}, false } func validatePaiGow(c TableConfig) error { r := c.PaiGow.Normalize() if c.MinBet > pgMaxWager { return fmt.Errorf("pai_gow min_bet must not exceed %d", pgMaxWager) } if r.Variant != PaiGowClassic && r.Variant != PaiGowEZ && r.Variant != PaiGowNoPush && r.Variant != PaiGowFaceUp { return fmt.Errorf("pai_gow variant must be classic, ez, no_push, or face_up") } if c.MaxPlayers > 6 { return fmt.Errorf("pai gow allows at most six players") } if r.ActionTicks < 1 || r.ActionTicks > 600 { return fmt.Errorf("pai_gow action_ticks must be 1-600") } if r.PushAceHigh != "" { if r.Variant != PaiGowFaceUp { return fmt.Errorf("push_ace_high belongs on a face_up table") } if _, ok := r.pushAceRates(); !ok { return fmt.Errorf("push_ace_high must be 5_15_40 or 5_12_40") } } if r.Variant == PaiGowFaceUp && r.PushAceHigh == "" { return fmt.Errorf("face_up requires push_ace_high 5_15_40 or 5_12_40") } return nil } func (c PaiGowConfig) Label() string { switch c.Normalize().Variant { case PaiGowEZ: return "EZ Pai Gow Poker" case PaiGowNoPush: return "No Push Pai Gow Poker" case PaiGowFaceUp: return "Face Up Pai Gow Poker" } return "Pai Gow Poker" } func paiGowRules(c TableConfig) string { r := c.PaiGow.Normalize() wheel := "A2345 is the second-highest straight." if !r.A2345High() { wheel = "A2345 is the lowest straight." } s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. " + wheel + "\nArrange two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. Your five-card hand must strictly outrank your two-card hand. The board then shows the five high cards and the two low cards, and set locks the hand and reveals the dealer; copies go to the dealer.\nBoth hands win: win; both lose: loss; one wins: push." switch r.Variant { case PaiGowClassic: s += " Wins pay 0.95:1 (profit rounded down, minimum one credit)." case PaiGowEZ: s += " No commission; dealer's best five-card hand exactly queen-high pushes all main bets." case PaiGowFaceUp: s += " No commission; dealer sets and reveals both hands before you set yours. Dealer's best five-card hand exactly ace-high automatically pushes all main bets; Fortune and Envy still settle." case PaiGowNoPush: s += " No commission; split results use two extra cards: player must beat dealer, ties lose; Joker is ace." } s += "\nHouse way: preserve the best high-hand category, maximize low within it; split two pair, full houses and trips-pairs when this improves low to a pair. Never foul. house arranges the house way and still needs set; autoset on locks any legal arrangement immediately (autoset off restores the confirmation). A multiplayer timeout or disconnect uses the house way; a lone player is never timed out.\nBet main first, optionally bet N fortune before dealing; unbet takes every undealt wager back. Fortune uses all seven cards regardless of split; highest listed prize only, profit odds plus stake returned." if r.Envy { s += "\nEnvy: Fortune stake of at least 5 qualifies for fixed bonuses on OTHER active players' hands, never your own or dealer; multiple bonuses add." } else { s += "\nEnvy off: four of a kind pays 30:1 instead of 25:1 (custom enhancement)." } return s } // ParsePaiGowDigits accepts exactly two or five UNIQUE card positions; unlike // video poker toggles, repeated digits are an error rather than silently ignored. func ParsePaiGowDigits(s string) ([]int, bool) { var out []int var seen [7]bool for _, r := range s { if unicode.IsSpace(r) { continue } if r < '1' || r > '7' || seen[r-'1'] { return nil, false } seen[r-'1'] = true out = append(out, int(r-'1')) } return out, len(out) == 2 || len(out) == 5 } // Lexicographic category and kickers. Zero padding permits comparisons between // five and two cards without treating a two-card flush/straight as a hand. type pgRank [6]int func pgCompare(a, b pgRank) int { for i := range a { if a[i] > b[i] { return 1 } if a[i] < b[i] { return -1 } } return 0 } // pgNatural keeps the default-rules entry point used by tests and callers that // do not thread a table configuration. Production evaluation goes through // pgNaturalRules. // //nolint:unused // default-rules entry point func pgNatural(cards []card) pgRank { return pgNaturalRules(cards, true) } func pgNaturalRules(cards []card, a2345High bool) pgRank { counts := [15]int{} flush := len(cards) == 5 for _, c := range cards { counts[pokerRank(c)]++ if c.suit != cards[0].suit { flush = false } } var ranks []int for r := 14; r >= 2; r-- { if counts[r] > 0 { ranks = append(ranks, r) } } sort.SliceStable(ranks, func(i, j int) bool { return counts[ranks[i]] > counts[ranks[j]] }) straight := 0 if len(cards) == 5 && len(ranks) == 5 { sorted := append([]int(nil), ranks...) sort.Ints(sorted) if sorted[4]-sorted[0] == 4 { straight = sorted[4] } if sorted[0] == 2 && sorted[1] == 3 && sorted[2] == 4 && sorted[3] == 5 && sorted[4] == 14 { if a2345High { straight = 15 } else { straight = 5 } } if straight == 14 { straight = 16 } } out := pgRank{} switch { case counts[ranks[0]] == 5: out[0] = 9 case straight > 0 && flush: out[0] = 8 out[1] = straight return out case counts[ranks[0]] == 4: out[0] = 7 case len(ranks) > 1 && counts[ranks[0]] == 3 && counts[ranks[1]] == 2: out[0] = 6 case flush: out[0] = 5 case straight > 0: out[0] = 4 out[1] = straight return out case counts[ranks[0]] == 3: out[0] = 3 case len(ranks) > 1 && counts[ranks[0]] == 2 && counts[ranks[1]] == 2: out[0] = 2 case counts[ranks[0]] == 2: out[0] = 1 } copy(out[1:], ranks) return out } func pgEvaluate(cards []card) pgRank { return pgEvaluateRules(cards, true) } func pgEvaluateRules(cards []card, a2345High bool) pgRank { j := -1 for i, c := range cards { if c.rank == "JK" { j = i break } } if j < 0 { return pgNaturalRules(cards, a2345High) } copyCards := append([]card(nil), cards...) copyCards[j] = card{"A", "joker"} best := pgNaturalRules(copyCards, a2345High) if len(cards) != 5 { return best } for _, s := range []string{"clubs", "diamonds", "hearts", "spades"} { for _, r := range []string{"2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"} { replacement := card{r, s} duplicate := false for i, c := range cards { if i != j && c == replacement { duplicate = true } } if duplicate { continue } copyCards[j] = replacement v := pgNaturalRules(copyCards, a2345High) if (v[0] == 4 || v[0] == 5 || v[0] == 8) && pgCompare(v, best) > 0 { best = v } } } return best } // pgRankWords names card ranks for the hand-strength labels shown at the // showdown. Index 14 is ACE so a pair of aces reads "PAIR OF ACES". var pgRankWords = [...]string{"", "", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN", "JACK", "QUEEN", "KING", "ACE"} func pgRankWord(v int) string { if v >= 2 && v <= 14 { return pgRankWords[v] } return "?" } func pgRankPlural(v int) string { word := pgRankWord(v) switch word { case "SIX": return "SIXES" case "ACE": return "ACES" default: return word + "S" } } func pgStraightName(v int) string { switch v { case 16: return "ACE-HIGH" case 15: return "FIVE-HIGH" default: return pgRankWord(v) + "-HIGH" } } // pgRankName renders a hand strength for the showdown, for example // "PAIR OF ACES", "TWO PAIR ACES AND KINGS" or "STRAIGHT ACE-HIGH". func pgRankName(r pgRank) string { switch r[0] { case 9: return "FIVE OF A KIND " + pgRankPlural(r[1]) case 8: if r[1] == 16 { return "ROYAL FLUSH" } return "STRAIGHT FLUSH " + pgStraightName(r[1]) case 7: return "FOUR OF A KIND " + pgRankPlural(r[1]) case 6: return "FULL HOUSE " + pgRankPlural(r[1]) + " FULL OF " + pgRankPlural(r[2]) case 5: return "FLUSH " + pgRankWord(r[1]) + "-HIGH" case 4: return "STRAIGHT " + pgStraightName(r[1]) case 3: return "THREE OF A KIND " + pgRankPlural(r[1]) case 2: return "TWO PAIR " + pgRankPlural(r[1]) + " AND " + pgRankPlural(r[2]) case 1: return "PAIR OF " + pgRankPlural(r[1]) default: if r[1] > 0 { return "HIGH CARD " + pgRankWord(r[1]) } return "HIGH CARD" } } type pgSplit struct{ high, low []card } func pgSet(cards []card, digits []int) pgSplit { var selected [7]bool for _, i := range digits { selected[i] = true } s := pgSplit{} for i, c := range cards { if selected[i] == (len(digits) == 2) { s.low = append(s.low, c) } else { s.high = append(s.high, c) } } return s } func (s pgSplit) valid() bool { return s.validRules(true) } func (s pgSplit) validRules(a2345High bool) bool { return len(s.high) == 5 && len(s.low) == 2 && pgCompare(pgEvaluateRules(s.high, a2345High), pgEvaluateRules(s.low, a2345High)) > 0 } func pgHouse(cards []card) pgSplit { return pgHouseRules(cards, true) } func pgHouseRules(cards []card, a2345High bool) pgSplit { var options []pgSplit best := pgRank{} for i := 0; i < 7; i++ { for j := i + 1; j < 7; j++ { s := pgSet(cards, []int{i, j}) if !s.validRules(a2345High) { continue } options = append(options, s) if pgCompare(pgEvaluateRules(s.high, a2345High), best) > 0 { best = pgEvaluateRules(s.high, a2345High) } } } var chosen pgSplit var low, high pgRank for _, s := range options { h, l := pgEvaluateRules(s.high, a2345High), pgEvaluateRules(s.low, a2345High) eligible := h[0] == best[0] // Split two pair/full house only for a paired front with at least a pair behind. if (best[0] == 2 || best[0] == 6) && l[0] == 1 && h[0] >= 1 { eligible = true } if !eligible { continue } if chosen.high == nil || pgCompare(l, low) > 0 || pgCompare(l, low) == 0 && pgCompare(h, high) > 0 { chosen, low, high = s, l, h } } return chosen } type FortuneRow struct { Hand string Odds, Envy int } // FortunePayTable returns the Wizard of Odds Fortune table 8, the lowest-edge // published fixed schedule among its eight tables. With Envy disabled, quads // receive a custom +5. func FortunePayTable(envy bool) []FortuneRow { rows := []FortuneRow{{"Natural seven-card straight flush", 1000, 500}, {"Royal flush + royal match", 750, 250}, {"Wild seven-card straight flush", 500, 150}, {"Five aces", 250, 100}, {"Royal flush", 150, 50}, {"Straight flush", 50, 20}, {"Four of a kind", 25, 5}, {"Full house", 5, 0}, {"Flush", 4, 0}, {"Three of a kind", 3, 0}, {"Straight", 2, 0}, {"Three pair", 1, 0}} if !envy { rows[6].Odds = 30 for i := range rows { rows[i].Envy = 0 } } return rows } func pgSevenFlush(cards []card) bool { suit := "" counts := [15]int{} wild := 0 for _, c := range cards { if c.rank == "JK" { wild++ continue } if suit == "" { suit = c.suit } if suit != c.suit { return false } counts[pokerRank(c)]++ } for high := 7; high <= 14; high++ { missing := 0 for r := high - 6; r <= high; r++ { v := r if v == 1 { v = 14 } if counts[v] == 0 { missing++ } } if missing == wild { return true } } return false } func pgFortune(cards []card, envy bool) (FortuneRow, bool) { return pgFortuneRules(cards, envy, true) } func pgFortuneRules(cards []card, envy, a2345High bool) (FortuneRow, bool) { rows := FortunePayTable(envy) joker := false counts := [15]int{} for _, c := range cards { if c.rank == "JK" { joker = true counts[14]++ } else { counts[pokerRank(c)]++ } } if pgSevenFlush(cards) && !joker { return rows[0], true } bestIndex := len(rows) // Enumerate all 21 five-card subsets. Fortune ranks trips above straights, // so choose by pay-table priority, NOT conventional poker rank. for i := 0; i < 7; i++ { for j := i + 1; j < 7; j++ { s := pgSet(cards, []int{i, j}) v := pgEvaluateRules(s.high, a2345High) index := len(rows) switch v[0] { case 9: index = 3 case 8: if v[1] == 16 { index = 4 if s.low[0].suit == s.low[1].suit && (s.low[0].rank == "K" && s.low[1].rank == "Q" || s.low[0].rank == "Q" && s.low[1].rank == "K") { index = 1 } } else { index = 5 } case 7: index = 6 case 6: index = 7 case 5: index = 8 case 3: index = 9 case 4: index = 10 } if index < bestIndex { bestIndex = index } } } if joker && pgSevenFlush(cards) && bestIndex > 2 { bestIndex = 2 } pairs := 0 for _, n := range counts { if n >= 2 { pairs++ } } if pairs >= 3 && bestIndex > 11 { bestIndex = 11 } if bestIndex == len(rows) { return FortuneRow{Hand: "No Fortune win"}, false } return rows[bestIndex], true } // pgBest keeps the default-rules entry point used by tests and callers that do // not thread a table configuration. Production evaluation goes through // pgBestRules. // //nolint:unused // default-rules entry point func pgBest(cards []card) pgRank { return pgBestRules(cards, true) } func pgBestRules(cards []card, a2345High bool) pgRank { var best pgRank for i := 0; i < 7; i++ { for j := i + 1; j < 7; j++ { v := pgEvaluateRules(pgSet(cards, []int{i, j}).high, a2345High) if pgCompare(v, best) > 0 { best = v } } } return best } // Automatic pushes use the best of all seven dealer cards, not just the // chosen high hand. Pair/straight/flush hands headed by an ace do not qualify. func pgDealerPush(cards []card, variant string) string { return pgDealerPushRules(cards, variant, true) } func pgDealerPushRules(cards []card, variant string, a2345High bool) string { if variant != PaiGowEZ && variant != PaiGowFaceUp { return "" } v := pgBestRules(cards, a2345High) if v[0] == 0 { if variant == PaiGowEZ && v[1] == 12 { return "push (dealer queen-high)" } if variant == PaiGowFaceUp && v[1] == 14 { return "push (dealer ace-high)" } } return "" } func pgMainResult(player, dealer pgSplit, dealerCards []card, variant string, tie [2]card) string { return pgMainResultRules(player, dealer, dealerCards, variant, tie, true) } func pgMainResultRules(player, dealer pgSplit, dealerCards []card, variant string, tie [2]card, a2345High bool) string { if push := pgDealerPushRules(dealerCards, variant, a2345High); push != "" { return push } high := pgCompare(pgEvaluateRules(player.high, a2345High), pgEvaluateRules(dealer.high, a2345High)) > 0 low := pgCompare(pgEvaluateRules(player.low, a2345High), pgEvaluateRules(dealer.low, a2345High)) > 0 if high && low { return "win" } if !high && !low { return "loss" } if variant == PaiGowNoPush { rank := func(c card) int { if c.rank == "JK" { return 14 } return pokerRank(c) } if rank(tie[0]) > rank(tie[1]) { return "win (tiebreaker)" } return "loss (tiebreaker)" } return "push" } // pgPushAceClass resolves Face Up's optional Push Ace High side bet. // The dealer's seven cards decide the base event at reveal time; ace-high // with a Joker pays 15:1, without a Joker 5:1, and both sides ace-high 40:1 type pgPushAce struct { klass string // "both", "joker", "plain", or "none" rate int desc string } func pgPushAceClass(dealer, player []card, rates [3]int) pgPushAce { return pgPushAceClassRules(dealer, player, rates, true) } func pgPushAceClassRules(dealer, player []card, rates [3]int, a2345High bool) pgPushAce { if pgBestRules(dealer, a2345High)[0] != 0 || pgBestRules(dealer, a2345High)[1] != 14 { return pgPushAce{klass: "none"} } for _, c := range dealer { if c.rank == "JK" { return pgPushAce{klass: "joker", rate: rates[1], desc: "dealer ace-high with Joker"} } } if pgBestRules(player, a2345High)[0] == 0 && pgBestRules(player, a2345High)[1] == 14 { return pgPushAce{klass: "both", rate: rates[2], desc: "both ace-high"} } return pgPushAce{klass: "plain", rate: rates[0], desc: "dealer ace-high without Joker"} } func pgMainPay(stake int, result, variant string) int { if strings.HasPrefix(result, "push") { return stake } if !strings.HasPrefix(result, "win") { return 0 } if variant == PaiGowClassic { return stake + max(1, stake*95/100) } return stake * 2 }