diff options
| author | historia <[not public]> | 2026-10-01 04:26:20 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-01 04:26:20 -0400 |
| commit | 0ba388bbe229dd1ccfcb0831d21a5542ae0935f1 (patch) | |
| tree | c5686cf404e3dc5001d2c9b2792749ae7a3c9801 /internal/casino/video_poker.go | |
| parent | e3ff06fc79682673194c8913f0feb9e90920e7d0 (diff) | |
| download | thehouseoficarus-0ba388bbe229dd1ccfcb0831d21a5542ae0935f1.tar.gz | |
feat: video poker machines added to casino
Diffstat (limited to 'internal/casino/video_poker.go')
| -rw-r--r-- | internal/casino/video_poker.go | 545 |
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 +} |
