aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/video_poker_rules.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/video_poker_rules.go')
-rw-r--r--internal/casino/video_poker_rules.go307
1 files changed, 307 insertions, 0 deletions
diff --git a/internal/casino/video_poker_rules.go b/internal/casino/video_poker_rules.go
new file mode 100644
index 0000000..340815d
--- /dev/null
+++ b/internal/casino/video_poker_rules.go
@@ -0,0 +1,307 @@
+package casino
+
+import (
+ "strings"
+ "unicode"
+)
+
+type pokerSchedule struct {
+ name string
+ pays map[string]int
+}
+
+// pokerCategories is the fixed pay-table row order, top payout first.
+var pokerCategories = []string{"Natural royal flush", "Royal flush", "Four deuces", "Five of a kind", "Wild royal flush", "Straight flush", "Four aces with 2-4 kicker", "Four 2s-4s with A-4 kicker", "Four aces", "Four 2s-4s", "Four 5s-Ks", "Four of a kind", "Full house", "Flush", "Straight", "Three of a kind", "Two pair", "Jacks or better", "Kings or better"}
+
+// pokerPayTable resolves the per-coin (five-coin) rates for a resolved
+// game/pay-table pair. Every listed schedule is a published one; see
+// building_guide/video_poker.md for the sources.
+func pokerPayTable(c VideoPokerConfig) (pokerSchedule, bool) {
+ c = c.Normalize()
+ if c.Variant == "" {
+ return pokerSchedule{}, false
+ }
+ p := map[string]int{"Royal flush": 800, "Straight flush": 50, "Four of a kind": 25, "Straight": 4, "Three of a kind": 3, "Two pair": 2, "Jacks or better": 1}
+ name := variantTitle(c.Variant)
+ switch c.Variant {
+ case "jacks_or_better":
+ if !switchTwo(&p, c.PayTable, map[string][2]int{"9/6": {9, 6}, "9/5": {9, 5}, "8/6": {8, 6}, "8/5": {8, 5}, "7/5": {7, 5}}) {
+ return pokerSchedule{}, false
+ }
+ case "bonus_poker":
+ if !switchTwo(&p, c.PayTable, map[string][2]int{"8/5": {8, 5}, "7/5": {7, 5}, "6/5": {6, 5}}) {
+ return pokerSchedule{}, false
+ }
+ p["Four aces"], p["Four 2s-4s"], p["Four 5s-Ks"] = 80, 40, 25
+ delete(p, "Four of a kind")
+ case "bonus_poker_deluxe":
+ if !switchTwo(&p, c.PayTable, map[string][2]int{"9/6": {9, 6}, "9/5": {9, 5}, "8/6": {8, 6}, "8/5": {8, 5}, "7/5": {7, 5}}) {
+ return pokerSchedule{}, false
+ }
+ p["Four of a kind"], p["Two pair"] = 80, 1
+ case "double_bonus", "double_double_bonus":
+ p["Two pair"] = 1
+ delete(p, "Four of a kind")
+ p["Four aces"], p["Four 2s-4s"], p["Four 5s-Ks"] = 160, 80, 50
+ if c.Variant == "double_bonus" {
+ p["Straight"] = 5
+ switch c.PayTable {
+ case "10/7":
+ p["Full house"], p["Flush"] = 10, 7
+ case "9/7":
+ p["Full house"], p["Flush"] = 9, 7
+ case "9/6/5":
+ p["Full house"], p["Flush"] = 9, 6
+ case "9/6/4":
+ p["Full house"], p["Flush"], p["Straight"] = 9, 6, 4
+ case "9/5/4":
+ p["Full house"], p["Flush"], p["Straight"] = 9, 5, 4
+ case "8/5":
+ p["Full house"], p["Flush"], p["Straight"] = 8, 5, 4
+ default:
+ return pokerSchedule{}, false
+ }
+ } else {
+ name = "Double Double Bonus"
+ p["Four aces with 2-4 kicker"], p["Four 2s-4s with A-4 kicker"] = 400, 160
+ switch c.PayTable {
+ case "10/6":
+ p["Full house"], p["Flush"] = 10, 6
+ case "9/6":
+ p["Full house"], p["Flush"] = 9, 6
+ case "9/5":
+ p["Full house"], p["Flush"] = 9, 5
+ case "8/6":
+ p["Full house"], p["Flush"] = 8, 6
+ case "8/5":
+ p["Full house"], p["Flush"] = 8, 5
+ default:
+ return pokerSchedule{}, false
+ }
+ }
+ case "deuces_wild":
+ name = "Deuces Wild"
+ p = map[string]int{"Natural royal flush": 800, "Four deuces": 200, "Wild royal flush": 25, "Five of a kind": 15, "Straight flush": 9, "Four of a kind": 5, "Full house": 3, "Flush": 2, "Straight": 2, "Three of a kind": 1}
+ switch c.PayTable {
+ case "25/15/9/5/3/2":
+ case "25/15/9/4/4/3":
+ p["Four of a kind"], p["Full house"], p["Flush"] = 4, 4, 3
+ case "25/16/13/4/3/2":
+ p["Five of a kind"], p["Straight flush"], p["Four of a kind"] = 16, 13, 4
+ case "20/12/10/4/4/3":
+ p["Wild royal flush"], p["Five of a kind"], p["Straight flush"], p["Four of a kind"], p["Full house"], p["Flush"] = 20, 12, 10, 4, 4, 3
+ default:
+ return pokerSchedule{}, false
+ }
+ case "joker_poker":
+ if c.PayTable != "50/17/7/5/3" && c.PayTable != "50/16/7/5/3" && c.PayTable != "50/15/7/5/3" {
+ return pokerSchedule{}, false
+ }
+ name = "Joker Poker (Kings or Better)"
+ p = map[string]int{"Natural royal flush": 800, "Five of a kind": 200, "Wild royal flush": 100, "Straight flush": 50, "Four of a kind": 17, "Full house": 7, "Flush": 5, "Straight": 3, "Three of a kind": 2, "Two pair": 1, "Kings or better": 1}
+ if c.PayTable == "50/16/7/5/3" {
+ p["Four of a kind"] = 16
+ }
+ if c.PayTable == "50/15/7/5/3" {
+ p["Four of a kind"] = 15
+ }
+ default:
+ return pokerSchedule{}, false
+ }
+ return pokerSchedule{name + " / " + c.PayTable, p}, true
+}
+
+// switchTwo applies a full-house/flush pair to the schedule map and reports
+// whether the pay-table name was one of the listed editions.
+func switchTwo(p *map[string]int, payTable string, editions map[string][2]int) bool {
+ if edition, ok := editions[payTable]; ok {
+ (*p)["Full house"], (*p)["Flush"] = edition[0], edition[1]
+ return true
+ }
+ return false
+}
+
+func pokerRules(c VideoPokerConfig) string {
+ c = c.Normalize()
+ schedule, ok := pokerPayTable(c)
+ if !ok {
+ return "This cabinet is not configured with a playable game."
+ }
+ lines := []string{c.CabinetLabel() + " / " + schedule.name,
+ "One fresh deck per deal. bet 1-4 loads coins (up to five per hand);",
+ "bet max loads five. Bare bet repeats the previous selection. deal",
+ "charges the full wager and deals; numbers toggle holds; d draws.",
+ "denom 1|10|50 sets credits per coin. paytable shows this box again.",
+ "Payouts are total coins per hand (stake included), not profit odds.",
+ c.PairRule(),
+ }
+ if c.Feature != "" {
+ lines = append(lines, c.FeatureRules())
+ }
+ lines = append(lines, "Only the highest-paying category pays on each line.")
+ return strings.Join(lines, "\n")
+}
+
+// PairRule names the lowest paying hand family of the selected game.
+func (c VideoPokerConfig) PairRule() string {
+ c = c.Normalize()
+ switch c.Variant {
+ case "deuces_wild":
+ return "Every 2 is wild. Three of a kind is the lowest win; only the highest category pays."
+ case "joker_poker":
+ return "One joker is wild. A pair of kings or aces is the lowest win; a wild royal is not a natural royal."
+ case "bonus_poker", "bonus_poker_deluxe", "double_bonus", "double_double_bonus":
+ return "A pair of jacks or better is the lowest win; four of a kind carries bonus rates by rank."
+ }
+ return "A pair of jacks or better is the lowest win."
+}
+
+// ParsePokerDigits accepts the unordered digits of a hold selection: every
+// distinct digit toggles that card once per command; spacing and order do not matter.
+func ParsePokerDigits(input string) ([]int, bool) {
+ var digits []int
+ var seen [5]bool
+ for _, r := range input {
+ if unicode.IsSpace(r) {
+ continue
+ }
+ if r < '1' || r > '5' {
+ return nil, false
+ }
+ i := int(r - '1')
+ if !seen[i] {
+ digits = append(digits, i)
+ seen[i] = true
+ }
+ }
+ return digits, len(digits) > 0
+}
+
+func pokerRank(c card) int {
+ switch c.rank {
+ case "A":
+ return 14
+ case "K":
+ return 13
+ case "Q":
+ return 12
+ case "J":
+ return 11
+ }
+ return c.blackjackValue()
+}
+
+// evaluatePoker scores one five-card line against the resolved schedule. Wild
+// evaluation uses rank deficits instead of enumerating wild-card assignments.
+func evaluatePoker(cards [5]card, cfg VideoPokerConfig) (string, int) {
+ cfg = cfg.Normalize()
+ schedule, ok := pokerPayTable(cfg)
+ if !ok {
+ return "No win", 0
+ }
+ counts := [15]int{}
+ wild := 0
+ suit := ""
+ flush := true
+ for _, c := range cards {
+ r := pokerRank(c)
+ if cfg.Variant == "deuces_wild" && r == 2 || cfg.Variant == "joker_poker" && c.rank == "JK" {
+ wild++
+ continue
+ }
+ counts[r]++
+ if suit == "" {
+ suit = c.suit
+ } else if suit != c.suit {
+ flush = false
+ }
+ }
+ straight, royal := false, false
+ for high := 5; high <= 14; high++ {
+ fits := true
+ for r := 2; r <= 14; r++ {
+ if counts[r] == 0 {
+ continue
+ }
+ in := r >= high-4 && r <= high
+ if high == 5 && r == 14 {
+ in = true
+ }
+ if !in || counts[r] > 1 {
+ fits = false
+ break
+ }
+ }
+ if fits {
+ straight = true
+ if high == 14 {
+ royal = true
+ }
+ }
+ }
+ maxCount, quad, kicker, pairs := 0, 0, 0, 0
+ for r := 2; r <= 14; r++ {
+ if counts[r] > maxCount {
+ maxCount = counts[r]
+ }
+ if counts[r] == 4 {
+ quad = r
+ }
+ if counts[r] == 1 {
+ kicker = r
+ }
+ if counts[r] == 2 {
+ pairs++
+ }
+ }
+ var candidates []string
+ add := func(ok bool, k string) {
+ if ok {
+ candidates = append(candidates, k)
+ }
+ }
+ if cfg.Variant == "deuces_wild" || cfg.Variant == "joker_poker" {
+ add(flush && royal && wild == 0, "Natural royal flush")
+ add(cfg.Variant == "deuces_wild" && wild == 4, "Four deuces")
+ add(flush && royal && wild > 0, "Wild royal flush")
+ add(maxCount+wild >= 5, "Five of a kind")
+ add(maxCount+wild >= 4, "Four of a kind")
+ if cfg.Variant == "joker_poker" {
+ add(pairs == 2 || pairs == 1 && wild == 1, "Two pair")
+ add(counts[13]+wild >= 2 || counts[14]+wild >= 2, "Kings or better")
+ }
+ } else {
+ add(flush && royal, "Royal flush")
+ add(quad > 0, "Four of a kind")
+ add(quad == 14, "Four aces")
+ add(quad >= 2 && quad <= 4, "Four 2s-4s")
+ add(quad >= 5 && quad <= 13, "Four 5s-Ks")
+ add(quad == 14 && kicker >= 2 && kicker <= 4, "Four aces with 2-4 kicker")
+ add(quad >= 2 && quad <= 4 && (kicker == 14 || kicker >= 2 && kicker <= 4), "Four 2s-4s with A-4 kicker")
+ add(pairs == 2, "Two pair")
+ add(counts[11] == 2 || counts[12] == 2 || counts[13] == 2 || counts[14] == 2, "Jacks or better")
+ }
+ full := false
+ for a := 2; a <= 14; a++ {
+ for b := 2; b <= 14; b++ {
+ if a == b || counts[a] > 3 || counts[b] > 2 {
+ continue
+ }
+ if counts[a]+counts[b]+wild == 5 {
+ full = true
+ }
+ }
+ }
+ add(flush && straight, "Straight flush")
+ add(full, "Full house")
+ add(flush, "Flush")
+ add(straight, "Straight")
+ add(maxCount+wild >= 3, "Three of a kind")
+ best, pay := "No win", 0
+ for _, k := range candidates {
+ if schedule.pays[k] > pay {
+ best, pay = k, schedule.pays[k]
+ }
+ }
+ return best, pay
+}