aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-03 02:16:00 -0400
committerhistoria <[not public]>2026-10-03 02:16:00 -0400
commit6232d41e5639f9c38e47b2bb65266e343ca26e8f (patch)
treead0c1676838a6c7e162d3a5c055f914016dac803 /internal/game
parentefccf58741ae43d9e891c6b743b146982b344ccf (diff)
downloadthehouseoficarus-6232d41e5639f9c38e47b2bb65266e343ca26e8f.tar.gz
feat: add casino_hints option and standardize casino command syntax
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go48
-rw-r--r--internal/game/casino_commands.go35
-rw-r--r--internal/game/casino_commands_test.go8
-rw-r--r--internal/game/casino_hints_test.go62
-rw-r--r--internal/game/casino_house_edge.go28
-rw-r--r--internal/game/casino_look_test.go2
-rw-r--r--internal/game/casino_rules_list_test.go4
-rw-r--r--internal/game/casino_test.go40
-rw-r--r--internal/game/casino_ux.go8
-rw-r--r--internal/game/cmd_color.go1
-rw-r--r--internal/game/cmd_verbs.go25
-rw-r--r--internal/game/look_casino_test.go2
-rw-r--r--internal/game/pai_gow_test.go9
-rw-r--r--internal/game/render_craps_test.go2
-rw-r--r--internal/game/render_help.go2
-rw-r--r--internal/game/render_roulette_test.go2
-rw-r--r--internal/game/roulette_test.go3
-rw-r--r--internal/game/video_poker_test.go2
18 files changed, 227 insertions, 56 deletions
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 <game> [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 <amount>, 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 <amount> [on] <target> | bet min|max [on] <target>.
+ // 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 <n> on <bet>. 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 <amount> [on <target>], 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 <amount> [on <target>], 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 <amount> [on <target>], 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 <amount> [on <target>], 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 <amount> on <target>", 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 <amount> 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 <amount> on <target>", 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, "<n>") {
- entry.bet = strings.ReplaceAll(wager.Bet, "<n>", 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 <amount>") {
+ 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 <amount>"] {
+ 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{"<number>", "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 <amount>, 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 <amount>", 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 <amount>\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 <amount> / 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 <amount>"
+ list[0].bet = "bet N"
g.showCasinoListTable(sess, "Bet", list)
return
}
- g.showCasinoListTable(sess, "bet <n> 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 <amount> 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 <amount> fortune", "bet <amount>", "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 <amount>") {
+ 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 <n> on <bet>")
+ 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 <amount> [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 <n> on <bet>")
+ 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 <amount> on") || len(listTargets(text)) == 0 {
+ if strings.Contains(text, "bet N on") || len(listTargets(text)) == 0 {
t.Fatal(text)
}
}