diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/blackjack_rules.go | 33 | ||||
| -rw-r--r-- | internal/game/casino.go | 25 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 46 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 71 | ||||
| -rw-r--r-- | internal/game/look_entities.go | 4 | ||||
| -rw-r--r-- | internal/game/look_target.go | 2 | ||||
| -rw-r--r-- | internal/game/render_video_poker.go | 216 | ||||
| -rw-r--r-- | internal/game/vesta_world_test.go | 18 | ||||
| -rw-r--r-- | internal/game/video_poker_banks.go | 145 | ||||
| -rw-r--r-- | internal/game/video_poker_cabinet_test.go | 177 | ||||
| -rw-r--r-- | internal/game/video_poker_render_test.go | 112 | ||||
| -rw-r--r-- | internal/game/video_poker_test.go | 79 |
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) + } +} |
