From 0ba388bbe229dd1ccfcb0831d21a5542ae0935f1 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 1 Oct 2026 04:26:20 -0400 Subject: feat: video poker machines added to casino --- internal/casino/config_validate.go | 3 + internal/casino/registry.go | 14 + internal/casino/table_ux.go | 3 + internal/casino/types.go | 32 +- internal/casino/video_poker.go | 545 +++++++++++++++++++++++++ internal/casino/video_poker_bonus_test.go | 127 ++++++ internal/casino/video_poker_config.go | 218 ++++++++++ internal/casino/video_poker_lifecycle_test.go | 327 +++++++++++++++ internal/casino/video_poker_profiles.go | 171 ++++++++ internal/casino/video_poker_regression_test.go | 135 ++++++ internal/casino/video_poker_rules.go | 307 ++++++++++++++ internal/casino/video_poker_test.go | 141 +++++++ 12 files changed, 2008 insertions(+), 15 deletions(-) create mode 100644 internal/casino/video_poker.go create mode 100644 internal/casino/video_poker_bonus_test.go create mode 100644 internal/casino/video_poker_config.go create mode 100644 internal/casino/video_poker_lifecycle_test.go create mode 100644 internal/casino/video_poker_profiles.go create mode 100644 internal/casino/video_poker_regression_test.go create mode 100644 internal/casino/video_poker_rules.go create mode 100644 internal/casino/video_poker_test.go (limited to 'internal/casino') diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index a35f039..74dc0a4 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -358,6 +358,9 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { } for _, c := range tables { check("table", c.ID, c.Validate()) + if c.Game == GameVideoPoker { + continue + } // Cabinets are addressed by unique ID, not game name. checkAddressable(strings.ToLower(string(c.Game)), c.ID) } // A machine addresses by its variant when one is declared; a bare game diff --git a/internal/casino/registry.go b/internal/casino/registry.go index 049d9f1..dfce704 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -41,6 +41,20 @@ var tableGames map[GameName]tableGameDef func init() { tableGames = map[GameName]tableGameDef{ + GameVideoPoker: { + validate: func(c TableConfig) error { + if c.MaxPlayers > 1 { + return fmt.Errorf("video poker cabinets have one seat") + } + return c.VideoPoker.Validate() + }, + normalize: func(c TableConfig) TableConfig { c.VideoPoker = c.VideoPoker.Normalize(); c.MaxPlayers = 1; return c }, + ruleBlockName: "video_poker", + hasRuleBlock: func(c TableConfig) bool { return c.VideoPoker != (VideoPokerConfig{}) }, + new: newVideoPokerTable, + ruleText: func(c TableConfig) string { return pokerRules(c.VideoPoker) }, + helpTopic: func(c TableConfig) string { return pokerHelpTopic(c.VideoPoker) }, + }, GameCraps: { validate: validateCraps, normalize: func(c TableConfig) TableConfig { c.Craps = c.Craps.Normalize(); return c }, diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 768c041..7c3ab36 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -40,6 +40,9 @@ func withTableHints(commands []MenuCommand, wagerRows []WagerRow) []MenuCommand func (c TableConfig) RulesText() string { return rulesTextOnTable(c) } func (c TableConfig) Label() string { + if c.Game == GameVideoPoker { + return c.VideoPoker.CabinetLabel() + } if c.Game == GameRoulette { label := "Roulette / Single zero" if c.Roulette.DoubleZero { diff --git a/internal/casino/types.go b/internal/casino/types.go index ecded85..b863ee7 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -14,11 +14,12 @@ func betRangeText(minBet, maxBet int) string { type GameName string const ( - GameSlots GameName = "slots" - GameBlackjack GameName = "blackjack" - GameBaccarat GameName = "baccarat" - GameRoulette GameName = "roulette" - GameCraps GameName = "craps" + GameVideoPoker GameName = "video_poker" + GameSlots GameName = "slots" + GameBlackjack GameName = "blackjack" + GameBaccarat GameName = "baccarat" + GameRoulette GameName = "roulette" + GameCraps GameName = "craps" ) type BlackjackConfig struct { @@ -121,16 +122,17 @@ func (c BaccaratConfig) Normalize() BaccaratConfig { } type TableConfig struct { - ID string `yaml:"id"` - Game GameName `yaml:"game"` - MaxPlayers int `yaml:"max_players,omitempty"` - BettingTicks int `yaml:"betting_ticks,omitempty"` - MinBet int `yaml:"min_bet,omitempty"` - MaxBet int `yaml:"max_bet,omitempty"` - Blackjack BlackjackConfig `yaml:"blackjack,omitempty"` - Baccarat BaccaratConfig `yaml:"baccarat,omitempty"` - Roulette RouletteConfig `yaml:"roulette,omitempty"` - Craps CrapsConfig `yaml:"craps,omitempty"` + ID string `yaml:"id"` + Game GameName `yaml:"game"` + MaxPlayers int `yaml:"max_players,omitempty"` + BettingTicks int `yaml:"betting_ticks,omitempty"` + MinBet int `yaml:"min_bet,omitempty"` + MaxBet int `yaml:"max_bet,omitempty"` + Blackjack BlackjackConfig `yaml:"blackjack,omitempty"` + Baccarat BaccaratConfig `yaml:"baccarat,omitempty"` + Roulette RouletteConfig `yaml:"roulette,omitempty"` + Craps CrapsConfig `yaml:"craps,omitempty"` + VideoPoker VideoPokerConfig `yaml:"video_poker,omitempty"` } type MachineConfig struct { diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go new file mode 100644 index 0000000..485d849 --- /dev/null +++ b/internal/casino/video_poker.go @@ -0,0 +1,545 @@ +package casino + +import ( + "fmt" + "math/rand" + "strconv" + "strings" + "sync" +) + +// PokerWallet atomically settles the character and shared machine state. +// Ordinary Wallet implementations remain usable for in-memory simulations. +type PokerWallet interface { + Wallet + LoadPokerBanks(roomID int, cabinetID string) (map[string][]int, error) + PayPoker(amount int, stakeID string, roomID int, cabinetID string, banks map[string][]int) error +} + +type PokerLine struct { + Cards []CardView + Category string + Multiplier, Coins int +} + +type VideoPokerSnapshot struct { + Config VideoPokerConfig + Variant string + ShowPayTable bool + Cards []CardView + Held [5]bool + Drawing bool + Result string + NoWin bool + Coins int + Denomination int + Hands int + FeatureOn bool + Multiplier int + Dice [2]int + Current, Next []int + Lines []PokerLine +} + +func (*VideoPokerSnapshot) snapshot() {} + +type pokerPlayer struct { + config VideoPokerConfig + variant string + cards [5]card + remaining []card + held [5]bool + wager Wager + wallet Wallet + active, drawn, disconnected bool + loaded, lastBet, denomination int + hands int + multiplier int + featureOn bool + dice [2]int + current, next []int + lines []PokerLine + payout int + result, bankKey string +} + +// cost is the full currency price of dealing the loaded wager. +func (p *pokerPlayer) cost() int { + coins := p.loaded + if p.featureOn && coins == 5 { + coins += p.config.FeatureFeeCoins() + } + return coins * p.denomination * p.hands +} + +// Video poker uses the shared table lifecycle adapter (wallet, room routing, +// disconnects, eviction) but is a single-occupant cabinet, never a table. +type videoPokerTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + banks map[string][]int + banksLoaded bool + settings VideoPokerConfig + players map[string]*pokerPlayer +} + +func newVideoPokerTable(room int, c TableConfig, rng *rand.Rand) tableGame { + t := &videoPokerTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, banks: map[string][]int{}, players: map[string]*pokerPlayer{}} + t.settings = c.VideoPoker.Normalize() + return t +} + +func (t *videoPokerTable) base() *tableBase { return t.tableBase } +func (t *videoPokerTable) idle() bool { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.players) == 0 +} +func (t *videoPokerTable) hasParticipant(name string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.players[name] != nil +} +func (t *videoPokerTable) overview() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.overviewLocked() +} +func (t *videoPokerTable) overviewLocked() string { + return t.settings.CabinetLabel() + " | denom 1/10/50 | one player | 1-5 coins per hand" +} +func (t *videoPokerTable) wagers(string) []WagerRow { return nil } +func (t *videoPokerTable) hints(name string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + return t.hintsLocked(name) +} +func (t *videoPokerTable) hintsLocked(name string) []MenuCommand { + if t.players[name] == nil { + return nil + } + commands := []MenuCommand{{Command: "bet [1-4|max]", Desc: "Load coins (up to five per hand)"}, {Command: "deal", Desc: "Pay and deal the loaded wager"}} + if p := t.players[name]; p != nil && p.active { + commands = []MenuCommand{{Command: "1 3 5", Desc: "Toggle held cards (531 also works)"}, {Command: "draw", Desc: "Replace unheld cards on every line"}} + if p.drawn { + commands = []MenuCommand{{Command: "draw", Desc: "Retry payment of the same final hands"}} + } + } else { + commands = append(commands, + MenuCommand{Command: "game ", Desc: t.settings.GameChoices()}, + MenuCommand{Command: "payout low|normal|full", Desc: "Set the cabinet-wide pay quality"}, + MenuCommand{Command: "denom <1|10|50>", Desc: "Credits per coin"}, + MenuCommand{Command: "hands ", Desc: "Supported hand counts are announced by hands"}, + ) + if cabinetFeature(t.settings.Cabinet) != "" { + commands = append(commands, MenuCommand{Command: "feature on|off", Desc: "Toggle the extra-cost bonus"}) + } + commands = append(commands, MenuCommand{Command: "paytable", Desc: "Show the pay table"}) + } + return append(commands, MenuCommand{Command: "rules", Desc: "This cabinet's guide"}, MenuCommand{Command: "stop", Desc: "Leave the cabinet"}) +} + +// gameConfig builds the player's resolved config for a chosen game under the +// cabinet's current payout tier. +func (t *videoPokerTable) gameConfig(variant string) (VideoPokerConfig, bool) { + for _, allowed := range t.settings.VariantOptions() { + if allowed != variant { + continue + } + cfg := VideoPokerConfig{Cabinet: t.settings.Cabinet, Payout: t.settings.Payout, Variant: variant} + cfg = cfg.Normalize() + if _, ok := pokerPayTable(cfg); ok { + return cfg, true + } + } + return VideoPokerConfig{}, false +} + +func (t *videoPokerTable) status(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(name, true) +} +func (t *videoPokerTable) statusLocked(name string, showTable bool) []Event { + p := t.players[name] + if p == nil { + return []Event{t.private(name, t.overviewLocked())} + } + coins := p.loaded + if p.active || len(p.lines) > 0 { + coins = p.lastBet + } + var current, next []int + if len(p.current) > 0 { + current = append([]int(nil), p.current...) + } + if len(p.next) > 0 { + next = append([]int(nil), p.next...) + } else if !p.active && p.config.Feature == PokerUltimateX { + next = append([]int(nil), t.banks[t.multiplierKey(p)]...) + } + snap := &VideoPokerSnapshot{Config: p.config, Variant: p.variant, ShowPayTable: showTable, Held: p.held, Drawing: p.active && !p.drawn, Result: p.result, NoWin: p.payout == 0 && p.drawn, Coins: coins, Denomination: p.denomination, Hands: p.hands, FeatureOn: p.featureOn, Multiplier: p.multiplier, Dice: p.dice, Current: current, Next: next} + if p.remaining != nil { + for _, c := range p.cards { + snap.Cards = append(snap.Cards, CardView{Rank: c.rank, Suit: c.suit}) + } + } + for _, line := range p.lines { + line.Cards = append([]CardView(nil), line.Cards...) + snap.Lines = append(snap.Lines, line) + } + e := t.private(name, t.settings.CabinetLabel()+" — "+p.config.GameLabel()) + e.Color = "casino_menu" + e.Snapshot = snap + if !p.active && p.loaded > 0 { + e.Message += fmt.Sprintf(" — %d loaded; deal costs %d credits", p.loaded, p.cost()) + } + e.Menu = &MenuView{Heading: e.Message, ShowBalance: true, Commands: t.hintsLocked(name)} + return []Event{e} +} + +func (t *videoPokerTable) join(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.players[name] != nil { + return t.statusLocked(name, true) + } + if len(t.players) > 0 { + return []Event{t.private(name, "This video poker cabinet is occupied.")} + } + cfg := t.settings + cfg.PayTable = "" + cfg = cfg.Normalize() + t.players[name] = &pokerPlayer{config: cfg, variant: cfg.Variant, denomination: 1, hands: CabinetHandCounts(cfg.Cabinet, cfg.Variant)[0], featureOn: cfg.Feature != "", multiplier: 1} + return t.statusLocked(name, true) +} +func (t *videoPokerTable) leave(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.players[name]; p != nil && p.active { + return []Event{t.private(name, "Finish your hand with draw before leaving.")} + } + delete(t.players, name) + return []Event{t.private(name, "You leave video poker. Showing multipliers stay with the machine.")} +} + +func (t *videoPokerTable) multiplierKey(p *pokerPlayer) string { + return fmt.Sprintf("%s/%s/%d/%d", p.variant, t.settings.Payout, p.denomination, p.hands) +} + +func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players[name] + if p == nil { + return nil + } + if p.config.Feature == PokerUltimateX && !t.banksLoaded { + if wallet, ok := w.(PokerWallet); ok { + banks, err := wallet.LoadPokerBanks(t.RoomID, t.Config.ID) + if err != nil { + return []Event{t.private(name, "Machine bank unavailable; try again later: "+err.Error())} + } + if banks != nil { + t.banks = banks + } + } + t.banksLoaded = true + } + if spot == "view" && !p.active { + p.wallet = w + return t.statusLocked(name, true) + } + if spot != "deal" { + return []Event{t.private(name, "Load coins with bet, then deal.")} + } + if p.active { + return []Event{t.private(name, "Finish this hand with draw first.")} + } + if p.loaded == 0 { + return []Event{t.private(name, "Load coins first: bet [1-4|max].")} + } + if p.featureOn && p.loaded != 5 { + return []Event{t.private(name, "The feature requires five wager coins per hand. Use bet max or feature off.")} + } + if w == nil { + return []Event{t.private(name, "Wallet unavailable.")} + } + stake, err := w.Wager(p.cost()) + if err != nil { + return []Event{t.private(name, "The wager could not be placed: "+err.Error())} + } + p.wallet, p.wager, p.lastBet = w, stake, p.loaded + p.bankKey = t.multiplierKey(p) + p.loaded = 0 + current := make([]int, p.hands) + for i := range current { + current[i] = 1 + } + if p.config.Feature == PokerUltimateX { + bank := t.banks[p.bankKey] + for i := range current { + if i < len(bank) && bank[i] >= 1 && bank[i] <= 12 { + current[i] = bank[i] + } + } + } + p.current = current + p.next = nil + p.lines = nil + p.payout = 0 + p.multiplier, p.dice = 1, [2]int{} + if p.featureOn { + p.multiplier, p.dice = pokerRandomMultiplier(p.config.Feature, t.rng) + } + deck := newShoe(1, 0, t.rng) + if p.variant == "joker_poker" { + deck.cards = append(deck.cards, card{rank: "JK", suit: "joker"}) + t.rng.Shuffle(len(deck.cards), func(i, j int) { deck.cards[i], deck.cards[j] = deck.cards[j], deck.cards[i] }) + } + for i := range p.cards { + p.cards[i] = deck.draw() + } + p.remaining = append([]card(nil), deck.cards...) + p.active, p.drawn, p.held, p.result = true, false, [5]bool{}, "" + return t.statusLocked(name, false) +} +func (t *videoPokerTable) rebet(name string) (string, int) { + return "", 0 // Coin loading is session state handled by the cabinet's own commands. +} +func (t *videoPokerTable) action(name, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players[name] + if p == nil { + return nil + } + fields := strings.Fields(action) + if len(fields) == 0 { + return nil + } + cmd := fields[0] + if cmd == "paytable" && len(fields) == 1 { + return t.statusLocked(name, true) + } + if p.active { + if action == "draw" { + return t.drawLocked(name, p) + } + if p.drawn { + return []Event{t.private(name, "Settlement is pending; draw retries payment without drawing again.")} + } + digits, ok := ParsePokerDigits(strings.TrimPrefix(action, "hold ")) + if !ok { + return []Event{t.private(name, "Finish this hand first. Toggle holds with 531, then draw.")} + } + for _, d := range digits { + p.held[d] = !p.held[d] + } + return t.statusLocked(name, false) + } + switch cmd { + case "coins": + if len(fields) > 2 { + break + } + n := 0 + switch { + case len(fields) == 1: + // Bare repeat: the previous wager, or five initially. + n = p.lastBet + if n == 0 { + n = 5 + } + case fields[1] == "max": + n = 5 + default: + add, err := strconv.Atoi(fields[1]) + if err != nil || add < 1 || add > 4 { + return []Event{t.private(name, "Use bet [1-4|max].")} + } + n = min(5, p.loaded+add) + } + p.loaded, p.lastBet = n, n + p.lines, p.remaining, p.result, p.current, p.next = nil, nil, "", nil, nil + p.multiplier, p.dice = 1, [2]int{} + return t.statusLocked(name, len(fields) > 1) + case "denom", "denomination": + if len(fields) != 2 { + break + } + n, err := strconv.Atoi(fields[1]) + if err != nil || n != 1 && n != 10 && n != 50 { + break + } + p.denomination = n + p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + return t.statusLocked(name, true) + case "hands": + if len(fields) != 2 { + return []Event{t.private(name, fmt.Sprintf("Supported hands: %v", CabinetHandCounts(p.config.Cabinet, p.variant)))} + } + n, err := strconv.Atoi(fields[1]) + if err != nil { + break + } + for _, allowed := range CabinetHandCounts(t.settings.Cabinet, p.variant) { + if n == allowed { + p.hands = n + p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + return t.statusLocked(name, true) + } + } + case "game": + if len(fields) < 2 { + return []Event{t.private(name, "Games: "+p.config.GameChoices())} + } + variant, ok := cabinetVariantAliases[strings.ToLower(strings.Join(fields[1:], " "))] + if !ok { + break + } + cfg, ok := t.gameConfig(variant) + if !ok { + return []Event{t.private(name, "That game is not available on this cabinet. Games: "+t.settings.GameChoices())} + } + p.config, p.variant, p.hands = cfg, variant, CabinetHandCounts(cfg.Cabinet, variant)[0] + p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + return t.statusLocked(name, true) + case "payout": + if len(fields) != 2 { + break + } + tier := strings.ToLower(fields[1]) + if tier != "low" && tier != "normal" && tier != "full" { + break + } + cfg := (VideoPokerConfig{Cabinet: p.config.Cabinet, Payout: tier, Variant: p.variant}).Normalize() + if err := cfg.Validate(); err != nil { + return []Event{t.private(name, err.Error())} + } + t.settings.Payout = tier + t.settings.PayTable = "" + t.settings = t.settings.Normalize() + p.config = cfg + p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + return t.statusLocked(name, true) + case "feature": + if len(fields) != 2 || cabinetFeature(t.settings.Cabinet) == "" || fields[1] != "on" && fields[1] != "off" { + break + } + p.featureOn = fields[1] == "on" + return t.statusLocked(name, false) + } + return []Event{t.private(name, "Use bet [1-4|max], deal, game , payout low|normal|full, denom 1|10|50, hands , paytable, or feature on|off on a bonus cabinet.")} +} + +func (t *videoPokerTable) drawLocked(name string, p *pokerPlayer) []Event { + if !p.drawn { + p.next = make([]int, p.hands) + for line := 0; line < p.hands; line++ { + // Each line draws independently from its OWN copy of the undealt + // deck; all original cards, including discards, are excluded. + deck := append([]card(nil), p.remaining...) + t.rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) + cards := p.cards + draw := 0 + for i := range cards { + if !p.held[i] { + cards[i] = deck[draw] + draw++ + } + } + category, rate := evaluatePoker(cards, p.config) + mult := p.multiplier * p.current[line] + coins := pokerCoinPay(category, rate, p.lastBet) * mult + view := PokerLine{Category: category, Multiplier: mult, Coins: coins} + for _, c := range cards { + view.Cards = append(view.Cards, CardView{Rank: c.rank, Suit: c.suit}) + } + p.lines = append(p.lines, view) + p.payout += coins * p.denomination + p.next[line] = 1 + if p.featureOn && p.config.Feature == PokerUltimateX { + p.next[line] = p.config.NextAward(p.hands, category) + } + } + p.drawn = true + } + // Prepare a new bank without mutating the visible machine. Failed writes + // leave both entitlement and the frozen final hands available for retry. + banks := make(map[string][]int, len(t.banks)) + for key, values := range t.banks { + banks[key] = append([]int(nil), values...) + } + if p.config.Feature == PokerUltimateX { + delete(banks, p.bankKey) + for _, n := range p.next { + if n > 1 { + banks[p.bankKey] = append([]int(nil), p.next...) + break + } + } + } + var err error + if wallet, ok := p.wallet.(PokerWallet); ok && p.config.Feature == PokerUltimateX { + err = wallet.PayPoker(p.payout, p.wager.ID, t.RoomID, t.Config.ID, banks) + } else { + err = p.wallet.PayCredits(p.payout, p.wager.ID) + } + if err != nil { + p.result = "Payment pending; draw retries the SAME final hands" + return t.statusLocked(name, false) + } + t.banks = banks + p.active = false + p.result = fmt.Sprintf("Total return %d coins = %d credits", p.payout/p.denomination, p.payout) + events := t.statusLocked(name, false) + if p.disconnected { + delete(t.players, name) + } + return events +} +func (t *videoPokerTable) markDisconnected(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players[name] + if p == nil { + return nil + } + p.disconnected = true + if p.active { + return t.drawLocked(name, p) + } + delete(t.players, name) + return nil +} +func (t *videoPokerTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + for name, p := range t.players { + if p.disconnected && p.active { + events = append(events, t.drawLocked(name, p)...) + } + } + return events +} +func (t *videoPokerTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + for name, p := range t.players { + if p.wallet != nil { + _, _, err := p.wallet.RefundOutstanding() + if err != nil { + events = append(events, t.private(name, "Shutdown refund remains pending for startup recovery.")) + } + } + p.active = false + p.remaining = nil + p.lines = nil + p.loaded = 0 + } + return events +} diff --git a/internal/casino/video_poker_bonus_test.go b/internal/casino/video_poker_bonus_test.go new file mode 100644 index 0000000..6fd06b2 --- /dev/null +++ b/internal/casino/video_poker_bonus_test.go @@ -0,0 +1,127 @@ +package casino + +import "testing" + +func TestPokerUltimateXPerLineAndMachineBanks(t *testing.T) { + table, w, bank := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetUXMulti}) + save := bank + table.action("alice", "hands 3") + p := table.players["alice"] + key := table.multiplierKey(p) + table.banks[key] = []int{2, 3, 4} + // A bank at denomination 1 does not carry into denomination 10. + table.action("alice", "denom 10") + pokerDeal(t, table, w) + for _, m := range p.current { + if m != 1 { + t.Fatal("denomination imported a saved multiplier") + } + } + table.action("alice", "draw") + // Restore denomination 1: the saved bank applies again. + table.action("alice", "denom 1") + pokerDeal(t, table, w) + for i, m := range []int{2, 3, 4} { + if p.current[i] != m { + t.Fatal("same-denomination bank lost") + } + } + p.cards = pokerHand("Ah 3h 6h 9h Jh") + table.action("alice", "12345") + before := w.paid + table.action("alice", "draw") + if w.paid-before != 270 { + t.Fatalf("expected 5 coins * 6 flush * (2+3+4), got %d", w.paid-before) + } + for i, line := range p.lines { + if line.Multiplier != i+2 || p.next[i] != 11 || table.banks[key][i] != 11 { + t.Fatal("current multiplier compounded into the next award") + } + } + if save.writes == 0 { + t.Fatal("the engine bank sink was never invoked") + } + // Feature off: no fee, no new multipliers, saved ones still pay. + table.action("alice", "feature off") + pokerDeal(t, table, w) + if p.wager.Total != 15 { + t.Fatal("cashout charged the feature") + } + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + before = w.paid + table.action("alice", "draw") + if w.paid-before != 132000 { + t.Fatalf("cashout returned %d", w.paid-before) + } + // A five-coin hand consumes the multipliers it paid with: the new bank + // is 1x because the feature is off. + for _, m := range table.banks[key] { + if m != 1 { + t.Fatal("spent bank was not cleared") + } + } + // The next five-coin hand plays at plain rates. + pokerDeal(t, table, w) + for _, m := range p.current { + if m != 1 { + t.Fatal("consumed bank applied twice") + } + } + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + before = w.paid + table.action("alice", "draw") + if w.paid-before != 12000 { + t.Fatalf("plain royal returned %d", w.paid-before) + } +} +func TestPokerRandomFeaturePayoutExcludesFee(t *testing.T) { + for _, feature := range []string{PokerSuperTimesPay, PokerHotRoll} { + cabinet := CabinetSuperTimesPay + if feature == PokerHotRoll { + cabinet = CabinetHotRoll + } + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: cabinet}) + table.action("alice", "hands 3") + table.action("alice", "denom 10") + pokerDeal(t, table, w) + p := table.players["alice"] + p.multiplier = 8 // pin the rolled bonus + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + table.action("alice", "draw") + if w.paid != 3*4000*8*10 { + t.Fatalf("%s multiplied the feature fee into the payout: %d", feature, w.paid) + } + for _, line := range p.lines { + if line.Multiplier != 8 || line.Coins != 32000 { + t.Fatal("the bonus was not shared across all lines") + } + } + } +} +func TestPokerDisconnectedFailedSettlementRetriesWithoutRedraw(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + p := table.players["alice"] + w.fail = true + table.markDisconnected("alice") + if !table.hasParticipant("alice") || !p.drawn || !p.active { + t.Fatal("failed disconnected stake was dropped") + } + final := p.lines[0].Cards + table.tick() + for i, c := range final { + if p.lines[0].Cards[i] != c { + t.Fatal("retry redrew cards") + } + } + w.fail = false + table.tick() + paid := w.paid + table.tick() + if !table.idle() || len(w.open) != 0 || w.paid != paid { + t.Fatal("retry did not settle exactly once") + } +} diff --git a/internal/casino/video_poker_config.go b/internal/casino/video_poker_config.go new file mode 100644 index 0000000..918fed3 --- /dev/null +++ b/internal/casino/video_poker_config.go @@ -0,0 +1,218 @@ +package casino + +import ( + "fmt" + "strings" +) + +// Cabinet and Payout are authored settings. The remaining fields identify the +// selected game and its resolved schedule; schedules are never separate games. +type VideoPokerConfig struct { + Cabinet string `yaml:"cabinet,omitempty"` + Payout string `yaml:"payout,omitempty"` + Variant string `yaml:"-"` + PayTable string `yaml:"-"` + Feature string `yaml:"-"` +} + +const ( + CabinetClassic = "classic" + CabinetUXSingle = "ultimate_x_single" + CabinetUXMulti = "ultimate_x_multi" + CabinetSuperTimesPay = "super_times_pay" + CabinetHotRoll = "hot_roll" +) + +func cabinetFeature(c string) string { + switch c { + case CabinetUXSingle, CabinetUXMulti: + return PokerUltimateX + case CabinetSuperTimesPay: + return PokerSuperTimesPay + case CabinetHotRoll: + return PokerHotRoll + } + return "" +} + +var cabinetVariantAliases = map[string]string{ + "jacks": "jacks_or_better", "jacks_or_better": "jacks_or_better", "jacks or better": "jacks_or_better", + "bonus": "bonus_poker", "bonus_poker": "bonus_poker", "bonus poker": "bonus_poker", + "deluxe": "bonus_poker_deluxe", "bonus_poker_deluxe": "bonus_poker_deluxe", "bonus poker deluxe": "bonus_poker_deluxe", + "double": "double_bonus", "double_bonus": "double_bonus", "double bonus": "double_bonus", + "ddb": "double_double_bonus", "double_double_bonus": "double_double_bonus", "double double bonus": "double_double_bonus", + "deuces": "deuces_wild", "deuces_wild": "deuces_wild", "deuces wild": "deuces_wild", + "joker": "joker_poker", "joker_poker": "joker_poker", "joker poker": "joker_poker", +} +var cabinetGames = map[string][]string{ + CabinetClassic: {"jacks_or_better", "bonus_poker", "bonus_poker_deluxe", "double_bonus", "double_double_bonus", "deuces_wild", "joker_poker"}, + CabinetUXSingle: {"jacks_or_better", "bonus_poker", "bonus_poker_deluxe", "double_bonus", "double_double_bonus", "deuces_wild", "joker_poker"}, + // Bonus Poker has only two verified multi-line schedules; omit rather than + // inventing a third quality tier. + CabinetUXMulti: {"jacks_or_better", "bonus_poker_deluxe", "double_bonus", "double_double_bonus", "deuces_wild", "joker_poker"}, + CabinetSuperTimesPay: {"jacks_or_better", "bonus_poker", "double_bonus", "double_double_bonus", "deuces_wild"}, + CabinetHotRoll: {"jacks_or_better", "double_double_bonus"}, +} + +// Each tuple is low, normal, full. These are named published schedules, not +// interpolated payouts. Full means the highest offered on this cabinet, not +// a promise of identical RTP across games. Sources: building_guide/video_poker.md. +var cabinetSchedules = map[string]map[string][3]string{ + CabinetClassic: { + "jacks_or_better": {"7/5", "8/5", "9/6"}, + "bonus_poker": {"6/5", "7/5", "8/5"}, + "bonus_poker_deluxe": {"8/5", "9/5", "9/6"}, + "double_bonus": {"9/6/4", "9/7", "10/7"}, + "double_double_bonus": {"8/5", "9/6", "10/6"}, + "deuces_wild": {"20/12/10/4/4/3", "25/15/9/4/4/3", "25/15/9/5/3/2"}, + "joker_poker": {"50/15/7/5/3", "50/16/7/5/3", "50/17/7/5/3"}, + }, + CabinetUXSingle: { + "jacks_or_better": {"8/5", "8/6", "9/6"}, + "bonus_poker": {"6/5", "7/5", "8/5"}, + "bonus_poker_deluxe": {"8/5", "8/6", "9/6"}, + "double_bonus": {"9/6/4", "9/6/5", "9/7"}, + "double_double_bonus": {"8/5", "9/5", "9/6"}, + "deuces_wild": {"25/16/13/4/3/2", "20/12/10/4/4/3", "25/15/9/4/4/3"}, + "joker_poker": {"50/15/7/5/3", "50/16/7/5/3", "50/17/7/5/3"}, + }, + CabinetUXMulti: { + "jacks_or_better": {"7/5", "8/5", "8/6"}, + "bonus_poker_deluxe": {"7/5", "8/5", "8/6"}, + "double_bonus": {"9/5/4", "9/6/4", "9/6/5"}, + "double_double_bonus": {"8/5", "9/5", "9/6"}, + "deuces_wild": {"25/16/13/4/3/2", "20/12/10/4/4/3", "25/15/9/4/4/3"}, + "joker_poker": {"50/15/7/5/3", "50/16/7/5/3", "50/17/7/5/3"}, + }, + CabinetSuperTimesPay: { + "jacks_or_better": {"7/5", "8/5", "9/6"}, + "bonus_poker": {"6/5", "7/5", "8/5"}, + "double_bonus": {"9/6/4", "9/6/5", "9/7"}, + "double_double_bonus": {"8/5", "8/6", "9/6"}, + "deuces_wild": {"20/12/10/4/4/3", "25/15/9/4/4/3", "25/15/9/5/3/2"}, + }, + CabinetHotRoll: { + "jacks_or_better": {"8/5", "9/5", "9/6"}, + "double_double_bonus": {"8/5", "9/5", "9/6"}, + }, +} + +func CabinetHandCounts(cabinet, variant string) []int { + switch cabinet { + case CabinetUXSingle: + return []int{1} + case CabinetUXMulti, CabinetHotRoll: + return []int{3, 5, 10} + case CabinetSuperTimesPay: + return []int{1, 3, 5, 10} + } + if variant == "jacks_or_better" { + return []int{1, 3, 5, 10, 25, 50, 100} + } + return []int{1} +} +func variantTitle(v string) string { + switch v { + case "jacks_or_better": + return "Jacks or Better" + case "bonus_poker": + return "Bonus Poker" + case "bonus_poker_deluxe": + return "Bonus Poker Deluxe" + case "double_bonus": + return "Double Bonus" + case "double_double_bonus": + return "Double Double Bonus" + case "deuces_wild": + return "Deuces Wild" + case "joker_poker": + return "Joker Poker (Kings or Better)" + } + return v +} +func (c VideoPokerConfig) CabinetLabel() string { + c = c.Normalize() + label := "Classic Video Poker" + switch c.Cabinet { + case CabinetUXSingle: + label = "Ultimate X Single Line" + case CabinetUXMulti: + label = "Ultimate X Multi-Hand" + case CabinetSuperTimesPay: + label = "Super Times Pay" + case CabinetHotRoll: + label = "Hot Roll" + } + return label + " / " + c.Payout + " pay" +} +func (c VideoPokerConfig) Normalize() VideoPokerConfig { + if c.Cabinet == "" { + c.Cabinet = CabinetClassic + } + if c.Payout == "" { + c.Payout = "full" + } + if c.Variant == "" { + c.Variant = "jacks_or_better" + } + if alias, ok := cabinetVariantAliases[c.Variant]; ok { + c.Variant = alias + } + c.Feature = cabinetFeature(c.Cabinet) + ix := -1 + switch c.Payout { + case "low": + ix = 0 + case "normal": + ix = 1 + case "full": + ix = 2 + } + // Explicit schedules are used by evaluator tests. Authored configurations + // and game selection always resolve from the cabinet-wide tier. + if c.PayTable == "" && ix >= 0 { + if schedules, ok := cabinetSchedules[c.Cabinet][c.Variant]; ok { + c.PayTable = schedules[ix] + } + } + return c +} +func (c VideoPokerConfig) Validate() error { + rawFeature := c.Feature + c = c.Normalize() + if _, ok := cabinetGames[c.Cabinet]; !ok { + return fmt.Errorf("unknown video poker cabinet %q", c.Cabinet) + } + if rawFeature != "" && rawFeature != c.Feature { + return fmt.Errorf("video poker feature does not match cabinet") + } + if c.Payout != "low" && c.Payout != "normal" && c.Payout != "full" { + return fmt.Errorf("video poker payout must be low, normal or full") + } + schedules, ok := cabinetSchedules[c.Cabinet][c.Variant] + if !ok { + return fmt.Errorf("unsupported game %q on %s", c.Variant, c.Cabinet) + } + expected := (VideoPokerConfig{Cabinet: c.Cabinet, Payout: c.Payout, Variant: c.Variant}).Normalize() + if c.PayTable != expected.PayTable { + return fmt.Errorf("schedule %q does not match cabinet tier (%v)", c.PayTable, schedules) + } + if _, ok := pokerPayTable(c); !ok { + return fmt.Errorf("unsupported schedule %q", c.PayTable) + } + return nil +} +func (c VideoPokerConfig) VariantOptions() []string { + return append([]string(nil), cabinetGames[c.Normalize().Cabinet]...) +} +func (c VideoPokerConfig) GameLabel() string { + c = c.Normalize() + return variantTitle(c.Variant) + " / " + c.PayTable +} +func (c VideoPokerConfig) GameChoices() string { return strings.Join(c.VariantOptions(), ", ") } + +type PokerPayRow struct { + Name string + Coins [5]int + NextAward int +} diff --git a/internal/casino/video_poker_lifecycle_test.go b/internal/casino/video_poker_lifecycle_test.go new file mode 100644 index 0000000..3107a93 --- /dev/null +++ b/internal/casino/video_poker_lifecycle_test.go @@ -0,0 +1,327 @@ +package casino + +import ( + "math/rand" + "reflect" + "strings" + "testing" +) + +// pokerTestWallet models atomic settlement failure without filesystem I/O. +type pokerTestWallet struct { + testWallet + fail bool + bank *pokerTestBank +} + +func (w *pokerTestWallet) LoadPokerBanks(int, string) (map[string][]int, error) { return nil, nil } +func (w *pokerTestWallet) PayPoker(n int, id string, room int, cabinet string, banks map[string][]int) error { + if err := w.PayCredits(n, id); err != nil { + return err + } + return w.bank.save(room, cabinet, banks) +} + +func (w *pokerTestWallet) PayCredits(n int, ids ...string) error { + if w.fail { + return ErrWalletPersistence + } + return w.testWallet.PayCredits(n, ids...) +} + +func (e Event) MessageStarts(prefix string) bool { return strings.HasPrefix(e.Message, prefix) } + +type pokerTestBank struct{ writes int } + +func (b *pokerTestBank) save(int, string, map[string][]int) error { b.writes++; return nil } + +func pokerTestCabinet(t *testing.T, cfg VideoPokerConfig) (*videoPokerTable, *pokerTestWallet, *pokerTestBank) { + t.Helper() + tableCfg := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: cfg} + if err := tableCfg.Validate(); err != nil { + t.Fatal(err) + } + table := newVideoPokerTable(1, tableCfg.Normalize(), rand.New(rand.NewSource(1))).(*videoPokerTable) + w := &pokerTestWallet{testWallet: testWallet{credits: 10000000}} + bank := &pokerTestBank{} + w.bank = bank + table.join("alice") + return table, w, bank +} +func pokerDeal(t *testing.T, table *videoPokerTable, w Wallet) { + t.Helper() + table.action("alice", "coins max") + table.bet("alice", "deal", 0, w) + if !table.players["alice"].active { + t.Fatal("deal failed") + } +} +func TestPokerCabinetOccupancyAndHints(t *testing.T) { + table, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + table.join("bob") + if table.hasParticipant("bob") { + t.Fatal("cabinet had two occupants") + } + if table.hints("bob") != nil { + t.Fatal("non-occupant saw hints") + } + for _, h := range table.hints("alice") { + if h.Command == "list" { + t.Fatal("irrelevant list hint") + } + } + if table.overview() == "" { + t.Fatal("empty overview") + } +} +func TestPokerCoinsDenominationAndRoyal(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + p := table.players["alice"] + for want := 1; want <= 2; want++ { + events := table.action("alice", "coins 1") + snap := events[0].Snapshot.(*VideoPokerSnapshot) + if p.loaded != want || !snap.ShowPayTable || snap.Coins != want { + t.Fatalf("load: coins=%d %+v", p.loaded, snap) + } + } + if len(w.open) != 0 || p.remaining != nil { + t.Fatal("coin loading charged or dealt") + } + table.action("alice", "denom 10") + table.bet("alice", "deal", 0, w) + if p.wager.Total != 20 { + t.Fatalf("cost %d", p.wager.Total) + } + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + table.action("alice", "draw") + if w.paid != 5000 { + t.Fatalf("two-coin royal: %d", w.paid) + } + events := table.action("alice", "coins") + if p.loaded != 2 || events[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { + t.Fatal("bare bet did not repeat silently") + } + // A second coin adds to the loaded wager and reprints the pay table. + events = table.action("alice", "coins 1") + if p.loaded != 3 || !events[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { + t.Fatal("second coin did not reprint the pay table") + } + table.action("alice", "coins max") + table.bet("alice", "deal", 0, w) + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + table.action("alice", "draw") + if w.paid != 45000 { + t.Fatalf("max royal total %d", w.paid) + } + rows := p.config.PayRows(1) + if rows[0].Coins != [5]int{250, 500, 750, 1000, 4000} { + t.Fatalf("royal columns %+v", rows[0]) + } + // An uninitialized cabinet repeats the max wager on the first bare bet. + table2, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + table2.players["alice"].loaded = 0 + table2.action("alice", "coins") + if table2.players["alice"].loaded != 5 { + t.Fatal("initial bare bet not max") + } +} +func TestPokerHoldToggleAndMultiHand(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + table.action("alice", "hands 100") + pokerDeal(t, table, w) + p := table.players["alice"] + original := p.cards + table.action("alice", "531") + if !p.held[0] || p.held[1] || !p.held[2] || p.held[3] || !p.held[4] { + t.Fatalf("first toggle: %+v", p.held) + } + table.action("alice", "1") + if p.held[0] { + t.Fatal("digit did not toggle back off") + } + table.action("alice", "1") + table.status("alice") + table.action("alice", "paytable") + if p.remaining == nil || !p.active { + t.Fatal("read commands changed deal") + } + table.action("alice", "denom 50") + table.action("alice", "game jacks") + if p.denomination != 1 || p.variant != "jacks_or_better" { + t.Fatal("settings changed during a hand") + } + table.action("alice", "draw") + if len(p.lines) != 100 || len(w.open) != 0 || p.active { + t.Fatal("bad multi-hand settlement") + } + unique := map[string]bool{} + for _, line := range p.lines { + for _, i := range []int{0, 2, 4} { + if line.Cards[i].Rank != original[i].rank || line.Cards[i].Suit != original[i].suit { + t.Fatal("hold lost") + } + } + for _, i := range []int{1, 3} { + for _, c := range original { + if line.Cards[i].Rank == c.rank && line.Cards[i].Suit == c.suit { + t.Fatal("original card returned") + } + } + } + if line.Cards[1] == line.Cards[3] { + t.Fatal("duplicate within one hand") + } + unique[line.Cards[1].Rank+line.Cards[1].Suit] = true + } + if len(unique) < 10 { + t.Fatal("lines did not draw independently") + } + paid := w.paid + table.action("alice", "draw") + if w.paid != paid { + t.Fatal("second draw paid again") + } + for _, e := range table.status("alice") { + if e.Public || e.PrivateTo != "alice" { + t.Fatal("private cards leaked") + } + } + // Settings change freely between rounds. + table.action("alice", "denom 50") + table.action("alice", "hands 1") + if p.denomination != 50 || p.hands != 1 { + t.Fatal("settings refused between rounds") + } + table.leave("alice") + if !table.idle() { + t.Fatal("seat not released") + } +} +func TestPokerGameAndPayoutSwitching(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + p := table.players["alice"] + if !table.action("alice", "game jacks")[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { + t.Fatal("explicit switch did not show the pay table") + } + table.action("alice", "game ddb") + if p.config.Variant != "double_double_bonus" || p.config.PayTable != "10/6" { + t.Fatalf("alias switch: %+v", p.config) + } + table.action("alice", "payout low") + if p.config.PayTable != "8/5" { + t.Fatalf("tier not applied to the current game: %+v", p.config) + } + table.action("alice", "game joker") + if p.config.PayTable != "50/15/7/5/3" { + t.Fatalf("tier lost switching to joker: %+v", p.config) + } + events := table.action("alice", "game unknown") + if !events[0].MessageStarts("Use bet") || p.variant != "joker_poker" { + t.Fatal("unknown game accepted") + } + table.action("alice", "coins max") + pokerDeal(t, table, w) + if !table.action("alice", "game jacks")[0].MessageStarts("Finish this hand first.") { + t.Fatal("game switched during a hand") + } + table.action("alice", "draw") + // A hundred-hand classic Jacks or Better edition is reachable. + table.action("alice", "game jacks") + table.action("alice", "hands 100") + if p.hands != 100 { + t.Fatal("hundred-play unavailable") + } + table.action("alice", "hands 3") + if p.hands != 3 { + t.Fatal("multi-hand unavailable") + } +} +func TestPokerFeatureFeesAndHandCounts(t *testing.T) { + for _, tt := range []struct { + cabinet string + payTable string + feature string + hands, max int + }{{CabinetUXSingle, "9/6", PokerUltimateX, 1, 1}, {CabinetUXMulti, "8/6", PokerUltimateX, 3, 10}, {CabinetSuperTimesPay, "9/6", PokerSuperTimesPay, 1, 10}, {CabinetHotRoll, "9/6", PokerHotRoll, 3, 10}} { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: tt.cabinet}) + p := table.players["alice"] + table.action("alice", "hands 100") + if p.hands != tt.hands { + t.Fatal("unsupported hand count accepted") + } + table.action("alice", "coins 1") + table.bet("alice", "deal", 0, w) + if p.active || len(w.open) != 0 { + t.Fatal("feature allowed a partial bet") + } + pokerDeal(t, table, w) + fee := 5 + if tt.feature == PokerSuperTimesPay { + fee = 1 + } + if p.wager.Total != (5+fee)*tt.hands { + t.Fatalf("%s fee %d", tt.feature, p.wager.Total) + } + if p.config.Feature != tt.feature || p.config.PayTable != tt.payTable { + t.Fatalf("%s cabinet: %+v", tt.cabinet, p.config) + } + table.action("alice", "draw") + table.action("alice", "feature off") + pokerDeal(t, table, w) + if p.wager.Total != 5*tt.hands { + t.Fatal("feature off still charged a fee") + } + } +} +func TestPokerSettlementRetry(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + p := table.players["alice"] + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + w.fail = true + table.action("alice", "draw") + if !p.active || !p.drawn || len(w.open) != 1 { + t.Fatal("failed payout lost stake") + } + final := append([]PokerLine(nil), p.lines...) + table.action("alice", "draw") + if !reflect.DeepEqual(final, p.lines) || w.paid != 0 { + t.Fatal("failed final hands changed or paid") + } + w.fail = false + table.action("alice", "draw") + table.action("alice", "draw") + if w.paid != 4000 || len(w.open) != 0 || p.active { + t.Fatalf("retry: paid=%d", w.paid) + } +} +func TestPokerDisconnectAndRefund(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + table.markDisconnected("alice") + if table.hasParticipant("alice") || len(w.open) != 0 { + t.Fatal("disconnect failed to settle") + } + table, w, _ = pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + table.refundAll() + table.refundAll() + if w.paid != 5 || len(w.open) != 0 { + t.Fatal("refund not exactly once") + } +} +func TestPokerJokerDeck(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Variant: "joker_poker"}) + pokerDeal(t, table, w) + p := table.players["alice"] + seen := map[card]bool{} + for _, c := range append(append([]card(nil), p.cards[:]...), p.remaining...) { + seen[c] = true + } + if len(seen) != 53 || !seen[card{rank: "JK", suit: "joker"}] { + t.Fatal("not a 53-card deck") + } +} diff --git a/internal/casino/video_poker_profiles.go b/internal/casino/video_poker_profiles.go new file mode 100644 index 0000000..9471ee6 --- /dev/null +++ b/internal/casino/video_poker_profiles.go @@ -0,0 +1,171 @@ +package casino + +import "math/rand" + +// Bonus feature identifiers (cabinet editions). +const ( + PokerUltimateX = "ultimate_x" + PokerSuperTimesPay = "super_times_pay" + PokerHotRoll = "hot_roll" +) + +// NextAward exposes the published Ultimate X multiplier the named hand earns +// for the selected hand count; 1 means the line earned nothing. +func (c VideoPokerConfig) NextAward(hands int, category string) int { + if cabinetFeature(c.Normalize().Cabinet) != PokerUltimateX { + return 1 + } + return pokerNextMultiplier(c.Cabinet, c.Variant, hands, category) +} + +// Each tuple is [single-line, 3-play, 5-play, 10-play]. These are the +// published Ultimate X award schedules, NOT an interpolation. +func pokerNextMultiplier(cabinet, variant string, hands int, category string) int { + ix := 0 + switch { + case cabinet == CabinetUXSingle: + ix = 0 + case hands == 3: + ix = 1 + case hands == 5: + ix = 2 + case hands == 10: + ix = 3 + } + var m map[string][4]int + switch variant { + case "deuces_wild": + m = map[string][4]int{"Natural royal flush": {2, 2, 2, 4}, "Four deuces": {2, 2, 2, 4}, "Wild royal flush": {2, 2, 2, 4}, "Five of a kind": {2, 2, 3, 3}, "Straight flush": {11, 12, 12, 12}, "Four of a kind": {7, 7, 7, 7}, "Full house": {5, 5, 5, 5}, "Flush": {5, 5, 5, 5}, "Straight": {3, 3, 3, 3}, "Three of a kind": {2, 2, 2, 2}} + case "joker_poker": + m = map[string][4]int{"Natural royal flush": {2, 2, 3, 4}, "Five of a kind": {2, 2, 3, 4}, "Wild royal flush": {2, 2, 3, 4}, "Straight flush": {2, 2, 3, 4}, "Four of a kind": {2, 2, 2, 2}, "Full house": {6, 7, 7, 7}, "Flush": {6, 6, 6, 6}, "Straight": {5, 5, 5, 5}, "Three of a kind": {4, 4, 4, 4}, "Two pair": {3, 3, 3, 3}, "Kings or better": {2, 2, 2, 2}} + case "double_bonus", "double_double_bonus": + m = map[string][4]int{"Royal flush": {2, 2, 2, 4}, "Straight flush": {2, 2, 2, 4}, "Four aces": {2, 2, 2, 4}, "Four 2s-4s": {2, 2, 2, 4}, "Four aces with 2-4 kicker": {2, 2, 2, 4}, "Four 2s-4s with A-4 kicker": {2, 2, 2, 4}, "Four 5s-Ks": {2, 2, 3, 3}, "Full house": {12, 12, 12, 12}, "Flush": {10, 10, 10, 10}, "Straight": {7, 8, 8, 8}, "Three of a kind": {4, 4, 4, 4}, "Two pair": {3, 3, 3, 3}, "Jacks or better": {2, 2, 2, 2}} + if variant == "double_double_bonus" { + for _, k := range []string{"Four aces", "Four 2s-4s", "Four aces with 2-4 kicker", "Four 2s-4s with A-4 kicker"} { + row := m[k] + row[0] = 4 + m[k] = row + } + m["Royal flush"] = [4]int{4, 2, 2, 4} + m["Straight flush"] = [4]int{4, 2, 2, 4} + m["Four 5s-Ks"] = [4]int{4, 2, 3, 3} + } + default: + m = map[string][4]int{"Royal flush": {2, 2, 2, 7}, "Straight flush": {2, 2, 2, 7}, "Four of a kind": {2, 2, 3, 3}, "Full house": {12, 12, 12, 12}, "Flush": {10, 11, 11, 11}, "Straight": {7, 7, 7, 7}, "Three of a kind": {4, 4, 4, 4}, "Two pair": {3, 3, 3, 3}, "Jacks or better": {2, 2, 2, 2}} + } + if variant == "bonus_poker" { + for _, k := range []string{"Royal flush", "Straight flush", "Four aces", "Four 2s-4s", "Four 5s-Ks"} { + m[k] = [4]int{3, 2, 2, 4} + } + m["Four 5s-Ks"] = [4]int{3, 2, 3, 3} + m["Straight"] = [4]int{7, 8, 8, 8} + } + if row, ok := m[category]; ok { + return row[ix] + } + return 1 +} + +// FeatureRules names each feature's published cost and trigger behavior. +func (c VideoPokerConfig) FeatureRules() string { + switch cabinetFeature(c.Normalize().Cabinet) { + case PokerUltimateX: + return "Ultimate X: feature on costs five extra coins per hand. Each winning line earns the NEXT hand's multiplier for that line; a current multiplier never multiplies another multiplier. The feature is not required to use a multiplier that is already showing. Multipliers stay with the machine until they are used." + case PokerSuperTimesPay: + return "Super Times Pay: feature on costs one extra coin per hand. Before the deal a 1-in-15 trigger awards one multiplier shared by every line: 2x/3x/4x/5x/8x/10x with probabilities 17/33/16/24/6/4 percent." + case PokerHotRoll: + return "Hot Roll: feature on costs five extra coins per hand. Before the deal a 1-in-6 trigger rolls two fair dice; their sum multiplies every line this round." + } + return "This cabinet carries no bonus feature." +} + +// FeatureFeeCoins is the extra coin cost per hand while the feature is on. +func (c VideoPokerConfig) FeatureFeeCoins() int { + switch cabinetFeature(c.Normalize().Cabinet) { + case PokerUltimateX, PokerHotRoll: + return 5 + case PokerSuperTimesPay: + return 1 + } + return 0 +} + +// pokerCoinPay is the total coin return of one line: rates are per coin, and +// the 52-card natural royal pays the flat 250-per-coin table below max bet. +func pokerCoinPay(category string, perCoin, coins int) int { + if coins < 1 || coins > 5 { + return 0 + } + if (category == "Royal flush" || category == "Natural royal flush") && coins < 5 { + return 250 * coins + } + return perCoin * coins +} + +// PayRows renders the pay table for the selected game and hand count. +func (c VideoPokerConfig) PayRows(hands int) []PokerPayRow { + c = c.Normalize() + schedule, ok := pokerPayTable(c) + if !ok { + return nil + } + var rows []PokerPayRow + for _, k := range pokerCategories { + if n := schedule.pays[k]; n > 0 { + row := PokerPayRow{Name: k, NextAward: c.NextAward(hands, k)} + for i := range row.Coins { + row.Coins[i] = pokerCoinPay(k, n, i+1) + } + rows = append(rows, row) + } + } + return rows +} + +// pokerRandomMultiplier rolls the deal-time bonus of Super Times Pay and +// Hot Roll; 1 means no multiplier. Hot Roll returns the dice that produced it. +func pokerRandomMultiplier(feature string, rng *rand.Rand) (int, [2]int) { + if feature == PokerHotRoll && rng.Intn(6) == 0 { + dice := [2]int{rng.Intn(6) + 1, rng.Intn(6) + 1} + return dice[0] + dice[1], dice + } + if feature == PokerSuperTimesPay && rng.Intn(15) == 0 { + n := rng.Intn(100) + for _, v := range []struct{ n, m int }{{17, 2}, {33, 3}, {16, 4}, {24, 5}, {6, 8}, {4, 10}} { + if n < v.n { + return v.m, [2]int{} + } + n -= v.n + } + } + return 1, [2]int{} +} + +// pokerHelpTopic names the help page the "rules" command opens while seated +// at this cabinet; every shipped cabinet type has its own page. +func pokerHelpTopic(c VideoPokerConfig) string { + switch c.Normalize().Cabinet { + case CabinetUXSingle: + return "ultimate x single line" + case CabinetUXMulti: + return "ultimate x multi hand" + case CabinetSuperTimesPay: + return "super times pay" + case CabinetHotRoll: + return "hot roll" + } + return "video poker" +} + +// FeatureLabel is the feature's display name for badges and menus. +func (c VideoPokerConfig) FeatureLabel() string { + switch cabinetFeature(c.Normalize().Cabinet) { + case PokerUltimateX: + return "Ultimate X" + case PokerSuperTimesPay: + return "Super Times Pay" + case PokerHotRoll: + return "Hot Roll" + } + return "Classic" +} diff --git a/internal/casino/video_poker_regression_test.go b/internal/casino/video_poker_regression_test.go new file mode 100644 index 0000000..634ebc9 --- /dev/null +++ b/internal/casino/video_poker_regression_test.go @@ -0,0 +1,135 @@ +package casino + +import ( + "reflect" + "strconv" + "testing" +) + +func TestPokerEveryCabinetTierGameAndAward(t *testing.T) { + for cabinet, games := range cabinetGames { + table, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: cabinet}) + for _, tier := range []string{"low", "normal", "full"} { + table.action("alice", "payout "+tier) + for _, variant := range games { + events := table.action("alice", "game "+variant) + p := table.players["alice"] + if p.variant != variant || p.config.Payout != tier || len(events) != 1 || events[0].Snapshot == nil { + t.Fatalf("%s/%s/%s failed selection: %+v", cabinet, tier, variant, p.config) + } + if err := p.config.Validate(); err != nil { + t.Fatal(err) + } + for _, hands := range CabinetHandCounts(cabinet, variant) { + table.action("alice", "hands "+strconv.Itoa(hands)) + for _, row := range p.config.PayRows(hands) { + if row.Coins[4] <= 0 { + t.Fatalf("zero payout: %s", row.Name) + } + if p.config.Feature == PokerUltimateX && (row.NextAward < 2 || row.NextAward > 12) { + t.Fatalf("missing UX award: %s/%s/%d/%s = %d", cabinet, variant, hands, row.Name, row.NextAward) + } + } + } + } + } + table.action("alice", "payout low") + table.leave("alice") + table.join("bob") + if table.players["bob"].config.Payout != "low" { + t.Fatal("machine tier changed with occupant") + } + } +} + +func TestPokerInvalidLoadPreservesPreviousCoins(t *testing.T) { + table, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + table.action("alice", "coins 2") + for _, input := range []string{"coins nope", "coins 0", "coins 5", "coins -1", "coins 1 2"} { + table.action("alice", input) + if table.players["alice"].loaded != 2 || table.players["alice"].lastBet != 2 { + t.Fatalf("invalid load %q changed wager", input) + } + } +} + +func TestPokerDuplicateHoldDigitsToggleOnce(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + table.action("alice", "5 3 1 1 3 5") + want := [5]bool{true, false, true, false, true} + if table.players["alice"].held != want { + t.Fatal("duplicates did not toggle once") + } + table.action("alice", "hold none") + if table.players["alice"].held != want { + t.Fatal("hold none changed cards") + } + table.action("alice", "hold 531") + if table.players["alice"].held != ([5]bool{}) { + t.Fatal("second hold command did not release cards") + } +} + +func TestPokerFailedUXSettlementPreservesBankAndFinalHands(t *testing.T) { + table, w, bank := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetUXSingle}) + p := table.players["alice"] + key := table.multiplierKey(p) + table.banks[key] = []int{3} + pokerDeal(t, table, w) + p.cards = pokerHand("Ah 3h 6h 9h Jh") + table.action("alice", "12345") + w.fail = true + table.action("alice", "draw") + frozen := append([]PokerLine(nil), p.lines...) + if bank.writes != 0 || !reflect.DeepEqual(table.banks[key], []int{3}) || !p.active { + t.Fatal("failed payout consumed old bank") + } + table.action("alice", "draw") + if !reflect.DeepEqual(frozen, p.lines) { + t.Fatal("retry changed final cards") + } + w.fail = false + table.action("alice", "draw") + if w.paid != 90 || bank.writes != 1 || !reflect.DeepEqual(table.banks[key], []int{10}) { + t.Fatalf("retry: paid=%d bank=%v writes=%d", w.paid, table.banks[key], bank.writes) + } + table.action("alice", "draw") + if bank.writes != 1 || w.paid != 90 { + t.Fatal("settlement replayed") + } + table.leave("alice") + table.join("bob") + snap := table.status("bob")[0].Snapshot.(*VideoPokerSnapshot) + if !reflect.DeepEqual(snap.Next, []int{10}) { + t.Fatal("new occupant did not inherit earned bank") + } +} + +func TestPokerKnownFeatureScheduleValues(t *testing.T) { + cases := []struct{ cabinet, tier, variant, table string }{ + {CabinetHotRoll, "normal", "jacks_or_better", "9/5"}, + {CabinetHotRoll, "normal", "double_double_bonus", "9/5"}, + {CabinetSuperTimesPay, "full", "double_double_bonus", "9/6"}, + {CabinetSuperTimesPay, "full", "double_bonus", "9/7"}, + {CabinetUXMulti, "full", "double_bonus", "9/6/5"}, + } + for _, tt := range cases { + c := (VideoPokerConfig{Cabinet: tt.cabinet, Payout: tt.tier, Variant: tt.variant}).Normalize() + if c.PayTable != tt.table { + t.Fatalf("%+v => %s", tt, c.PayTable) + } + } + for _, variant := range []string{"bonus_poker", "double_double_bonus"} { + cfg := (VideoPokerConfig{Cabinet: CabinetUXSingle, Variant: variant}).Normalize() + want := 3 + if variant == "double_double_bonus" { + want = 4 + } + for _, category := range []string{"Royal flush", "Straight flush", "Four aces", "Four 2s-4s", "Four 5s-Ks"} { + if got := cfg.NextAward(1, category); got != want { + t.Fatalf("%s %s: %dx != %dx", variant, category, got, want) + } + } + } +} 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 +} diff --git a/internal/casino/video_poker_test.go b/internal/casino/video_poker_test.go new file mode 100644 index 0000000..bd9e38d --- /dev/null +++ b/internal/casino/video_poker_test.go @@ -0,0 +1,141 @@ +package casino + +import ( + "strings" + "testing" +) + +func pokerHand(text string) [5]card { + var cards [5]card + suits := map[byte]string{'c': "clubs", 'd': "diamonds", 'h': "hearts", 's': "spades"} + for i, s := range strings.Fields(text) { + if s == "JK" { + cards[i] = card{rank: "JK", suit: "joker"} + continue + } + cards[i] = card{rank: s[:len(s)-1], suit: suits[s[len(s)-1]]} + } + return cards +} +func TestPokerEvaluation(t *testing.T) { + tests := []struct { + variant, table, hand, category string + pay int + }{ + {"jacks_or_better", "9/6", "Ah Kh Qh Jh 10h", "Royal flush", 800}, + {"jacks_or_better", "9/6", "Ah Ac As Ad 10h", "Four of a kind", 25}, + {"jacks_or_better", "9/6", "Ah 2h 3h 4h 5h", "Straight flush", 50}, + {"jacks_or_better", "9/6", "Ah 2c 3h 4h 5h", "Straight", 4}, + {"jacks_or_better", "9/6", "Qh Kh Ac 2h 3h", "No win", 0}, + {"jacks_or_better", "9/6", "10h 10c 4s 6h 8c", "No win", 0}, + {"jacks_or_better", "9/6", "Jh Jc 4s 6h 8c", "Jacks or better", 1}, + {"jacks_or_better", "9/6", "Jh Jc Js 6h 6c", "Full house", 9}, + {"jacks_or_better", "8/5", "Jh Jc Js 6h 6c", "Full house", 8}, + {"jacks_or_better", "8/5", "Jh 3h 8h 6h 9h", "Flush", 5}, + {"jacks_or_better", "9/6", "Jh Jc 4s 4h 8c", "Two pair", 2}, + {"bonus_poker", "8/5", "Ah Ac As Ad 2c", "Four aces", 80}, + {"bonus_poker", "8/5", "3h 3c 3s 3d Ac", "Four 2s-4s", 40}, + {"bonus_poker", "8/5", "Kh Kc Ks Kd Ac", "Four 5s-Ks", 25}, + {"double_bonus", "10/7", "Ah Ac As Ad 2c", "Four aces", 160}, + {"double_bonus", "10/7", "3h 3c 3s 3d Ac", "Four 2s-4s", 80}, + {"double_bonus", "10/7", "Kh Kc Ks Kd Ac", "Four 5s-Ks", 50}, + {"double_bonus", "10/7", "Ah 2c 3h 4h 5h", "Straight", 5}, + {"double_bonus", "9/6/4", "Ah 2c 3h 4h 5h", "Straight", 4}, + {"double_double_bonus", "10/6", "Ah Ac As Ad 2c", "Four aces with 2-4 kicker", 400}, + {"double_double_bonus", "10/6", "Ah Ac As Ad 5c", "Four aces", 160}, + {"double_double_bonus", "9/6", "4h 4c 4s 4d Ac", "Four 2s-4s with A-4 kicker", 160}, + {"double_double_bonus", "8/5", "2h 2c 2s 2d 4c", "Four 2s-4s with A-4 kicker", 160}, + {"double_double_bonus", "10/6", "4h 4c 4s 4d 5c", "Four 2s-4s", 80}, + {"double_double_bonus", "10/6", "Jh Jc Js 6h 6c", "Full house", 10}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Kh Qh Jh 10h", "Natural royal flush", 800}, + {"deuces_wild", "25/15/9/5/3/2", "2h 2c 2s 2d Ac", "Four deuces", 200}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Kh Qh 2c 10h", "Wild royal flush", 25}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Kh Qc 2c 10h", "Straight", 2}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Ac As 2c 2h", "Five of a kind", 15}, + {"deuces_wild", "25/15/9/5/3/2", "3h 4h 5h 2c 2h", "Straight flush", 9}, + {"deuces_wild", "25/15/9/5/3/2", "3h 3c 3s 2c 7h", "Four of a kind", 5}, + {"deuces_wild", "25/15/9/5/3/2", "3h 3c 7s 2c 7h", "Full house", 3}, + {"deuces_wild", "25/15/9/5/3/2", "3h 4h 8h 2c Jh", "Flush", 2}, + {"deuces_wild", "20/12/10/4/4/3", "3h 4h 8h 2c Jh", "Flush", 3}, + {"deuces_wild", "20/12/10/4/4/3", "Ah Kh Qh 2c 10h", "Wild royal flush", 20}, + {"deuces_wild", "25/15/9/5/3/2", "3h 3c 8s 2c Jh", "Three of a kind", 1}, + {"deuces_wild", "25/15/9/5/3/2", "3h 3c 8s 8c Jh", "No win", 0}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Ac 8s 5c Jh", "No win", 0}, + {"joker_poker", "50/17/7/5/3", "Ah Kh Qh Jh 10h", "Natural royal flush", 800}, + {"joker_poker", "50/17/7/5/3", "Ah Kh Qh JK 10h", "Wild royal flush", 100}, + {"joker_poker", "50/17/7/5/3", "Ah Ac As Ad JK", "Five of a kind", 200}, + {"joker_poker", "50/17/7/5/3", "Kh 3c 5s 8d JK", "Kings or better", 1}, + {"joker_poker", "50/17/7/5/3", "Qh Qc 5s 8d 9h", "No win", 0}, + {"joker_poker", "50/17/7/5/3", "Qh Qc 5s 5d 9h", "Two pair", 1}, + {"joker_poker", "50/17/7/5/3", "Kh Kc 5s 5d JK", "Full house", 7}, + {"bonus_poker_deluxe", "9/6", "2h 2c 2s 2d Ac", "Four of a kind", 80}, + {"bonus_poker_deluxe", "9/6", "Jh Jc 4s 4h 8c", "Two pair", 1}, + } + for _, tt := range tests { + t.Run(tt.variant+"/"+tt.hand, func(t *testing.T) { + got, pay := evaluatePoker(pokerHand(tt.hand), VideoPokerConfig{Variant: tt.variant, PayTable: tt.table}) + if got != tt.category || pay != tt.pay { + t.Fatalf("got %s/%d, want %s/%d", got, pay, tt.category, tt.pay) + } + }) + } +} +func TestPokerHoldDigits(t *testing.T) { + for _, input := range []string{"531", "5 3 1", " 5\t3\n1 "} { + digits, ok := ParsePokerDigits(input) + if !ok || len(digits) != 3 { + t.Fatalf("%q: %v %v", input, digits, ok) + } + } + for _, input := range []string{"", " ", "0", "6", "123456", "1,3", "-1", "1x3"} { + if _, ok := ParsePokerDigits(input); ok { + t.Fatalf("accepted %q", input) + } + } +} +func TestPokerCabinets(t *testing.T) { + // Every cabinet type normalizes to its fixed feature and default game. + cfg := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Cabinet: CabinetUXMulti}}.Normalize() + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.VideoPoker.Feature != PokerUltimateX || cfg.VideoPoker.Payout != "full" || cfg.VideoPoker.Variant != "jacks_or_better" { + t.Fatalf("UX normalize: %+v", cfg.VideoPoker) + } + if cfg.VideoPoker.PayTable != "8/6" { + t.Fatalf("UX multi schedule %q", cfg.VideoPoker.PayTable) + } + single := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Cabinet: CabinetUXSingle}}.Normalize() + if single.VideoPoker.PayTable != "9/6" { + t.Fatalf("UX single schedule %q", single.VideoPoker.PayTable) + } + hr := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Cabinet: CabinetHotRoll}}.Normalize() + if hr.VideoPoker.PayTable != "9/6" || hr.VideoPoker.Feature != PokerHotRoll { + t.Fatalf("hot roll: %+v", hr.VideoPoker) + } + // Authored overrides are rejected; tiers must be known values. + for _, c := range []VideoPokerConfig{{Cabinet: "unknown"}, {Payout: "high"}, {Feature: "ux_stp"}} { + if (TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: c}).Validate() == nil { + t.Fatalf("accepted %+v", c) + } + } + // Low/normal tiers resolve verified short-pay schedules on classic cabinets. + tiers := map[string]string{"full": "9/6", "normal": "8/5", "low": "7/5"} + for tier, want := range tiers { + cfg := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Payout: tier}}.Normalize() + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.VideoPoker.PayTable != want { + t.Fatalf("%s: %q", tier, cfg.VideoPoker.PayTable) + } + } + // The pay table carries across games: tier survives a game switch. + mid := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Payout: "normal", Variant: "deuces_wild"}}.Normalize() + if mid.VideoPoker.PayTable != "25/15/9/4/4/3" || mid.VideoPoker.Feature != "" { + t.Fatalf("mid deuces: %+v", mid.VideoPoker) + } + if (TableConfig{ID: "bj", Game: GameBlackjack, VideoPoker: VideoPokerConfig{Cabinet: CabinetClassic, Payout: "full", Variant: "jacks_or_better"}}).Validate() == nil { + t.Fatal("foreign block accepted") + } +} -- cgit v1.2.3