From 6232d41e5639f9c38e47b2bb65266e343ca26e8f Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 3 Oct 2026 02:16:00 -0400 Subject: feat: add casino_hints option and standardize casino command syntax --- internal/game/casino.go | 48 +++++++++++++++---------- internal/game/casino_commands.go | 35 +++++++++++++++++-- internal/game/casino_commands_test.go | 8 ++--- internal/game/casino_hints_test.go | 62 +++++++++++++++++++++++++++++++++ internal/game/casino_house_edge.go | 28 +++++++++++++-- internal/game/casino_look_test.go | 2 +- internal/game/casino_rules_list_test.go | 4 +-- internal/game/casino_test.go | 40 +++++++++++++-------- internal/game/casino_ux.go | 8 ++--- internal/game/cmd_color.go | 1 + internal/game/cmd_verbs.go | 25 +++++++++++++ internal/game/look_casino_test.go | 2 +- internal/game/pai_gow_test.go | 9 +++-- internal/game/render_craps_test.go | 2 +- internal/game/render_help.go | 2 +- internal/game/render_roulette_test.go | 2 +- internal/game/roulette_test.go | 3 ++ internal/game/video_poker_test.go | 2 +- 18 files changed, 227 insertions(+), 56 deletions(-) create mode 100644 internal/game/casino_hints_test.go (limited to 'internal/game') diff --git a/internal/game/casino.go b/internal/game/casino.go index 0b16114..8f18614 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -342,7 +342,7 @@ func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) { if len(args) > 1 { amount, err := strconv.Atoi(args[1]) if !choice.table || len(args) > 3 || (args[1] != "min" && args[1] != "max" && (err != nil || amount <= 0)) { - sess.WriteLine("Use play [amount|min|max] [target]. No wager was placed.") + sess.WriteLine("Use play GAME [N|min|max] [on BET]. No wager was placed.") return } } @@ -406,7 +406,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { return } } - sess.WriteLine("Use bet, bet , or bet max.") + sess.WriteLine("Use bet, bet N, or bet max.") return } table, playing := g.Casino.Participation(p.Name) @@ -424,7 +424,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { g.emitCasinoEvents(g.Casino.RebetAll(table.RoomID, table.Config.ID, p.Name, casinoWallet{g: g, p: p})) return } - // Table bet syntax: bet | bet [on] | bet min|max [on] . + // Table bet syntax: bet | bet N [on BET] | bet min|max [on BET]. // 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 // next token is always the target. That makes numeric targets reachable @@ -438,26 +438,21 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { for i, arg := range args { if strings.EqualFold(arg, "on") { if sawOn || spot != "" || i == len(args)-1 { - sess.WriteLine("Use bet on . No wager was placed.") + sess.WriteLine("Use bet N on BET. No wager was placed.") return } sawOn = true continue } - if !sawOn && !(joinTarget && hasAmount && spot != "") { + // Once an amount is set, every remaining token belongs to the target + // ("bet 2 2" and "bet 2 on 2" are identical). Before that, a numeric + // token sets the amount and anything else starts the target. + if !sawOn && !hasAmount { if strings.EqualFold(arg, "min") { - if hasAmount { - sess.WriteLine("Use bet, bet [on ], bet min, or bet max.") - return - } amount, hasAmount = table.Config.MinBet, true continue } if strings.EqualFold(arg, "max") { - if hasAmount { - sess.WriteLine("Use bet, bet [on ], bet min, or bet max.") - return - } amount = min(table.Config.MaxBet, casinoAffordable(p)) if amount < 1 { sess.WriteLine("You don't have any chips or credits to bet.") @@ -467,10 +462,6 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { continue } if value, err := strconv.Atoi(arg); err == nil && value > 0 { - if hasAmount { - sess.WriteLine("Use bet, bet [on ], bet min, or bet max.") - return - } amount, hasAmount = value, true continue } @@ -483,7 +474,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { spot += " " + strings.ToLower(arg) continue } - sess.WriteLine("Use bet, bet [on ], bet min, or bet max.") + sess.WriteLine("Use bet, bet N [on BET], bet min, or bet max.") return } if table.Config.Game == casino.GameBaccarat { @@ -657,6 +648,9 @@ func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message heading = message } commands = append([]casino.MenuCommand(nil), menu.Commands...) + if !g.casinoHintsVisible(sess, menu) { + commands = nil + } for i := range commands { if commands[i].Cost > casinoAffordable(sess.Player) { commands[i].Desc += " [unavailable: insufficient funds]" @@ -665,6 +659,24 @@ func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message return balance, heading, commands } +// casinoHintsVisible decides whether a menu's command list prints. "on" +// always shows hints (the historical behavior); "off" never does; the default +// "once" shows them only on the menu a player gets when sitting down. Players +// can always ask for the live list with "what" or "verbs". +func (g *Game) casinoHintsVisible(sess *net.Session, menu *casino.MenuView) bool { + if sess == nil || sess.Player == nil || menu == nil { + return true + } + switch sess.Player.OptionString("casino_hints") { + case "off": + return false + case "once": + return menu.ShowHints + default: + return true + } +} + // casinoBoardReminder splits a persistent-board menu (craps, roulette, video // poker, Pai Gow) so its instructions print under the board they describe. // Every other game, and compact displays, keep their menu in one block. diff --git a/internal/game/casino_commands.go b/internal/game/casino_commands.go index f4ca79f..b6deb21 100644 --- a/internal/game/casino_commands.go +++ b/internal/game/casino_commands.go @@ -1,6 +1,7 @@ package game import ( + "regexp" "strings" "unicode/utf8" @@ -8,6 +9,34 @@ import ( "thehouseoficarus/internal/net" ) +// casinoCommandVariable matches a command variable: an uppercase run such as +// N in "bet N on BET" or MAX in "bet [1-4|MAX]". Literal command text is +// lowercase, so every uppercase run is a placeholder the player replaces. +var casinoCommandVariable = regexp.MustCompile(`[A-Z]+`) + +// colorizeCommand paints literal command text in the command color and each +// uppercase variable in the distinct variable color. Mode "none" collapses to +// plain text, and text without variables gets a single color wrapper. +func (g *Game) colorizeCommand(sess *net.Session, text string) string { + matches := casinoCommandVariable.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return g.colorize(sess, "command", text) + } + var sb strings.Builder + last := 0 + for _, m := range matches { + if m[0] > last { + sb.WriteString(g.colorize(sess, "command", text[last:m[0]])) + } + sb.WriteString(g.colorize(sess, "command_variable", text[m[0]:m[1]])) + last = m[1] + } + if last < len(text) { + sb.WriteString(g.colorize(sess, "command", text[last:])) + } + return sb.String() +} + // Wrap before Session.WriteLine: generic prose wrapping strips alignment from // overlong lines. Every casino menu uses the same two-space command indent and // hanging description column, capped at 80 even when prose wrapping is off. @@ -35,7 +64,7 @@ func (g *Game) casinoCommandColumns(sess *net.Session, commands []casino.MenuCom n := utf8.RuneCountInString(c.Command) if n > limit/2 { for _, line := range casinoWrapWords(c.Command, max(1, limit-2)) { - lines = append(lines, " "+g.colorize(sess, "command", line)) + lines = append(lines, " "+g.colorizeCommand(sess, line)) } for _, line := range casinoWrapWords(c.Desc, max(1, limit-4)) { lines = append(lines, " "+line) @@ -43,13 +72,13 @@ func (g *Game) casinoCommandColumns(sess *net.Session, commands []casino.MenuCom continue } if strings.TrimSpace(c.Desc) == "" { - lines = append(lines, " "+g.colorize(sess, "command", c.Command)) + lines = append(lines, " "+g.colorizeCommand(sess, c.Command)) continue } indent := width + 4 desc := casinoWrapWords(c.Desc, max(1, limit-indent)) cmd := c.Command + strings.Repeat(" ", width-n) - lines = append(lines, " "+g.colorize(sess, "command", cmd)+" "+desc[0]) + lines = append(lines, " "+g.colorizeCommand(sess, cmd)+" "+desc[0]) for _, line := range desc[1:] { lines = append(lines, strings.Repeat(" ", indent)+line) } diff --git a/internal/game/casino_commands_test.go b/internal/game/casino_commands_test.go index cd558ea..6b41ecc 100644 --- a/internal/game/casino_commands_test.go +++ b/internal/game/casino_commands_test.go @@ -12,13 +12,13 @@ import ( func TestCasinoCommandsWrapBeforeSessionWithoutLosingIndent(t *testing.T) { commands := []casino.MenuCommand{ - {Command: "bet on ", Desc: "Place bets until the dice roll; list gives targets"}, + {Command: "bet N on BET", Desc: "Place bets until the dice roll; list gives targets"}, {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"}, {Command: "unbet", Desc: "Remove all your bets"}, {Command: "table", Desc: "View the felt"}, {Command: "insurance", Desc: "Buy insurance; a deliberately long description which requires a hanging continuation line"}, {Command: "decline", Desc: "Continue without insurance (also the timeout default)"}, - {Command: "bet on player/banker/tie/player_pair/banker_pair", Desc: "One wager per round; 'on' is optional"}, + {Command: "bet N on player/banker/tie/player_pair/banker_pair", Desc: "One wager per round; 'on' is optional"}, {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, {Command: "spin", Desc: "Spin using your current wager"}, {Command: "autospin", Desc: "Start automatic betting"}, @@ -58,7 +58,7 @@ func TestCasinoCommandsHangingIndentAndShortDescription(t *testing.T) { sess := &net.Session{Player: player.New("a")} g := &Game{} commands := []casino.MenuCommand{ - {Command: "bet on ", Desc: "Place a wager; targets: list"}, + {Command: "bet N on BET", Desc: "Place a wager; targets: list"}, {Command: "table", Desc: "View the felt"}, } text := plainRoulette(g.casinoMenuCommands(sess, commands)) @@ -81,7 +81,7 @@ func TestCasinoCommandsHangingIndentAndShortDescription(t *testing.T) { } func TestCasinoCompactHintsUseOnlyAvailableCommands(t *testing.T) { p := player.New("a") - p.Options = map[string]any{"casino_display": "compact"} + p.Options = map[string]any{"casino_display": "compact", "casino_hints": "on"} p.Credits = 10 sess := &net.Session{Player: p} g := &Game{} diff --git a/internal/game/casino_hints_test.go b/internal/game/casino_hints_test.go new file mode 100644 index 0000000..21739d2 --- /dev/null +++ b/internal/game/casino_hints_test.go @@ -0,0 +1,62 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// The default must be the low-noise "once" mode. +func TestCasinoHintsDefaultIsOnce(t *testing.T) { + if got := player.New("alice").OptionString("casino_hints"); got != "once" { + t.Fatalf("casino_hints default=%q, want once", got) + } +} + +// casino_hints on shows hints on every menu; once shows them at sit-down only; +// off never does. "what" always lists the live hints regardless. +func TestCasinoHintsOption(t *testing.T) { + for _, tc := range []struct { + mode string + join, after bool + }{ + {"on", true, true}, + {"once", true, false}, + {"off", false, false}, + } { + t.Run(tc.mode, func(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + g.Hub = net.NewHub() + g.Hub.EnterRoom(sess, 1) + sess.Player.Options = map[string]any{"color": "none", "unicode": true, "casino_hints": tc.mode} + cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{SpinTicks: 8}} + g.emitCasinoEvents(g.Casino.Join(1, cfg, sess.Player.Name)) + if got := strings.Contains(string(conn.data), "bet N on BET"); got != tc.join { + t.Fatalf("join hints=%v, want %v: %q", got, tc.join, conn.data) + } + sess.ClearPrompt() + g.executeBet(sess, []string{"2", "2"}, "bet 2 2") + if !strings.Contains(string(conn.data), "You bet 2 on 2.") { + t.Fatalf("bet N N did not resolve: %q", conn.data) + } + for i := 0; i < 8; i++ { + conn.data = nil + g.CasinoTick() + } + if got := strings.Contains(string(conn.data), "bet N on BET"); got != tc.after { + t.Fatalf("post-settlement hints=%v, want %v: %q", got, tc.after, conn.data) + } + // "what" is an explicit request: it always lists the live hints + // and never the room's exits while seated. + conn.data = nil + g.executeVerbs(sess, nil, "what") + text := string(conn.data) + if !strings.Contains(text, "bet N on BET") || strings.Contains(text, "Exits:") || strings.Contains(text, "north") { + t.Fatalf("what at the table wrong: %q", text) + } + }) + } +} diff --git a/internal/game/casino_house_edge.go b/internal/game/casino_house_edge.go index 1abbcac..de0036b 100644 --- a/internal/game/casino_house_edge.go +++ b/internal/game/casino_house_edge.go @@ -76,8 +76,8 @@ func (g *Game) casinoListRows(sess *net.Session, t *casino.Table, wagers []casin // Different target returns need distinct rows, not a list of unlabeled RTPs. for _, part := range row.edge.Parts { entry := row - if strings.Contains(wager.Bet, "") { - entry.bet = strings.ReplaceAll(wager.Bet, "", part.Label) + if bet, ok := expandBetNumber(wager.Bet, part.Label); ok { + entry.bet = bet } else if part.Label != "" { // The label refines the shared win text (hop's doubles), so append // it: the base phrase stays "Next roll is a/b (doubles)". @@ -90,6 +90,24 @@ func (g *Game) casinoListRows(sess *net.Session, t *casino.Table, wagers []casin return rows } +// expandBetNumber replaces the standalone number variable N in a grouped +// wager row ("buy N", "odds dont N") with one concrete number. It reports +// false when the row has no N token, leaving the label to refine the win text. +func expandBetNumber(bet, number string) (string, bool) { + fields := strings.Fields(bet) + found := false + for i, f := range fields { + if f == "N" { + fields[i] = number + found = true + } + } + if !found { + return bet, false + } + return strings.Join(fields, " "), true +} + // Explicit list-only presentation. Wrap plain cells before applying color and // handing them to the existing ANSI-aware table renderer; never truncate rules. // The table stands alone: list names no heading and no balance above it, and @@ -174,7 +192,11 @@ func (g *Game) showCasinoListTable(sess *net.Session, betHeader string, rows []c category = "casino_action" } physical[i] = parts[line] - if category != "" { + switch category { + case "command": + physical[i] = g.colorizeCommand(sess, physical[i]) + case "": + default: physical[i] = g.colorize(sess, category, physical[i]) } } diff --git a/internal/game/casino_look_test.go b/internal/game/casino_look_test.go index cdb0b59..732bc6b 100644 --- a/internal/game/casino_look_test.go +++ b/internal/game/casino_look_test.go @@ -81,7 +81,7 @@ func TestLookStandingShowsFixtureOnly(t *testing.T) { h.hub.EnterRoom(sess, 4) h.g.executeLook(sess, nil, "look") text := lookOutput(t, conn) - if strings.Contains(text, "bet ") { + if strings.Contains(text, "bet N") { t.Fatalf("standing look leaked a betting menu: %q", text) } if !strings.Contains(text, "play craps") { diff --git a/internal/game/casino_rules_list_test.go b/internal/game/casino_rules_list_test.go index f0c72d2..f3a18e6 100644 --- a/internal/game/casino_rules_list_test.go +++ b/internal/game/casino_rules_list_test.go @@ -170,7 +170,7 @@ func TestListSingleBetGamesRenderOneRow(t *testing.T) { conn.data = nil g.executeList(sess, nil, "list") text = stripANSI(string(conn.data)) - if targets := listTargets(text); !targets["bet "] { + if targets := listTargets(text); !targets["bet N"] { t.Fatalf("blackjack list: %q", text) } } @@ -255,7 +255,7 @@ func TestListRouletteAndBaccarat(t *testing.T) { g.executeList(sess, nil, "list") text := stripANSI(string(conn.data)) targets := listTargets(text) - for _, want := range []string{"", "red / black", "voisins", "basket"} { + for _, want := range []string{"NUMBER", "red / black", "voisins", "basket"} { if !targets[want] { t.Fatalf("roulette list missing %q: %q", want, text) } diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go index 2f72d07..0b82945 100644 --- a/internal/game/casino_test.go +++ b/internal/game/casino_test.go @@ -63,7 +63,7 @@ func TestExecuteBetRejectsInvalidMachineArgs(t *testing.T) { for _, args := range [][]string{{"banana"}, {"-5"}, {"10", "extra"}} { conn.data = nil g.executeBet(sess, args, "bet "+strings.Join(args, " ")) - if !strings.Contains(string(conn.data), "Use bet, bet , or bet max.") { + if !strings.Contains(string(conn.data), "Use bet, bet N, or bet max.") { t.Fatalf("bet %v: expected usage error, got %q", args, string(conn.data)) } } @@ -110,13 +110,18 @@ func TestExecuteBetParsesBaccaratSpots(t *testing.T) { if !strings.Contains(string(conn.data), "must be on player, banker, or tie") { t.Fatalf("bet 5 on 17: expected the table to receive the numeric spot, got %q", string(conn.data)) } - // Two amounts or two spots are usage errors. - for _, args := range [][]string{{"10", "20"}, {"player", "banker"}} { - conn.data = nil - g.executeBet(sess, args, "bet "+strings.Join(args, " ")) - if !strings.Contains(string(conn.data), "Use bet,") { - t.Fatalf("bet %v: expected usage error, got %q", args, string(conn.data)) - } + // A number after the amount is the target ("bet 10 20" == "bet 10 on + // 20"), so baccarat rejects the value rather than the syntax. + conn.data = nil + g.executeBet(sess, []string{"10", "20"}, "bet 10 20") + if !strings.Contains(string(conn.data), "must be on player, banker, or tie") { + t.Fatalf("bet 10 20: expected the table to receive the numeric spot, got %q", string(conn.data)) + } + // Two spots remain a usage error. + conn.data = nil + g.executeBet(sess, []string{"player", "banker"}, "bet player banker") + if !strings.Contains(string(conn.data), "Use bet,") { + t.Fatalf("bet player banker: expected usage error, got %q", string(conn.data)) } // A bare first bet asks for a target (via the rebet path). conn.data = nil @@ -129,22 +134,29 @@ func TestExecuteBetParsesBaccaratSpots(t *testing.T) { func TestCasinoMenuRendersAlignedColoredCommands(t *testing.T) { g := &Game{Casino: casino.NewManager(1)} conn := &casinoTestConn{} - sess := &net.Session{Player: player.New("alice"), Conn: conn} + p := player.New("alice") + p.Options = map[string]any{"casino_hints": "on"} + sess := &net.Session{Player: p, Conn: conn} g.loggedInChars = map[string]*net.Session{"alice": sess} menu := &casino.MenuView{Heading: "Your slot machine bet is set to 10.", ShowBalance: true, Commands: []casino.MenuCommand{ - {Command: "bet ", Desc: "Set your wager"}, + {Command: "bet N", Desc: "Set your wager"}, {Command: "spin", Desc: "Spin using your current wager"}, }} msg := g.casinoPrivateMessage(sess, "casino_menu", menu, "Your slot machine bet is set to 10.") if !strings.Contains(regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(msg, ""), "Available 0 (0 chips + 0 credits)") { t.Fatalf("missing balance line: %q", msg) } - // Commands are painted with the "command" color (0A bold). - if !strings.Contains(msg, "\033[1m\033[38;5;10mbet \033[0m") { - t.Fatalf("command not colored: %q", msg) + // Literal text uses the "command" color (0A bold); the N variable uses the + // distinct "command_variable" color (B4 bold). + if !strings.Contains(msg, color.FgCode("xterm256", 10)) || !strings.Contains(msg, color.FgCode("xterm256", 180)) { + t.Fatalf("command/variable not colored: %q", msg) + } + plain := regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(msg, "") + if !strings.Contains(plain, "bet N") { + t.Fatalf("command text missing: %q", plain) } // Shorter commands are right-padded to align the descriptions. - if !strings.Contains(msg, "\033[1m\033[38;5;10mspin \033[0m Spin using your current wager") { + if !strings.Contains(msg, "\033[1m\033[38;5;10mspin \033[0m Spin using your current wager") { t.Fatalf("commands not aligned: %q", msg) } } diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 4c9accb..e44da54 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -355,7 +355,7 @@ func (g *Game) executeList(sess *net.Session, args []string, rawInput string) { return } if machine, ok := g.Casino.MachineFor(p.Name); ok { - row := casinoListRow{bet: "bet / max", odds: "paytable", win: "spin / autospin"} + row := casinoListRow{bet: "bet N / MAX", odds: "paytable", win: "spin / autospin"} if _, enabled := g.casinoEdgePrecision(sess); enabled { row.edge = machine.Config.HouseEdge() } @@ -380,7 +380,7 @@ func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) { } } rows := g.casinoListRows(sess, t, []casino.WagerRow{{Bet: "main", Payout: "paytable", Win: "deal"}}) - rows[0].bet = "bet [1-4|max]" + rows[0].bet = "bet [1-4|MAX]" g.showCasinoListTable(sess, "Bet", rows) return } @@ -406,11 +406,11 @@ func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) { wager.Payout = fmt.Sprintf("1:1; BJ %g:1", t.Config.Blackjack.Normalize().BlackjackPayout) } list := g.casinoListRows(sess, t, []casino.WagerRow{wager}) - list[0].bet = "bet " + list[0].bet = "bet N" g.showCasinoListTable(sess, "Bet", list) return } - g.showCasinoListTable(sess, "bet on", g.casinoListRows(sess, t, rows)) + g.showCasinoListTable(sess, "bet N on", g.casinoListRows(sess, t, rows)) } // showCasinoGameLook appends the live game state and hint menu for the diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go index 104d359..304e99f 100644 --- a/internal/game/cmd_color.go +++ b/internal/game/cmd_color.go @@ -181,6 +181,7 @@ var colorCategoryOrder = []string{ // Commands "command", + "command_variable", // Drops / consume / fire "drop_message", diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go index 24957ab..d13b803 100644 --- a/internal/game/cmd_verbs.go +++ b/internal/game/cmd_verbs.go @@ -4,6 +4,7 @@ import ( "strings" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) @@ -46,6 +47,19 @@ var alwaysAvailable = []string{ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { p := sess.Player + // A seated casino player has no room verbs: the only valid commands are + // the game's live hints, so what/verbs shows those instead of exits and + // objects that cannot be used mid-hand. + if g.Casino != nil { + if table, playing := g.Casino.Participation(p.Name); playing { + if table != nil { + g.showCasinoHints(sess, table.Hints(p.Name)) + } else if machine, ok := g.Casino.MachineFor(p.Name); ok { + g.showCasinoHints(sess, machine.Hints()) + } + return + } + } showAll := len(args) == 1 && strings.ToLower(args[0]) == "all" seen := make(map[string]bool) @@ -208,6 +222,17 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) { } } +// showCasinoHints prints the live command hints for a seated player. It is +// the casino branch of the what/verbs command, so a casino never lists room +// exits that are not valid options while seated. +func (g *Game) showCasinoHints(sess *net.Session, commands []casino.MenuCommand) { + if len(commands) == 0 { + sess.WriteLine(g.colorize(sess, "casino_action", "No commands are available right now.")) + return + } + sess.WriteLine(g.casinoMenuCommands(sess, commands)) +} + func addUnique(list *[]string, seen *map[string]bool, cmd string) { if (*seen)[cmd] { return diff --git a/internal/game/look_casino_test.go b/internal/game/look_casino_test.go index 651c74f..d53e02d 100644 --- a/internal/game/look_casino_test.go +++ b/internal/game/look_casino_test.go @@ -42,7 +42,7 @@ func TestLookCasinoTableDetailsAndBoards(t *testing.T) { t.Fatalf("look craps missing %q: %s", want, text) } } - for _, gone := range []string{"PASS ONLY", "bet on pass", "HOUSE EDITION"} { + for _, gone := range []string{"PASS ONLY", "bet N on pass", "HOUSE EDITION"} { if strings.Contains(text, gone) { t.Fatalf("look craps showed invariant content %q: %s", gone, text) } diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go index 5ba367e..830b080 100644 --- a/internal/game/pai_gow_test.go +++ b/internal/game/pai_gow_test.go @@ -299,11 +299,16 @@ func TestPaiGowFeltAndJokerAllModes(t *testing.T) { func TestPaiGowContextHintsFollowState(t *testing.T) { g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n min_bet: 5\n max_bet: 1000\n pai_gow:\n envy: true\n") g.executePlay(sess, []string{"pai"}, "play pai") + // Isolate the state-driven hint content from the casino_hints option. + if sess.Player.Options == nil { + sess.Player.Options = map[string]any{} + } + sess.Player.Options["casino_hints"] = "on" // The join menu carries the context hints: the side bet, the main wager, // and the shared tail. text := stripANSI(string(conn.data)) - for _, want := range []string{"bet fortune", "bet ", "table", "rules", "stop"} { + for _, want := range []string{"bet N fortune", "bet N", "table", "rules", "stop"} { if !strings.Contains(text, want) { t.Fatalf("missing %q in the join menu: %s", want, text) } @@ -313,7 +318,7 @@ func TestPaiGowContextHintsFollowState(t *testing.T) { conn.data = nil g.executeTableStatus(sess, nil, "table") text = stripANSI(string(conn.data)) - if !strings.Contains(text, "PAI GOW POKER") || strings.Contains(text, "bet ") { + if !strings.Contains(text, "PAI GOW POKER") || strings.Contains(text, "bet N") { t.Fatalf("table should be the felt alone: %s", text) } diff --git a/internal/game/render_craps_test.go b/internal/game/render_craps_test.go index b6a70be..4445844 100644 --- a/internal/game/render_craps_test.go +++ b/internal/game/render_craps_test.go @@ -207,7 +207,7 @@ func TestCrapsRemindersFollowArt(t *testing.T) { conn.data = nil g.emitCasinoEvents(g.Casino.Table(1, "c").Status("alice")) text := plainRoulette(string(conn.data)) - felt, commands := strings.Index(text, "PASS LINE"), strings.Index(text, "bet on ") + felt, commands := strings.Index(text, "PASS LINE"), strings.Index(text, "bet N on BET") if felt < 0 || commands < felt { t.Fatal("reminder above graphic", text) } diff --git a/internal/game/render_help.go b/internal/game/render_help.go index 28f2c10..9e8b45a 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -67,7 +67,7 @@ var commandList = []cmdEntry{ {"table / status", "Instant", "Redisplay the table felt; rules for the help page"}, {"rules", "Instant", "Help page for the casino game you're playing (one per house edition)"}, {"watch", "Instant", "Watch detailed table action; watch off to stop"}, - {"bet / rebet", "Active", "Repeat a wager, or bet [target]; min/max accepted"}, + {"bet / rebet", "Active", "Repeat a wager, or bet N [on BET]; min/max accepted"}, {"autobet / autospin", "Active", "Automatically repeat a casino wager"}, {"spin", "Active", "Spin the current casino wager"}, {"hit / stand / double / split", "Active", "Play blackjack (shortcuts: h / st / dd / sp)"}, diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index e4fe650..cfd8b01 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -605,7 +605,7 @@ func TestRouletteMenuPutsReminderAfterBoard(t *testing.T) { out := strings.ReplaceAll(string(conn.data), "\r\n", "\n") tracker := strings.Index(out, "History: last 0 spins") board := strings.Index(out, "R = red, B = black, G = green") - reminder := strings.Index(out, "bet on ") + reminder := strings.Index(out, "bet N on BET") if tracker < 0 || board < 0 || reminder < 0 { t.Fatalf("missing tracker, board, or reminder:\n%s", out) } diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go index 481044d..fb773a3 100644 --- a/internal/game/roulette_test.go +++ b/internal/game/roulette_test.go @@ -57,6 +57,9 @@ func TestRouletteCombinationTargetParsing(t *testing.T) { g.loggedInChars = map[string]*net.Session{"alice": sess} // Multi-token targets are joined for roulette, with or without "on". valid := [][]string{ + // A numeric target needs no "on": "bet 10 17" == "bet 10 on 17". + {"10", "17"}, + {"10", "on", "17"}, {"10", "on", "split", "1", "2"}, {"10", "on", "split", "1-2"}, {"10", "split", "1-2"}, diff --git a/internal/game/video_poker_test.go b/internal/game/video_poker_test.go index 8ec44d7..4ea7275 100644 --- a/internal/game/video_poker_test.go +++ b/internal/game/video_poker_test.go @@ -73,7 +73,7 @@ func TestBlackjackListUsesRealActionCommands(t *testing.T) { conn.data = nil g.executeList(sess, nil, "list") text := stripANSI(string(conn.data)) - if strings.Contains(text, "bet on") || len(listTargets(text)) == 0 { + if strings.Contains(text, "bet N on") || len(listTargets(text)) == 0 { t.Fatal(text) } } -- cgit v1.2.3