aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/pai_gow_rules.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/pai_gow_rules.go')
-rw-r--r--internal/casino/pai_gow_rules.go503
1 files changed, 503 insertions, 0 deletions
diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go
new file mode 100644
index 0000000..8a9842c
--- /dev/null
+++ b/internal/casino/pai_gow_rules.go
@@ -0,0 +1,503 @@
+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 <digits>; set locks your hand. Timeout/disconnect uses house way.\nBet main first, optionally bet <amount> 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
+}