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 ActionTicks int `yaml:"action_ticks,omitempty"` } 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() s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. A2345 is the second-highest straight.\nSet two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. High must strictly outrank low. 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. Use house or set ; set locks your hand. Timeout/disconnect uses house way.\nBet main first, optionally bet fortune before dealing. 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 } func pgNatural(cards []card) 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 { straight = 15 } 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 { j := -1 for i, c := range cards { if c.rank == "JK" { j = i break } } if j < 0 { return pgNatural(cards) } copyCards := append([]card(nil), cards...) copyCards[j] = card{"A", "joker"} best := pgNatural(copyCards) 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 := pgNatural(copyCards) if (v[0] == 4 || v[0] == 5 || v[0] == 8) && pgCompare(v, best) > 0 { best = v } } } return best } 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 len(s.high) == 5 && len(s.low) == 2 && pgCompare(pgEvaluate(s.high), pgEvaluate(s.low)) > 0 } func pgHouse(cards []card) 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.valid() { continue } options = append(options, s) if pgCompare(pgEvaluate(s.high), best) > 0 { best = pgEvaluate(s.high) } } } var chosen pgSplit var low, high pgRank for _, s := range options { h, l := pgEvaluate(s.high), pgEvaluate(s.low) 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 } // 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) { 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 := pgEvaluate(s.high) 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 } func pgBest(cards []card) pgRank { var best pgRank for i := 0; i < 7; i++ { for j := i + 1; j < 7; j++ { v := pgEvaluate(pgSet(cards, []int{i, j}).high) 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 { if variant != PaiGowEZ && variant != PaiGowFaceUp { return "" } v := pgBest(cards) 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 { if push := pgDealerPush(dealerCards, variant); push != "" { return push } high := pgCompare(pgEvaluate(player.high), pgEvaluate(dealer.high)) > 0 low := pgCompare(pgEvaluate(player.low), pgEvaluate(dealer.low)) > 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 { if pgBest(dealer)[0] != 0 || pgBest(dealer)[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 pgBest(player)[0] == 0 && pgBest(player)[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 }