diff options
Diffstat (limited to 'internal/casino/pai_gow_rules.go')
| -rw-r--r-- | internal/casino/pai_gow_rules.go | 503 |
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 +} |
