aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-24 20:06:41 -0400
committerhistoria <[not public]>2026-09-24 20:07:31 -0400
commit13db3e50cb6e29eedbf25416e5284bf08b0adefb (patch)
treed5e9b99030b25cc7fc102288b694c133f8832f84 /internal/game
parentd114c747a7672c7e4daab3a5b5f1b2f066ebc912 (diff)
downloadthehouseoficarus-13db3e50cb6e29eedbf25416e5284bf08b0adefb.tar.gz
feat: casino table ux: aliases, rebet hints, deadline notifications, spectator mode
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go192
-rw-r--r--internal/game/casino_test.go15
-rw-r--r--internal/game/casino_ux.go221
-rw-r--r--internal/game/casino_ux_test.go153
-rw-r--r--internal/game/cmd_registry.go10
-rw-r--r--internal/game/cmd_stop.go12
-rw-r--r--internal/game/look_entities.go17
-rw-r--r--internal/game/quit_casino_test.go11
-rw-r--r--internal/game/render_help.go11
9 files changed, 593 insertions, 49 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 698cb78..579371c 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -340,15 +340,36 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool
func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) {
p := sess.Player
- if p == nil || len(args) == 0 {
- sess.WriteLine("Play what?")
+ if p == nil {
return
}
if _, playing := g.Casino.Participation(p.Name); playing {
sess.WriteLine("You're already playing a game. Type \"stop\" between rounds to leave it.")
return
}
- g.startCasinoGame(sess, p, args[0])
+ choices := g.casinoChoices(p.RoomID)
+ input := ""
+ if len(args) > 0 {
+ input = args[0]
+ }
+ choice, ok := resolveCasinoChoice(choices, input)
+ if !ok {
+ g.showCasinoChoices(sess, choices)
+ return
+ }
+ if len(args) > 1 {
+ amount, err := strconv.Atoi(args[1])
+ if !choice.table || len(args) > 3 || (args[1] != "min" && args[1] != "max" && (err != nil || amount <= 0)) {
+ sess.WriteLine("Use play <game> [amount|min|max] [target]. No wager was placed.")
+ return
+ }
+ }
+ g.startCasinoGame(sess, p, choice.address)
+ if len(args) > 1 {
+ if t, joined := g.Casino.Participation(p.Name); joined && t != nil {
+ g.executeBet(sess, args[1:], rawInput)
+ }
+ }
}
func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName string) {
@@ -411,8 +432,12 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
// next token is always the target. That makes numeric targets reachable
// ("bet 5 on 17"), which roulette, craps, and keno need.
amount, spot, hasAmount, sawOn := 0, "", false, false
- for _, arg := range args {
+ for i, arg := range args {
if strings.EqualFold(arg, "on") {
+ if sawOn || spot != "" || i == len(args)-1 {
+ sess.WriteLine("Use bet <amount> on <target>. No wager was placed.")
+ return
+ }
sawOn = true
continue
}
@@ -453,6 +478,16 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
}
spot = strings.ToLower(arg)
}
+ if table.Config.Game == casino.GameBaccarat {
+ switch spot {
+ case "b":
+ spot = "banker"
+ case "p":
+ spot = "player"
+ case "t":
+ spot = "tie"
+ }
+ }
if !hasAmount {
var lastSpot string
lastSpot, amount = g.Casino.Rebet(table.RoomID, table.Config.ID, p.Name)
@@ -512,11 +547,19 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
if event.PrivateTo != "" && sess.Player != nil && sess.Player.Name == event.PrivateTo {
continue
}
- message := event.Message
- if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
- message += "\n" + rendered
+ details := g.seesCasinoDetails(sess, event)
+ if event.Detail && !details {
+ continue
+ }
+ message := g.casinoTimeText(event.Message)
+ if details {
+ if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
+ message += "\n" + rendered
+ }
+ }
+ if message != "" {
+ sess.WriteLine(g.colorize(sess, category, message))
}
- sess.WriteLine(g.colorize(sess, category, message))
}
}
if event.PrivateTo == "" {
@@ -526,7 +569,7 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
if text == "" {
text = event.Message
}
- if text == "" && event.Menu == nil {
+ if text == "" && event.Menu == nil && event.Snapshot == nil {
continue
}
g.charsMu.Lock()
@@ -535,10 +578,13 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
if sess == nil {
continue
}
- privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, text)
+ privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, g.casinoTimeText(text))
if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
privateMessage += "\n" + rendered
}
+ if event.ShowBalance {
+ privateMessage += "\n" + g.casinoBalance(sess)
+ }
sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage))
if category == "casino_menu" {
g.writePrompt(sess)
@@ -561,15 +607,55 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca
}
balance := ""
if menu.ShowBalance {
- chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player)))
- credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits))
- balance = fmt.Sprintf("You have %s chips and %s credits.", chips, credits)
+ balance = g.casinoBalance(sess)
}
- heading := menu.Heading
+ heading := g.casinoTimeText(menu.Heading)
if heading == "" {
heading = message
}
- return g.renderCasinoCommands(sess, balance, heading, menu.Commands)
+ commands := append([]casino.MenuCommand(nil), menu.Commands...)
+ for i := range commands {
+ if commands[i].Cost > casinoAffordable(sess.Player) {
+ commands[i].Desc += " [unavailable: insufficient funds]"
+ }
+ }
+ if sess.Player.OptionString("casino_display") == "compact" && menu.Repeat {
+ var repeat string
+ for _, c := range commands {
+ if c.Command == "bet" {
+ repeat = g.colorize(sess, "command", "bet / rebet") + " " + c.Desc
+ break
+ }
+ }
+ return g.colorize(sess, "casino_action", heading) + "\n" + balance + "\n " + repeat + "\n " + g.colorize(sess, "command", "bet <amount> [target] | bet min/max | table | sitout | stop")
+ }
+ if sess.Player.OptionString("casino_display") == "compact" && !menu.ShowBalance {
+ var hints []string
+ for _, c := range commands {
+ label := c.Command
+ switch c.Command {
+ case "hit":
+ label = "h:hit"
+ case "stand":
+ label = "st:stand"
+ case "double":
+ label = "dd:double"
+ case "split":
+ label = "sp:split"
+ }
+ if c.Cost > 0 {
+ label += fmt.Sprintf(" (+%d)", c.Cost)
+ }
+ category := "command"
+ if c.Cost > casinoAffordable(sess.Player) {
+ label += " [unavailable]"
+ category = "casino_warning"
+ }
+ hints = append(hints, g.colorize(sess, category, label))
+ }
+ return g.colorize(sess, "casino_action", heading) + "\n " + strings.Join(hints, " ")
+ }
+ return g.renderCasinoCommands(sess, balance, heading, commands)
}
// renderCasinoSnapshot renders a game's structured view for one session. It is
@@ -582,13 +668,17 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot)
}
mode := g.colorMode(sess)
unicode := sess.Player.OptionBool("unicode")
+ size := sess.Player.OptionInt("card_size")
+ if sess.Player.OptionString("casino_display") == "compact" {
+ size = 1
+ }
switch s := snapshot.(type) {
case *casino.SlotSnapshot:
return renderSlotSnapshot(s, mode, unicode)
case *casino.BlackjackSnapshot:
- return renderBlackjackSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ return renderBlackjackSnapshotFor(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt, sess.Player.Name)
case *casino.BaccaratSnapshot:
- return renderBaccaratSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ return renderBaccaratSnapshot(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt)
}
return ""
}
@@ -603,7 +693,7 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string,
sb.WriteString("\n\n")
}
if heading != "" {
- sb.WriteString(heading)
+ sb.WriteString(g.colorize(sess, "casino_action", heading))
sb.WriteString("\n\n")
}
width := 0
@@ -614,7 +704,11 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string,
}
for _, c := range commands {
padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command))
- cmd := g.colorize(sess, "command", padded)
+ category := "command"
+ if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) {
+ category = "casino_warning"
+ }
+ cmd := g.colorize(sess, category, padded)
if c.Desc != "" {
fmt.Fprintf(&sb, " %s %s\n", cmd, c.Desc)
} else {
@@ -624,7 +718,7 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string,
return strings.TrimRight(sb.String(), "\n")
}
-func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string {
+func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string {
if snapshot == nil {
return ""
}
@@ -636,11 +730,35 @@ func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, un
} else if snapshot.DealerShownScore != "" {
dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):"
}
- lines = append(lines, cardLine(mode, dealerTitle))
- lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
+ if size == 1 {
+ lines = append(lines, cardLine(mode, dealerTitle)+" "+strings.Join(cardsLine(mode, snapshot.Dealer, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, dealerTitle))
+ lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
+ }
for _, player := range snapshot.Players {
- lines = append(lines, cardLine(mode, fmt.Sprintf("%s (%s):", player.Name, player.Score)))
- lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...)
+ name := player.Name
+ if name == viewer {
+ name = "You"
+ }
+ marker := " "
+ if player.Active {
+ marker = "> "
+ }
+ detail := ""
+ if player.Hand > 0 {
+ detail = fmt.Sprintf(" | Hand %d/%d | Wager %d | %s", player.Hand, player.Hands, player.Bet, player.Status)
+ }
+ if player.Active && player.Name != viewer {
+ detail = strings.ReplaceAll(detail, "Your turn", "Playing")
+ }
+ title := fmt.Sprintf("%s%s (%s)%s:", marker, name, player.Score, detail)
+ if size == 1 {
+ lines = append(lines, cardLine(mode, title)+" "+strings.Join(cardsLine(mode, player.Cards, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, title))
+ lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...)
+ }
}
return strings.Join(lines, "\n")
}
@@ -653,10 +771,14 @@ func renderBaccaratSnapshot(snapshot *casino.BaccaratSnapshot, mode string, unic
}
size = cardRenderSize(unicode, size)
var lines []string
- lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):"))
- lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...)
- lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):"))
- lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...)
+ if size == 1 {
+ lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Player, size, unicode, style, art), " "), cardLine(mode, "Banker ("+snapshot.BankerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Banker, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):"))
+ lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...)
+ lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):"))
+ lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...)
+ }
for _, bet := range snapshot.Bets {
lines = append(lines, cardLine(mode, fmt.Sprintf("%s bets %d on %s.", bet.Name, bet.Amount, bet.Spot)))
}
@@ -676,7 +798,16 @@ func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, sty
if size == 1 {
parts := make([]string, len(cards))
for i, card := range cards {
- parts[i] = colorizeCardSymbol(mode, card)
+ if !unicode {
+ if card.Hidden {
+ parts[i] = "[??]"
+ } else {
+ suit := map[string]string{"hearts": "H", "diamonds": "D", "spades": "S", "clubs": "C"}[card.Suit]
+ parts[i] = card.Rank + suit
+ }
+ } else {
+ parts[i] = colorizeCardSymbol(mode, card)
+ }
}
return []string{strings.Join(parts, " ")}
}
@@ -696,6 +827,9 @@ func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, sty
}
func cardRenderSize(unicode bool, size int) int {
+ if size == 1 {
+ return 1
+ }
if !unicode {
return 5
}
diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go
index ffd9958..b3bfb9b 100644
--- a/internal/game/casino_test.go
+++ b/internal/game/casino_test.go
@@ -1,6 +1,7 @@
package game
import (
+ "regexp"
"strings"
"testing"
"time"
@@ -108,7 +109,7 @@ func TestCasinoMenuRendersAlignedColoredCommands(t *testing.T) {
{Command: "spin", Desc: "Spin using your current wager"},
}}
msg := g.casinoPrivateMessage(sess, "casino_menu", menu, "Your slot machine bet is set to 10.")
- if !strings.Contains(msg, "You have 0 chips and 0 credits.") {
+ if !strings.Contains(regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(msg, ""), "Available 0 (0 chips + 0 credits)") {
t.Fatalf("missing balance line: %q", msg)
}
// Commands are painted with the "command" color (0A bold).
@@ -176,11 +177,11 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) {
Dealer: []casino.CardView{{Rank: "?", Hidden: true}, {Rank: "K", Suit: "hearts"}},
Players: []casino.BlackjackPlayerView{{Name: "alice", Score: "20", Cards: []casino.CardView{{Rank: "5", Suit: "clubs"}, {Rank: "Q", Suit: "spades"}}}},
}
- plain := renderBlackjackSnapshot(snapshot, "none", false, 5, "dark", nil)
+ plain := renderBlackjackSnapshotFor(snapshot, "none", false, 5, "dark", nil, "")
if !strings.Contains(plain, "+---+") || !strings.Contains(plain, "Dealer:") || !strings.Contains(plain, "alice (20):") {
t.Fatalf("unexpected blackjack art: %q", plain)
}
- output := renderBlackjackSnapshot(snapshot, "xterm256", false, 5, "dark", nil)
+ output := renderBlackjackSnapshotFor(snapshot, "xterm256", false, 5, "dark", nil, "")
if !strings.Contains(output, "\033[38;5;") {
t.Fatalf("expected colored blackjack art: %q", output)
}
@@ -192,15 +193,15 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) {
t.Fatalf("blackjack line exceeds console width: %d", color.VisibleLen(line))
}
}
- unicodeOutput := renderBlackjackSnapshot(snapshot, "none", true, 7, "dark", nil)
+ unicodeOutput := renderBlackjackSnapshotFor(snapshot, "none", true, 7, "dark", nil, "")
if !strings.Contains(unicodeOutput, "│5 │") || !strings.Contains(unicodeOutput, "│ 5│") || !strings.Contains(unicodeOutput, "│ ♣ │") {
t.Fatalf("unexpected Unicode card alignment: %q", unicodeOutput)
}
- coloredUnicode := renderBlackjackSnapshot(snapshot, "xterm256", true, 7, "dark", nil)
+ coloredUnicode := renderBlackjackSnapshotFor(snapshot, "xterm256", true, 7, "dark", nil, "")
if !strings.Contains(coloredUnicode, "\033[38;5;15m") || !strings.Contains(coloredUnicode, "\033[38;5;27m") {
t.Fatalf("expected white borders and blue card art: %q", coloredUnicode)
}
- light := renderBlackjackSnapshot(snapshot, "xterm256", true, 7, "light", nil)
+ light := renderBlackjackSnapshotFor(snapshot, "xterm256", true, 7, "light", nil, "")
if !strings.Contains(light, "\033[48;5;15m") {
t.Fatalf("expected white card background in light style: %q", light)
}
@@ -208,7 +209,7 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) {
if strings.Contains(lightLines[1], "\033[48;5;15m") || strings.Contains(lightLines[7], "\033[48;5;15m") {
t.Fatalf("card edges should not have a background: %q", light)
}
- lightPlain := renderBlackjackSnapshot(snapshot, "none", true, 7, "light", nil)
+ lightPlain := renderBlackjackSnapshotFor(snapshot, "none", true, 7, "light", nil, "")
if !strings.Contains(lightPlain, "▀▀▀▀▀▀▀") || !strings.Contains(lightPlain, "▄▄▄▄▄▄▄") {
t.Fatalf("expected half-height light card edges: %q", lightPlain)
}
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
new file mode 100644
index 0000000..fdd4533
--- /dev/null
+++ b/internal/game/casino_ux.go
@@ -0,0 +1,221 @@
+package game
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+)
+
+type casinoChoice struct {
+ address string
+ names []string
+ table bool
+}
+
+// Exact addresses beat aliases; prefixes must identify exactly one game.
+func resolveCasinoChoice(choices []casinoChoice, input string) (casinoChoice, bool) {
+ input = strings.ToLower(input)
+ for _, c := range choices {
+ if input != "" && strings.EqualFold(c.address, input) {
+ return c, true
+ }
+ }
+ if input != "" {
+ var exact []casinoChoice
+ for _, c := range choices {
+ for _, name := range c.names {
+ if strings.EqualFold(name, input) {
+ exact = append(exact, c)
+ break
+ }
+ }
+ }
+ if len(exact) == 1 {
+ return exact[0], true
+ }
+ if len(exact) > 1 {
+ return casinoChoice{}, false
+ }
+ }
+ matches := []casinoChoice{}
+ for _, c := range choices {
+ matched := input == ""
+ for _, name := range c.names {
+ if strings.EqualFold(name, input) || (input != "" && strings.HasPrefix(strings.ToLower(name), input)) {
+ matched = true
+ }
+ }
+ if matched {
+ matches = append(matches, c)
+ }
+ }
+ if len(matches) == 1 {
+ return matches[0], true
+ }
+ return casinoChoice{}, false
+}
+
+func (g *Game) casinoChoices(roomID int) []casinoChoice {
+ room, err := g.World.LoadRoom(roomID)
+ if err != nil {
+ return nil
+ }
+ var choices []casinoChoice
+ for _, c := range room.CasinoTables {
+ names := []string{string(c.Game)}
+ switch c.Game {
+ case casino.GameBlackjack:
+ names = append(names, "bj")
+ case casino.GameBaccarat:
+ names = append(names, "bac")
+ }
+ choices = append(choices, casinoChoice{address: string(c.Game), names: names, table: true})
+ }
+ for _, c := range room.CasinoMachines {
+ choices = append(choices, casinoChoice{address: c.ID, names: []string{c.ID, string(c.Game), c.Variant}})
+ }
+ return choices
+}
+
+func (g *Game) showCasinoChoices(sess *net.Session, choices []casinoChoice) {
+ if len(choices) == 0 {
+ sess.WriteLine("There are no casino games here.")
+ return
+ }
+ commands := []casino.MenuCommand{}
+ for _, c := range choices {
+ commands = append(commands, casino.MenuCommand{Command: "play " + c.address, Desc: "Join this game"})
+ }
+ sess.WriteLine(g.renderCasinoCommands(sess, "", "Choose a game (use an unambiguous name or abbreviation):", commands))
+}
+
+func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
+ if sess.Player == nil {
+ return
+ }
+ if len(args) > 0 && args[0] != "rules" && args[0] != "help" {
+ sess.WriteLine("Use table, table rules, or table help.")
+ return
+ }
+ t, playing := g.Casino.Participation(sess.Player.Name)
+ if !playing || t == nil {
+ g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID))
+ return
+ }
+ if len(args) > 0 && args[0] == "rules" {
+ text := t.Config.BlackjackRulesText()
+ if t.Config.Game == casino.GameBaccarat {
+ text = t.Config.BaccaratRulesText()
+ }
+ sess.WriteLine(g.colorize(sess, "casino_action", text))
+ return
+ }
+ g.emitCasinoEvents(t.Status(sess.Player.Name))
+ if len(args) > 0 {
+ sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", []casino.MenuCommand{
+ {Command: "bet / rebet", Desc: "Repeat the displayed wager"},
+ {Command: "bet min / bet max", Desc: "Use the table minimum / affordable maximum"},
+ {Command: "h / st / dd / sp", Desc: "Blackjack: hit / stand / double / split (s and d still move)"},
+ {Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"},
+ {Command: "sitout", Desc: "Skip future rounds; bet to rejoin"},
+ {Command: "stop", Desc: "Leave now, or after your committed round"},
+ {Command: "option casino_display compact", Desc: "Short cards and action hints (or detailed for card art)"},
+ {Command: "table rules", Desc: "Review this table’s rules"},
+ }))
+ }
+}
+
+func (g *Game) executeWatch(sess *net.Session, args []string, _ string) {
+ if sess.Player == nil {
+ return
+ }
+ if len(args) == 1 && args[0] == "off" {
+ sess.CasinoWatchTable = ""
+ sess.WriteLine(g.colorize(sess, "casino_action", "You stop watching table details."))
+ return
+ }
+ if len(args) > 1 {
+ sess.WriteLine("Use watch [game] or watch off.")
+ return
+ }
+ choices := []casinoChoice{}
+ for _, c := range g.casinoChoices(sess.Player.RoomID) {
+ if c.table {
+ choices = append(choices, c)
+ }
+ }
+ input := ""
+ if len(args) == 1 {
+ input = args[0]
+ }
+ c, ok := resolveCasinoChoice(choices, input)
+ if !ok {
+ sess.WriteLine("Choose a table to watch: watch blackjack or watch baccarat.")
+ return
+ }
+ t, ok := g.casinoTable(sess.Player.RoomID, c.address)
+ if !ok {
+ return
+ }
+ sess.CasinoWatchRoom = sess.Player.RoomID
+ sess.CasinoWatchTable = t.Config.ID
+ sess.WriteLine(g.colorize(sess, "casino_action", "Watching "+t.Config.Label()+". Detailed public cards are visible; watch off stops watching."))
+ g.emitCasinoEvents(t.Status(sess.Player.Name))
+}
+
+func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool {
+ if sess.Player == nil {
+ return false
+ }
+ // Slot output is unchanged; watch controls multiplayer tables only.
+ if g.Casino.Table(event.RoomID, event.TableID) == nil {
+ return true
+ }
+ if sess.CasinoWatchRoom == event.RoomID && sess.CasinoWatchTable == event.TableID {
+ return true
+ }
+ // Settlement may have removed a queued departure before these events
+ // are delivered. Their own final cards must still be visible.
+ switch s := event.Snapshot.(type) {
+ case *casino.BlackjackSnapshot:
+ for _, p := range s.Players {
+ if p.Name == sess.Player.Name {
+ return true
+ }
+ }
+ case *casino.BaccaratSnapshot:
+ for _, p := range s.Bets {
+ if p.Name == sess.Player.Name {
+ return true
+ }
+ }
+ }
+ t, ok := g.Casino.Participation(sess.Player.Name)
+ return ok && t != nil && t.RoomID == event.RoomID && t.Config.ID == event.TableID
+}
+
+var casinoTicksPattern = regexp.MustCompile(`([0-9]+) ticks`)
+
+// Casino logic counts engine ticks; present deadlines in actual configured seconds.
+func (g *Game) casinoTimeText(text string) string {
+ ms := 600
+ if g.Ticks != nil {
+ ms = g.Ticks.TickLengthMilliseconds()
+ }
+ return casinoTicksPattern.ReplaceAllStringFunc(text, func(s string) string {
+ n, _ := strconv.Atoi(strings.Fields(s)[0])
+ return fmt.Sprintf("%g seconds", float64(n)*float64(ms)/1000)
+ })
+}
+
+func (g *Game) casinoBalance(sess *net.Session) string {
+ if sess.Player == nil {
+ return ""
+ }
+ 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)))
+}
diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go
new file mode 100644
index 0000000..046d7d3
--- /dev/null
+++ b/internal/game/casino_ux_test.go
@@ -0,0 +1,153 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func stripANSI(s string) string {
+ return regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(s, "")
+}
+
+func uxCasinoGame(t *testing.T) (*Game, *net.Session, *casinoTestConn) {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ room := "name: Casino\ncasino_tables:\n - id: bj1\n game: blackjack\n min_bet: 1\n max_bet: 100\n blackjack:\n action_ticks: 25\n - id: bac1\n game: baccarat\n min_bet: 5\n max_bet: 100\n"
+ if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(room), 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), AccountStore: player.NewAccountStore(dir), Ticks: engine.New()},
+ 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)
+}
+
+func TestPlayAliasesJoinGames(t *testing.T) {
+ for input, game := range map[string]string{"bj": "blackjack", "black": "blackjack", "bac": "baccarat", "baccarat": "baccarat"} {
+ g, sess, conn := uxCasinoGame(t)
+ g.executePlay(sess, strings.Fields(input), "play "+input)
+ table, ok := g.Casino.Participation("alice")
+ if !ok || table == nil || string(table.Config.Game) != game {
+ t.Fatalf("play %s: participation=%v game=%q", input, ok, game)
+ }
+ if !strings.Contains(stripANSI(string(conn.data)), "Next hand") {
+ t.Fatalf("play %s: missing betting menu, got %q", input, stripANSI(string(conn.data)))
+ }
+ }
+}
+
+func TestPlayAmbiguousAndUnknownListChoices(t *testing.T) {
+ g, sess, conn := uxCasinoGame(t)
+ _ = g
+ for _, input := range []string{"", "b", "zzz"} {
+ conn.data = nil
+ args := []string{}
+ if input != "" {
+ args = strings.Fields("play " + input)[1:]
+ }
+ g.executePlay(sess, args, "play "+input)
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "play blackjack") || !strings.Contains(text, "play baccarat") {
+ t.Fatalf("play %q: expected choice list, got %q", input, text)
+ }
+ }
+}
+
+func TestPlayWithAmountJoinsAndBets(t *testing.T) {
+ g, sess, conn := uxCasinoGame(t)
+ g.executePlay(sess, []string{"bj", "25"}, "play bj 25")
+ if !strings.Contains(stripANSI(string(conn.data)), "You bet 25 credits.") {
+ t.Fatalf("missing wager confirmation: %q", stripANSI(string(conn.data)))
+ }
+}
+
+func TestTableStatusShowsStateAndRules(t *testing.T) {
+ g, sess, conn := uxCasinoGame(t)
+ g.executePlay(sess, []string{"bj"}, "play bj")
+ g.executeTableStatus(sess, nil, "table")
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "Limits") || !strings.Contains(text, "seated") {
+ t.Fatalf("status missing overview: %q", text)
+ }
+ conn.data = nil
+ g.executeTableStatus(sess, []string{"rules"}, "table rules")
+ if !strings.Contains(stripANSI(string(conn.data)), "payout") {
+ t.Fatalf("table rules missing: %q", stripANSI(string(conn.data)))
+ }
+}
+
+func TestWatchAndSpectatorDetailFlow(t *testing.T) {
+ g, sess, conn := uxCasinoGame(t)
+ g.executePlay(sess, []string{"bj", "10"}, "play bj 10")
+ // Alice, seated, sees the deal; watch off is still acknowledged.
+ if _, seated := g.Casino.Participation("alice"); !seated {
+ t.Fatal("alice not seated")
+ }
+ g.executeWatch(sess, []string{"off"}, "watch off")
+ if !strings.Contains(stripANSI(string(conn.data)), "stop watching") {
+ t.Fatalf("watch off not confirmed: %q", stripANSI(string(conn.data)))
+ }
+}
+
+func TestBlackjackShortcutsReachTheTable(t *testing.T) {
+ g, sess, conn := uxCasinoGame(t)
+ cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100, Blackjack: casino.BlackjackConfig{ActionTicks: 3}}
+ g.Casino.Join(1, cfg, "alice")
+ // Rounds deal instantly with a lone bettor; dealer aces and naturals vary
+ // by seed, so repeat short rounds until a hit actually lands.
+ hit := false
+ for round := 0; round < 12 && !hit; round++ {
+ g.executeBet(sess, nil, "rebet")
+ g.executeTableAction(sess, "decline")
+ for attempt := 0; attempt < 3 && !hit; attempt++ {
+ g.executeTableAction(sess, "h")
+ hit = strings.Contains(stripANSI(string(conn.data)), "You hit")
+ }
+ g.executeTableAction(sess, "st")
+ }
+ if _, seated := g.Casino.Participation("alice"); !seated {
+ t.Fatal("shortcut actions should never remove the seat")
+ }
+ if !hit {
+ t.Fatalf("shortcuts did not hit: %q", stripANSI(string(conn.data)))
+ }
+}
+
+func TestRebetRepeatsWager(t *testing.T) {
+ g, sess, conn := uxCasinoGame(t)
+ cfg := casino.TableConfig{ID: "bac", Game: casino.GameBaccarat, MinBet: 5, MaxBet: 100}
+ g.Casino.Join(1, cfg, "alice")
+ g.Casino.Bet(1, "bac", "alice", "banker", 20, casinoWallet{g: g, p: sess.Player})
+ conn.data = nil
+ // The first round deals and settles instantly with a single bettor, so a
+ // fresh betting window opens; rebet should repeat 20 on banker.
+ g.executeBet(sess, nil, "rebet")
+ if !strings.Contains(stripANSI(string(conn.data)), "on banker") {
+ t.Fatalf("rebet did not target banker: %q", stripANSI(string(conn.data)))
+ }
+}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index ae4c895..d5676d5 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -25,6 +25,16 @@ var commandRegistry = map[string]commandDef{
"kill": {(*Game).executeAttack, ClassActive},
"work": {(*Game).executeWork, ClassActive},
"play": {(*Game).executePlay, ClassActive},
+ "rebet": {(*Game).executeBet, ClassActive},
+ "table": {(*Game).executeTableStatus, ClassInstant},
+ "status": {(*Game).executeTableStatus, 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},
+ "h": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "hit") }, ClassActive},
+ "st": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "stand") }, ClassActive},
+ "dd": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "double") }, ClassActive},
+ "sp": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "split") }, ClassActive},
"bet": {(*Game).executeBet, ClassActive},
"spin": {(*Game).executeBet, ClassActive},
"autobet": {(*Game).executeAutoBet, ClassActive},
diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go
index 4b287ce..2f2a7ee 100644
--- a/internal/game/cmd_stop.go
+++ b/internal/game/cmd_stop.go
@@ -10,7 +10,17 @@ func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) {
// queue; cancel a pending quit countdown explicitly.
g.cancelRest(p.Name)
if _, playing := g.Casino.Participation(p.Name); playing {
- g.leaveCasinoGames(p)
+ if table, _ := g.Casino.Participation(p.Name); table != nil {
+ // Normal leave refuses committed wagers. Only an explicit stop
+ // queues a departure; movement/quit retain their existing policy.
+ events := g.Casino.Leave(table.RoomID, table.Config.ID, p.Name)
+ if _, stillSeated := g.Casino.Participation(p.Name); stillSeated {
+ events = g.Casino.Action(table.RoomID, table.Config.ID, p.Name, "leave_after")
+ }
+ g.emitCasinoEvents(events)
+ } else {
+ g.leaveCasinoGames(p)
+ }
return
}
ss, _ := g.safespot.Get(p.Name)
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index 1239f31..8bf762d 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -230,8 +230,21 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string {
}
var lines []string
for _, table := range room.CasinoTables {
- name := casinoGameLabel(table.Game) + " table"
- lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name)))
+ cfg := table.Normalize()
+ label := fmt.Sprintf("%s | Limits %d–%d | 0/%d seated | Betting open", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers)
+ if g.Casino != nil {
+ if live := g.Casino.Table(room.ID, table.ID); live != nil {
+ label = g.casinoTimeText(live.Overview())
+ }
+ }
+ alias := string(table.Game)
+ if table.Game == casino.GameBlackjack {
+ alias = "bj"
+ }
+ if table.Game == casino.GameBaccarat {
+ alias = "bac"
+ }
+ lines = append(lines, g.colorize(sess, "casino_action", label), " "+g.colorize(sess, "command", "play "+alias)+" Join | "+g.colorize(sess, "command", "watch "+alias)+" Spectate")
}
for _, machine := range room.CasinoMachines {
label := casinoGameLabel(machine.Game) + " machine"
diff --git a/internal/game/quit_casino_test.go b/internal/game/quit_casino_test.go
index 02dadb0..6ffe2d5 100644
--- a/internal/game/quit_casino_test.go
+++ b/internal/game/quit_casino_test.go
@@ -267,12 +267,11 @@ func TestQuitNowAtMachineHoldsUntilLogout(t *testing.T) {
}
}
-// Stop and quit share one departure path: both refuse mid-round and both
-// leave gracefully between rounds.
-func TestStopMatchesQuitCasinoPolicy(t *testing.T) {
+// Stop queues a departure; quit still refuses a committed round.
+func TestStopQueuesDepartureWithoutForfeiting(t *testing.T) {
cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100}
- // Mid-round: stop refuses just like quit.
+ // Mid-round: stop queues a departure without removing or forfeiting.
g, sess := quitTestGame(t)
g.Casino.Join(1, cfg, "alice")
g.Casino.Join(1, cfg, "bob")
@@ -281,8 +280,8 @@ func TestStopMatchesQuitCasinoPolicy(t *testing.T) {
conn := sess.Conn.(*casinoTestConn)
conn.data = nil
g.executeStop(sess, nil, "stop")
- if !strings.Contains(string(conn.data), "middle of a blackjack round") {
- t.Fatalf("expected mid-round refusal, got %q", string(conn.data))
+ if !strings.Contains(string(conn.data), "leave after this round") {
+ t.Fatalf("expected queued departure, got %q", string(conn.data))
}
if _, playing := g.Casino.Participation("alice"); !playing {
t.Fatal("stop mid-round removed the player from the table")
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index a11ac74..b41af99 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -63,12 +63,15 @@ var commandList = []cmdEntry{
{"jack / jackin", "Active", "Jack into a terminal (Hacking)"},
{"look / l", "Instant", "Look around or examine things"},
{"map", "Instant", "Display an ASCII map of the area"},
- {"play", "Active", "Join a casino game in the current room"},
- {"bet", "Active", "Place a casino wager (e.g. bet 100 on banker)"},
+ {"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"},
+ {"watch", "Instant", "Watch detailed table action; watch off to stop"},
+ {"sitout", "Instant", "Sit out future table rounds; bet to rejoin"},
+ {"bet / rebet", "Active", "Repeat a wager, or bet <amount> [target]; min/max accepted"},
{"autobet / autospin", "Active", "Automatically repeat a casino wager"},
{"spin", "Active", "Spin the current casino wager"},
- {"hit / stand / double / split", "Active", "Play a blackjack hand"},
- {"insurance / surrender", "Active", "Use blackjack table options"},
+ {"hit / stand / double / split", "Active", "Play blackjack (shortcuts: h / st / dd / sp)"},
+ {"insurance / decline / surrender", "Active", "Answer an insurance offer or surrender a blackjack hand"},
{"mine", "Active", "Mine rocks (Mining)"},
{"mix", "Active", "Mix potions (Pharmacy)"},
{"mods / modlist", "Instant", "List available science modules"},