aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/pai_gow.md2
-rw-r--r--building_guide/rooms.md2
-rw-r--r--data/rooms/casino/6195.yaml2
-rw-r--r--data/rooms/casino/6196.yaml2
-rw-r--r--data/rooms/casino/6197.yaml2
-rw-r--r--data/rooms/casino/6198.yaml2
-rw-r--r--internal/casino/craps_variants.go3
-rw-r--r--internal/casino/pai_gow.go24
-rw-r--r--internal/casino/table.go10
-rw-r--r--internal/casino/table_ux.go26
-rw-r--r--internal/casino/types.go2
-rw-r--r--internal/casino/video_poker.go19
-rw-r--r--internal/game/blackjack_rules.go39
-rw-r--r--internal/game/casino.go9
-rw-r--r--internal/game/casino_ux.go25
-rw-r--r--internal/game/look_casino.go128
-rw-r--r--internal/game/look_casino_test.go121
-rw-r--r--internal/game/look_entities.go2
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/pai_gow_test.go53
-rw-r--r--internal/game/render_pai_gow.go21
21 files changed, 421 insertions, 75 deletions
diff --git a/building_guide/pai_gow.md b/building_guide/pai_gow.md
index 40e6029..ebc1da9 100644
--- a/building_guide/pai_gow.md
+++ b/building_guide/pai_gow.md
@@ -4,7 +4,7 @@ Configure a `casino_tables` entry:
```yaml
- id: fortune
- game: pai_gow
+ game: paigow
max_players: 6
min_bet: 5
max_bet: 1000
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index c8b9c6e..635c43c 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -449,7 +449,7 @@ casino configurations. Raw file editing is not a validated save endpoint.
- IDs must be nonempty, have no surrounding whitespace, and be unique across
the room's tables and machines.
- Supported table games are `blackjack`, `baccarat`, `roulette`, `craps`, and
- `pai_gow` ([Pai Gow configuration and variants](pai_gow.md)). Single-seat
+ `paigow` ([Pai Gow configuration and variants](pai_gow.md)). Single-seat
`video_poker` cabinets also use the table adapter ([cabinet guide](video_poker.md)).
Roulette configuration basics: `roulette.double_zero` selects the American
38-pocket wheel (otherwise the European 37-pocket wheel), `roulette.spin_ticks`
diff --git a/data/rooms/casino/6195.yaml b/data/rooms/casino/6195.yaml
index 4e8084d..7084a13 100644
--- a/data/rooms/casino/6195.yaml
+++ b/data/rooms/casino/6195.yaml
@@ -11,7 +11,7 @@ exits:
room: 6197
casino_tables:
- id: fortune
- game: pai_gow
+ game: paigow
max_players: 6
min_bet: 5
max_bet: 1000
diff --git a/data/rooms/casino/6196.yaml b/data/rooms/casino/6196.yaml
index 07dd081..3f3266c 100644
--- a/data/rooms/casino/6196.yaml
+++ b/data/rooms/casino/6196.yaml
@@ -9,7 +9,7 @@ exits:
room: 6198
casino_tables:
- id: ez
- game: pai_gow
+ game: paigow
max_players: 6
min_bet: 5
max_bet: 1000
diff --git a/data/rooms/casino/6197.yaml b/data/rooms/casino/6197.yaml
index fabbc75..2290e6a 100644
--- a/data/rooms/casino/6197.yaml
+++ b/data/rooms/casino/6197.yaml
@@ -7,7 +7,7 @@ exits:
room: 6195
casino_tables:
- id: no_push
- game: pai_gow
+ game: paigow
max_players: 6
min_bet: 5
max_bet: 1000
diff --git a/data/rooms/casino/6198.yaml b/data/rooms/casino/6198.yaml
index e60b5dd..020cac5 100644
--- a/data/rooms/casino/6198.yaml
+++ b/data/rooms/casino/6198.yaml
@@ -7,7 +7,7 @@ exits:
room: 6196
casino_tables:
- id: face_up
- game: pai_gow
+ game: paigow
max_players: 6
min_bet: 5
max_bet: 1000
diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go
index 7d4d246..4818231 100644
--- a/internal/casino/craps_variants.go
+++ b/internal/casino/craps_variants.go
@@ -55,6 +55,9 @@ func (c CrapsConfig) title() string {
return "Bank Craps"
}
}
+
+// Title is the human-readable edition name shown by the look command.
+func (c CrapsConfig) Title() string { return c.title() }
func (c CrapsConfig) allows(kind string) bool {
switch c.variant() {
case "simplified", "high_point", "die_rich":
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go
index 21abf4b..17d8971 100644
--- a/internal/casino/pai_gow.go
+++ b/internal/casino/pai_gow.go
@@ -70,7 +70,21 @@ func (t *paiGowTable) hasParticipant(name string) bool {
func (t *paiGowTable) overview() string {
t.mu.Lock()
defer t.mu.Unlock()
- return fmt.Sprintf("%s | %s | %d/%d seats", t.Config.Label(), t.phase, len(t.players), t.Config.MaxPlayers)
+ return fmt.Sprintf("%s | %d/%d seated", t.Config.Label(), len(t.players), t.Config.MaxPlayers)
+}
+func (t *paiGowTable) seats() []string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ names := make([]string, 0, len(t.players))
+ for _, p := range t.players {
+ names = append(names, p.name)
+ }
+ return names
+}
+func (t *paiGowTable) state() string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.phase
}
func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
p := t.player(name)
@@ -145,7 +159,13 @@ func (t *paiGowTable) statusLocked(name string) []Event {
}
e := t.private(name, t.Config.Label())
e.Snapshot = s
- e.Menu = &MenuView{Heading: t.Config.Label() + " | " + t.phase, ShowBalance: true, Commands: t.hintsLocked(name)}
+ // Context-aware hints are attached as a live menu; the shared board
+ // renderer places them below the felt. Stand-ins (for example a watcher)
+ // get no menu, so they cannot be told how to bet a game they have not joined.
+ if commands := t.hintsLocked(name); len(commands) > 0 {
+ e.Color = "casino_menu"
+ e.Menu = &MenuView{Heading: t.Config.Label() + " | " + t.phase, ShowBalance: true, Commands: commands}
+ }
return []Event{e}
}
func (t *paiGowTable) status(name string) []Event {
diff --git a/internal/casino/table.go b/internal/casino/table.go
index 719bed3..88ffc62 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -21,6 +21,10 @@ type tableGame interface {
idle() bool
status(name string) []Event
overview() string
+ // seats and state expose the live occupancy and lifecycle phase for the
+ // look command's table-details view. They are read-only.
+ seats() []string
+ state() 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
@@ -118,6 +122,12 @@ func (t *Table) idle() bool { return t.game.idle() }
func (t *Table) Status(name string) []Event { return t.game.status(name) }
func (t *Table) Overview() string { return t.game.overview() }
+// Seats lists the seated players in seat order for the look command.
+func (t *Table) Seats() []string { return t.game.seats() }
+
+// State is the table's live lifecycle phase for the look command.
+func (t *Table) State() string { return t.game.state() }
+
// RulesText is this table's full rule text (the "table rules" wording).
func (t *Table) RulesText() string { return rulesTextOnTable(t.Config) }
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index 55c491b..d0211f3 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -77,17 +77,27 @@ func (c *tableCore[P]) overview() string {
func (c *tableCore[P]) overviewLocked() string {
// Concise enough to sit beside a look minimap without wrapping into it.
- // The betting countdown lives in the menu heading and warning lines.
- return fmt.Sprintf("%s | Limits %s | %d/%d seated", c.Config.Label(), compactBetRange(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers)
+ // The betting countdown lives in the menu heading and warning lines; the
+ // wager range is shown in the betting menu and on the rule card instead.
+ return fmt.Sprintf("%s | %d/%d seated", c.Config.Label(), c.players.count(), c.Config.MaxPlayers)
}
-// compactBetRange renders the wager range in the short form used by the
-// one-line table overview (the verbose form stays in the betting menu).
-func compactBetRange(minBet, maxBet int) string {
- if maxBet == maxBetUnlimited {
- return fmt.Sprintf("%d+", minBet)
+// seats lists the seated players in seat order for the look details view.
+func (c *tableCore[P]) seats() []string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ names := make([]string, 0, c.players.count())
+ for _, p := range c.players.ordered() {
+ names = append(names, p.participantPtr().Name)
}
- return fmt.Sprintf("%d-%d", minBet, maxBet)
+ return names
+}
+
+// state is the shared lifecycle phase for the look details view.
+func (c *tableCore[P]) state() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return string(c.phase)
}
func (c *tableCore[P]) warning(name, text string) Event {
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 1b0826a..9c000b5 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -14,7 +14,7 @@ func betRangeText(minBet, maxBet int) string {
type GameName string
const (
- GamePaiGow GameName = "pai_gow"
+ GamePaiGow GameName = "paigow"
GameVideoPoker GameName = "video_poker"
GameSlots GameName = "slots"
GameBlackjack GameName = "blackjack"
diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go
index 8c9f66c..e5012f5 100644
--- a/internal/casino/video_poker.go
+++ b/internal/casino/video_poker.go
@@ -109,6 +109,25 @@ func (t *videoPokerTable) overview() string {
func (t *videoPokerTable) overviewLocked() string {
return t.settings.CabinetName() + " machine"
}
+func (t *videoPokerTable) seats() []string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ names := make([]string, 0, len(t.players))
+ for name := range t.players {
+ names = append(names, name)
+ }
+ return names
+}
+func (t *videoPokerTable) state() string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ for _, p := range t.players {
+ if p.active {
+ return "playing"
+ }
+ }
+ return "waiting"
+}
func (t *videoPokerTable) wagers(string) []WagerRow { return nil }
func (t *videoPokerTable) hints(name string) []MenuCommand {
t.mu.Lock()
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index 045626c..7cea977 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -8,20 +8,26 @@ import (
)
// Tables are room fixtures in their own right, not authored scenery objects.
+// Each game resolves through the shared details view; the felt games append
+// their per-table rule card there.
func (g *Game) lookBlackjackTable(sess *net.Session, target string) bool {
- return g.lookCardTable(sess, target, casino.GameBlackjack)
+ return g.lookCasinoTable(sess, target, casino.GameBlackjack)
}
func (g *Game) lookBaccaratTable(sess *net.Session, target string) bool {
- return g.lookCardTable(sess, target, casino.GameBaccarat)
+ return g.lookCasinoTable(sess, target, casino.GameBaccarat)
}
func (g *Game) lookCrapsTable(sess *net.Session, target string) bool {
- return g.lookCardTable(sess, target, casino.GameCraps)
+ return g.lookCasinoTable(sess, target, casino.GameCraps)
}
func (g *Game) lookRouletteTable(sess *net.Session, target string) bool {
- return g.lookCardTable(sess, target, casino.GameRoulette)
+ return g.lookCasinoTable(sess, target, casino.GameRoulette)
+}
+
+func (g *Game) lookPaiGowTable(sess *net.Session, target string) bool {
+ return g.lookCasinoTable(sess, target, casino.GamePaiGow)
}
// A cabinet's published pay table is public; another player's cards are not.
@@ -56,28 +62,3 @@ func (g *Game) lookVideoPokerCabinet(sess *net.Session, target string) bool {
}
return false
}
-
-func (g *Game) lookCardTable(sess *net.Session, target string, game casino.GameName) bool {
- room, err := g.World.LoadRoom(sess.Player.RoomID)
- if err != nil {
- return false
- }
- target = strings.TrimSpace(strings.ToLower(target))
- for _, cfg := range room.CasinoTables {
- if cfg.Game != game {
- continue
- }
- id := strings.ToLower(cfg.ID)
- if target != string(game) && target != string(game)+" table" && target != id && target != id+" table" && !(target == "table" && len(room.CasinoTables) == 1) {
- continue
- }
- // Use the active configuration: live edits may be pending until a round ends.
- table, ok := g.casinoTable(room.ID, string(cfg.Game))
- if !ok {
- return false
- }
- sess.WriteLine(g.renderCasinoRules(sess, table.Config))
- return true
- }
- return false
-}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index e6c0f27..db5bfad 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -632,8 +632,8 @@ func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message
return balance, heading, commands
}
-// casinoBoardReminder splits roulette and craps menus so instructions can be
-// printed under the betting board, which is the table the reminder refers to.
+// casinoBoardReminder splits a persistent-board menu (craps, roulette, video
+// poker, Pai Gow) so its instructions print under the board they describe.
// Every other game, and compact displays, keep their menu in one block.
func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (header, reminder string, ok bool) {
if category != "casino_menu" || menu == nil || sess.Player == nil {
@@ -641,11 +641,12 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas
}
_, isCraps := snapshot.(*casino.CrapsSnapshot)
_, isPoker := snapshot.(*casino.VideoPokerSnapshot)
+ _, isPaiGow := snapshot.(*casino.PaiGowSnapshot)
s, isRoulette := snapshot.(*casino.RouletteSnapshot)
- if !isPoker && !isCraps && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
+ if !isPoker && !isCraps && !isPaiGow && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", "", false
}
- if !isPoker && !isCraps && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
+ if !isPoker && !isCraps && !isPaiGow && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
// The compact reminder is already a short block; leave it intact.
return "", "", false
}
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index 6f7964f..1d66f43 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -61,6 +61,21 @@ func resolveCasinoChoice(choices []casinoChoice, input string) (casinoChoice, bo
return casinoChoice{}, false
}
+// casinoTableAliases lists the names the play, list and look commands accept
+// for a multiplayer table, beyond its raw game name and fixture id.
+func casinoTableAliases(cfg casino.TableConfig) []string {
+ names := []string{string(cfg.Game)}
+ switch cfg.Game {
+ case casino.GameBlackjack:
+ names = append(names, "bj")
+ case casino.GameBaccarat:
+ names = append(names, "bac")
+ case casino.GamePaiGow:
+ names = append(names, "pai", "pai gow", "pai gow poker")
+ }
+ return names
+}
+
func (g *Game) casinoChoices(roomID int) []casinoChoice {
room, err := g.World.LoadRoom(roomID)
if err != nil {
@@ -73,15 +88,7 @@ func (g *Game) casinoChoices(roomID int) []casinoChoice {
choices = append(choices, casinoChoice{address: c.ID, names: names, table: true})
continue
}
- names := []string{string(c.Game)}
- switch c.Game {
- case casino.GameBlackjack:
- names = append(names, "bj")
- case casino.GameBaccarat:
- names = append(names, "bac")
- case casino.GamePaiGow:
- names = append(names, "paigow", "pai", "pai gow", "pai gow poker")
- }
+ names := casinoTableAliases(c)
choices = append(choices, casinoChoice{address: string(c.Game), names: names, table: true})
}
for _, c := range room.CasinoMachines {
diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go
new file mode 100644
index 0000000..6fa2959
--- /dev/null
+++ b/internal/game/look_casino.go
@@ -0,0 +1,128 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+)
+
+// lookCasinoTable renders a table fixture's details for a look target: the
+// wager limits, the live state and seated players, and this table's rule
+// variants. The invariant roulette and craps graphics and the generic rule
+// dumps are deliberately omitted; blackjack, baccarat and Pai Gow keep their
+// felt because it carries per-table rules and pay tables. It reports whether
+// the target named this room's game.
+func (g *Game) lookCasinoTable(sess *net.Session, target string, game casino.GameName) bool {
+ table, ok := g.resolveRoomTable(sess, target, game)
+ if !ok {
+ return false
+ }
+ // Use the live table's normalized config: the room fixture may omit
+ // defaults (seat count, bet limits) that EnsureTable fills in.
+ sess.WriteLine(g.renderCasinoTableDetails(sess, table.Config, table))
+ return true
+}
+
+// resolveRoomTable matches a look target against the room's table for game
+// using the same names and prefix rules as the play command, plus the fixture
+// id, an optional " table" suffix, and the bare "table" target when the room
+// holds exactly one table.
+func (g *Game) resolveRoomTable(sess *net.Session, target string, game casino.GameName) (*casino.Table, bool) {
+ if g.Casino == nil || sess.Player == nil {
+ return nil, false
+ }
+ room, err := g.World.LoadRoom(sess.Player.RoomID)
+ if err != nil {
+ return nil, false
+ }
+ target = strings.TrimSpace(strings.ToLower(target))
+ target = strings.TrimSuffix(target, " table")
+ address := string(game)
+ if target == "table" {
+ if len(room.CasinoTables) != 1 || room.CasinoTables[0].Game != game {
+ return nil, false
+ }
+ } else {
+ var choices []casinoChoice
+ for _, cfg := range room.CasinoTables {
+ if cfg.Game != game {
+ continue
+ }
+ choices = append(choices, casinoChoice{address: string(cfg.Game), names: append(casinoTableAliases(cfg), cfg.ID), table: true})
+ }
+ choice, ok := resolveCasinoChoice(choices, target)
+ if !ok {
+ return nil, false
+ }
+ address = choice.address
+ }
+ table, ok := g.casinoTable(room.ID, address)
+ if !ok {
+ return nil, false
+ }
+ return table, true
+}
+
+// renderCasinoTableDetails formats the details block and, for the felt games,
+// the per-table rule card. It never prints the craps or roulette board.
+func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfig, table *casino.Table) string {
+ seats := table.Seats()
+ seated := "no one"
+ if len(seats) > 0 {
+ seated = strings.Join(seats, ", ")
+ }
+ lines := []string{
+ g.colorize(sess, "casino_action", cfg.Label()),
+ fmt.Sprintf(" Limits %s", betLimitsText(cfg.MinBet, cfg.MaxBet)),
+ fmt.Sprintf(" Seats %d/%d", len(seats), cfg.MaxPlayers),
+ fmt.Sprintf(" State %s", table.State()),
+ fmt.Sprintf(" Rules %s", casinoTableSummary(cfg)),
+ fmt.Sprintf(" Seated %s", seated),
+ }
+ switch cfg.Game {
+ case casino.GameBlackjack:
+ lines = append(lines, "", g.renderCasinoSnapshot(sess, &casino.BlackjackRulesView{Config: cfg}))
+ case casino.GameBaccarat:
+ lines = append(lines, "", g.renderCasinoSnapshot(sess, &casino.BaccaratRulesView{Config: cfg}))
+ case casino.GamePaiGow:
+ lines = append(lines, "", renderPaiGowFelt(cfg, g.colorMode(sess), sess.Player.OptionBool("unicode")))
+ }
+ return strings.Join(lines, "\n")
+}
+
+// casinoTableSummary is the compact per-table rule description used by the
+// look details block. The full wording stays in RulesText.
+func casinoTableSummary(cfg casino.TableConfig) string {
+ switch cfg.Game {
+ case casino.GameBlackjack:
+ b := cfg.Blackjack.Normalize()
+ dealer := "dealer stands on soft 17"
+ if b.DealerHitsSoft17() {
+ dealer = "dealer hits soft 17"
+ }
+ return fmt.Sprintf("%d decks; %s; blackjack pays %s", b.Decks, dealer, ratioText(b.BlackjackPayout))
+ case casino.GameBaccarat:
+ return fmt.Sprintf("%d decks", cfg.Baccarat.Normalize().Decks)
+ case casino.GameRoulette:
+ summary := "single-zero wheel"
+ if cfg.Roulette.DoubleZero {
+ summary = "double-zero wheel"
+ }
+ if cfg.Roulette.Racetrack {
+ summary += "; racetrack call bets"
+ }
+ return summary
+ case casino.GameCraps:
+ return cfg.Craps.Normalize().Title()
+ case casino.GamePaiGow:
+ p := cfg.PaiGow.Normalize()
+ envy := "Envy off"
+ if p.Envy {
+ envy = "Envy on"
+ }
+ return p.Label() + "; " + envy
+ }
+ return cfg.Label()
+}
diff --git a/internal/game/look_casino_test.go b/internal/game/look_casino_test.go
new file mode 100644
index 0000000..37205cd
--- /dev/null
+++ b/internal/game/look_casino_test.go
@@ -0,0 +1,121 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+)
+
+// A room with one table of each kind, so look targets and aliases resolve.
+const lookCasinoRoom = `name: Look Room
+casino_tables:
+ - id: bjtable
+ game: blackjack
+ blackjack:
+ decks: 2
+ blackjack_payout: 2
+ - id: crock
+ game: craps
+ craps:
+ variant: simplified
+ - id: wheel
+ game: roulette
+ - id: pgtable
+ game: paigow
+ pai_gow:
+ variant: classic
+ envy: true
+`
+
+func TestLookCasinoTableShowsDetailsWithoutInvariantBoards(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, lookCasinoRoom)
+
+ // Craps: details only. The graphical felt and the full rule dump are gone.
+ conn.data = nil
+ if !g.lookCrapsTable(sess, "craps") {
+ t.Fatal("look craps did not match")
+ }
+ text := stripANSI(string(conn.data))
+ for _, want := range []string{"Craps", "Limits", "Seats", "State", "Rules", "Simplified Craps", "Seated"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("look craps missing %q: %s", want, text)
+ }
+ }
+ for _, gone := range []string{"PASS ONLY", "bet <amount> on pass", "HOUSE EDITION"} {
+ if strings.Contains(text, gone) {
+ t.Fatalf("look craps showed invariant content %q: %s", gone, text)
+ }
+ }
+
+ // Roulette: details only, no betting board or payout reference.
+ conn.data = nil
+ if !g.lookRouletteTable(sess, "roulette") {
+ t.Fatal("look roulette did not match")
+ }
+ text = stripANSI(string(conn.data))
+ if !strings.Contains(text, "Roulette / Single zero") || !strings.Contains(text, "single-zero wheel") {
+ t.Fatalf("look roulette missing variant summary: %s", text)
+ }
+ for _, gone := range []string{"Roulette payouts", "History: last"} {
+ if strings.Contains(text, gone) {
+ t.Fatalf("look roulette showed invariant board %q: %s", gone, text)
+ }
+ }
+}
+
+func TestLookCasinoTableAliasesAndFeltGames(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, lookCasinoRoom)
+
+ // Play shortcuts resolve through look too, and the felt games keep their
+ // per-table rule card.
+ for target, want := range map[string]string{"bj": "BLACKJACK PAYS 2 TO 1", "paigow": "FORTUNE", "pai": "FORTUNE"} {
+ conn.data = nil
+ g.doLookTarget(sess, target)
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, want) {
+ t.Fatalf("look %s missing %q: %s", target, want, text)
+ }
+ }
+ if g.lookCrapsTable(sess, "bj") {
+ t.Fatal("look craps matched a blackjack target")
+ }
+}
+
+func TestLookCasinoUsesLiveNormalizedConfig(t *testing.T) {
+ // The room fixture omits max_players and limits; look must report the
+ // defaults EnsureTable fills in, not zero values.
+ g, sess, conn := casinoCrapsGame(t, "name: Bare\ncasino_tables:\n - id: wheel\n game: roulette\n")
+ conn.data = nil
+ if !g.lookRouletteTable(sess, "wheel") {
+ t.Fatal("look by fixture id did not match")
+ }
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "Seats 0/8") || !strings.Contains(text, "Limits 1-") {
+ t.Fatalf("look missed normalized config: %s", text)
+ }
+}
+
+func TestPaigowGameNameAndRoomListing(t *testing.T) {
+ g, sess, _ := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n pai_gow:\n envy: true\n")
+ g.executePlay(sess, []string{"paigow"}, "play paigow")
+ table, ok := g.Casino.Participation(sess.Player.Name)
+ if !ok || table == nil || table.Config.Game != casino.GamePaiGow {
+ t.Fatalf("play paigow did not join: %v %v", ok, table)
+ }
+ if string(table.Config.Game) != "paigow" {
+ t.Fatalf("game name = %q, want paigow", table.Config.Game)
+ }
+ // The room listing advertises the short name and no longer leaks limits.
+ room, err := g.World.LoadRoom(sess.Player.RoomID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := stripANSI(strings.Join(g.showRoomCasinos(sess, room), "\n"))
+ if !strings.Contains(text, "play paigow") || !strings.Contains(text, "Pai Gow Poker | 1/6 seated") {
+ t.Fatalf("room listing wrong: %s", text)
+ }
+ if strings.Contains(text, "Limits") {
+ t.Fatalf("room listing still shows limits: %s", text)
+ }
+}
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index bfd36ca..4c3cb6e 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -235,7 +235,7 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string {
lines = append(lines, g.colorize(sess, "casino_action", cfg.VideoPoker.CabinetName()+" machine"), " "+g.colorize(sess, "command", "play "+cfg.ID))
continue
}
- label := fmt.Sprintf("%s | Limits %d-%d | 0/%d seated", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers)
+ label := fmt.Sprintf("%s | 0/%d seated", cfg.Label(), cfg.MaxPlayers)
if g.Casino != nil {
if live := g.Casino.Table(room.ID, table.ID); live != nil {
label = g.casinoTimeText(live.Overview())
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 3f8cd15..d296b35 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) {
+ if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) {
return
}
diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go
index 614d0dd..f13655b 100644
--- a/internal/game/pai_gow_test.go
+++ b/internal/game/pai_gow_test.go
@@ -11,7 +11,7 @@ import (
)
func TestPaiGowCommandsLookAndSettlement(t *testing.T) {
- g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: pai_gow\n pai_gow:\n envy: true\n")
+ g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n pai_gow:\n envy: true\n")
g.executePlay(sess, []string{"pai"}, "play pai")
table, playing := g.Casino.Participation(sess.Player.Name)
if !playing || table.Config.Game != casino.GamePaiGow {
@@ -20,7 +20,7 @@ func TestPaiGowCommandsLookAndSettlement(t *testing.T) {
conn.data = nil
g.executeTableStatus(sess, []string{"rules"}, "table rules")
text := stripANSI(string(conn.data))
- for _, want := range []string{"FORTUNE", "ENVY (FIXED)", "750:1", "Three pair"} {
+ for _, want := range []string{"FORTUNE", "ENVY", "750:1", "Three pair"} {
if !strings.Contains(text, want) {
t.Fatalf("missing %s: %s", want, text)
}
@@ -102,10 +102,22 @@ func TestPaiGowFeltAndJokerAllModes(t *testing.T) {
if !strings.Contains(felt, "Three pair") || !strings.Contains(felt, "1000:1") {
t.Fatal(felt)
}
+ // The felt shows only the limit and commission; the per-state command
+ // hints come from the live menu, not baked-in felt text.
+ for _, want := range []string{"LIMITS", "ODDS", "COMMISSION", "Copies lose"} {
+ if !strings.Contains(felt, want) {
+ t.Fatalf("missing %q: %s", want, felt)
+ }
+ }
+ for _, gone := range []string{"PROFIT ODDS", "FIVE HIGH / TWO LOW", "MAIN WIN", "24 sets cards", "Bet main, optional"} {
+ if strings.Contains(felt, gone) {
+ t.Fatalf("stale felt text %q: %s", gone, felt)
+ }
+ }
if !envy && (!strings.Contains(felt, "ENVY OFF") || !strings.Contains(felt, "30:1")) {
t.Fatal(felt)
}
- if cfg.PaiGow.Envy && (strings.Contains(felt, "ENVY OFF") || !strings.Contains(felt, "ENVY (FIXED)")) {
+ if cfg.PaiGow.Envy && (strings.Contains(felt, "ENVY OFF") || !strings.Contains(felt, "ENVY") || !strings.Contains(felt, "Envy pays when other players hit quads or better.")) {
t.Fatal(felt)
}
}
@@ -116,6 +128,41 @@ func TestPaiGowFeltAndJokerAllModes(t *testing.T) {
t.Fatal(felt)
}
}
+func TestPaiGowContextHintsFollowState(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n min_bet: 5\n max_bet: 1000\n pai_gow:\n envy: true\n")
+ g.executePlay(sess, []string{"pai"}, "play pai")
+
+ // A fresh bettor is told how to place the main wager.
+ conn.data = nil
+ g.executeTableStatus(sess, nil, "table")
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "bet <amount>") {
+ t.Fatalf("missing main-bet hint: %s", text)
+ }
+
+ // After the main wager the live menu offers Fortune and deal, not a second
+ // main bet, and the shared tail is present.
+ conn.data = nil
+ g.executeBet(sess, []string{"20"}, "bet 20")
+ text = stripANSI(string(conn.data))
+ for _, want := range []string{"bet <amount> fortune", "deal", "table rules", "rules", "stop"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("missing %q after main bet: %s", want, text)
+ }
+ }
+
+ // Setting offers the split selectors and the house-way shortcut.
+ g.executeBet(sess, []string{"5", "fortune"}, "bet 5 fortune")
+ g.executeTableAction(sess, "deal")
+ conn.data = nil
+ g.executeTableStatus(sess, nil, "table")
+ text = stripANSI(string(conn.data))
+ for _, want := range []string{"24 / 13456", "house"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("missing %q while setting: %s", want, text)
+ }
+ }
+}
func TestVestaPaiGowRooms(t *testing.T) {
_, rooms, _ := loadVesta(t)
for id, variant := range map[int]string{6195: casino.PaiGowClassic, 6196: casino.PaiGowEZ, 6197: casino.PaiGowNoPush, 6198: casino.PaiGowFaceUp} {
diff --git a/internal/game/render_pai_gow.go b/internal/game/render_pai_gow.go
index 2005daf..239ddb4 100644
--- a/internal/game/render_pai_gow.go
+++ b/internal/game/render_pai_gow.go
@@ -12,23 +12,23 @@ func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string
c := newCrapsCanvas(25)
c.box(0, 0, 76, 24)
c.center(1, 75, 1, strings.ToUpper(cfg.PaiGow.Label()), crapsGold)
- c.center(1, 75, 2, "FIVE HIGH / TWO LOW | COPIES LOSE | LIMITS "+betLimitsText(cfg.MinBet, cfg.MaxBet), crapsInk)
- note := "MAIN WIN 0.95:1 | 5% COMMISSION"
+ c.center(1, 75, 2, "LIMITS "+betLimitsText(cfg.MinBet, cfg.MaxBet), crapsInk)
+ note := "5% COMMISSION"
switch cfg.PaiGow.Normalize().Variant {
case casino.PaiGowEZ:
- note = "MAIN WIN 1:1 | DEALER QUEEN-HIGH PUSHES"
+ note = "NO COMMISSION | DEALER QUEEN-HIGH PUSHES"
case casino.PaiGowNoPush:
- note = "MAIN WIN 1:1 | SPLIT RESULT: EXTRA CARD HIGH WINS, TIES LOSE"
+ note = "NO COMMISSION | SPLIT: HIGHER EXTRA CARD WINS, TIES LOSE"
case casino.PaiGowFaceUp:
- note = "DEALER FACE UP | ACE-HIGH PUSHES | PUSH ACE HIGH " + strings.ReplaceAll(cfg.PaiGow.PushAceHigh, "_", "/") + ":1"
+ note = "NO COMMISSION | DEALER FACE UP | PUSH ACE HIGH " + strings.ReplaceAll(cfg.PaiGow.PushAceHigh, "_", "/") + ":1"
}
c.center(1, 75, 3, note, crapsInk)
c.box(0, 4, 76, 19)
c.text(3, 5, "FORTUNE - BEST OF ALL SEVEN CARDS", crapsGold)
- c.text(48, 5, "PROFIT ODDS", crapsGold)
+ c.text(50, 5, "ODDS", crapsGold)
envy := "ENVY OFF"
if cfg.PaiGow.Envy {
- envy = "ENVY (FIXED)"
+ envy = "ENVY"
}
c.text(62, 5, envy, crapsGold)
for i, row := range casino.FortunePayTable(cfg.PaiGow.Envy) {
@@ -41,12 +41,11 @@ func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string
c.center(1, 75, 18, "Highest prize only. Winning Fortune wagers also return their stake.", crapsMuted)
note = "Envy disabled: quads improved from 25:1 to 30:1."
if cfg.PaiGow.Envy {
- note = "Fortune 5+ earns Envy on other players, not yourself or dealer."
+ note = "Envy pays when other players hit quads or better."
}
c.center(1, 75, 20, note, crapsInk)
- c.center(1, 75, 21, "24 sets cards 2/4 LOW; 13456 sets five HIGH; house sets house way.", crapsInk)
- c.center(1, 75, 22, "High must outrank low. Joker: ace or completes straight/flush.", crapsMuted)
- c.center(1, 75, 23, "Bet main, optional bet <amount> fortune, then deal.", crapsMuted)
+ c.center(1, 75, 21, "High must outrank low. Copies lose.", crapsMuted)
+ c.center(1, 75, 22, "Joker: ace or completes straight/flush.", crapsMuted)
return c.render(mode, unicode)
}
func renderPaiGow(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string {