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 } // clearResult clears the previous hand's presentation when preparing a new // wager. Loaded coins and the cabinet's earned multiplier banks are unchanged. func (p *pokerPlayer) clearResult() { p.lines, p.remaining, p.current, p.next = nil, nil, nil, nil p.result, p.payout, p.drawn = "", 0, false p.held = [5]bool{} p.multiplier, p.dice = 1, [2]int{} } // 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.CabinetName() + " machine" } func (t *videoPokerTable) seats() []string { t.mu.Lock() defer t.mu.Unlock() names := make([]string, 0, len(t.players)) for name := range t.players { names = append(names, name) } return names } func (t *videoPokerTable) state() string { t.mu.Lock() defer t.mu.Unlock() for _, p := range t.players { if p.active { return "playing" } } return "waiting" } 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: "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} } // board is the table-only view for the table command: the paytable and game // state without the balance line or command menu. func (t *videoPokerTable) board(name string) Snapshot { t.mu.Lock() defer t.mu.Unlock() for _, e := range t.statusLocked(name, true) { if e.Snapshot != nil { return e.Snapshot } } return nil } 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.")} } 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.")} } 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, walletErrorMessage(err, "Not enough chips and credits for that wager."))} } 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 stripMenuBalance(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 "unbet": // Coins loaded but not yet dealt are the only removable wager; once // the hand is dealt the stake is in play. if p.loaded == 0 { return []Event{t.private(name, "Nothing to take back.")} } p.loaded = 0 p.clearResult() return t.statusLocked(name, true) 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.clearResult() 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.clearResult() 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.clearResult() 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.clearResult() 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" p.clearResult() return t.statusLocked(name, false) } return []Event{t.private(name, "Use bet [1-4|MAX], unbet, deal, game NAME, 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 = "" events := t.statusLocked(name, false) lines := make([]SettlementLine, 0, len(p.lines)) for i, hand := range p.lines { stake := p.wager.Total / p.hands returned := hand.Coins * p.denomination result := "loss" if hand.Coins > 0 { result = "win" } odds := "Varies" if hand.Coins > 0 { odds = profitOdds(returned-stake, stake) } lines = append(lines, SettlementLine{Label: fmt.Sprintf("Hand %d · %s", i+1, hand.Category), Odds: odds, Stake: stake, Returned: returned, Result: result}) } events[0].Settlement = lines 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 }