aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-01 04:26:20 -0400
committerhistoria <[not public]>2026-10-01 04:26:20 -0400
commit0ba388bbe229dd1ccfcb0831d21a5542ae0935f1 (patch)
treec5686cf404e3dc5001d2c9b2792749ae7a3c9801 /internal
parente3ff06fc79682673194c8913f0feb9e90920e7d0 (diff)
downloadthehouseoficarus-0ba388bbe229dd1ccfcb0831d21a5542ae0935f1.tar.gz
feat: video poker machines added to casino
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/config_validate.go3
-rw-r--r--internal/casino/registry.go14
-rw-r--r--internal/casino/table_ux.go3
-rw-r--r--internal/casino/types.go32
-rw-r--r--internal/casino/video_poker.go545
-rw-r--r--internal/casino/video_poker_bonus_test.go127
-rw-r--r--internal/casino/video_poker_config.go218
-rw-r--r--internal/casino/video_poker_lifecycle_test.go327
-rw-r--r--internal/casino/video_poker_profiles.go171
-rw-r--r--internal/casino/video_poker_regression_test.go135
-rw-r--r--internal/casino/video_poker_rules.go307
-rw-r--r--internal/casino/video_poker_test.go141
-rw-r--r--internal/game/blackjack_rules.go33
-rw-r--r--internal/game/casino.go25
-rw-r--r--internal/game/casino_ux.go46
-rw-r--r--internal/game/cmd_registry.go71
-rw-r--r--internal/game/look_entities.go4
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/render_video_poker.go216
-rw-r--r--internal/game/vesta_world_test.go18
-rw-r--r--internal/game/video_poker_banks.go145
-rw-r--r--internal/game/video_poker_cabinet_test.go177
-rw-r--r--internal/game/video_poker_render_test.go112
-rw-r--r--internal/game/video_poker_test.go79
24 files changed, 2917 insertions, 34 deletions
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 <name>", 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 <count>", 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 <name>, payout low|normal|full, denom 1|10|50, hands <count>, 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")
+ }
+}
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index afd8e4a..045626c 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -24,6 +24,39 @@ func (g *Game) lookRouletteTable(sess *net.Session, target string) bool {
return g.lookCardTable(sess, target, casino.GameRoulette)
}
+// A cabinet's published pay table is public; another player's cards are not.
+func (g *Game) lookVideoPokerCabinet(sess *net.Session, target string) bool {
+ if g.Casino == nil || sess.Player == nil {
+ return false
+ }
+ room, err := g.World.LoadRoom(sess.Player.RoomID)
+ if err != nil {
+ return false
+ }
+ target = strings.TrimSpace(strings.ToLower(target))
+ for _, cfg := range room.CasinoTables {
+ if cfg.Game != casino.GameVideoPoker {
+ continue
+ }
+ id := strings.ToLower(cfg.ID)
+ if target != id && target != id+" machine" && target != id+" cabinet" {
+ continue
+ }
+ table, ok := g.casinoTable(room.ID, cfg.ID)
+ if !ok {
+ return false
+ }
+ if seated, playing := g.Casino.Participation(sess.Player.Name); playing && seated == table {
+ g.emitCasinoEvents(table.Status(sess.Player.Name))
+ return true
+ }
+ c := table.Config.VideoPoker.Normalize()
+ sess.WriteLine(table.Config.Label() + g.renderCasinoSnapshot(sess, &casino.VideoPokerSnapshot{Config: c, ShowPayTable: true, Denomination: 1, Hands: casino.CabinetHandCounts(c.Cabinet, c.Variant)[0], FeatureOn: c.Feature != "", Multiplier: 1}))
+ return true
+ }
+ return false
+}
+
func (g *Game) lookCardTable(sess *net.Session, target string, game casino.GameName) bool {
room, err := g.World.LoadRoom(sess.Player.RoomID)
if err != nil {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index f29efd6..8d6e2a5 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -263,7 +263,7 @@ func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) {
return nil, false
}
for _, cfg := range room.CasinoTables {
- if strings.EqualFold(string(cfg.Game), gameName) {
+ if (cfg.Game == casino.GameVideoPoker && strings.EqualFold(cfg.ID, gameName)) || (cfg.Game != casino.GameVideoPoker && strings.EqualFold(string(cfg.Game), gameName)) {
table := g.Casino.EnsureTable(roomID, cfg)
if table == nil {
return nil, false
@@ -372,7 +372,13 @@ func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName str
sess.WriteLine(fmt.Sprintf("There is no %s table here.", gameName))
return
}
- g.emitCasinoEvents(g.Casino.Join(table.RoomID, table.Config, p.Name))
+ events := g.Casino.Join(table.RoomID, table.Config, p.Name)
+ if table.Config.Game == casino.GameVideoPoker {
+ if joined, playing := g.Casino.Participation(p.Name); playing && joined == table {
+ events = g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, "view", 0, casinoWallet{g: g, p: p})
+ }
+ }
+ g.emitCasinoEvents(events)
}
func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
@@ -409,6 +415,10 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
sess.WriteLine("You're not playing a game.")
return
}
+ if table.Config.Game == casino.GameVideoPoker {
+ g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, strings.TrimSpace("coins "+strings.Join(args, " "))))
+ return
+ }
// Table bet syntax: bet | bet <amount> [on] <target> | bet min|max [on] <target>.
// BEFORE an "on" keyword, a token that parses as an amount (or min/max)
// sets the wager and any other token is the bet target; AFTER "on", the
@@ -517,6 +527,10 @@ func (g *Game) executeTableAction(sess *net.Session, action string) {
sess.WriteLine("You're not playing a multiplayer casino game.")
return
}
+ if table.Config.Game == casino.GameVideoPoker && action == "deal" {
+ g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, "deal", 0, casinoWallet{g: g, p: p}))
+ return
+ }
g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, action))
}
@@ -626,11 +640,12 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas
return "", "", false
}
_, isCraps := snapshot.(*casino.CrapsSnapshot)
+ _, isPoker := snapshot.(*casino.VideoPokerSnapshot)
s, isRoulette := snapshot.(*casino.RouletteSnapshot)
- if !isCraps && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
+ if !isPoker && !isCraps && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", "", false
}
- if !isCraps && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
+ if !isPoker && !isCraps && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
// The compact reminder is already a short block; leave it intact.
return "", "", false
}
@@ -711,6 +726,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
// how verbose the command menus are.
size := sess.Player.OptionInt("card_size")
switch s := snapshot.(type) {
+ case *casino.VideoPokerSnapshot:
+ return renderVideoPoker(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt)
case *casino.CrapsSnapshot:
return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style"))
case *casino.RouletteSnapshot:
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 2881a49..750d05d 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -68,6 +68,10 @@ func (g *Game) casinoChoices(roomID int) []casinoChoice {
}
var choices []casinoChoice
for _, c := range room.CasinoTables {
+ if c.Game == casino.GameVideoPoker {
+ choices = append(choices, casinoChoice{address: c.ID, names: []string{c.ID, "video_poker", "vp", c.VideoPoker.Normalize().Variant}, table: true})
+ continue
+ }
names := []string{string(c.Game)}
switch c.Game {
case casino.GameBlackjack:
@@ -121,7 +125,11 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
if len(args) > 0 && args[0] == "rules" {
// The felt and the exact, per-table wording stay under table rules;
// bare rules opens the game's own help page.
- sess.WriteLine(g.renderCasinoRules(sess, t.Config))
+ if t.Config.Game == casino.GameVideoPoker {
+ g.executeTableAction(sess, "paytable")
+ } else {
+ sess.WriteLine(g.renderCasinoRules(sess, t.Config))
+ }
return
}
if len(args) > 0 && args[0] == "help" {
@@ -138,6 +146,9 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
// Reuse the game's current-state menu instead of advertising actions
// (insurance, split, rebet, etc.) that may not be legal right now.
g.emitCasinoEvents(t.Status(sess.Player.Name))
+ if t.Config.Game == casino.GameVideoPoker {
+ return
+ }
commands := []casino.MenuCommand{
{Command: "rules", Desc: "Help page for this game variant"},
{Command: "list", Desc: "Every bet available right now, with payouts"},
@@ -181,6 +192,10 @@ func (g *Game) executeWatch(sess *net.Session, args []string, _ string) {
if !ok {
return
}
+ if t.Config.Game == casino.GameVideoPoker {
+ sess.WriteLine("Poker hands are private. Use look " + t.Config.ID + " to inspect this cabinet.")
+ return
+ }
sess.CasinoWatchRoom = sess.Player.RoomID
sess.CasinoWatchTable = t.Config.ID
sess.WriteLine(g.colorize(sess, "casino_action", "Watching "+t.Config.Label()+". Detailed public cards are visible; watch off stops watching."))
@@ -347,12 +362,41 @@ func (g *Game) executeList(sess *net.Session, args []string, rawInput string) {
// become a brief table; a single row is one line; an empty list means the
// only bet is the ordinary amount-only wager.
func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) {
+ if t.Config.Game == casino.GameVideoPoker {
+ for _, h := range t.Hints(sess.Player.Name) {
+ if h.Command == "draw" {
+ sess.WriteLine("No wagers are available during this hand; finish with draw.")
+ return
+ }
+ }
+ sess.WriteLine("This cabinet has one wager: load coins with bet [1-4|max], then deal. Use paytable for payouts.")
+ return
+ }
rows := t.Wagers(sess.Player.Name)
+ // Blackjack rows are actions, not targets for a new bet.
+ if t.Config.Game == casino.GameBlackjack && len(rows) > 0 {
+ commands := make([]casino.MenuCommand, 0, len(rows))
+ for _, r := range rows {
+ commands = append(commands, casino.MenuCommand{Command: r.Bet, Desc: strings.Trim(r.Payout+" | "+r.Win, " |")})
+ }
+ sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), "Available actions", commands))
+ return
+ }
switch {
case len(rows) == 1:
r := rows[0]
sess.WriteLine(g.colorize(sess, "casino_action", "Your only wager: bet <amount> on "+r.Bet+" — "+r.Payout+" — "+r.Win+"."))
case len(rows) == 0:
+ canBet := false
+ for _, h := range t.Hints(sess.Player.Name) {
+ if strings.HasPrefix(h.Command, "bet ") {
+ canBet = true
+ }
+ }
+ if !canBet {
+ sess.WriteLine(g.colorize(sess, "casino_action", "No new wagers are available right now."))
+ return
+ }
sess.WriteLine(g.colorize(sess, "casino_action", "The only wager here is bet <amount>") + " (" + betRangeWords(t.Config.MinBet, t.Config.MaxBet) + ").")
default:
commands := make([]casino.MenuCommand, 0, len(rows))
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index fa3518a..d7b808b 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -5,6 +5,7 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/casino"
"thehouseoficarus/internal/net"
)
@@ -16,16 +17,46 @@ type commandDef struct {
}
var commandRegistry = map[string]commandDef{
- "get": {(*Game).executeGet, ClassActive},
- "take": {(*Game).executeGet, ClassActive},
- "grab": {(*Game).executeGet, ClassActive},
- "pick": {(*Game).executeGet, ClassActive},
- "drop": {(*Game).executeDrop, ClassActive},
- "attack": {(*Game).executeAttack, ClassActive},
- "kill": {(*Game).executeAttack, ClassActive},
- "work": {(*Game).executeWork, ClassActive},
- "play": {(*Game).executePlay, ClassActive},
- "rebet": {(*Game).executeBet, ClassActive},
+ "get": {(*Game).executeGet, ClassActive},
+ "take": {(*Game).executeGet, ClassActive},
+ "grab": {(*Game).executeGet, ClassActive},
+ "pick": {(*Game).executeGet, ClassActive},
+ "drop": {(*Game).executeDrop, ClassActive},
+ "attack": {(*Game).executeAttack, ClassActive},
+ "kill": {(*Game).executeAttack, ClassActive},
+ "work": {(*Game).executeWork, ClassActive},
+ "play": {(*Game).executePlay, ClassActive},
+ "rebet": {(*Game).executeBet, ClassActive},
+ "draw": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "draw") }, ClassActive},
+ "hold": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "hold "+strings.Join(args, " "))
+ }, ClassInstant},
+ "deal": {func(g *Game, s *net.Session, args []string, _ string) {
+ if len(args) > 0 {
+ s.WriteLine("Use deal.")
+ return
+ }
+ g.executeTableAction(s, "deal")
+ }, ClassActive},
+ "paytable": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "paytable") }, ClassInstant},
+ "denom": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "denom "+strings.Join(args, " "))
+ }, ClassInstant},
+ "denomination": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "denom "+strings.Join(args, " "))
+ }, ClassInstant},
+ "hands": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "hands "+strings.Join(args, " "))
+ }, ClassInstant},
+ "feature": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "feature "+strings.Join(args, " "))
+ }, ClassInstant},
+ "game": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "game "+strings.Join(args, " "))
+ }, ClassInstant},
+ "payout": {func(g *Game, s *net.Session, args []string, _ string) {
+ g.executeTableAction(s, "payout "+strings.Join(args, " "))
+ }, ClassInstant},
"roll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "roll") }, ClassActive},
"autoroll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "autoroll") }, ClassInstant},
"unbet": {func(g *Game, s *net.Session, args []string, _ string) {
@@ -192,6 +223,9 @@ var commandRegistry = map[string]commandDef{
}
func classifyCommand(cmd string) CommandClass {
+ if _, ok := casino.ParsePokerDigits(cmd); ok {
+ return ClassInstant
+ }
if def, ok := commandRegistry[cmd]; ok {
return def.class
}
@@ -226,6 +260,23 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
}
}
+ // Video poker: numeric tokens toggle holds and d draws, so both are
+ // intercepted before the shared registry (where d means "down").
+ _, pokerDigits := casino.ParsePokerDigits(cmd)
+ if p != nil && g.Casino != nil && (cmd == "d" || pokerDigits) {
+ if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GameVideoPoker {
+ if cmd == "d" {
+ if len(args) != 0 {
+ sess.WriteLine("Use d or draw.")
+ return
+ }
+ g.executeTableAction(sess, "draw")
+ return
+ }
+ g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " "))
+ return
+ }
+ }
if def, ok := commandRegistry[cmd]; ok {
def.handler(g, sess, args, rawInput)
return
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index e120c9c..c38b6e7 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -231,6 +231,10 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string {
var lines []string
for _, table := range room.CasinoTables {
cfg := table.Normalize()
+ if cfg.Game == casino.GameVideoPoker {
+ lines = append(lines, g.colorize(sess, "casino_action", cfg.Label()+" cabinet | denom 1/10/50 | one player"), " "+g.colorize(sess, "command", "play "+cfg.ID))
+ continue
+ }
label := fmt.Sprintf("%s | Limits %d-%d | 0/%d seated", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers)
if g.Casino != nil {
if live := g.Casino.Table(room.ID, table.ID); live != nil {
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 2311710..3f8cd15 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) {
+ if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) {
return
}
diff --git a/internal/game/render_video_poker.go b/internal/game/render_video_poker.go
new file mode 100644
index 0000000..d7b1a57
--- /dev/null
+++ b/internal/game/render_video_poker.go
@@ -0,0 +1,216 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/cardart"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+var (
+ pokerBlue = color.NearestXterm256(0, 2, 105)
+ pokerRed = color.NearestXterm256(254, 0, 0)
+ pokerYellow = color.NearestXterm256(247, 231, 14)
+)
+
+func pokerInk(mode, text string, active bool) string {
+ bg := pokerBlue
+ if active {
+ bg = pokerRed
+ }
+ return color.Render(mode, color.ColorSpec{Fg: pokerYellow, Bg: bg, Bold: true}, text)
+}
+func renderPokerPayTable(s *casino.VideoPokerSnapshot, mode string) string {
+ rows := s.Config.PayRows(s.Hands)
+ if len(rows) == 0 {
+ return ""
+ }
+ nameWidth, coinWidth := len("COINS PER HAND")+3, 6
+ for _, row := range rows {
+ nameWidth = max(nameWidth, color.VisibleLen(row.Name)+3)
+ for _, n := range row.Coins {
+ coinWidth = max(coinWidth, len(fmt.Sprint(n))+2)
+ }
+ }
+ hasNext := s.Config.Feature == casino.PokerUltimateX
+ widths := []int{nameWidth, coinWidth, coinWidth, coinWidth, coinWidth, coinWidth}
+ if hasNext {
+ widths = append(widths, 8)
+ }
+ border := func(left, middle, right string) string {
+ parts := make([]string, len(widths))
+ for i, w := range widths {
+ parts[i] = strings.Repeat("─", w)
+ }
+ return pokerInk(mode, left+strings.Join(parts, middle)+right, false)
+ }
+ row := func(cells []string, leaders bool) string {
+ var b strings.Builder
+ b.WriteString(pokerInk(mode, "│", false))
+ for i, text := range cells {
+ if i == 0 && leaders {
+ text = " " + text + " " + strings.Repeat(".", widths[0]-color.VisibleLen(text)-2)
+ } else {
+ pad := max(0, widths[i]-color.VisibleLen(text))
+ text = strings.Repeat(" ", pad/2) + text + strings.Repeat(" ", pad-pad/2)
+ }
+ b.WriteString(pokerInk(mode, text, i > 0 && i <= 5 && s.Coins == i))
+ b.WriteString(pokerInk(mode, "│", false))
+ }
+ return b.String()
+ }
+ headers := []string{"COINS PER HAND", "1", "2", "3", "4", "5"}
+ if hasNext {
+ headers = append(headers, "NEXT X")
+ }
+ lines := []string{s.Config.GameLabel() + " — " + s.Config.Payout + " pay", border("┌", "┬", "┐"), row(headers, false), border("├", "┼", "┤")}
+ for _, pay := range rows {
+ cells := []string{pay.Name}
+ for _, n := range pay.Coins {
+ cells = append(cells, fmt.Sprint(n))
+ }
+ if hasNext {
+ cells = append(cells, fmt.Sprintf("%dx", pay.NextAward))
+ }
+ lines = append(lines, row(cells, true))
+ }
+ lines = append(lines, border("└", "┴", "┘"), fmt.Sprintf("1 coin = %d credits | %d hand(s) | selected coin column in red", s.Denomination, s.Hands))
+ if s.Config.Feature != "" {
+ lines = append(lines, fmt.Sprintf("Feature ON adds %d coin(s) per hand; requires five base coins.", s.Config.FeatureFeeCoins()))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func renderPokerCards(cards []casino.CardView, held *[5]bool, mode string, unicode bool, size int, style string, store *cardart.Store) []string {
+ if len(cards) == 0 {
+ return nil
+ }
+ art := make([][]string, len(cards))
+ widths := make([]int, len(cards))
+ height := 0
+ for i, c := range cards {
+ art[i] = cardsLine(mode, []casino.CardView{c}, size, unicode, style, store)
+ height = max(height, len(art[i]))
+ for _, line := range art[i] {
+ widths[i] = max(widths[i], color.VisibleLen(line))
+ }
+ if held != nil {
+ widths[i] = max(widths[i], 6)
+ }
+ }
+ var lines []string
+ for r := 0; r < height; r++ {
+ parts := make([]string, len(cards))
+ for i := range cards {
+ text := ""
+ if r < len(art[i]) {
+ text = art[i][r]
+ }
+ parts[i] = rouletteCenter(text, widths[i])
+ }
+ lines = append(lines, strings.Join(parts, " "))
+ }
+ if held != nil {
+ labels := make([]string, len(cards))
+ for i := range cards {
+ text := fmt.Sprint(i + 1)
+ if held[i] {
+ text = color.Render(mode, color.ColorSpec{Fg: pokerYellow, Bg: -1, Bold: true}, text+" HELD")
+ }
+ labels[i] = rouletteCenter(text, widths[i])
+ }
+ lines = append(lines, strings.Join(labels, " "))
+ }
+ return lines
+}
+
+func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string {
+ if s.Hands > 1 {
+ size = min(size, 5)
+ }
+ if s.Hands >= 25 {
+ size = 1
+ }
+ if s.Hands >= 100 {
+ unicode = true
+ }
+ var lines []string
+ if s.ShowPayTable {
+ lines = append(lines, renderPokerPayTable(s, mode), "")
+ }
+ feature := "CLASSIC"
+ if s.Config.Feature != "" {
+ feature = s.Config.FeatureLabel() + " OFF"
+ if s.FeatureOn {
+ feature = s.Config.FeatureLabel() + " ON"
+ }
+ }
+ badge := pokerInk(mode, " ["+feature+"] ", false)
+ bonus := ""
+ mult := s.Multiplier
+ if s.Config.Feature == casino.PokerUltimateX {
+ for _, n := range s.Current {
+ mult = max(mult, n)
+ }
+ }
+ if mult > 1 {
+ text := fmt.Sprintf(" %dX! ", mult)
+ if s.Config.Feature == casino.PokerUltimateX && s.Hands > 1 {
+ text = fmt.Sprintf(" Up to %dX! ", mult)
+ }
+ bonus = pokerInk(mode, text, true)
+ }
+ if s.Dice[0] > 0 {
+ bonus += fmt.Sprintf(" Dice [%d] + [%d]", s.Dice[0], s.Dice[1])
+ }
+ if len(s.Cards) > 0 && len(s.Lines) == 0 {
+ art := renderPokerCards(s.Cards, &s.Held, mode, unicode, size, style, store)
+ if len(art) > 0 {
+ art[0] += " " + badge + bonus
+ }
+ lines = append(lines, art...)
+ } else if len(s.Lines) == 0 {
+ lines = append(lines, badge+bonus)
+ }
+ for i, line := range s.Lines {
+ art := renderPokerCards(line.Cards, nil, mode, unicode, size, style, store)
+ if i == 0 && len(art) > 0 {
+ art[0] += " " + badge + bonus
+ }
+ if len(s.Lines) > 1 && len(art) > 0 {
+ art[0] = fmt.Sprintf("%3d: ", i+1) + art[0]
+ }
+ lines = append(lines, art...)
+ text := "No win"
+ fg := 15
+ if line.Coins > 0 {
+ text = fmt.Sprintf("WIN: %s — %d coins", line.Category, line.Coins)
+ fg = pokerYellow
+ }
+ if line.Multiplier > 1 {
+ text += fmt.Sprintf(" (%dx)", line.Multiplier)
+ }
+ if len(s.Next) > i && s.Next[i] > 1 {
+ text += fmt.Sprintf(" | earns %dx", s.Next[i])
+ }
+ // Results always follow the cards, including compact hundred-play.
+ lines = append(lines, color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text))
+ }
+ if s.Result != "" {
+ fg := pokerYellow
+ if s.NoWin {
+ fg = 15
+ }
+ lines = append(lines, color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, s.Result))
+ }
+ if !s.Drawing && len(s.Lines) == 0 && s.Config.Feature == casino.PokerUltimateX && len(s.Next) > 0 {
+ var parts []string
+ for i, n := range s.Next {
+ parts = append(parts, fmt.Sprintf("%d:%dx", i+1, n))
+ }
+ lines = append(lines, "Multipliers on the machine: "+strings.Join(parts, " "))
+ }
+ return "\n" + strings.Join(lines, "\n") + "\n"
+}
diff --git a/internal/game/vesta_world_test.go b/internal/game/vesta_world_test.go
index 1a26bf6..b719e24 100644
--- a/internal/game/vesta_world_test.go
+++ b/internal/game/vesta_world_test.go
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
+ "thehouseoficarus/internal/casino"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/validate"
@@ -292,6 +293,16 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) {
future++
}
count := len(r.CasinoTables) + len(r.CasinoMachines)
+ // Individually addressed video-poker cabinets share a lounge.
+ // Multiplayer games still require their own salons.
+ for _, cfg := range r.CasinoTables {
+ if cfg.Game == casino.GameVideoPoker {
+ count--
+ if cfg.Normalize().MaxPlayers != 1 {
+ t.Errorf("room %d: poker cabinet is not single-seat", id)
+ }
+ }
+ }
if count > 1 {
t.Errorf("room %d: games must have individual salons", id)
}
@@ -309,7 +320,7 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) {
classic = classic || machine.Variant == ""
}
}
- if games["blackjack"] != 5 || games["baccarat"] != 3 || games["roulette"] != 3 || games["craps"] != 7 || games["slots"] != 5 || !soft17 || !surrender || !singleDeck || !huff || !classic {
+ if games["blackjack"] != 5 || games["baccarat"] != 3 || games["roulette"] != 3 || games["craps"] != 7 || games["slots"] != 5 || games["video_poker"] != 5 || !soft17 || !surrender || !singleDeck || !huff || !classic {
t.Errorf("casino variant coverage lost: %v", games)
}
if future != 0 {
@@ -317,7 +328,7 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) {
}
// One salon per game sits on the gaming street; each keeps its editions
// within a couple of steps north or south, so no variant is ever far away.
- street := map[int]string{6101: "blackjack", 6127: "baccarat", 6182: "roulette", 6185: "craps", 6118: "slots"}
+ street := map[int]string{6101: "blackjack", 6127: "baccarat", 6182: "roulette", 6185: "craps", 6118: "slots", 6192: "video_poker"}
for id, game := range street {
r := rooms[id]
found := false
@@ -339,7 +350,8 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) {
6127: {world.West, world.East},
6182: {world.West, world.East},
6185: {world.West, world.East},
- 6118: {world.West},
+ 6118: {world.West, world.East},
+ 6192: {world.West},
} {
r := rooms[id]
for _, dir := range want {
diff --git a/internal/game/video_poker_banks.go b/internal/game/video_poker_banks.go
new file mode 100644
index 0000000..bb18837
--- /dev/null
+++ b/internal/game/video_poker_banks.go
@@ -0,0 +1,145 @@
+package game
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+)
+
+var _ casino.PokerWallet = casinoWallet{}
+
+type pokerBankFile struct {
+ Banks map[string][]int `yaml:"banks"`
+}
+
+func (g *Game) pokerBankPath(roomID int, cabinetID string) (string, error) {
+ if cabinetID == "" {
+ return "", fmt.Errorf("empty cabinet id")
+ }
+ for _, r := range cabinetID {
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') {
+ return "", fmt.Errorf("invalid cabinet id %q", cabinetID)
+ }
+ }
+ return filepath.Join(g.DataDir, "casino", "video_poker_banks", fmt.Sprintf("%d_%s.yaml", roomID, cabinetID)), nil
+}
+
+// Missing is a new cabinet. Corrupt/unreadable state is NOT an empty bank:
+// refuse deals instead of silently destroying or replaying entitlements.
+func (w casinoWallet) LoadPokerBanks(roomID int, cabinetID string) (map[string][]int, error) {
+ path, err := w.g.pokerBankPath(roomID, cabinetID)
+ if err != nil {
+ return nil, err
+ }
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return map[string][]int{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var file pokerBankFile
+ decoder := yaml.NewDecoder(bytes.NewReader(data))
+ decoder.KnownFields(true)
+ if err := decoder.Decode(&file); err != nil {
+ return nil, err
+ }
+ for key, values := range file.Banks {
+ if len(strings.Split(key, "/")) != 4 || len(values) == 0 || len(values) > 10 {
+ return nil, fmt.Errorf("invalid multiplier bank %q", key)
+ }
+ for _, n := range values {
+ if n < 1 || n > 12 {
+ return nil, fmt.Errorf("invalid multiplier %d", n)
+ }
+ }
+ }
+ return file.Banks, nil
+}
+
+// PayPoker replaces the character payout/stake ledger AND the whole cabinet
+// bank in one undo-journal transaction, on the game owner goroutine. A crash
+// before commit restores both; startup then refunds the still-pending stake.
+// A committed payout cannot leave an old bank that another player can collect.
+func (w casinoWallet) PayPoker(amount int, stakeID string, roomID int, cabinetID string, banks map[string][]int) error {
+ candidate := *w.p
+ candidate.PendingWagers = append([]player.PendingWager(nil), w.p.PendingWagers...)
+ if stakeID == "" || len(takePendingWagers(&candidate, []string{stakeID})) != 1 {
+ return casino.ErrWalletPersistence
+ }
+ sum, err := addCreditsChecked(candidate.Credits, amount)
+ if err != nil {
+ return err
+ }
+ candidate.Credits = sum
+ path, err := w.g.pokerBankPath(roomID, cabinetID)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ for _, directory := range []string{filepath.Dir(path), filepath.Dir(filepath.Dir(path)), w.g.DataDir} {
+ d, err := os.Open(directory)
+ if err != nil {
+ return err
+ }
+ err = d.Sync()
+ d.Close()
+ if err != nil {
+ return err
+ }
+ }
+ bankData, err := yaml.Marshal(pokerBankFile{Banks: banks})
+ if err != nil {
+ return err
+ }
+ charData, err := w.g.AccountStore.MarshalCharacter(&candidate)
+ if err != nil {
+ return err
+ }
+ charPath := w.g.AccountStore.CharPath(w.p.Name)
+ if err := os.MkdirAll(w.g.journalDir(), 0700); err != nil {
+ return err
+ }
+ txn := w.g.newTxn()
+ if err := txn.Replace(charPath, charData, 0600); err != nil {
+ return err
+ }
+ if err := txn.Replace(path, bankData, 0600); err != nil {
+ return err
+ }
+ if err := txn.Commit(); err != nil {
+ // A commit-point/unlink error is ambiguous. Finish recovery before any
+ // retry or later character save, then reconcile the actual pair of files.
+ // If recovery/directory sync fails, fail closed, as startup recovery does.
+ w.g.recoverJournals()
+ dir, syncErr := os.Open(w.g.journalDir())
+ if syncErr == nil {
+ syncErr = dir.Sync()
+ dir.Close()
+ }
+ if syncErr != nil {
+ panic(fmt.Sprintf("poker journal durability: %v", syncErr))
+ }
+ actualChar, cerr := os.ReadFile(charPath)
+ actualBank, berr := os.ReadFile(path)
+ charApplied := cerr == nil && bytes.Equal(actualChar, charData)
+ bankApplied := berr == nil && bytes.Equal(actualBank, bankData)
+ if !charApplied {
+ return err
+ } // recovered before-images; no live mutation
+ if !bankApplied {
+ panic("poker settlement recovery left inconsistent files")
+ }
+ }
+ w.p.Credits = candidate.Credits
+ w.p.PendingWagers = candidate.PendingWagers
+ return nil
+}
diff --git a/internal/game/video_poker_cabinet_test.go b/internal/game/video_poker_cabinet_test.go
new file mode 100644
index 0000000..a327bce
--- /dev/null
+++ b/internal/game/video_poker_cabinet_test.go
@@ -0,0 +1,177 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/persistence"
+)
+
+const uxTestRoom = "name: Cabinets\ncasino_tables:\n - id: ux_multi\n game: video_poker\n video_poker:\n cabinet: ultimate_x_multi\n"
+
+func TestPokerBankAtomicSettlementAndRetry(t *testing.T) {
+ g, sess, _ := casinoCrapsGame(t, uxTestRoom)
+ g.DataDir = t.TempDir()
+ w := casinoWallet{g: g, p: sess.Player}
+ stake, err := w.Wager(10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ banks := map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}}
+ // A failure before journal creation cannot apply either half.
+ if err := os.WriteFile(g.journalDir(), []byte("blocked"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err == nil {
+ t.Fatal("accepted failed persistence")
+ }
+ if sess.Player.Credits != 90 || len(sess.Player.PendingWagers) != 1 {
+ t.Fatal("failure mutated character")
+ }
+ loaded, err := w.LoadPokerBanks(1, "ux_multi")
+ if err != nil || len(loaded) != 0 {
+ t.Fatalf("bank changed: %v %v", loaded, err)
+ }
+ if err := os.Remove(g.journalDir()); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err != nil {
+ t.Fatal(err)
+ }
+ disk, err := g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ loaded, err = w.LoadPokerBanks(1, "ux_multi")
+ if err != nil || disk.Credits != 140 || sess.Player.Credits != 140 || len(disk.PendingWagers) != 0 || !reflect.DeepEqual(loaded, banks) {
+ t.Fatalf("settlement: %v %+v", err, disk)
+ }
+ if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err == nil {
+ t.Fatal("duplicate stake paid")
+ }
+}
+
+func TestPokerSeatsPassMachineBankAcrossRestart(t *testing.T) {
+ g, sess, _ := casinoCrapsGame(t, uxTestRoom)
+ g.DataDir = t.TempDir()
+ w := casinoWallet{g: g, p: sess.Player}
+ stake, err := w.Wager(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ banks := map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}}
+ if err := w.PayPoker(0, stake.ID, 1, "ux_multi", banks); err != nil {
+ t.Fatal(err)
+ }
+ // Restart the casino manager: the bank comes from disk, not a player map.
+ g.Casino = casino.NewManager(2)
+ g.executePlay(sess, []string{"ux_multi"}, "play ux_multi")
+ table, ok := g.Casino.Participation("alice")
+ if !ok || table == nil {
+ t.Fatal("not seated")
+ }
+ g.Casino.Join(1, table.Config, "bob")
+ if _, playing := g.Casino.Participation("bob"); playing {
+ t.Fatal("second occupant admitted")
+ }
+ snap := table.Status("alice")[0].Snapshot.(*casino.VideoPokerSnapshot)
+ if !reflect.DeepEqual(snap.Next, []int{2, 3, 4}) {
+ t.Fatalf("bank not displayed on join: %v", snap.Next)
+ }
+ if !g.leaveCasinoGames(sess.Player) {
+ t.Fatal("could not leave")
+ }
+ g.Casino.Join(1, table.Config, "bob")
+ snap = table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot)
+ if !reflect.DeepEqual(snap.Next, []int{2, 3, 4}) {
+ t.Fatalf("next occupant did not inherit: %v", snap.Next)
+ }
+ // Settings keep separate banks; merely viewing them never consumes them.
+ g.Casino.Action(1, table.Config.ID, "bob", "denom 10")
+ if got := table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot).Next; len(got) != 0 {
+ t.Fatalf("wrong denomination bank: %v", got)
+ }
+ g.Casino.Action(1, table.Config.ID, "bob", "denom 1")
+ if got := table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot).Next; !reflect.DeepEqual(got, []int{2, 3, 4}) {
+ t.Fatal("bank lost on switch")
+ }
+}
+
+func TestPokerCorruptBankRefusesDeal(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, uxTestRoom)
+ g.DataDir = t.TempDir()
+ path, _ := g.pokerBankPath(1, "ux_multi")
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("banks: [broken"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ g.executePlay(sess, []string{"ux_multi"}, "play ux_multi")
+ g.executeBet(sess, []string{"max"}, "bet max")
+ g.executeTableAction(sess, "deal")
+ if sess.Player.Credits != 100 || len(sess.Player.PendingWagers) != 0 || !strings.Contains(string(conn.data), "Machine bank unavailable") {
+ t.Fatal("corrupt bank did not block deal")
+ }
+}
+
+func TestPokerInterruptedTransactionRecovery(t *testing.T) {
+ g, sess, _ := casinoCrapsGame(t, uxTestRoom)
+ g.DataDir = t.TempDir()
+ w := casinoWallet{g: g, p: sess.Player}
+ stake, err := w.Wager(10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path, _ := g.pokerBankPath(1, "ux_multi")
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := yaml.Marshal(pokerBankFile{Banks: map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}}})
+ if err := os.WriteFile(path, before, 0600); err != nil {
+ t.Fatal(err)
+ }
+ next := *sess.Player
+ next.Credits += 50
+ next.PendingWagers = nil
+ charData, _ := g.AccountStore.MarshalCharacter(&next)
+ bankData, _ := yaml.Marshal(pokerBankFile{})
+ txn := g.newTxn()
+ if err := txn.Replace(g.AccountStore.CharPath("alice"), charData, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Replace(path, bankData, 0600); err != nil {
+ t.Fatal(err)
+ }
+ journal, err := txn.MarshalJournal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(g.journalDir(), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(g.journalDir(), "interrupted.json"), journal, 0600); err != nil {
+ t.Fatal(err)
+ }
+ // Process died after applying just the character replacement.
+ if err := persistence.WriteFile(g.AccountStore.CharPath("alice"), charData, 0600); err != nil {
+ t.Fatal(err)
+ }
+ g.recoverJournals()
+ restored, err := g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if restored.Credits != 90 || len(restored.PendingWagers) != 1 || restored.PendingWagers[0].ID != stake.ID {
+ t.Fatal("character before-image not restored")
+ }
+ restoredBank, err := w.LoadPokerBanks(1, "ux_multi")
+ if err != nil || !reflect.DeepEqual(restoredBank["jacks_or_better/full/1/3"], []int{2, 3, 4}) {
+ t.Fatal("bank before-image not restored")
+ }
+}
diff --git a/internal/game/video_poker_render_test.go b/internal/game/video_poker_render_test.go
new file mode 100644
index 0000000..d618c36
--- /dev/null
+++ b/internal/game/video_poker_render_test.go
@@ -0,0 +1,112 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func TestPokerPayTableBordersPaletteAndColumns(t *testing.T) {
+ for _, cabinet := range []string{casino.CabinetClassic, casino.CabinetUXSingle, casino.CabinetUXMulti} {
+ cfg := (casino.VideoPokerConfig{Cabinet: cabinet, Variant: "double_double_bonus"}).Normalize()
+ snap := &casino.VideoPokerSnapshot{Config: cfg, Coins: 3, Hands: 3, Denomination: 10}
+ panel := renderPokerPayTable(snap, "xterm256")
+ for _, want := range []string{"┌", "┬", "┐", "│", "└", "┴", "┘", "....", "4000", "COINS PER HAND"} {
+ if !strings.Contains(panel, want) {
+ t.Fatalf("panel missing %q: %s", want, panel)
+ }
+ }
+ for _, seq := range []string{fmt.Sprintf("38;5;%d", pokerYellow), fmt.Sprintf("48;5;%d", pokerBlue), fmt.Sprintf("48;5;%d", pokerRed)} {
+ if !strings.Contains(panel, seq) {
+ t.Fatalf("missing palette %s", seq)
+ }
+ }
+ width := -1
+ for _, line := range strings.Split(stripANSI(panel), "\n") {
+ if !strings.HasPrefix(line, "│") && !strings.HasPrefix(line, "┌") && !strings.HasPrefix(line, "├") && !strings.HasPrefix(line, "└") {
+ continue
+ }
+ if width < 0 {
+ width = color.VisibleLen(line)
+ }
+ if color.VisibleLen(line) != width {
+ t.Fatalf("unaligned pay table: %q (%d != %d)", line, color.VisibleLen(line), width)
+ }
+ if strings.HasPrefix(line, "│") {
+ cells := strings.Split(line, "│")
+ want := 8
+ if cabinet != casino.CabinetClassic {
+ want = 9
+ }
+ if len(cells) != want {
+ t.Fatalf("bad column count %d: %s", len(cells), line)
+ }
+ }
+ }
+ if cabinet != casino.CabinetClassic && strings.Count(stripANSI(panel), "NEXT X") != 1 {
+ t.Fatal("missing/duplicate next award heading")
+ }
+ plain := renderPokerPayTable(snap, "none")
+ if strings.Contains(plain, "\x1b") {
+ t.Fatal("plain display contains escapes")
+ }
+ }
+}
+
+func TestPokerResultsBelowCardsAndHoldStyle(t *testing.T) {
+ cards := []casino.CardView{{Rank: "A", Suit: "hearts"}, {Rank: "K", Suit: "hearts"}, {Rank: "Q", Suit: "hearts"}, {Rank: "J", Suit: "hearts"}, {Rank: "10", Suit: "hearts"}}
+ cfg := (casino.VideoPokerConfig{Cabinet: casino.CabinetClassic}).Normalize()
+ for _, hands := range []int{1, 3, 100} {
+ for _, win := range []bool{false, true} {
+ line := casino.PokerLine{Cards: cards, Category: "No win", Multiplier: 1}
+ fg, text := 15, "No win"
+ if win {
+ line.Category = "Royal flush"
+ line.Coins = 4000
+ fg = pokerYellow
+ text = "WIN: Royal flush — 4000 coins"
+ }
+ snap := &casino.VideoPokerSnapshot{Config: cfg, Hands: hands, Lines: []casino.PokerLine{line}, Multiplier: 1}
+ out := renderVideoPoker(snap, "xterm256", true, 7, "", nil)
+ styled := color.Render("xterm256", color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text)
+ if !strings.Contains(out, styled) {
+ t.Fatalf("missing result styling: %q", out)
+ }
+ plain := stripANSI(out)
+ result := strings.Index(plain, text)
+ // The result starts its own line and follows the card/badge line.
+ if result < 0 || result == 0 || plain[result-1] != '\n' || strings.Index(plain, "CLASSIC") > result {
+ t.Fatalf("result not below cards: %s", plain)
+ }
+ if hands == 100 && !strings.Contains(plain, "♥") {
+ t.Fatal("hundred-play missing compact Unicode suits")
+ }
+ }
+ }
+ held := [5]bool{true, false, true, false, true}
+ art := strings.Join(renderPokerCards(cards, &held, "xterm256", true, 5, "", nil), "\n")
+ for _, n := range []int{1, 3, 5} {
+ if !strings.Contains(art, color.Render("xterm256", color.ColorSpec{Fg: pokerYellow, Bg: -1, Bold: true}, fmt.Sprintf("%d HELD", n))) {
+ t.Fatal("HELD not bold yellow")
+ }
+ }
+}
+
+func TestPokerDrawShortcutLeavesDownAlone(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, "name: Poker\ncasino_tables:\n - id: vp\n game: video_poker\n")
+ g.executeCommand(sess, "d", nil, "d")
+ if !strings.Contains(string(conn.data), "You can't go that way.") {
+ t.Fatalf("d did not route down outside poker: %s", conn.data)
+ }
+ g.executePlay(sess, []string{"vp"}, "play vp")
+ g.executeBet(sess, nil, "bet")
+ g.executeTableAction(sess, "deal")
+ conn.data = nil
+ g.executeCommand(sess, "d", nil, "d")
+ if len(sess.Player.PendingWagers) != 0 || strings.Contains(string(conn.data), "You can't go that way.") {
+ t.Fatal("seated d did not settle the hand")
+ }
+}
diff --git a/internal/game/video_poker_test.go b/internal/game/video_poker_test.go
new file mode 100644
index 0000000..f2ea43f
--- /dev/null
+++ b/internal/game/video_poker_test.go
@@ -0,0 +1,79 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+)
+
+func TestVideoPokerCommandsAndLook(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, "name: Poker\ncasino_tables:\n - id: vp\n game: video_poker\n")
+ g.executePlay(sess, []string{"vp"}, "play vp")
+ conn.data = nil
+ g.executeRules(sess, nil, "rules")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "Jacks or Better") {
+ t.Fatal(text)
+ }
+ g.executeBet(sess, []string{"max"}, "bet max")
+ g.executeTableAction(sess, "deal")
+ if classifyCommand("531") != ClassInstant {
+ t.Fatal("numeric holds rejected by dispatch")
+ }
+ g.executeCommand(sess, "531", nil, "531")
+ table, _ := g.Casino.Participation(sess.Player.Name)
+ snap := table.Status(sess.Player.Name)[0].Snapshot.(*casino.VideoPokerSnapshot)
+ if snap.Held != ([5]bool{true, false, true, false, true}) {
+ t.Fatalf("numeric holds: %+v", snap.Held)
+ }
+ conn.data = nil
+ room, _ := g.World.LoadRoom(1)
+ g.showCasinoGameLook(sess, sess.Player, room)
+ text := stripANSI(string(conn.data))
+ for _, want := range []string{"1 HELD", "3 HELD", "5 HELD", "draw"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("look missing %q: %s", want, text)
+ }
+ }
+ after := table.Status(sess.Player.Name)[0].Snapshot.(*casino.VideoPokerSnapshot)
+ if after.Held != snap.Held {
+ t.Fatal("look altered holds")
+ }
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "No wagers are available") || strings.Contains(text, "COINS PER HAND") {
+ t.Fatal(text)
+ }
+ g.executeCommand(sess, "1", []string{"3", "5"}, "1 3 5")
+ if got := table.Status(sess.Player.Name)[0].Snapshot.(*casino.VideoPokerSnapshot).Held; got != ([5]bool{}) {
+ t.Fatalf("holds did not toggle off: %v", got)
+ }
+ g.executeCommand(sess, "d", nil, "d")
+ if table.Status(sess.Player.Name)[0].Snapshot.(*casino.VideoPokerSnapshot).Drawing || len(sess.Player.PendingWagers) != 0 {
+ t.Fatal("d did not draw")
+ }
+ g.executeCommand(sess, "game", []string{"deuces", "wild"}, "game deuces wild")
+ conn.data = nil
+ g.executeTableStatus(sess, []string{"rules"}, "table rules")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "Deuces Wild / 25/15/9/5/3/2") || !strings.Contains(text, "4000") || !strings.Contains(text, "COINS PER HAND") {
+ t.Fatal(text)
+ }
+}
+func TestBlackjackListUsesRealActionCommands(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, "name: BJ\ncasino_tables:\n - id: bj\n game: blackjack\n")
+ g.executePlay(sess, []string{"bj"}, "play bj")
+ // Deal until a hand has available action rows (a natural can settle immediately).
+ table, _ := g.Casino.Participation(sess.Player.Name)
+ for i := 0; i < 10 && len(table.Wagers(sess.Player.Name)) == 0; i++ {
+ g.executeBet(sess, []string{"1"}, "bet 1")
+ }
+ if len(table.Wagers(sess.Player.Name)) == 0 {
+ t.Fatal("no action rows")
+ }
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ if strings.Contains(text, "bet <amount> on") || !strings.Contains(text, "Available actions") {
+ t.Fatal(text)
+ }
+}