aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-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
12 files changed, 909 insertions, 19 deletions
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)
+ }
+}