diff options
Diffstat (limited to 'internal')
43 files changed, 320 insertions, 122 deletions
diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go index b6285c4..ddd0270 100644 --- a/internal/admin/api_colors.go +++ b/internal/admin/api_colors.go @@ -294,7 +294,7 @@ func buildPreview() []colorSection { ln(seg(" 2. ", "dialog_options"), seg("Just passing through.", "dialog")), ln(seg("[enter to continue]", "dialog_options")), txt(""), - ln(seg(" ", ""), seg("bet <amount>", "command"), seg(" Set your wager", "")), + ln(seg(" ", ""), seg("bet ", "command"), seg("N", "command_variable"), seg(" on ", "command"), seg("BET", "command_variable"), seg(" Set your wager", "")), ln(seg(" ", ""), seg("spin", "command"), seg(" Spin the reels", "")), }, }) diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 3140fa8..f1ba951 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -120,7 +120,7 @@ func (t *baccaratTable) menuEvent(name string) Event { } commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)}, - MenuCommand{Command: "bet <n> on <bet>"}, + MenuCommand{Command: "bet N on BET"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table", Desc: "Check the table"}) commands = withTableHints(commands, t.wagerRows()) @@ -182,7 +182,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even spot = p.mainSpot } if spot == "" { - return []Event{t.private(name, "Bet on what? Try bet <n> on "+t.Rules.betChoices()+".")} + return []Event{t.private(name, "Bet on what? Try bet N on "+t.Rules.betChoices()+".")} } if !t.Rules.allowsSpot(spot) { return []Event{t.private(name, "Baccarat bets must be on player, banker, or tie, or an enabled optional bet. Available: "+t.Rules.betChoices()+".")} @@ -262,7 +262,7 @@ func (t *baccaratTable) rebetAll(name string, wallet Wallet) []Event { return []Event{t.private(name, "You have already bet a main wager this round.")} } if !p.hasLastBet() { - return []Event{t.private(name, "Bet on what? Try bet <n> on "+t.Rules.betChoices()+".")} + return []Event{t.private(name, "Bet on what? Try bet N on "+t.Rules.betChoices()+".")} } var events []Event for _, spot := range t.Rules.spots() { diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index eee5023..5656129 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -118,7 +118,7 @@ func (t *blackjackTable) menuEvent(name string) Event { } e := t.betMenuEvent(name, []MenuCommand{ {Command: "bet", Desc: fmt.Sprintf("%s: %d credits (also: rebet)", desc, amount)}, - {Command: "bet <amount>", Desc: "Change wager; min/max accepted"}, + {Command: "bet N", Desc: "Change wager; min/max accepted"}, {Command: "stop", Desc: "Leave; during a round, leave afterward"}, {Command: "table", Desc: "Check the table"}, }) diff --git a/internal/casino/blackjack_wagers.go b/internal/casino/blackjack_wagers.go index 23afac8..211962f 100644 --- a/internal/casino/blackjack_wagers.go +++ b/internal/casino/blackjack_wagers.go @@ -2,7 +2,7 @@ package casino import "fmt" -// Blackjack's only standing wager is bet <amount>, so outside the hand's +// Blackjack's only standing wager is bet N, so outside the hand's // decision points its wager list is empty (which also suppresses the "list" // hint). During the insurance window and the player's own turn, the extra // wagers and wagers-that-return-money get listed: insurance, decline, diff --git a/internal/casino/craps.go b/internal/casino/craps.go index 6fa6c79..73c67c4 100644 --- a/internal/casino/craps.go +++ b/internal/casino/craps.go @@ -209,7 +209,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event { spot = p.lastSpot } if spot == "" && t.Config.Craps.variant() == "simplified" { - // Pass is the only target, so "bet <amount>" needs no target at all. + // Pass is the only target, so "bet N" needs no target at all. spot = "pass" } spot = strings.ToLower(strings.TrimSpace(spot)) diff --git a/internal/casino/craps_rules.go b/internal/casino/craps_rules.go index 60a7c0f..1c5e998 100644 --- a/internal/casino/craps_rules.go +++ b/internal/casino/craps_rules.go @@ -119,11 +119,11 @@ func (c TableConfig) CrapsRulesText() string { odds = fmt.Sprintf("%dx", r.Odds) } return prefix + fmt.Sprintf("Bank Craps | Free odds %s | Field 12 pays %d:1\n"+ - "bet <n> on pass / dontpass / come / dontcome / field\n"+ - "bet <n> on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+ - "bet <n> on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+ - "bet <n> on odds pass / odds come 6 (also dontpass/dontcome)\n"+ - "bet <n> on fire / small / tall / all (before shooter's first roll only)\n"+ + "bet N on pass / dontpass / come / dontcome / field\n"+ + "bet N on place 6 / buy 4 / lay 10 / hard 8 / hop 2 3\n"+ + "bet N on seven / craps / eleven / ace-deuce / aces / boxcars / horn\n"+ + "bet N on odds pass / odds come 6 (also dontpass/dontcome)\n"+ + "bet N on fire / small / tall / all (before shooter's first roll only)\n"+ "Pass/come 1:1; don't bars 12. Place 4/10 9:5, 5/9 7:5, 6/8 7:6.\n"+ "Buy/lay true odds; 5%% commission on buy STAKE / lay WIN, rounded down, wins only.\n"+ "Field 2 pays 2:1; 3/4/9/10/11 pay 1:1. Hard 4/10 7:1; 6/8 9:1.\n"+ diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go index 0a7932d..e64feb3 100644 --- a/internal/casino/craps_ux.go +++ b/internal/casino/craps_ux.go @@ -17,16 +17,16 @@ func (t *crapsTable) commands(name string) []MenuCommand { if p.lastBet > 0 { commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Rebet %d on pass (also: rebet)", p.lastBet)}, - MenuCommand{Command: "bet <amount>", Desc: "New amount on pass; the dice roll 3 ticks after a bet"}) + MenuCommand{Command: "bet N", Desc: "New amount on pass; the dice roll 3 ticks after a bet"}) } else { - commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Wager on pass; the dice roll 3 ticks after a bet"}) + commands = append(commands, MenuCommand{Command: "bet N", Desc: "Wager on pass; the dice roll 3 ticks after a bet"}) } } else { - target := "<bet>" + target := "BET" if !t.Config.Craps.allows("buy") { target = "pass" } - commands = append(commands, MenuCommand{Command: "bet <n> on " + target, Desc: "Place a wager"}) + commands = append(commands, MenuCommand{Command: "bet N on " + target, Desc: "Place a wager"}) } } if p.HasBet { diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go index bcb06d0..634af36 100644 --- a/internal/casino/craps_variants.go +++ b/internal/casino/craps_variants.go @@ -213,17 +213,17 @@ func (c CrapsConfig) variantRulesText() string { rules += "\nTrue odds on 4/10: 2:1; 5/9: 3:2; 6/8: 6:5. Lay and don't odds invert these ratios. Buy/lay commission: 5% buy STAKE / lay PROFIT, rounded down, on wins only; removals have no commission." } if c.variant() == "crapless" { - rules += fmt.Sprintf("\nPass/come pay 1:1 on their point before 7; 7 loses. Line bets only on come-out; come requires a table point.\nTargets: pass / come / odds pass / odds come <number> / place <number> / buy <number> / lay <number>.\nPlace 4/10 pays 9:5; 5/9 7:5; 6/8 7:6.\nfield: 2 pays 2:1, 12 pays %d:1, 3/4/9/10/11 pay 1:1; others lose.\nhard 4/10 pays 7:1; hard 6/8 pays 9:1; easy number or 7 loses.\nseven 4:1; craps 7:1; eleven / ace-deuce 15:1; aces / boxcars 30:1.\nhorn splits equally among 2/3/11/12; hop <a> <b> pays 15:1 easy / 30:1 double, unordered.\nPlace/buy/hardways and come odds are off on come-out; established come contracts and lay always work.\nNo Fire or ATS. Making a point retains the shooter; seven-out rotates.", c.Field12) + rules += fmt.Sprintf("\nPass/come pay 1:1 on their point before 7; 7 loses. Line bets only on come-out; come requires a table point.\nTargets: pass / come / odds pass / odds come NUMBER / place NUMBER / buy NUMBER / lay NUMBER.\nPlace 4/10 pays 9:5; 5/9 7:5; 6/8 7:6.\nfield: 2 pays 2:1, 12 pays %d:1, 3/4/9/10/11 pay 1:1; others lose.\nhard 4/10 pays 7:1; hard 6/8 pays 9:1; easy number or 7 loses.\nseven 4:1; craps 7:1; eleven / ace-deuce 15:1; aces / boxcars 30:1.\nhorn splits equally among 2/3/11/12; hop A B pays 15:1 easy / 30:1 double, unordered.\nPlace/buy/hardways and come odds are off on come-out; established come contracts and lay always work.\nNo Fire or ATS. Making a point retains the shooter; seven-out rotates.", c.Field12) } if c.variant() == "new_york" { rules += "\nPass come-out 7/11 wins, 2/3/12 loses; 4/5/6/8/9/10 establish a point. Make it before 7 to win, all 1:1. Don't pass is opposite, except come-out 12 pushes. Line bets only on come-out. Making a point retains the shooter; seven-out rotates." } cadence := "Solo roll anytime; multiplayer waits 10 ticks, then dealer rolls after 60 ticks." autoroll := "Bets stay open until the roll. autoroll repeats every 3 ticks, respecting the betting window." - betLine := "Bet: bet <n> on pass; the felt is authoritative for this edition." + betLine := "Bet: bet N on pass; the felt is authoritative for this edition." stopLine := "stop cancels autoroll; manual stop waits for live bets." if c.variant() == "simplified" { - betLine = "Bet: bet <amount> — always on pass; bare bet rebets your last wager." + betLine = "Bet: bet N — always on pass; bare bet rebets your last wager." cadence = "The dice roll automatically 3 ticks after the first bet." autoroll = "" stopLine = "stop waits for live bets." diff --git a/internal/casino/craps_wagers.go b/internal/casino/craps_wagers.go index a36f43d..beb8722 100644 --- a/internal/casino/craps_wagers.go +++ b/internal/casino/craps_wagers.go @@ -80,8 +80,8 @@ func (t *crapsTable) wagerRows(name string) []WagerRow { // win condition and the 5% vig applies only to the winning wager. func crapsBuyLayRows() []WagerRow { return []WagerRow{ - {"buy <n>", "true", "This number rolled before 7; 5% commission on wins"}, - {"lay <n>", "true", "7 rolled before this number; 5% commission on wins"}, + {"buy N", "true", "This number rolled before 7; 5% commission on wins"}, + {"lay N", "true", "7 rolled before this number; 5% commission on wins"}, } } @@ -95,7 +95,7 @@ func crapsPropRows(c CrapsConfig, bonuses bool) []WagerRow { {"eleven / ace-deuce", "15:1", "Next roll totals 11 / 3"}, {"aces / boxcars", "30:1", "Next roll is 2 / 12"}, {"horn", "27:4", "Splits the stake over 2/3/11/12"}, - {"hop <a> <b>", "15:1/30:1", "Next roll is a/b"}, + {"hop A B", "15:1/30:1", "Next roll is a/b"}, } if bonuses { rows = append(rows, @@ -149,6 +149,6 @@ func crapsPlaceRows(c CrapsConfig) []WagerRow { func crapsOddsRows() []WagerRow { return []WagerRow{ {"odds pass / come", "true", "Free odds behind the point"}, - {"odds dont <n>", "true", "Laid against the point at inverse odds"}, + {"odds dont N", "true", "Laid against the point at inverse odds"}, } } diff --git a/internal/casino/house_edge_craps.go b/internal/casino/house_edge_craps.go index 6b86571..d634aa6 100644 --- a/internal/casino/house_edge_craps.go +++ b/internal/casino/house_edge_craps.go @@ -10,9 +10,9 @@ func crapsHouseEdge(cfg TableConfig, bet string) HouseEdge { switch bet { case "pass", "come", "dontpass", "dontcome": return edge(-crapsContractEV(c, bet, 0)) - case "odds pass", "odds pass / come", "odds come <n>", "odds dontpass", "odds dont <n>", "odds dontcome <n>": + case "odds pass", "odds pass / come", "odds come N", "odds dontpass", "odds dont N", "odds dontcome N": return edge(0) - case "buy <n>", "lay <n>": + case "buy N", "lay N": e := HouseEdge{Note: "5% commission on wins only; bet multiples keep the vig whole"} for _, n := range []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12} { if !c.number(n) { @@ -33,7 +33,7 @@ func crapsHouseEdge(cfg TableConfig, bet string) HouseEdge { return edge(-crapsResolvedEV(c, "eleven", 0)) case "aces / boxcars": return edge(-crapsResolvedEV(c, "aces", 0)) - case "hop <a> <b>": + case "hop A B": return HouseEdge{Parts: []EdgePart{{"", -crapsResolvedEV(c, "hop", 12)}, {"(doubles)", -crapsResolvedEV(c, "hop", 11)}}} case "small / tall": return edge(1 - 35*crapsCollectProbability([]int{2, 3, 4, 5, 6})) diff --git a/internal/casino/house_edge_test.go b/internal/casino/house_edge_test.go index 0704edc..bb9c924 100644 --- a/internal/casino/house_edge_test.go +++ b/internal/casino/house_edge_test.go @@ -17,9 +17,9 @@ func TestKnownHouseEdges(t *testing.T) { bet string want float64 // expected loss per unit stake (unrounded fraction) }{ - {"roulette straight", TableConfig{Game: GameRoulette}, "<number>", 1.0 / 37}, + {"roulette straight", TableConfig{Game: GameRoulette}, "NUMBER", 1.0 / 37}, {"roulette even money", TableConfig{Game: GameRoulette}, "red / black", 1.0 / 37}, - {"roulette DZ straight", TableConfig{Game: GameRoulette, Roulette: RouletteConfig{DoubleZero: true}}, "<number>", 2.0 / 38}, + {"roulette DZ straight", TableConfig{Game: GameRoulette, Roulette: RouletteConfig{DoubleZero: true}}, "NUMBER", 2.0 / 38}, {"roulette DZ basket", TableConfig{Game: GameRoulette, Roulette: RouletteConfig{DoubleZero: true}}, "basket", 3.0 / 38}, {"craps pass", TableConfig{Game: GameCraps}, "pass", 0.014141414141414}, {"craps dontpass", TableConfig{Game: GameCraps}, "dontpass", 0.013636363636364}, @@ -108,14 +108,14 @@ func TestUnrecognizedTargetNeverInherits(t *testing.T) { } } -// TestCrapsGroupedTargetsHaveRTP: the list names grouped rows (4/10, buy <n>, +// TestCrapsGroupedTargetsHaveRTP: the list names grouped rows (4/10, buy N, // odds pass / come). Each must resolve to a real edge, not the unknown dash. func TestCrapsGroupedTargetsHaveRTP(t *testing.T) { for _, bet := range []string{ "place 4/10", "place 5/9", "place 6/8", "hard 4/10", "hard 6/8", - "odds pass / come", "odds dont <n>", - "buy <n>", "lay <n>", + "odds pass / come", "odds dont N", + "buy N", "lay N", } { got := (TableConfig{Game: GameCraps}).HouseEdge(bet) if len(got.Parts) == 0 { @@ -132,20 +132,20 @@ func TestCrapsGroupedTargetsHaveRTP(t *testing.T) { // The <n> rows expand to every legal number, including the crapless extras. for _, want := range []string{"2", "3", "11", "12"} { found := false - for _, part := range crapless.HouseEdge("lay <n>").Parts { + for _, part := range crapless.HouseEdge("lay N").Parts { if part.Label == want { found = true } } if !found { - t.Fatalf("crapless lay <n> missing number %s", want) + t.Fatalf("crapless lay N missing number %s", want) } } } // TestHopBetsTwoParts pins the split hop analysis (easy vs doubles). func TestHopBetsTwoParts(t *testing.T) { - got := (TableConfig{Game: GameCraps}).HouseEdge("hop <a> <b>") + got := (TableConfig{Game: GameCraps}).HouseEdge("hop A B") if len(got.Parts) != 2 { t.Fatalf("hop analysis: %s", got.Text(2)) } diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 610e745..0733384 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -103,7 +103,7 @@ func (m *Machine) start(name string, wallet Wallet) []Event { return m.private("You're in the middle of a spin!") } if m.bet <= 0 { - return m.private("Set a wager first with 'bet <amount>'.") + return m.private("Set a wager first with 'bet N'.") } return m.beginSpinLocked(m.bet, wallet, fmt.Sprintf("%s pulls the lever on the slots machine.", name), @@ -139,7 +139,7 @@ func (m *Machine) enableAutoBet(name string, wallet Wallet) []Event { return m.private("You're in the middle of a spin!") } if m.bet <= 0 { - return m.private("Set a wager first with 'bet <amount>'.") + return m.private("Set a wager first with 'bet N'.") } m.player.Wallet = wallet m.autoBet = true @@ -522,12 +522,19 @@ func formatWins(wins []SlotWin, ways int) string { return message[:len(message)-1] } +// Hints is the machine's live command list for the "what" command. +func (m *Machine) Hints() []MenuCommand { + m.mu.Lock() + defer m.mu.Unlock() + return m.commands() +} + func (m *Machine) commands() []MenuCommand { if m.autoBet { return []MenuCommand{{Command: "stop", Desc: "Stop automatic betting"}} } commands := []MenuCommand{ - {Command: "bet <amount>", Desc: "Set your wager and spin"}, + {Command: "bet N", Desc: "Set your wager and spin"}, {Command: "bet max", Desc: "Bet the maximum and spin"}, } if m.bet > 0 { diff --git a/internal/casino/manager.go b/internal/casino/manager.go index 8a33df6..bdd9249 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -205,7 +205,7 @@ func (m *Manager) JoinMachine(roomID int, cfg MachineConfig, name string) []Even if machine == nil { return []Event{{RoomID: roomID, TableID: cfg.ID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}} } - return machine.join(name) + return markHints(machine.join(name)) } func (m *Manager) SetMachineBet(roomID int, machineID, name string, amount int, wallet Wallet) []Event { @@ -247,7 +247,21 @@ func (m *Manager) Join(roomID int, cfg TableConfig, name string) []Event { if table == nil { return []Event{{RoomID: roomID, TableID: cfg.ID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}} } - return table.join(name) + return markHints(table.join(name)) +} + +// markHints tags the first menu in a join/status result so the presentation +// layer always shows its command hints under the "casino_hints once" default. +// Recurring menus (after a bet or action) leave the flag clear, and other +// events (seat notices, rule felts) are untouched. +func markHints(events []Event) []Event { + for i := range events { + if events[i].Menu != nil { + events[i].Menu.ShowHints = true + break + } + } + return events } func (m *Manager) Leave(roomID int, tableID, name string) []Event { diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go index 0de79e3..a2dafcf 100644 --- a/internal/casino/pai_gow.go +++ b/internal/casino/pai_gow.go @@ -148,12 +148,12 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand { // Side bets are placed before the main wager: the main bet is what // deals, so there is no later chance to add them. if p.fortune.Total == 0 { - commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet; place before your main bet"}) + commands = append(commands, MenuCommand{Command: "bet N fortune", Desc: "Optional Fortune side bet; place before your main bet"}) } if faceUp && p.pushace.Total == 0 { - commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet; place before your main bet"}) + commands = append(commands, MenuCommand{Command: "bet N pushace", Desc: "Optional Push Ace High side bet; place before your main bet"}) } - commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; deals once every bettor is in"}) + commands = append(commands, MenuCommand{Command: "bet N", Desc: "Main wager; deals once every bettor is in"}) } if p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0 { commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back all your wagers before the deal"}) diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go index 0539c9f..494cde7 100644 --- a/internal/casino/pai_gow_rules.go +++ b/internal/casino/pai_gow_rules.go @@ -110,7 +110,7 @@ func paiGowRules(c TableConfig) string { case PaiGowNoPush: s += " No commission; split results use two extra cards: player must beat dealer, ties lose; Joker is ace." } - s += "\nHouse way: preserve the best high-hand category, maximize low within it; split two pair, full houses and trips-pairs when this improves low to a pair. Never foul. house arranges the house way and still needs set; autoset on locks any legal arrangement immediately (autoset off restores the confirmation). A multiplayer timeout or disconnect uses the house way; a lone player is never timed out.\nBet main first, optionally bet <amount> fortune before dealing; unbet takes every undealt wager back. Fortune uses all seven cards regardless of split; highest listed prize only, profit odds plus stake returned." + s += "\nHouse way: preserve the best high-hand category, maximize low within it; split two pair, full houses and trips-pairs when this improves low to a pair. Never foul. house arranges the house way and still needs set; autoset on locks any legal arrangement immediately (autoset off restores the confirmation). A multiplayer timeout or disconnect uses the house way; a lone player is never timed out.\nBet main first, optionally bet N fortune before dealing; unbet takes every undealt wager back. Fortune uses all seven cards regardless of split; highest listed prize only, profit odds plus stake returned." if r.Envy { s += "\nEnvy: Fortune stake of at least 5 qualifies for fixed bonuses on OTHER active players' hands, never your own or dealer; multiple bonuses add." } else { diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 8f45d49..ad0a706 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -518,7 +518,7 @@ func (t *rouletteTable) menuEvent(name string, board bool) Event { commands := []MenuCommand{} p := t.players.get(name) if t.phase != phasePlaying && p != nil && !p.HasBet { - commands = append(commands, MenuCommand{Command: "bet <n> on <bet>", Desc: "Place one wager"}) + commands = append(commands, MenuCommand{Command: "bet N on BET", Desc: "Place one wager"}) if p.lastBet > 0 { commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) } diff --git a/internal/casino/roulette_wagers.go b/internal/casino/roulette_wagers.go index 7562582..5879f03 100644 --- a/internal/casino/roulette_wagers.go +++ b/internal/casino/roulette_wagers.go @@ -13,17 +13,17 @@ func (t *rouletteTable) wagerRows() []WagerRow { } chance := func(n int) string { return fmt.Sprintf("%d/%d", n, pockets) } rows := []WagerRow{ - {"<number>", "35:1", "One pocket (" + chance(1) + ")"}, + {"NUMBER", "35:1", "One pocket (" + chance(1) + ")"}, {"red / black", "1:1", "The color (" + chance(18) + ")"}, {"odd / even", "1:1", "The parity (" + chance(18) + ")"}, {"low / high", "1:1", "Low 1-18 / high 19-36 (" + chance(18) + ")"}, {"1to12 / 13to24 / 25to36", "2:1", "A dozen (" + chance(12) + ")"}, {"row1 / row2 / row3", "2:1", "A numbered row (" + chance(12) + ")"}, - {"split <a> <b>", "17:1", "Two touching pockets (" + chance(2) + ")"}, - {"street <a> <b> <c>", "11:1", "Three numbers in a street (" + chance(3) + ")"}, - {"trio 0 <a> <b>", "11:1", "A zero-edge trio (" + chance(3) + ")"}, - {"corner <a> <b> <c> <d>", "8:1", "A 2x2 square (" + chance(4) + ")"}, - {"line <a> … <f>", "5:1", "Two adjoining streets (" + chance(6) + ")"}, + {"split A B", "17:1", "Two touching pockets (" + chance(2) + ")"}, + {"street A B C", "11:1", "Three numbers in a street (" + chance(3) + ")"}, + {"trio 0 A B", "11:1", "A zero-edge trio (" + chance(3) + ")"}, + {"corner A B C D", "8:1", "A 2x2 square (" + chance(4) + ")"}, + {"line A … F", "5:1", "Two adjoining streets (" + chance(6) + ")"}, {"basket", basketOdds, "0, 1, 2, 3 (+ 00) (" + chance(basketPockets) + ")"}, } if t.Config.Roulette.Racetrack && !t.Config.Roulette.DoubleZero { @@ -32,7 +32,7 @@ func (t *rouletteTable) wagerRows() []WagerRow { WagerRow{"tiers", "2:1", "The 12 pockets opposite zero, 6 chips"}, WagerRow{"orphelins", "31:5 or 13:5", "The two orphan arcs, 5 chips"}, WagerRow{"zero / jeu zero", "8:1 or 7:2", "The 7 pockets nearest zero, 4 chips"}, - WagerRow{"neighbors <n>", "3:1", "Pocket n plus four numbers each side, 9 chips"}, + WagerRow{"neighbors N", "3:1", "Pocket n plus four numbers each side, 9 chips"}, ) } return rows diff --git a/internal/casino/table.go b/internal/casino/table.go index d93d91e..d9d3525 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -127,8 +127,9 @@ func (t *Table) refundAll() []Event { return t.game.refundAll() } // idle on the Table wrapper delegates to the game. func (t *Table) idle() bool { return t.game.idle() } -// Status is a read-only view: it never restarts a deadline or changes a wager. -func (t *Table) Status(name string) []Event { return t.game.status(name) } +// Status is a read-only view: it never restarts a deadline or changes a +// wager. It is an explicit look/watch, so its menu always carries hints. +func (t *Table) Status(name string) []Event { return markHints(t.game.status(name)) } // Board returns the game's table-only board for the table command: the live // wheel, dice layout or paytable, with no balance, command menu, or state @@ -165,7 +166,7 @@ func (t *Table) RulesText() string { return rulesTextOnTable(t.Config) } func (t *Table) HelpTopic() string { return t.Config.HelpTopic() } // Wagers is the game's live list of bets the player could make right now. -// Empty when nothing is worth naming (a lone "bet <amount>" wager, a +// Empty when nothing is worth naming (a lone "bet N" wager, a // committed round) — the same signal that suppresses the "list" hint. func (t *Table) Wagers(name string) []WagerRow { return t.game.wagers(name) } diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go index 98eaced..eafb05e 100644 --- a/internal/casino/table_hints.go +++ b/internal/casino/table_hints.go @@ -19,7 +19,7 @@ func (t *rouletteTable) hints(name string) []MenuCommand { } commands := []MenuCommand{} if t.phase != phasePlaying && !p.HasBet { - commands = append(commands, MenuCommand{Command: "bet <n> on <bet>", Desc: "Place one wager"}) + commands = append(commands, MenuCommand{Command: "bet N on BET", Desc: "Place one wager"}) if p.lastBet > 0 { commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"}) } @@ -82,7 +82,7 @@ func baccaratHintCommands(t *baccaratTable, name string) []MenuCommand { } return append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet)}, - MenuCommand{Command: "bet <n> on <bet>"}, + MenuCommand{Command: "bet N on BET"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table", Desc: "Check the table"}, ) diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index b570b36..5d70aaf 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -6,7 +6,7 @@ import ( ) // WagerRow is one line of a table's "list" of available bets, in the game's -// current state: the bet wording (what follows "bet <n> on"), its +// current state: the bet wording (what follows "bet N on"), its // payout, and a brief description of the win condition. type WagerRow struct { Bet string @@ -21,7 +21,7 @@ const ( // withTableHints finishes any game's hint list: "rules" is always present // while seated at a table, and "list" follows when the game named more than -// one wager a player might want to make (a lone bet is just "bet <amount>"). +// one wager a player might want to make (a lone bet is just "bet N"). // Games whose own menus already append these hints are not double-hinted. func withTableHints(commands []MenuCommand, wagerRows []WagerRow) []MenuCommand { for _, c := range commands { diff --git a/internal/casino/table_ux_test.go b/internal/casino/table_ux_test.go index 4d58a5f..f81d537 100644 --- a/internal/casino/table_ux_test.go +++ b/internal/casino/table_ux_test.go @@ -218,7 +218,7 @@ func TestRebetMenusAndMidRoundJoin(t *testing.T) { bac := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat}) bac.join("alice") first := bac.menuEvent("alice") - if first.Menu.Commands[0].Command == "bet <amount>" { + if first.Menu.Commands[0].Command == "bet N" { t.Fatal("first baccarat bet omitted target") } bac.bet("alice", "banker", 25, &testWallet{credits: 100}) diff --git a/internal/casino/types.go b/internal/casino/types.go index 5f9de1d..570d0ac 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -256,6 +256,9 @@ type MenuView struct { ShowBalance bool // prepend the player's chips/credits balance Commands []MenuCommand Repeat bool // returning bettor: compact clients can show a short reminder + // ShowHints marks a menu that always carries its command list under the + // "casino_hints once" option: the join menu and an explicit look/status. + ShowHints bool } // MenuCommand is one selectable command shown in a casino menu: the typed diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go index d2578b8..4cb9883 100644 --- a/internal/casino/video_poker.go +++ b/internal/casino/video_poker.go @@ -147,7 +147,7 @@ func (t *videoPokerTable) hintsLocked(name string) []MenuCommand { if t.players[name] == nil { return nil } - commands := []MenuCommand{{Command: "bet [1-4|max]", Desc: "Load coins (up to five per hand)"}, {Command: "deal", Desc: "Pay and deal the loaded wager"}} + commands := []MenuCommand{{Command: "bet [1-4|MAX]", Desc: "Load coins (up to five per hand)"}, {Command: "deal", Desc: "Pay and deal the loaded wager"}} if p := t.players[name]; p != nil && p.active { commands = []MenuCommand{{Command: "1 3 5", Desc: "Toggle held cards (531 also works)"}, {Command: "draw", Desc: "Replace unheld cards on every line"}} if p.drawn { @@ -155,9 +155,9 @@ func (t *videoPokerTable) hintsLocked(name string) []MenuCommand { } } else { commands = append(commands, - MenuCommand{Command: "game <name>", Desc: t.settings.GameChoices()}, - MenuCommand{Command: "denom <1|10|50>", Desc: "Credits per coin"}, - MenuCommand{Command: "hands <count>", Desc: "Supported hand counts are announced by hands"}, + MenuCommand{Command: "game NAME", Desc: t.settings.GameChoices()}, + MenuCommand{Command: "denom 1|10|50", Desc: "Credits per coin"}, + MenuCommand{Command: "hands COUNT", Desc: "Supported hand counts are announced by hands"}, ) if cabinetFeature(t.settings.Cabinet) != "" { commands = append(commands, MenuCommand{Command: "feature on|off", Desc: "Toggle the extra-cost bonus"}) @@ -298,7 +298,7 @@ func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event { return []Event{t.private(name, "Finish this hand with draw first.")} } if p.loaded == 0 { - return []Event{t.private(name, "Load coins first: bet [1-4|max].")} + return []Event{t.private(name, "Load coins first: bet [1-4|MAX].")} } if p.featureOn && p.loaded != 5 { return []Event{t.private(name, "The feature requires five wager coins per hand. Use bet max or feature off.")} @@ -406,7 +406,7 @@ func (t *videoPokerTable) action(name, action string) []Event { default: add, err := strconv.Atoi(fields[1]) if err != nil || add < 1 || add > 4 { - return []Event{t.private(name, "Use bet [1-4|max].")} + return []Event{t.private(name, "Use bet [1-4|MAX].")} } n = min(5, p.loaded+add) } @@ -462,7 +462,7 @@ func (t *videoPokerTable) action(name, action string) []Event { p.clearResult() return t.statusLocked(name, false) } - return []Event{t.private(name, "Use bet [1-4|max], unbet, deal, game <name>, denom 1|10|50, hands <count>, paytable, or feature on|off on a bonus cabinet.")} + return []Event{t.private(name, "Use bet [1-4|MAX], unbet, deal, game NAME, denom 1|10|50, hands COUNT, paytable, or feature on|off on a bonus cabinet.")} } func (t *videoPokerTable) drawLocked(name string, p *pokerPlayer) []Event { diff --git a/internal/config/config.go b/internal/config/config.go index ff081c1..8bd505d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,6 +63,7 @@ func DefaultColors() ColorsConfig { "dialog": "6E", "dialog_options": "bold", "command": "0A bold", + "command_variable": "B4 bold", "broadcast": "89", "sequence": "off", "fire": "B4", 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) } } diff --git a/internal/player/player.go b/internal/player/player.go index 0c92af3..43f5d7e 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -141,6 +141,7 @@ var OptionDefs = []OptionDef{ {"wrap_width", OptInt, 80, nil, "Wrap all output to this many columns (minimum 80)"}, {"unicode", OptBool, true, nil, "Unicode box-drawing characters"}, {"casino_display", OptString, "detailed", []string{"compact", "detailed"}, "Menu style: compact action hints, or detailed command menus"}, + {"casino_hints", OptString, "once", []string{"on", "once", "off"}, "Casino command hints: on always, once at sit-down, or off"}, {"card_size", OptInt, 9, nil, "Casino card art size (1, 5, 7, or 9; applies at every table)"}, {"card_style", OptString, "dark", []string{"dark", "light"}, "Casino card style (dark or light)"}, {"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"}, |
