aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/video_poker.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/video_poker.go')
-rw-r--r--internal/casino/video_poker.go545
1 files changed, 545 insertions, 0 deletions
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
+}