aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/help/baccarat.yaml3
-rw-r--r--data/help/blackjack.yaml3
-rw-r--r--data/help/casino.yaml6
-rw-r--r--data/help/crapless_craps.yaml26
-rw-r--r--data/help/craps.yaml33
-rw-r--r--data/help/diceless_craps.yaml20
-rw-r--r--data/help/die_rich_craps.yaml21
-rw-r--r--data/help/ez_baccarat.yaml22
-rw-r--r--data/help/high_point_craps.yaml20
-rw-r--r--data/help/new_york_craps.yaml22
-rw-r--r--data/help/roulette.yaml3
-rw-r--r--data/help/simplified_craps.yaml27
-rw-r--r--data/help/super6_baccarat.yaml21
-rw-r--r--internal/casino/baccarat.go3
-rw-r--r--internal/casino/baccarat_test.go2
-rw-r--r--internal/casino/baccarat_wagers.go39
-rw-r--r--internal/casino/blackjack.go4
-rw-r--r--internal/casino/blackjack_test.go4
-rw-r--r--internal/casino/blackjack_wagers.go41
-rw-r--r--internal/casino/craps_ux.go4
-rw-r--r--internal/casino/craps_variants.go24
-rw-r--r--internal/casino/craps_wagers.go144
-rw-r--r--internal/casino/registry.go66
-rw-r--r--internal/casino/roulette.go5
-rw-r--r--internal/casino/roulette_wagers.go39
-rw-r--r--internal/casino/table.go21
-rw-r--r--internal/casino/table_hints.go86
-rw-r--r--internal/casino/table_ux.go54
-rw-r--r--internal/casino/wagers_test.go100
-rw-r--r--internal/game/casino_look_test.go90
-rw-r--r--internal/game/casino_rules_list_test.go245
-rw-r--r--internal/game/casino_ux.go141
-rw-r--r--internal/game/cmd_look.go3
-rw-r--r--internal/game/cmd_registry.go3
-rw-r--r--internal/game/render_craps.go2
-rw-r--r--internal/game/render_help.go3
36 files changed, 1281 insertions, 69 deletions
diff --git a/data/help/baccarat.yaml b/data/help/baccarat.yaml
index b8d0e56..951e2f2 100644
--- a/data/help/baccarat.yaml
+++ b/data/help/baccarat.yaml
@@ -26,4 +26,5 @@ description: |
Side bets settle independently; a pair can win even on a tie.
Higher payouts do not mean better value: rare events win less often.
- table rules shows this table's exact payouts and available bets.
+ Typing rules at the table shows this page again; table rules shows
+ this table's exact payouts and available bets.
diff --git a/data/help/blackjack.yaml b/data/help/blackjack.yaml
index 3e4c2c8..1f34b1a 100644
--- a/data/help/blackjack.yaml
+++ b/data/help/blackjack.yaml
@@ -23,4 +23,5 @@ description: |
table rules lists the actual blackjack payout, insurance payout,
dealer soft-17 rule, splitting restrictions and surrender availability.
- table help shows controls; help casino explains wagers and returns.
+ Typing rules at the table shows this page again; help casino explains
+ wagers and returns.
diff --git a/data/help/casino.yaml b/data/help/casino.yaml
index 1ff11a7..b9756cb 100644
--- a/data/help/casino.yaml
+++ b/data/help/casino.yaml
@@ -1,5 +1,5 @@
name: casino
-aliases: [play, bet, rebet, table, watch]
+aliases: [play, bet, rebet, table, watch, rules]
category: Interaction
related: [roulette, racetrack, blackjack, baccarat, craps, slots]
description: |
@@ -10,7 +10,9 @@ description: |
bet 10 on <target> Choose a roulette, baccarat or craps bet.
bet / rebet Repeat your last table wager.
table Show the table; roulette also shows payout tables.
- table help Learn the current game and its controls.
+ rules The help page for the game you're playing — every
+ house edition has its own short page.
+ list Every bet accepted right now, with payouts.
table rules Check this table's exact rules and payouts.
stop Leave (after a committed table round).
diff --git a/data/help/crapless_craps.yaml b/data/help/crapless_craps.yaml
new file mode 100644
index 0000000..f05364a
--- /dev/null
+++ b/data/help/crapless_craps.yaml
@@ -0,0 +1,26 @@
+name: crapless craps
+aliases: [crapless]
+category: Interaction
+related: [craps, casino]
+description: |
+ Crapless Craps — the come-out 7 is the only loser.
+
+ play crapless craps Sit down
+ bet 10 on pass 1:1; come-out 7 wins, EVERY other total
+ (2,3,4,5,6,8,9,10,11,12) becomes the point
+ bet 10 on come A personal point, once a table point is ON
+ bet 30 on odds pass True odds behind your point (default 1x on
+ extra numbers 2/3/11/12, 3-4-5x elsewhere)
+ bet 12 on place 6 Also 4/5/8/9/10; 2/12 pay 11:2, 3/11 pay 11:4
+ bet 20 on buy 4 True odds; 5% stake commission, wins only
+ bet 40 on lay 10 True odds laid; 5% profit commission
+ bet 10 on field / hard 6 / seven / eleven / horn / hop 2 3
+ roll / autoroll Shoot now / roll on your turn automatically
+ table / list / rules Felt / bets and payouts / this page
+ unbet place 6 Return optional bets or odds before the roll
+ stop / sitout Leave / sit out once bets settle
+
+ No don't bets and no Fire/All/Tall/Small bonuses: with no losing
+ come-out, don't bets would be unplayable and bonuses unbeatable.
+ Place/buy/hardways and come odds are off on come-out; lay always
+ works. Commission rounds up; contracts cannot be removed.
diff --git a/data/help/craps.yaml b/data/help/craps.yaml
index 3537a80..2e1c826 100644
--- a/data/help/craps.yaml
+++ b/data/help/craps.yaml
@@ -1,15 +1,14 @@
name: craps
aliases: [roll, autoroll, unbet]
category: Interaction
-related: [casino, roulette]
+related: [casino, roulette, simplified craps, high point craps, crapless craps, new york craps, die rich craps, diceless craps]
description: |
Craps — multiplayer house editions at the Domita Fortuna casino.
From single-zero roulette, east reaches Bank Craps. North of the pit
waits Simplified, High Point, then Crapless; south waits New York,
- Die Rich, then Diceless. Join with play craps in each room; table
- rules gives that edition's exact targets and payouts. The examples
- below are BANK rules, not a promise that every edition accepts every
- bet.
+ Die Rich, then Diceless. Join with play craps in each room. The
+ page below is the BANK edition; every edition has a short help page
+ of its own — type rules at any table, or help <edition name>.
play craps Join the tail of the shooting queue
watch craps Watch without betting
@@ -69,24 +68,6 @@ description: |
or 3:1 according to table rules. Pass/come and bonus contracts cannot
be removed. Disconnecting forfeits live bets; shutdown refunds them.
- HOUSE EDITIONS (regional versions may differ):
- Simplified: pass only, one roll of two dice. The dice roll automatically
- 3 seconds after the first bet; there is no manual roll. Bet with
- 'bet <amount>' (always pass; a bare bet repeats your last wager).
- 2 pays 3:1; 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1. 5/6/7/8/9 lose.
- No point, odds or side bets. House edge: 2.78%.
- High Point: pass only, 1:1. Come-out 2/3 roll again; 11/12 win;
- 4-10 become the point. The next roll must EXCEED it to win.
- Die Rich: pass only, ONE die, 1:1. Come-out 6 wins; 1 loses;
- 2-5 become points. Repeat the point before 1 to win.
- These three have no odds or side bets; a loss passes the dice.
- Crapless: only 7 wins on come-out; every other total becomes a
- point. Pass/come, odds and bank side bets, but NO don't or bonus bets.
- Place 2/12 pays 11:2; 3/11 pays 11:4. True odds 2/12 pay 6:1;
- 3/11 pay 3:1 (lay inverses). Default extra-number odds limit is 1x.
- New York: bank pass/dontpass, free odds, buy and lay ONLY. Win-only
- 5% buy-stake / lay-profit commission, rounded up. No come or bonuses.
- Diceless: full bank rules, but two independent packs of six cards
- numbered 1-6 replace dice. Draw one from each; replace and reshuffle
- BOTH packs after EVERY draw. All 36 ordered pairs are equally likely;
- there is no finite shoe. Cards are displayed as cards, not dice.
+ HOUSE EDITIONS have their own help pages — simplified craps, high
+ point craps, die rich craps, crapless craps, new york craps and
+ diceless craps. Type rules while seated at that table.
diff --git a/data/help/diceless_craps.yaml b/data/help/diceless_craps.yaml
new file mode 100644
index 0000000..61348f6
--- /dev/null
+++ b/data/help/diceless_craps.yaml
@@ -0,0 +1,20 @@
+name: diceless craps
+aliases: [diceless]
+category: Interaction
+related: [craps, casino]
+description: |
+ Diceless Craps — full bank rules, dealt from cards.
+
+ play diceless craps Sit down
+ bet 10 on pass Every bank bet works (see help craps)
+ table / list / rules Felt / bets and payouts / this page
+
+ Two independent packs of six cards numbered 1-6 replace the dice.
+ Each roll draws one card from each pack, then replaces and
+ reshuffles BOTH packs — so every one of the 36 ordered pairs stays
+ equally likely on every single roll. There is no finite shoe and
+ nothing to count; counting systems do not apply.
+
+ Cards are displayed as cards, not dice, and each pack keeps its own
+ draw. Bets, odds, payouts and timers are exactly the bank craps
+ rules; help craps covers every wager, and rules repeats this page.
diff --git a/data/help/die_rich_craps.yaml b/data/help/die_rich_craps.yaml
new file mode 100644
index 0000000..e723598
--- /dev/null
+++ b/data/help/die_rich_craps.yaml
@@ -0,0 +1,21 @@
+name: die rich craps
+aliases: [die rich]
+category: Interaction
+related: [craps, casino]
+description: |
+ Die Rich Craps — pass only, played with a single die.
+
+ play die rich craps Sit down
+ bet 10 on pass The only bet; pays 1:1
+ roll / autoroll Shoot now / roll on your turn automatically
+ table / list / rules Felt / bets and payouts / this page
+ stop / sitout Leave / sit out once bets settle
+
+ Come-out 6 wins immediately and 1 loses immediately; totals 2-5
+ become the point. Repeat the point before rolling a 1 to win; any
+ other face rolls again, and a 1 kills every line bet and passes
+ the die.
+
+ No odds, no come bets, no side bets. Bets stay open until the roll;
+ multiplayer gives 10 seconds to bet, then the dealer rolls for a
+ shooter who waits another 60 seconds.
diff --git a/data/help/ez_baccarat.yaml b/data/help/ez_baccarat.yaml
new file mode 100644
index 0000000..bfa51c3
--- /dev/null
+++ b/data/help/ez_baccarat.yaml
@@ -0,0 +1,22 @@
+name: ez baccarat
+aliases: [ez]
+category: Interaction
+related: [baccarat, casino]
+description: |
+ EZ Baccarat — no commission; the Banker three-card 7 pushes.
+
+ play ez baccarat Sit down (or play baccarat in that salon)
+ bet 100 on banker Banker wins pay 1:1, EXCEPT a winning
+ three-card 7 pushes Banker bets
+ bet 100 on player 1:1; ties push (and still lose to a
+ pushing three-card Banker 7)
+ bet 100 on tie 8:1 on this table
+ bet 100 on dragon_7 40:1; Banker wins with a three-card 7
+ bet 100 on panda_8 25:1; Player wins with a three-card 8
+ bet 100 on player_pair / banker_pair 11:1 each
+ table / list / rules The table / bets and payouts / this page
+ stop / sitout Leave / sit out after this round
+
+ One wager per round. Highest last digit of the total wins (aces = 1,
+ tens = 0); drawing is automatic. help baccarat covers the shared
+ rules.
diff --git a/data/help/high_point_craps.yaml b/data/help/high_point_craps.yaml
new file mode 100644
index 0000000..7e35319
--- /dev/null
+++ b/data/help/high_point_craps.yaml
@@ -0,0 +1,20 @@
+name: high point craps
+aliases: [high point]
+category: Interaction
+related: [craps, casino]
+description: |
+ High Point Craps — pass only, and low totals can never win.
+
+ play high point craps Sit down (salon north of the bank pit)
+ bet 10 on pass The only bet; pays 1:1
+ roll / autoroll Shoot now / roll on your turn automatically
+ table / list / rules Felt / bets and payouts / this page
+ stop / sitout Leave / sit out once bets settle
+
+ The come-out 2 or 3 rolls again (nothing lost); 11 and 12 win
+ outright; 4-10 become the point. The next roll wins only if it is
+ HIGHER than the point — equal or lower loses, and the dice pass.
+
+ No odds, no come bets, no side bets. Bets stay open until the roll;
+ multiplayer gives 10 seconds to bet, then the dealer rolls for a
+ shooter who waits another 60 seconds.
diff --git a/data/help/new_york_craps.yaml b/data/help/new_york_craps.yaml
new file mode 100644
index 0000000..ae3a053
--- /dev/null
+++ b/data/help/new_york_craps.yaml
@@ -0,0 +1,22 @@
+name: new york craps
+aliases: [new york]
+category: Interaction
+related: [craps, casino]
+description: |
+ New York Craps — line bets and commission odds only.
+
+ play new york craps Sit down
+ bet 10 on pass Come-out 7/11 win; 2/3/12 lose; then point
+ bet 10 on dontpass Opposite side; come-out 12 pushes
+ bet 30 on odds pass Free odds behind your point (true odds)
+ bet 30 on odds dontpass Free odds laid against the point
+ bet 20 on buy 4 True odds; 5% STAKE commission, wins only
+ bet 40 on lay 10 True odds against the number; 5% WIN commission
+ roll / autoroll Shoot now / roll on your turn automatically
+ table / list / rules Felt / bets and payouts / this page
+ stop / sitout Leave / sit out once bets settle
+
+ No come, place, field, hardway, proposition or bonus bets — the
+ entire game is pass/don't pass with buy and lay behind them.
+ Buy is off on come-out; lay works from the start. Commission
+ rounds up to a credit. Bets stay open until the roll.
diff --git a/data/help/roulette.yaml b/data/help/roulette.yaml
index a6f4c23..c789ae6 100644
--- a/data/help/roulette.yaml
+++ b/data/help/roulette.yaml
@@ -50,7 +50,8 @@ description: |
on the wheel (9 chips)
Use help racetrack for the chip placements and each call bet's payouts.
- Use table for a formatted payout reference for your current wheel.
+ Typing rules at the table shows this page again; table gives a
+ formatted payout reference for your current wheel.
Call-bet returns depend on which chip placements win; overlapping
wins are added together. Fractional credit returns are rounded down.
diff --git a/data/help/simplified_craps.yaml b/data/help/simplified_craps.yaml
new file mode 100644
index 0000000..55f4822
--- /dev/null
+++ b/data/help/simplified_craps.yaml
@@ -0,0 +1,27 @@
+name: simplified craps
+aliases: [simplified]
+category: Interaction
+related: [craps, casino]
+description: |
+ Simplified Craps — the shortest table in the casino.
+
+ One bet (pass), one roll of two dice, and the dice roll themselves
+ 3 seconds after your bet. There is no roll command and no waiting
+ for other players: everyone bets their own pass line, every round.
+
+ bet 10 Wager on pass (the only bet there is)
+ bet / rebet Repeat your last wager
+ table The felt, the dice and your open wager
+ list This game's bets and payouts
+ rules This page
+ stop / sitout Leave / sit out once your bet settles
+
+ How each total pays (stake returns with every win):
+ 2 3:1
+ 3 or 4 1:1
+ 10 or 11 2:1
+ 12 5:1
+ 5, 6, 7, 8 or 9 lose
+
+ There is no point, no odds, no come bets and no side bets. A loss
+ passes the dice to the next shooter. House edge: 2.78%.
diff --git a/data/help/super6_baccarat.yaml b/data/help/super6_baccarat.yaml
new file mode 100644
index 0000000..f8940b8
--- /dev/null
+++ b/data/help/super6_baccarat.yaml
@@ -0,0 +1,21 @@
+name: super 6 baccarat
+aliases: [super 6]
+category: Interaction
+related: [baccarat, casino]
+description: |
+ Super 6 Baccarat — no commission; Banker sixes pay half.
+
+ play super 6 baccarat Sit down (or play baccarat in that salon)
+ bet 25 on banker Banker wins pay 1:1, EXCEPT a winning 6
+ pays 1:2 (two or three cards)
+ bet 25 on player 1:1; ties push
+ bet 25 on tie 9:1 on this table
+ bet 25 on player_pair 11:1; first two Player cards share a rank
+ bet 25 on banker_pair 11:1; first two Banker cards share a rank
+ bet 25 on super_6 12:1; Banker wins with 6
+ table / list / rules The table / bets and payouts / this page
+ stop / sitout Leave / sit out after this round
+
+ One wager per round. Player bets still lose when a Banker 6 halves.
+ Highest last digit of the total wins (aces = 1, tens = 0); drawing
+ is automatic. help baccarat covers the shared rules.
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 50ac111..122deed 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -54,7 +54,8 @@ func (t *baccaratTable) menuEvent(name string) Event {
if p := t.players.get(name); p != nil && p.lastBet > 0 {
commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot})
}
- commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t)"}, MenuCommand{Command: "bet <amount> on <target>", Desc: "One wager per round; targets: table rules"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"})
+ commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t)"}, MenuCommand{Command: "bet <amount> on <target>", Desc: "One wager per round; targets: list"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"})
+ commands = withTableHints(commands, t.wagerRows())
e := t.betMenuEvent(name, commands)
if p := t.players.get(name); p != nil {
e.Menu.Repeat = p.lastBet > 0
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go
index dce1629..b767c74 100644
--- a/internal/casino/baccarat_test.go
+++ b/internal/casino/baccarat_test.go
@@ -382,7 +382,7 @@ func TestBaccaratMenuAndActions(t *testing.T) {
if menu == nil || !menu.ShowBalance || !strings.Contains(menu.Heading, "Baccarat / Commission | Limits") {
t.Fatalf("join menu=%+v, want a baccarat betting menu", menu)
}
- if len(menu.Commands) != 4 || menu.Commands[0].Command != "bet 1 banker" || menu.Commands[2].Command != "stop" {
+ if len(menu.Commands) != 6 || menu.Commands[0].Command != "bet 1 banker" || menu.Commands[2].Command != "stop" || menu.Commands[4].Command != "rules" || menu.Commands[5].Command != "list" {
t.Fatalf("baccarat menu commands=%+v", menu.Commands)
}
if events := table.action("alice", "hit"); !hasMessage(events, "no player actions") {
diff --git a/internal/casino/baccarat_wagers.go b/internal/casino/baccarat_wagers.go
new file mode 100644
index 0000000..cf2653e
--- /dev/null
+++ b/internal/casino/baccarat_wagers.go
@@ -0,0 +1,39 @@
+package casino
+
+import "fmt"
+
+// Baccarat's wager list names every outcome and optional side bet this
+// variant actually offers, with profit odds and the bare win condition.
+// The no-commission exceptions travel in the payout wording so the list
+// stays truthful for every edition.
+func (t *baccaratTable) wagerRows() []WagerRow {
+ rules := t.Rules
+ banker := "0.95:1 (5% commission)"
+ switch rules.Variant {
+ case BaccaratSuper6:
+ banker = "1:1 (Banker 6 pays 1:2)"
+ case BaccaratEZ:
+ banker = "1:1 (3-card 7 pushes)"
+ }
+ rows := []WagerRow{
+ {"player (p)", "1:1", "Player's last digit beats Banker's"},
+ {"banker (b)", banker, "Banker's last digit beats Player's"},
+ {"tie (t)", fmt.Sprintf("%g:1", rules.TiePayout), "Both hands finish equal"},
+ }
+ if rules.PairBets {
+ rows = append(rows,
+ WagerRow{"player_pair", "11:1", "First two Player cards share a rank"},
+ WagerRow{"banker_pair", "11:1", "First two Banker cards share a rank"},
+ )
+ }
+ if rules.Dragon7 {
+ rows = append(rows, WagerRow{"dragon_7", "40:1", "Banker wins with a three-card 7"})
+ }
+ if rules.Panda8 {
+ rows = append(rows, WagerRow{"panda_8", "25:1", "Player wins with a three-card 8"})
+ }
+ if rules.Super6Bet {
+ rows = append(rows, WagerRow{"super_6", "12:1", "Banker wins with 6"})
+ }
+ return rows
+}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index bd5b86b..a047c71 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -123,6 +123,8 @@ func (t *blackjackTable) menuEvent(name string) Event {
{Command: "stop", Desc: "Leave; during a round, leave afterward"},
{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"},
})
+ // Blackjack has a single wager target, so no list hint; rules rides along.
+ e.Menu.Commands = withTableHints(e.Menu.Commands, nil)
if p := t.players.get(name); p != nil {
e.Menu.Repeat = p.lastBet > 0
}
@@ -572,7 +574,7 @@ func (t *blackjackTable) turnMenuLocked(name string) Event {
if p.Connected && t.bettorCount() <= 1 {
deadline = t.ceiling.remaining()
}
- e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn | %d ticks remaining", deadline), Commands: blackjackActionCommands(actions)}
+ e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn | %d ticks remaining", deadline), Commands: withTableHints(blackjackActionCommands(actions), nil)}
for i := range e.Menu.Commands {
c := &e.Menu.Commands[i]
if c.Command == "double" || c.Command == "split" {
diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go
index aff87c8..956298b 100644
--- a/internal/casino/blackjack_test.go
+++ b/internal/casino/blackjack_test.go
@@ -131,7 +131,7 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing.
t.Fatalf("prompt=%q", event.Message)
}
if event.Menu == nil || !strings.Contains(event.Menu.Heading, "Your turn |") || event.Menu.ShowBalance ||
- len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "hit" || event.Menu.Commands[1].Command != "stand" || event.Menu.Commands[2].Command != "double" {
+ len(event.Menu.Commands) != 4 || event.Menu.Commands[0].Command != "hit" || event.Menu.Commands[1].Command != "stand" || event.Menu.Commands[2].Command != "double" || event.Menu.Commands[3].Command != "rules" {
t.Fatalf("prompt menu=%+v", event.Menu)
}
}
@@ -142,7 +142,7 @@ func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) {
if event.Menu == nil || !event.Menu.ShowBalance {
t.Fatalf("menu event=%+v", event.Menu)
}
- if len(event.Menu.Commands) != 4 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" {
+ if len(event.Menu.Commands) != 5 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" || event.Menu.Commands[4].Command != "rules" {
t.Fatalf("bet menu commands=%+v", event.Menu.Commands)
}
}
diff --git a/internal/casino/blackjack_wagers.go b/internal/casino/blackjack_wagers.go
new file mode 100644
index 0000000..23afac8
--- /dev/null
+++ b/internal/casino/blackjack_wagers.go
@@ -0,0 +1,41 @@
+package casino
+
+import "fmt"
+
+// Blackjack's only standing wager is bet <amount>, 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,
+// double, split and surrender — the same facts their own hints carry.
+func (t *blackjackTable) wagerRows(name string) []WagerRow {
+ p := t.players.get(name)
+ if p == nil {
+ return nil
+ }
+ if t.phase == blackjackInsurance && p.HasBet && !p.declinedInsurance && p.insurance == 0 && len(p.hands) > 0 {
+ return []WagerRow{
+ {"insurance", fmt.Sprintf("%g:1", t.Rules.InsurancePayout), fmt.Sprintf("Covers dealer blackjack; the stake costs %d", p.hands[0].bet/2)},
+ {"decline", "", "Continue without insurance (also the timeout default)"},
+ }
+ }
+ if t.phase == blackjackPlaying && t.activeName() == name && t.activeHand < len(p.hands) {
+ hand := p.hands[t.activeHand]
+ var rows []WagerRow
+ for _, action := range t.availableActions(p, hand) {
+ switch action {
+ case "hit":
+ rows = append(rows, WagerRow{"hit", "", "Take another card"})
+ case "stand":
+ rows = append(rows, WagerRow{"stand", "", "Keep your total"})
+ case "double":
+ rows = append(rows, WagerRow{"double", "1:1 on the added stake", fmt.Sprintf("Wager %d more, take exactly one card, stand", hand.bet)})
+ case "split":
+ rows = append(rows, WagerRow{"split", "1:1 on the added stake", fmt.Sprintf("Wager %d more; play the pair as two hands", hand.bet)})
+ case "surrender":
+ rows = append(rows, WagerRow{"surrender", "half returned", "Give up the original two-card hand"})
+ }
+ }
+ return rows
+ }
+ return nil
+}
diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go
index 10f7fcb..b14354e 100644
--- a/internal/casino/craps_ux.go
+++ b/internal/casino/craps_ux.go
@@ -54,6 +54,10 @@ func (t *crapsTable) commands(name string) []MenuCommand {
stop = "Cancel autoroll; keep your bets"
}
commands = append(commands, MenuCommand{Command: "stop", Desc: stop})
+ // "rules" stays available to a seated player at every phase of every
+ // variant; "list" rides along whenever the wager table names more
+ // than one bet.
+ commands = withTableHints(commands, t.wagerRows(name))
}
return append(commands, MenuCommand{Command: "table", Desc: "View the felt"})
}
diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go
index a3c078f..7d4d246 100644
--- a/internal/casino/craps_variants.go
+++ b/internal/casino/craps_variants.go
@@ -13,6 +13,30 @@ func (c CrapsConfig) variant() string {
}
return c.Variant
}
+
+// helpTopic names the help page the "rules" command opens: every house
+//
+// edition has its own page (Simplified Craps is a two-screen quick guide,
+// not a link into the bank-craps manual), except diceless which plays full
+// bank rules with cards instead of dice.
+func (c CrapsConfig) helpTopic() string {
+ switch c.variant() {
+ case "simplified":
+ return "simplified craps"
+ case "high_point":
+ return "high point craps"
+ case "die_rich":
+ return "die rich craps"
+ case "new_york":
+ return "new york craps"
+ case "crapless":
+ return "crapless craps"
+ case "diceless":
+ return "diceless craps"
+ default:
+ return "craps"
+ }
+}
func (c CrapsConfig) title() string {
switch c.variant() {
case "simplified":
diff --git a/internal/casino/craps_wagers.go b/internal/casino/craps_wagers.go
new file mode 100644
index 0000000..d9af87f
--- /dev/null
+++ b/internal/casino/craps_wagers.go
@@ -0,0 +1,144 @@
+package casino
+
+import (
+ "fmt"
+)
+
+// Craps wager tables are built from the live table state: a come-out roll
+// offers the line bets, the one-roll props and (before the shooter's first
+// roll) the bonus bets; a point adds come, odds, place/buy/lay and the
+// hardways. The variants substitute their own single-bet lists. Every entry
+// pairs the bet wording with its payout and a one-line win condition, so the
+// list doubles as the game's brief rule reminder. Payouts are profit odds;
+// winning stakes return alongside the profit.
+func (t *crapsTable) wagerRows(name string) []WagerRow {
+ c := t.Config.Craps
+ rows := []WagerRow{}
+ switch c.variant() {
+ case "simplified":
+ // One automatic roll of two dice; pass is the only target.
+ rows = append(rows,
+ WagerRow{"pass", "up to 5:1", "One roll: 2 pays 3:1; 3/4 pay 1:1; 10/11 pay 2:1; 12 pays 5:1; 5-9 lose"},
+ )
+ case "high_point":
+ rows = append(rows,
+ WagerRow{"pass", "1:1", "Come-out 2/3 rolls again, 11/12 win; 4-10 make a point the next roll must EXCEED"},
+ )
+ case "die_rich":
+ rows = append(rows,
+ WagerRow{"pass", "1:1", "One die: come-out 6 wins, 1 loses; 2-5 make a point repeated before a 1"},
+ )
+ case "new_york":
+ rows = append(rows,
+ WagerRow{"pass", "1:1", "Come-out 7/11 wins; 2/3/12 loses; else the point repeats before a 7"},
+ WagerRow{"dontpass", "1:1", "Opposite side; come-out 12 pushes"},
+ )
+ if t.point != 0 {
+ rows = append(rows, crapsOddsRows(c)...)
+ rows = append(rows,
+ WagerRow{"buy <n>", "true odds", "True-odds bet on 4/5/6/8/9/10; 5% stake commission, wins only"},
+ WagerRow{"lay <n>", "true odds laid", "Bet AGAINST the number; 5% commission on winnings"},
+ )
+ }
+ case "crapless":
+ rows = append(rows,
+ WagerRow{"pass", "1:1", "Come-out only 7 wins; every other total becomes the point"},
+ )
+ if t.point != 0 {
+ rows = append(rows,
+ WagerRow{"come", "1:1", "A personal point with pass rules"},
+ )
+ rows = append(rows, crapsOddsRows(c)...)
+ rows = append(rows, crapsPlaceRows(c)...)
+ rows = append(rows,
+ WagerRow{"buy <n>", "true odds", "True-odds place bet; 5% stake commission, wins only"},
+ WagerRow{"lay <n>", "true odds laid", "Bet against the number; 5% commission on winnings"},
+ WagerRow{"field", fmt.Sprintf("12 pays %d:1", c.Field12), "One roll: 2 pays 2:1; 3/4/9/10/11 pay 1:1; 5/6/7/8 lose"},
+ WagerRow{"hard 4 / hard 10", "7:1", "The point arrives as an identical pair"},
+ WagerRow{"hard 6 / hard 8", "9:1", "The point arrives as an identical pair"},
+ WagerRow{"seven", "4:1", "Next roll totals 7"},
+ WagerRow{"craps", "7:1", "Next roll is 2, 3 or 12"},
+ WagerRow{"eleven / ace-deuce", "15:1", "Next roll totals 11 / 3"},
+ WagerRow{"aces / boxcars", "30:1", "Next roll is 2 / 12"},
+ WagerRow{"horn", "varies", "Splits the stake equally among 2/3/11/12"},
+ WagerRow{"hop <a> <b>", "15:1/30:1", "One-roll dice combination; 30:1 when both dice equal"},
+ )
+ } else {
+ rows = append(rows, crapsComeOutRows(c, t.rolled)...)
+ }
+ default: // bank and diceless
+ rows = append(rows,
+ WagerRow{"pass", "1:1", "Come-out 7/11 wins; 2/3/12 loses; else the point repeats before a 7"},
+ WagerRow{"dontpass", "1:1", "Opposite side; come-out 12 pushes"},
+ )
+ if t.point != 0 {
+ rows = append(rows,
+ WagerRow{"come", "1:1", "A personal point with pass rules"},
+ WagerRow{"dontcome", "1:1", "Don't come, same as don't pass; a 12 pushes"},
+ )
+ rows = append(rows, crapsOddsRows(c)...)
+ rows = append(rows, crapsPlaceRows(c)...)
+ rows = append(rows,
+ WagerRow{"buy <n>", "true odds", "True-odds place bet; 5% stake commission, wins only"},
+ WagerRow{"lay <n>", "true odds laid", "Bet against the number; 5% commission on winnings"},
+ WagerRow{"hard 4 / hard 10", "7:1", "The point arrives as an identical pair"},
+ WagerRow{"hard 6 / hard 8", "9:1", "The point arrives as an identical pair"},
+ )
+ }
+ rows = append(rows, crapsComeOutRows(c, t.rolled)...)
+ }
+ return rows
+}
+
+// crapsComeOutRows is the one-roll part of the list, available in every
+// multi-bet variant during every phase; bonus wagers drop out once the
+// shooter has rolled.
+func crapsComeOutRows(c CrapsConfig, rolled bool) []WagerRow {
+ rows := []WagerRow{
+ WagerRow{"field", fmt.Sprintf("12 pays %d:1", c.Field12), "One roll: 2 pays 2:1; 3/4/9/10/11 pay 1:1; 5/6/7/8 lose"},
+ WagerRow{"seven", "4:1", "Next roll totals 7"},
+ WagerRow{"craps", "7:1", "Next roll is 2, 3 or 12"},
+ WagerRow{"eleven / ace-deuce", "15:1", "Next roll totals 11 / 3"},
+ WagerRow{"aces / boxcars", "30:1", "Next roll is 2 / 12"},
+ WagerRow{"horn", "27:4 on 2/12", "Splits the stake equally among 2/3/11/12"},
+ WagerRow{"hop <a> <b>", "15:1/30:1", "One-roll dice combination; 30:1 when both dice equal"},
+ }
+ if !rolled {
+ // Bonus bets close before the shooter's first roll.
+ rows = append(rows,
+ WagerRow{"fire", "24/249/999:1", "Shooter makes 4/5/6 distinct points before sevening out"},
+ WagerRow{"small / tall", "34:1", "All of 2-6 (or 8-12) rolls before any 7"},
+ WagerRow{"all", "175:1", "All of 2-6 AND 8-12 in the same hand before any 7"},
+ )
+ }
+ return rows
+}
+
+// crapsPlaceRows lists one row per placeable number with its house payout.
+// Crapless tables also accept the "impossible" numbers at extended odds.
+func crapsPlaceRows(c CrapsConfig) []WagerRow {
+ numbers := []int{4, 5, 6, 8, 9, 10}
+ if c.variant() == "crapless" {
+ numbers = []int{2, 3, 4, 5, 6, 8, 9, 10, 11, 12}
+ }
+ rows := []WagerRow{}
+ for _, n := range numbers {
+ x, y := crapsPlaceOdds(n)
+ rows = append(rows, WagerRow{fmt.Sprintf("place %d", n), fmt.Sprintf("%d:%d", x, y), fmt.Sprintf("Number %d repeats before a 7", n)})
+ }
+ return rows
+}
+
+// crapsOddsRows describes the free-odds wagers with this table's odds cap.
+func crapsOddsRows(c CrapsConfig) []WagerRow {
+ limit := "3-4-5x (don't 6x)"
+ if c.Odds > 0 {
+ limit = fmt.Sprintf("%dx", c.Odds)
+ }
+ return []WagerRow{
+ {"odds pass", "true odds", "Extra stake behind your pass point, up to " + limit},
+ {"odds come <n>", "true odds", "Extra stake behind a travelled come bet"},
+ {"odds dontpass", "true odds laid", "Extra stake against the point, laid at inverse true odds"},
+ {"odds dontcome <n>", "true odds laid", "Laid odds behind a don't come bet"},
+ }
+}
diff --git a/internal/casino/registry.go b/internal/casino/registry.go
index c53fe53..049d9f1 100644
--- a/internal/casino/registry.go
+++ b/internal/casino/registry.go
@@ -6,11 +6,12 @@ import (
)
// tableGameDef is the single registration point for a multiplayer table game.
-// A game's validation, rule defaults, and constructor live together here, so
-// adding a game is one entry instead of edits to three independent switches
-// (TableConfig.Validate, TableConfig.Normalize, and newTable) that could
-// silently drift apart. The rule structs stay on TableConfig; this does not
-// introduce a config union.
+// A game's validation, rule defaults, constructor, help topic, and rule text
+// live together here, so adding a game is one entry instead of edits to
+// many independent switches (TableConfig.Validate, TableConfig.Normalize,
+// newTable, RulesText, and the presentation router) that could silently drift
+// apart. The rule structs stay on TableConfig; this does not introduce a
+// config union.
type tableGameDef struct {
// validate checks a raw (pre-Normalize) table config.
validate func(TableConfig) error
@@ -25,6 +26,12 @@ type tableGameDef struct {
// new builds the game. It is called only after validate passes and the
// shared table config has been normalized.
new func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame
+ // ruleText names the shared rule text builder (TableConfig.RulesText
+ // dispatch, keeping the "rules" wording with the game's registration).
+ ruleText func(TableConfig) string
+ // helpTopic names the help page "rules" opens at this table. A game with
+ // per-edition pages derives them from its config (craps variants).
+ helpTopic func(TableConfig) string
}
// tableGames is populated in init, not in a var initializer: the constructors
@@ -40,6 +47,8 @@ func init() {
ruleBlockName: "craps",
hasRuleBlock: func(c TableConfig) bool { return c.Craps != (CrapsConfig{}) },
new: func(room int, c TableConfig, rng *rand.Rand) tableGame { return newCrapsTable(room, c, rng) },
+ ruleText: func(c TableConfig) string { return c.CrapsRulesText() },
+ helpTopic: func(c TableConfig) string { return c.Craps.helpTopic() },
},
GameRoulette: {
validate: func(c TableConfig) error {
@@ -60,6 +69,8 @@ func init() {
ruleBlockName: "roulette",
hasRuleBlock: func(c TableConfig) bool { return c.Roulette != (RouletteConfig{}) },
new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { return newRouletteTable(roomID, cfg, rng) },
+ ruleText: rouletteRulesText,
+ helpTopic: func(TableConfig) string { return "roulette" },
},
GameBlackjack: {
validate: validateBlackjackTable,
@@ -69,6 +80,8 @@ func init() {
new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame {
return newBlackjackTable(roomID, cfg, rng)
},
+ ruleText: func(c TableConfig) string { return c.BlackjackRulesText() },
+ helpTopic: func(TableConfig) string { return "blackjack" },
},
GameBaccarat: {
validate: validateBaccaratTable,
@@ -78,6 +91,17 @@ func init() {
new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame {
return newBaccaratTable(roomID, cfg, rng)
},
+ ruleText: func(c TableConfig) string { return c.BaccaratRulesText() },
+ helpTopic: func(c TableConfig) string {
+ switch c.Baccarat.Normalize().Variant {
+ case BaccaratSuper6:
+ return "super 6 baccarat"
+ case BaccaratEZ:
+ return "ez baccarat"
+ default:
+ return "baccarat"
+ }
+ },
},
}
}
@@ -97,3 +121,35 @@ func tableGameDefFor(game GameName) (tableGameDef, bool) {
var slotVariants = map[string]func() SlotProfile{
"huff_and_even_more_puff": DefaultHuffAndPuffProfile,
}
+
+// HelpTopic is the help page the "rules" command opens while seated at this
+// table: a variant's own short page where the game has one (Simplified Craps,
+// Super 6 …), the game's main page otherwise.
+func (c TableConfig) HelpTopic() string {
+ if def, ok := tableGameDefFor(c.Game); ok && def.helpTopic != nil {
+ return def.helpTopic(c)
+ }
+ return string(c.Game)
+}
+
+// rulesTextOnTable is the shared route from a table config to its rule text.
+func rulesTextOnTable(cfg TableConfig) string {
+ if def, ok := tableGameDefFor(cfg.Game); ok && def.ruleText != nil {
+ return def.ruleText(cfg)
+ }
+ return cfg.Label()
+}
+
+// rouletteRulesText is roulette's table rules text: the payout table is
+// drawn by the presentation layer (the payout reference needs the paying
+// game's felt), so the engine text covers the fixed odds and table facts.
+func rouletteRulesText(c TableConfig) string {
+ rules := c.Label() + "\nStraight numbers pay 35:1; splits 17:1; streets and trios 11:1; corners 8:1; lines 5:1.\n" +
+ "1to12/13to24/25to36 and rows 2:1; other outside bets 1:1; the American basket pays 6:1.\n" +
+ "Combos: bet 10 on split 1 2 | bet 10 on street 1 2 3 | bet 10 on corner 1 2 4 5 |\n" +
+ "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 1 2 | bet 10 on basket.\n"
+ if c.Roulette.Racetrack {
+ rules += "Racetrack (single zero): bet 10 on voisins | tiers | orphelins | jeu zero | neighbors 17.\n"
+ }
+ return rules + "Zero pockets lose all outside bets. One wager per spin. Use help roulette."
+}
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index 2453fed..1a9f936 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -493,7 +493,7 @@ func (t *rouletteTable) menuEvent(name string) Event {
commands := []MenuCommand{}
p := t.players.get(name)
if t.phase != phasePlaying && p != nil && !p.HasBet {
- commands = append(commands, MenuCommand{Command: "bet <amount> on <target>", Desc: "Place one wager; targets: help roulette"})
+ commands = append(commands, MenuCommand{Command: "bet <amount> on <target>", Desc: "Place one wager; targets: list"})
if p.lastBet > 0 {
commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"})
}
@@ -502,6 +502,9 @@ func (t *rouletteTable) menuEvent(name string) Event {
commands = append(commands, MenuCommand{Command: "sitout / stop", Desc: "Sit out / leave when your wager settles"})
}
commands = append(commands, MenuCommand{Command: "table", Desc: "View the layout"})
+ if p != nil {
+ commands = withTableHints(commands, t.wagerRows())
+ }
e := t.betMenuEvent(name, commands)
e.Snapshot = t.view()
return e
diff --git a/internal/casino/roulette_wagers.go b/internal/casino/roulette_wagers.go
new file mode 100644
index 0000000..7562582
--- /dev/null
+++ b/internal/casino/roulette_wagers.go
@@ -0,0 +1,39 @@
+package casino
+
+import "fmt"
+
+// Roulette offers one wager per spin, but the target catalogue is the whole
+// layout, so the state-based list doubles as the payout reference: every
+// legal target with its profit odds and its chance (pockets out of an
+// equally-likely wheel). Racetrack call bets join on tables that offer them.
+func (t *rouletteTable) wagerRows() []WagerRow {
+ pockets, basketOdds, basketPockets := 37, "8:1", 4
+ if t.Config.Roulette.DoubleZero {
+ pockets, basketOdds, basketPockets = 38, "6:1", 5
+ }
+ chance := func(n int) string { return fmt.Sprintf("%d/%d", n, pockets) }
+ rows := []WagerRow{
+ {"<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) + ")"},
+ {"basket", basketOdds, "0, 1, 2, 3 (+ 00) (" + chance(basketPockets) + ")"},
+ }
+ if t.Config.Roulette.Racetrack && !t.Config.Roulette.DoubleZero {
+ rows = append(rows,
+ WagerRow{"voisins", "5:3 or 1:1", "17 pockets around zero, 9 chips"},
+ 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"},
+ )
+ }
+ return rows
+}
diff --git a/internal/casino/table.go b/internal/casino/table.go
index ddf357f..719bed3 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -21,6 +21,12 @@ type tableGame interface {
idle() bool
status(name string) []Event
overview() string
+ // wagers is the game's live list of bets the player could make right
+ // now (the "list" command's data; empty when nothing is worth naming,
+ // which also suppresses the "list" hint). hints is the full live hint
+ // list, finished with the shared "rules" and (multi-wager) "list".
+ wagers(name string) []WagerRow
+ hints(name string) []MenuCommand
// base returns the table identity shared with the Table handle.
base() *tableBase
}
@@ -111,3 +117,18 @@ 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) }
func (t *Table) Overview() string { return t.game.overview() }
+
+// RulesText is this table's full rule text (the "table rules" wording).
+func (t *Table) RulesText() string { return rulesTextOnTable(t.Config) }
+
+// HelpTopic is the help page the "rules" command opens while seated here.
+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
+// committed round) — the same signal that suppresses the "list" hint.
+func (t *Table) Wagers(name string) []WagerRow { return t.game.wagers(name) }
+
+// Hints is the game's live hint list, finished with the shared "rules" and
+// (multi-wager) "list" entries so look and the menus advertise both.
+func (t *Table) Hints(name string) []MenuCommand { return t.game.hints(name) }
diff --git a/internal/casino/table_hints.go b/internal/casino/table_hints.go
new file mode 100644
index 0000000..00eea53
--- /dev/null
+++ b/internal/casino/table_hints.go
@@ -0,0 +1,86 @@
+package casino
+
+import "fmt"
+
+// Per-game live-hint registration: every game answers the same two views
+// through the tableGame interface — its full hint list (finished with the
+// shared "rules" and "list" entries) and its wager table — so the shared
+// table controls never need a per-game switch. All entries are seated only;
+// a standing player is not told how to bet at a game they have not joined.
+
+func (t *crapsTable) wagers(name string) []WagerRow { return t.wagerRows(name) }
+func (t *crapsTable) hints(name string) []MenuCommand { return t.commands(name) }
+
+func (t *rouletteTable) wagers(name string) []WagerRow { return t.wagerRows() }
+func (t *rouletteTable) hints(name string) []MenuCommand {
+ p := t.players.get(name)
+ if p == nil {
+ return nil
+ }
+ commands := []MenuCommand{}
+ if t.phase != phasePlaying && !p.HasBet {
+ commands = append(commands, MenuCommand{Command: "bet <amount> on <target>", Desc: "Place one wager; targets: list"})
+ if p.lastBet > 0 {
+ commands = append(commands, MenuCommand{Command: "bet / rebet", Desc: "Repeat your previous wager"})
+ }
+ }
+ commands = append(commands, MenuCommand{Command: "sitout / stop", Desc: "Sit out / leave when your wager settles"})
+ commands = append(commands, MenuCommand{Command: "table", Desc: "View the layout"})
+ return withTableHints(commands, t.wagerRows())
+}
+
+// blackjackHintsIdle are the shared controls for a bettor with no structured
+// menu right now (an in-flight round has no betting menu to reuse).
+func blackjackHintsIdle() []MenuCommand {
+ return []MenuCommand{
+ {Command: "table", Desc: "View the table"},
+ {Command: "sitout", Desc: "Sit out after this round"},
+ {Command: "stop", Desc: "Leave after this round"},
+ }
+}
+
+func (t *blackjackTable) wagers(name string) []WagerRow { return t.wagerRows(name) }
+func (t *blackjackTable) hints(name string) []MenuCommand {
+ if t.players.get(name) == nil {
+ return nil
+ }
+ var commands []MenuCommand
+ if menu := t.menuEvent(name); menu.Menu != nil && len(menu.Menu.Commands) > 0 {
+ // The real menu is authoritative: betting, insurance, turn actions
+ // or the committed-wager view, exactly as the round allows.
+ commands = menu.Menu.Commands
+ } else {
+ commands = blackjackHintsIdle()
+ }
+ return withTableHints(commands, t.wagerRows(name))
+}
+
+func (t *baccaratTable) wagers(name string) []WagerRow { return t.wagerRows() }
+func (t *baccaratTable) hints(name string) []MenuCommand {
+ if t.players.get(name) == nil {
+ return nil
+ }
+ return withTableHints(baccaratHintCommands(t, name), t.wagerRows())
+}
+
+// baccaratHintCommands mirrors menuEvent's branches without re-deriving the
+// menu view: a committed wager never advertises another bet.
+func baccaratHintCommands(t *baccaratTable, name string) []MenuCommand {
+ p := t.players.get(name)
+ if p != nil && p.HasBet {
+ return []MenuCommand{
+ {Command: "stop", Desc: "Leave; during a round, leave afterward"},
+ {Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"},
+ }
+ }
+ commands := []MenuCommand{}
+ if p != nil && p.lastBet > 0 {
+ commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot})
+ }
+ return append(commands,
+ MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t)"},
+ MenuCommand{Command: "bet <amount> on <target>", Desc: "One wager per round; targets: list"},
+ MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"},
+ MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"},
+ )
+}
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index f4255be..768c041 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -5,29 +5,40 @@ import (
"strings"
)
-// RulesText is the shared rules view for table commands and room fixtures.
-func (c TableConfig) RulesText() string {
- switch c.Game {
- case GameBlackjack:
- return c.BlackjackRulesText()
- case GameBaccarat:
- return c.BaccaratRulesText()
- case GameCraps:
- return c.CrapsRulesText()
- case GameRoulette:
- rules := c.Label() + "\nStraight numbers pay 35:1; splits 17:1; streets and trios 11:1; corners 8:1; lines 5:1.\n" +
- "1to12/13to24/25to36 and rows 2:1; other outside bets 1:1; the American basket pays 6:1.\n" +
- "Combos: bet 10 on split 1 2 | bet 10 on street 1 2 3 | bet 10 on corner 1 2 4 5 |\n" +
- "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 1 2 | bet 10 on basket.\n"
- if c.Roulette.Racetrack {
- rules += "Racetrack (single zero): bet 10 on voisins | tiers | orphelins | jeu zero | neighbors 17.\n"
+// WagerRow is one line of a table's "list" of available bets, in the game's
+// current state: the bet wording (what follows "bet <amount> on"), its
+// payout, and a brief description of the win condition.
+type WagerRow struct {
+ Bet string
+ Payout string
+ Win string
+}
+
+const (
+ hintCommandRules = "rules"
+ hintCommandList = "list"
+)
+
+// 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>").
+// Games whose own menus already append these hints are not double-hinted.
+func withTableHints(commands []MenuCommand, wagerRows []WagerRow) []MenuCommand {
+ for _, c := range commands {
+ if c.Command == hintCommandRules {
+ return commands
}
- return rules + "Zero pockets lose all outside bets. One wager per spin. Use help roulette."
- default:
- return c.Label()
}
+ commands = append(commands, MenuCommand{Command: hintCommandRules, Desc: "This game's full help page"})
+ if len(wagerRows) > 1 {
+ commands = append(commands, MenuCommand{Command: hintCommandList, Desc: "List every bet available now, with payouts"})
+ }
+ return commands
}
+// RulesText is the shared rules view for table commands and room fixtures.
+func (c TableConfig) RulesText() string { return rulesTextOnTable(c) }
+
func (c TableConfig) Label() string {
if c.Game == GameRoulette {
label := "Roulette / Single zero"
@@ -83,11 +94,12 @@ func (c *tableCore[P]) warning(name, text string) Event {
func (c *tableCore[P]) committedMenu(name string) Event {
e := c.private(name, "Your wager is committed; waiting for the round.")
e.Color = "casino_menu"
- e.Menu = &MenuView{Heading: e.Message, Commands: []MenuCommand{
+ commands := []MenuCommand{
{Command: "table", Desc: "View the table"},
{Command: "sitout", Desc: "Sit out after this round"},
{Command: "stop", Desc: "Leave after this round"},
- }}
+ }
+ e.Menu = &MenuView{Heading: e.Message, Commands: withTableHints(commands, nil)}
return e
}
diff --git a/internal/casino/wagers_test.go b/internal/casino/wagers_test.go
new file mode 100644
index 0000000..9501c8a
--- /dev/null
+++ b/internal/casino/wagers_test.go
@@ -0,0 +1,100 @@
+package casino
+
+import "testing"
+
+// The "list" view doubles as the availability sheet: during the insurance
+// window it names the insurance stake and the decline, and during a turn it
+// names the wagers the current hand could still add (double/split/surrender).
+func TestBlackjackWagerListFollowsTheHand(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ w := &testWallet{credits: 100}
+ table.players.add("alice", &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}},
+ })
+ table.dealer.cards = []card{{rank: "K"}, {rank: "A"}}
+ table.phase = blackjackInsurance
+ rows := table.wagers("alice")
+ if len(rows) != 2 || rows[0].Bet != "insurance" || rows[1].Bet != "decline" {
+ t.Fatalf("insurance rows=%+v", rows)
+ }
+ if rows[0].Payout != "2:1" || rows[0].Win != "Covers dealer blackjack; the stake costs 5" {
+ t.Fatalf("insurance wording=%+v", rows[0])
+ }
+
+ // A turn with a splittable pair and surrender available lists both
+ // wagers; a hit/stand-only hand lists none.
+ table.phase = blackjackPlaying
+ table.activePlayer = 0
+ table.activeHand = 0
+ table.players.get("alice").hands = []*blackjackHand{{bet: 10, cards: []card{{rank: "8"}, {rank: "8"}}}}
+ table.dealer.cards = []card{{rank: "K"}, {rank: "7"}}
+ surrender := true
+ table.Rules.Surrender = &surrender
+ if rows := table.wagers("alice"); len(rows) == 0 {
+ t.Fatal("turn rows missing")
+ }
+ table.Rules.Surrender = nil
+ table.players.get("alice").hands = []*blackjackHand{{bet: 10, cards: []card{{rank: "K"}, {rank: "6"}}}}
+ rows = table.wagers("alice")
+ if len(rows) != 3 || rows[0].Bet != "hit" || rows[2].Bet != "double" {
+ t.Fatalf("plain hand rows=%+v", rows)
+ }
+}
+
+// The betting phase has exactly one wager: no rows, and no "list" hint.
+func TestBlackjackBettingPhaseHidesList(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, BettingTicks: 10}, newTestRand())
+ table.join("a")
+ hints := table.hints("a")
+ for _, c := range hints {
+ if c.Command == "list" {
+ t.Fatal("list advertised with a single wager", hints)
+ }
+ if c.Command != "rules" {
+ continue
+ }
+ }
+ foundRules := false
+ for _, c := range hints {
+ if c.Command == "rules" {
+ foundRules = true
+ }
+ }
+ if !foundRules {
+ t.Fatal("rules hint missing", hints)
+ }
+}
+
+// Craps variants that take one target (pass only) never advertise "list".
+func TestCrapsSingleBetVariantsHideList(t *testing.T) {
+ for _, v := range []string{"simplified", "high_point", "die_rich"} {
+ tb := variantFixture(v)
+ tb.join("a")
+ for _, c := range tb.hints("a") {
+ if c.Command == "list" {
+ t.Fatal(v, "list advertised for a single-bet variant")
+ }
+ if c.Command == "rules" {
+ break
+ }
+ }
+ }
+}
+
+// Multi-bet variants advertise the "list" hint at every phase.
+func TestCrapsMultiBetVariantsAdvertiseList(t *testing.T) {
+ for _, v := range []string{"bank", "crapless", "new_york", "diceless"} {
+ tb := variantFixture(v)
+ tb.join("a")
+ found := false
+ for _, c := range tb.hints("a") {
+ if c.Command == "list" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal(v, "list hint missing", tb.hints("a"))
+ }
+ }
+}
diff --git a/internal/game/casino_look_test.go b/internal/game/casino_look_test.go
new file mode 100644
index 0000000..71db40a
--- /dev/null
+++ b/internal/game/casino_look_test.go
@@ -0,0 +1,90 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "thehouseoficarus/internal/casino"
+)
+
+// lookOutput polls the queued session writer until the look output has
+// settled (no growth across two samples), then returns it, ANSI-stripped.
+func lookOutput(t *testing.T, conn *chanConn) string {
+ t.Helper()
+ last := 0
+ stable := 0
+ for stable < 2 {
+ n := len(conn.Bytes())
+ if n == last {
+ stable++
+ } else {
+ stable = 0
+ last = n
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return stripANSI(string(conn.Bytes()))
+}
+
+// TestLookAtTableShowsGameStateAndHints covers feature 1 with the full
+// production harness: a seated player's look shows the room first, then the
+// game table, then the current hint commands — rules always, list when the
+// game has multiple wagers.
+func TestLookAtTableShowsGameStateAndHints(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, conn := h.connect("alice")
+
+ // Add a casino room and move there.
+ room := "id: 4\nname: Simplified Salon\ncasino_tables:\n - id: simple\n game: craps\n craps:\n variant: simplified\n"
+ path := filepath.Join(h.dir, "rooms", "4.yaml")
+ if err := os.WriteFile(path, []byte(room), 0644); err != nil {
+ t.Fatal(err)
+ }
+ h.g.World.AddRoomPath(4, path)
+ sess.Player.RoomID = 4
+ h.hub.EnterRoom(sess, 4)
+
+ cfg := casino.TableConfig{ID: "simple", Game: casino.GameCraps, Craps: casino.CrapsConfig{Variant: "simplified"}}
+ h.g.Casino.Join(4, cfg, "alice")
+ h.g.executeLook(sess, nil, "look")
+ text := lookOutput(t, conn)
+ if !strings.Contains(text, "Simplified Salon") {
+ t.Fatalf("look lost the room: %q", text)
+ }
+ if !strings.Contains(strings.ToUpper(text), "SIMPLIFIED CRAPS") || !strings.Contains(text, "Limits") {
+ t.Fatalf("look missing the table state: %q", text)
+ }
+ if !strings.Contains(text, "rules") {
+ t.Fatalf("look missing the rules hint: %q", text)
+ }
+}
+
+// TestLookStandingShowsFixtureOnly: an unseated player's look keeps the
+// plain room listing (no game state block).
+func TestLookStandingShowsFixtureOnly(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, conn := h.connect("alice")
+ room := "id: 4\nname: Simplified Salon\ncasino_tables:\n - id: simple\n game: craps\n craps:\n variant: simplified\n"
+ path := filepath.Join(h.dir, "rooms", "4.yaml")
+ if err := os.WriteFile(path, []byte(room), 0644); err != nil {
+ t.Fatal(err)
+ }
+ h.g.World.AddRoomPath(4, path)
+ sess.Player.RoomID = 4
+ h.hub.EnterRoom(sess, 4)
+ h.g.executeLook(sess, nil, "look")
+ text := lookOutput(t, conn)
+ if strings.Contains(text, "bet <amount>") {
+ t.Fatalf("standing look leaked a betting menu: %q", text)
+ }
+ if !strings.Contains(text, "play craps") {
+ t.Fatalf("standing look missing the play hint: %q", text)
+ }
+}
diff --git a/internal/game/casino_rules_list_test.go b/internal/game/casino_rules_list_test.go
new file mode 100644
index 0000000..3d91ecf
--- /dev/null
+++ b/internal/game/casino_rules_list_test.go
@@ -0,0 +1,245 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+// casinoCrapsGame builds a one-room world with the named craps edition and
+// points the help loader at the shipped data dir, so the rules command and
+// the shipped-topic test exercise the real help pages.
+func casinoCrapsGame(t *testing.T, roomYAML string) (*Game, *net.Session, *casinoTestConn) {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(roomYAML), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ p := player.New("alice")
+ sess := &net.Session{Player: p, Conn: &casinoTestConn{}}
+ g := &Game{
+ Deps: Deps{World: world.New(dir), ObjectStore: object.NewObjectStore(dir), MobStore: world.NewMobStore(dir), AccountStore: player.NewAccountStore(dir), Ticks: engine.New(), DataDir: "../../data"},
+ Casino: casino.NewManager(1),
+ Combat: combat.NewTracker(),
+ safespot: NewSafespotManager(),
+ restTimers: make(map[string]uint64),
+ loggedInChars: map[string]*net.Session{"alice": sess},
+ }
+ p.Credits = 100
+ p.RoomID = 1
+ return g, sess, sess.Conn.(*casinoTestConn)
+}
+
+// TestRulesAtTableShowsVariantHelpPage pins the "rules" contract: seated at
+// Simplified Craps, rules must open the variant's own short page — not the
+// whole craps manual with the edition buried in a section.
+func TestRulesAtTableShowsVariantHelpPage(t *testing.T) {
+ room := "name: Simplified Salon\ncasino_tables:\n - id: simple\n game: craps\n craps:\n variant: simplified\n"
+ g, sess, conn := casinoCrapsGame(t, room)
+ g.executePlay(sess, []string{"craps"}, "play craps")
+ conn.data = nil
+ g.executeRules(sess, nil, "rules")
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "Simplified Craps") {
+ t.Fatalf("rules missing the variant page: %q", text)
+ }
+ if strings.Contains(text, "HOUSE EDITIONS") || strings.Contains(text, "bet 10 on dontcome") {
+ t.Fatalf("rules leaked the bank manual into the variant page: %q", text)
+ }
+}
+
+// TestRulesStandingInCasinoRoomAnswersRoomGame checks the unseated fallback:
+// standing in a one-table casino room answers that game's page.
+func TestRulesStandingInCasinoRoomAnswersRoomGame(t *testing.T) {
+ room := "name: Crapless Salon\ncasino_tables:\n - id: cl\n game: craps\n craps:\n variant: crapless\n"
+ g, sess, conn := casinoCrapsGame(t, room)
+ g.executeRules(sess, nil, "rules")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "Crapless Craps") {
+ t.Fatalf("standing rules did not answer the room's game: %q", text)
+ }
+}
+
+// TestRulesOutsideCasinoFallsBackToGuide checks rooms with no casino content.
+func TestRulesOutsideCasinoFallsBackToGuide(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, "name: Void\n")
+ g.executeRules(sess, nil, "rules")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "Casino quick guide") {
+ t.Fatalf("rules outside a casino: %q", text)
+ }
+}
+
+// TestListFollowsGameState covers the list contract end to end: come-out
+// lists the line and one-roll bets, a point adds place/odds rows, bonus
+// bets close after the first roll, and single-bet editions print one line.
+func TestListFollowsGameState(t *testing.T) {
+ room := "name: Bank Pit\ncasino_tables:\n - id: bank\n game: craps\n craps:\n variant: bank\n"
+ g, sess, conn := casinoCrapsGame(t, room)
+ g.executePlay(sess, []string{"craps"}, "play craps")
+ g.executeBet(sess, []string{"10", "pass"}, "bet 10 pass")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ for _, want := range []string{"bet <amount> on pass", "bet <amount> on dontpass", "bet <amount> on field", "bet <amount> on craps", "bet <amount> on fire"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("come-out list missing %q: %q", want, text)
+ }
+ }
+ for _, gone := range []string{"place 6", "odds pass"} {
+ if strings.Contains(text, gone) {
+ t.Fatalf("come-out list advertised %q: %q", gone, text)
+ }
+ }
+
+ // Establish a point: place/odds appear, bonus bets close.
+ g.Casino.Action(1, "bank", "alice", "roll")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text = stripANSI(string(conn.data))
+ if !strings.Contains(text, "place 6") || !strings.Contains(text, "odds pass") {
+ t.Fatalf("point list missing place/odds: %q", text)
+ }
+ if strings.Contains(text, "bet <amount> on pass\n") || strings.Contains(text, "fire") {
+ t.Fatalf("point list still advertising closed bets: %q", text)
+ }
+}
+
+// TestListSingleBetGamesStayBrief: editions with one wager (Simplified) and
+// one-wager games (blackjack) answer in one line, no table.
+func TestListSingleBetGamesStayBrief(t *testing.T) {
+ room := "name: Simple\ncasino_tables:\n - id: simple\n game: craps\n craps:\n variant: simplified\n"
+ g, sess, conn := casinoCrapsGame(t, room)
+ g.executePlay(sess, []string{"craps"}, "play craps")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ if strings.Contains(text, "Bet") && strings.Contains(text, "Pays") && strings.Contains(text, "Win if") {
+ t.Fatalf("single-bet game rendered a table: %q", text)
+ }
+ if !strings.Contains(text, "bet <amount> on pass") {
+ t.Fatalf("single-bet line missing the wager: %q", text)
+ }
+
+ // Blackjack: one wager, no table, and the list command still answers.
+ room = "name: BJ\ncasino_tables:\n - id: bj\n game: blackjack\n"
+ g, sess, conn = casinoCrapsGame(t, room)
+ g.executePlay(sess, []string{"bj"}, "play bj")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "only wager") {
+ t.Fatalf("blackjack list: %q", text)
+ }
+}
+
+// TestListAtShopKeepsShopMeaning guards the shop fallthrough: an explicit
+// shopmob argument always browses the shop.
+func TestListAtShopKeepsShopMeaning(t *testing.T) {
+ g, sess, _ := casinoCrapsGame(t, "name: Void\n")
+ g.executeList(sess, []string{"shopkeeper"}, "list shopkeeper")
+ if text := stripANSI(string(sess.Conn.(*casinoTestConn).data)); !strings.Contains(text, "no shop") && !strings.Contains(text, "shop") {
+ t.Fatalf("explicit list should reach the shop path: %q", text)
+ }
+}
+
+// TestShippedCasinoHelpTopicsResolve walks every shipped casino table and
+// machine and pins that its rules help topic actually resolves in data/help.
+func TestShippedCasinoHelpTopicsResolve(t *testing.T) {
+ dataDir := filepath.Join("..", "..", "data")
+ helps, err := LoadHelp(dataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resolves := func(topic string) bool {
+ for _, h := range helps {
+ if matchesHelpTopic(h, topic) {
+ return true
+ }
+ }
+ return false
+ }
+ roomsDir := filepath.Join(dataDir, "rooms")
+ err = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !strings.HasSuffix(path, ".yaml") {
+ return err
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var room world.Room
+ if err := yaml.Unmarshal(raw, &room); err != nil {
+ return err
+ }
+ for _, cfg := range room.CasinoTables {
+ topic := cfg.HelpTopic()
+ if !resolves(topic) {
+ t.Errorf("%s: table %s (%s) help topic %q does not resolve", path, cfg.ID, cfg.Game, topic)
+ }
+ }
+ for _, m := range room.CasinoMachines {
+ if !resolves(string(m.Game)) {
+ t.Errorf("%s: machine %s game %q does not resolve", path, m.ID, m.Game)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestRulesAtMachineShowsSlotsHelp: a slot machine player typing rules gets
+// the cabinet's own guide, not a dead end.
+func TestRulesAtMachineShowsSlotsHelp(t *testing.T) {
+ room := "name: Slots Salon\ncasino_machines:\n - id: puff\n game: slots\n"
+ g, sess, conn := casinoCrapsGame(t, room)
+ g.executePlay(sess, []string{"slots"}, "play slots")
+ conn.data = nil
+ g.executeRules(sess, nil, "rules")
+ if text := stripANSI(string(conn.data)); !strings.Contains(text, "Slots quick guide") {
+ t.Fatalf("machine rules: %q", text)
+ }
+}
+
+// TestListRouletteAndBaccarat: roulette lists the whole target catalogue and
+// baccarat lists every outcome and side bet its variant offers.
+func TestListRouletteAndBaccarat(t *testing.T) {
+ room := "name: Wheel Salon\ncasino_tables:\n - id: wheel\n game: roulette\n roulette:\n racetrack: true\n"
+ g, sess, conn := casinoCrapsGame(t, room)
+ g.executePlay(sess, []string{"roulette"}, "play roulette")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text := stripANSI(string(conn.data))
+ for _, want := range []string{"bet <amount> on <number>", "bet <amount> on red / black", "bet <amount> on voisins", "bet <amount> on basket"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("roulette list missing %q: %q", want, text)
+ }
+ }
+
+ room = "name: Crown Salon\ncasino_tables:\n - id: crown\n game: baccarat\n baccarat:\n variant: ez_baccarat\n dragon_7: true\n panda_8: true\n"
+ g, sess, conn = casinoCrapsGame(t, room)
+ g.executePlay(sess, []string{"bac"}, "play bac")
+ conn.data = nil
+ g.executeList(sess, nil, "list")
+ text = stripANSI(string(conn.data))
+ for _, want := range []string{"bet <amount> on banker (b)", "1:1 (3-card 7 pushes)", "bet <amount> on dragon_7", "bet <amount> on panda_8"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("baccarat list missing %q: %q", want, text)
+ }
+ }
+}
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index a2e7330..2881a49 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -8,6 +8,8 @@ import (
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
)
type casinoChoice struct {
@@ -104,6 +106,8 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
t, playing := g.Casino.Participation(sess.Player.Name)
if !playing || t == nil {
if len(args) > 0 && args[0] == "help" {
+ // Machine players get their cabinet's guide; everyone else the
+ // shared casino one.
topic := "casino"
if playing {
topic = "slots"
@@ -115,17 +119,19 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
return
}
if len(args) > 0 && args[0] == "rules" {
+ // The felt and the exact, per-table wording stay under table rules;
+ // bare rules opens the game's own help page.
sess.WriteLine(g.renderCasinoRules(sess, t.Config))
return
}
if len(args) > 0 && args[0] == "help" {
- g.doHelp(sess, string(t.Config.Game))
+ g.doHelp(sess, t.HelpTopic())
} else {
g.emitCasinoEvents(t.Status(sess.Player.Name))
if t.Config.Game == casino.GameRoulette {
sess.WriteLine(g.rouletteReminder(sess, t.Config))
} else {
- sess.WriteLine(g.colorize(sess, "command", "Learn to play: table help / help casino. Exact payouts: table rules."))
+ sess.WriteLine(g.colorize(sess, "command", "Learn to play: rules. Exact payouts: table rules."))
}
}
if len(args) > 0 {
@@ -133,8 +139,10 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
// (insurance, split, rebet, etc.) that may not be legal right now.
g.emitCasinoEvents(t.Status(sess.Player.Name))
commands := []casino.MenuCommand{
+ {Command: "rules", Desc: "Help page for this game variant"},
+ {Command: "list", Desc: "Every bet available right now, with payouts"},
+ {Command: "table rules", Desc: "This table’s exact rules and payouts"},
{Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"},
- {Command: "table rules", Desc: "Review this table’s rules"},
{Command: "help casino", Desc: "Quick guides to all casino games"},
}
sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands))
@@ -236,6 +244,14 @@ func (g *Game) casinoTimeText(text string) string {
})
}
+// betRangeWords renders the wager range in words for one-line hints.
+func betRangeWords(minBet, maxBet int) string {
+ if maxBet <= 0 || maxBet >= casino.DefaultMaxBet {
+ return fmt.Sprintf("%d or more", minBet)
+ }
+ return fmt.Sprintf("%d-%d", minBet, maxBet)
+}
+
func (g *Game) casinoBalance(sess *net.Session) string {
if sess.Player == nil {
return ""
@@ -243,3 +259,122 @@ func (g *Game) casinoBalance(sess *net.Session) string {
p := sess.Player
return fmt.Sprintf("Available %s (%s chips + %s credits)", g.colorize(sess, "casino_chips", strconv.Itoa(casinoAffordable(p))), g.colorize(sess, "casino_chips", strconv.Itoa(countChips(p))), g.colorize(sess, "casino_credits", strconv.Itoa(p.Credits)))
}
+
+// executeRules is the seated table's own help entry point: a variant's page
+// (Simplified Craps gets its own short page, not a section of the craps
+// manual), slots help at a machine, and the room's own game while merely
+// standing in a casino room. Explicit topics pass straight through to help.
+func (g *Game) executeRules(sess *net.Session, args []string, rawInput string) {
+ if sess.Player == nil {
+ return
+ }
+ if len(args) > 0 {
+ g.doHelp(sess, strings.Join(args, " "))
+ return
+ }
+ if g.Casino == nil {
+ g.doHelp(sess, "casino")
+ return
+ }
+ if topic := g.casinoRulesTopic(sess.Player.RoomID, sess.Player.Name); topic != "" {
+ g.doHelp(sess, topic)
+ return
+ }
+ // Not in a casino game or room: the shared guide still answers.
+ g.doHelp(sess, "casino")
+}
+
+// casinoRulesTopic picks the help page "rules" should open: the seated
+// table's variant page first, then a machine's game, then the room's own
+// casino games (so standing in the craps pit answers about craps). Mixed
+// games, or no casino content at all, fall back to the shared guide.
+func (g *Game) casinoRulesTopic(roomID int, name string) string {
+ if table, playing := g.Casino.Participation(name); playing && table != nil {
+ return table.HelpTopic()
+ }
+ if machine, ok := g.Casino.MachineFor(name); ok {
+ return string(machine.Config.Game)
+ }
+ room, err := g.World.LoadRoom(roomID)
+ if err != nil {
+ return ""
+ }
+ topic := ""
+ for _, cfg := range room.CasinoTables {
+ page := cfg.HelpTopic()
+ if topic == "" {
+ topic = page
+ } else if topic != page {
+ return "casino"
+ }
+ }
+ for _, m := range room.CasinoMachines {
+ page := string(m.Game)
+ if topic == "" {
+ topic = page
+ } else if topic != page {
+ return "casino"
+ }
+ }
+ return topic
+}
+
+// executeList shows the bets a casino game accepts right now — the game's
+// own state-driven list, from the come-out pass line to the travelling come
+// bets and one-roll props. Everywhere else (shops, plain rooms) "list"
+// keeps its shop meaning.
+func (g *Game) executeList(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ if len(args) > 0 || g.Casino == nil {
+ g.executeShopList(sess, args, rawInput)
+ return
+ }
+ if machine, ok := g.Casino.MachineFor(p.Name); ok {
+ sess.WriteLine(g.colorize(sess, "casino_action", "A "+casinoGameLabel(machine.Config.Game)+" takes one wager: bet <amount> (or bet max), then spin or autospin."))
+ return
+ }
+ if table, playing := g.Casino.Participation(p.Name); playing && table != nil {
+ g.showCasinoWagerList(sess, table)
+ return
+ }
+ g.executeShopList(sess, args, rawInput)
+}
+
+// showCasinoWagerList renders the game's live wager list. Two or more rows
+// become a brief table; a single row is one line; an empty list means the
+// only bet is the ordinary amount-only wager.
+func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) {
+ rows := t.Wagers(sess.Player.Name)
+ switch {
+ case len(rows) == 1:
+ r := rows[0]
+ sess.WriteLine(g.colorize(sess, "casino_action", "Your only wager: bet <amount> on "+r.Bet+" — "+r.Payout+" — "+r.Win+"."))
+ case len(rows) == 0:
+ sess.WriteLine(g.colorize(sess, "casino_action", "The only wager here is bet <amount>") + " (" + betRangeWords(t.Config.MinBet, t.Config.MaxBet) + ").")
+ default:
+ commands := make([]casino.MenuCommand, 0, len(rows))
+ for _, r := range rows {
+ commands = append(commands, casino.MenuCommand{Command: "bet <amount> on " + r.Bet, Desc: r.Payout + " | " + r.Win})
+ }
+ sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), t.Config.Label()+" — available bets", commands))
+ }
+}
+
+// showCasinoGameLook appends the live game state and hint menu for the
+// table the player is seated at, so look reads room first, then the table,
+// then the commands that matter now. Machines are skipped: their menus
+// arrive with every spin.
+func (g *Game) showCasinoGameLook(sess *net.Session, p *player.Player, room *world.Room) {
+ if g.Casino == nil || p == nil {
+ return
+ }
+ table, playing := g.Casino.Participation(p.Name)
+ if !playing || table == nil || table.RoomID != room.ID {
+ return
+ }
+ sess.WriteLine("")
+ g.emitCasinoEvents(table.Status(p.Name))
+}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 6fb80e5..70bc2c2 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -58,6 +58,9 @@ func (g *Game) doLook(sess *net.Session) {
g.showRoomExits(sess, p, room)
g.showRoomPlayers(sess, p, room)
+ // Seated at a table: look keeps its room-first order, then appends the
+ // live game state and the current hint menu.
+ g.showCasinoGameLook(sess, p, room)
}
func (g *Game) doExits(sess *net.Session) {
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index 853fb6a..fa3518a 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -33,6 +33,8 @@ var commandRegistry = map[string]commandDef{
}, ClassActive},
"table": {(*Game).executeTableStatus, ClassInstant},
"status": {(*Game).executeTableStatus, ClassInstant},
+ "rules": {(*Game).executeRules, ClassInstant},
+ "list": {(*Game).executeList, ClassInstant},
"watch": {(*Game).executeWatch, ClassInstant},
"sitout": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "sitout") }, ClassInstant},
"decline": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "decline") }, ClassActive},
@@ -148,7 +150,6 @@ var commandRegistry = map[string]commandDef{
"inspect": {(*Game).executeInspect, ClassInstant},
"walk": {(*Game).executeWalk, ClassActive},
"bank": {(*Game).executeBank, ClassActive},
- "list": {(*Game).executeShopList, ClassInstant},
"browse": {(*Game).executeShopList, ClassInstant},
"buy": {(*Game).executeShopBuy, ClassInstant},
"sell": {(*Game).executeShopSell, ClassInstant},
diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go
index 64c910e..12714dc 100644
--- a/internal/game/render_craps.go
+++ b/internal/game/render_craps.go
@@ -343,7 +343,7 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
}
out.WriteString("\n" + title(" "+strings.ToUpper(strings.ReplaceAll(variant, "_", " "))+" CRAPS"))
if point != "" {
- out.WriteString(subtle(" / "+point))
+ out.WriteString(subtle(" / " + point))
}
out.WriteString("\n")
if variant == "bank" || variant == "diceless" {
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index 6066e9a..36f73f7 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -32,7 +32,7 @@ var commandList = []cmdEntry{
{"attack / kill", "Active", "Attack a mob"},
{"autotrigger / auto", "Instant", "Set a module to auto-trigger"},
{"bank", "Active", "Open bank interface"},
- {"browse / list", "Instant", "Browse a shop's stock"},
+ {"browse / list", "Instant", "Browse a shop's stock; at a casino table, list every bet accepted now"},
{"burn", "Active", "Start a fire"},
{"buy", "Instant", "Buy from a shop"},
{"chop / cut", "Active", "Chop trees (Woodcutting)"},
@@ -65,6 +65,7 @@ var commandList = []cmdEntry{
{"map", "Instant", "Display an ASCII map of the area"},
{"play", "Active", "Join a game; play bj / play bac; optional amount and target"},
{"table / status", "Instant", "Redisplay table state; table rules / table help for details"},
+ {"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"},
{"sitout", "Instant", "Sit out future table rounds; bet to rejoin"},
{"bet / rebet", "Active", "Repeat a wager, or bet <amount> [target]; min/max accepted"},