From e3ff06fc79682673194c8913f0feb9e90920e7d0 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 30 Sep 2026 20:50:45 -0400 Subject: feat: look, rules, and list commands updated for casino games --- internal/casino/baccarat.go | 3 +- internal/casino/baccarat_test.go | 2 +- internal/casino/baccarat_wagers.go | 39 +++++ internal/casino/blackjack.go | 4 +- internal/casino/blackjack_test.go | 4 +- internal/casino/blackjack_wagers.go | 41 ++++++ internal/casino/craps_ux.go | 4 + internal/casino/craps_variants.go | 24 ++++ internal/casino/craps_wagers.go | 144 +++++++++++++++++++ internal/casino/registry.go | 66 ++++++++- internal/casino/roulette.go | 5 +- internal/casino/roulette_wagers.go | 39 +++++ internal/casino/table.go | 21 +++ internal/casino/table_hints.go | 86 +++++++++++ internal/casino/table_ux.go | 54 ++++--- internal/casino/wagers_test.go | 100 +++++++++++++ internal/game/casino_look_test.go | 90 ++++++++++++ internal/game/casino_rules_list_test.go | 245 ++++++++++++++++++++++++++++++++ internal/game/casino_ux.go | 141 +++++++++++++++++- internal/game/cmd_look.go | 3 + internal/game/cmd_registry.go | 3 +- internal/game/render_craps.go | 2 +- internal/game/render_help.go | 3 +- 23 files changed, 1085 insertions(+), 38 deletions(-) create mode 100644 internal/casino/baccarat_wagers.go create mode 100644 internal/casino/blackjack_wagers.go create mode 100644 internal/casino/craps_wagers.go create mode 100644 internal/casino/roulette_wagers.go create mode 100644 internal/casino/table_hints.go create mode 100644 internal/casino/wagers_test.go create mode 100644 internal/game/casino_look_test.go create mode 100644 internal/game/casino_rules_list_test.go (limited to 'internal') 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 ", 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 on ", 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 on ", 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 , 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 ", "true odds", "True-odds bet on 4/5/6/8/9/10; 5% stake commission, wins only"}, + WagerRow{"lay ", "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 ", "true odds", "True-odds place bet; 5% stake commission, wins only"}, + WagerRow{"lay ", "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 ", "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 ", "true odds", "True-odds place bet; 5% stake commission, wins only"}, + WagerRow{"lay ", "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 ", "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 ", "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 ", "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 on ", Desc: "Place one wager; targets: help roulette"}) + commands = append(commands, MenuCommand{Command: "bet on ", 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{ + {"", "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 ", "17:1", "Two touching pockets (" + chance(2) + ")"}, + {"street ", "11:1", "Three numbers in a street (" + chance(3) + ")"}, + {"trio 0 ", "11:1", "A zero-edge trio (" + chance(3) + ")"}, + {"corner ", "8:1", "A 2x2 square (" + chance(4) + ")"}, + {"line … ", "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 ", "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 " 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 on ", 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 ", 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 on ", 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 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 "). +// 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 ") { + 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 on pass", "bet on dontpass", "bet on field", "bet on craps", "bet 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 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 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 on ", "bet on red / black", "bet on voisins", "bet 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 on banker (b)", "1:1 (3-card 7 pushes)", "bet on dragon_7", "bet 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 (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 on "+r.Bet+" — "+r.Payout+" — "+r.Win+".")) + case len(rows) == 0: + sess.WriteLine(g.colorize(sess, "casino_action", "The only wager here is bet ") + " (" + 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 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 [target]; min/max accepted"}, -- cgit v1.2.3