aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config.yaml1
-rw-r--r--internal/casino/baccarat.go32
-rw-r--r--internal/casino/baccarat_test.go7
-rw-r--r--internal/casino/betting.go10
-rw-r--r--internal/casino/blackjack.go163
-rw-r--r--internal/casino/blackjack_test.go14
-rw-r--r--internal/casino/core.go10
-rw-r--r--internal/casino/table.go8
-rw-r--r--internal/casino/table_ux.go180
-rw-r--r--internal/casino/table_ux_test.go181
-rw-r--r--internal/casino/types.go32
-rw-r--r--internal/config/config.go73
-rw-r--r--internal/engine/tick.go28
-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
-rw-r--r--internal/net/server.go7
-rw-r--r--internal/player/player.go3
24 files changed, 1239 insertions, 152 deletions
diff --git a/config.yaml b/config.yaml
index 0ebf142..95992cb 100644
--- a/config.yaml
+++ b/config.yaml
@@ -63,6 +63,7 @@ default_colors:
global: "49"
broadcast: "89"
casino_action: "6E"
+ casino_warning: "C4 bold" # Red deadlines and unavailable actions
casino_menu: "FA"
casino_win: "FA"
casino_payout: "bold FA"
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 68f363e..e34518f 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -47,11 +47,16 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl
}
func (t *baccaratTable) menuEvent(name string) Event {
- return t.betMenuEvent(name, []MenuCommand{
- {Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"},
- {Command: "bet <amount> on " + t.Rules.betChoices(), Desc: "Wager on a specific outcome (one wager per round)"},
- {Command: "stop", Desc: "Leave the table"},
- })
+ commands := []MenuCommand{}
+ if p := t.players.get(name); p != nil && p.lastBet > 0 {
+ commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot})
+ }
+ commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t); min/max accepted"}, MenuCommand{Command: "bet <amount> on " + t.Rules.betChoices(), Desc: "One wager per round; 'on' is optional"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"})
+ e := t.betMenuEvent(name, commands)
+ if p := t.players.get(name); p != nil {
+ e.Menu.Repeat = p.lastBet > 0
+ }
+ return e
}
func (t *baccaratTable) join(name string) []Event {
@@ -111,6 +116,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
if err != nil {
return []Event{t.private(name, walletErrorMessage(err))}
}
+ p.SittingOut = false
p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot, p.stakeID = true, wager, wallet, amount, spot, wager.ID
events := []Event{t.playerAction(name,
fmt.Sprintf("%s bets %d credits on %s.", name, amount, spot),
@@ -132,6 +138,14 @@ func (t *baccaratTable) tick() []Event {
}
func (t *baccaratTable) action(name, action string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if events, handled := t.controlLocked(name, action); handled {
+ if t.phase == baccaratBetting && allBet(t.players.participants()) {
+ return append(events, t.deal()...)
+ }
+ return events
+ }
return []Event{t.private(name, "That game has no player actions.")}
}
@@ -171,11 +185,14 @@ func (t *baccaratTable) deal() []Event {
// stake pending on any failure, so a refund can never be paid on top
// of a payout that was not durable.
payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr)
- publicResult, privateResult := baccaratResultMessages(p.Name, result, payout)
+ publicResult, _ := baccaratResultMessages(p.Name, result, payout)
+ privateResult := resultText(result, p.Wager.Total, payout)
if payoutFailed {
privateResult = payoutFailedMessage()
+ publicResult = p.Name + "’s settlement is pending recovery."
}
settlement := t.playerAction(p.Name, publicResult, privateResult)
+ settlement.ShowBalance = !payoutFailed
if result == "win" {
if p.lastSpot == baccaratSpotTie {
settlement.Color = "casino_jackpot_win"
@@ -190,8 +207,9 @@ func (t *baccaratTable) deal() []Event {
// pending by an earlier failed payout, which must stay refundable.
}
t.resetRound()
+ events = append(events, t.finishControlsLocked()...)
for _, p := range t.players.ordered() {
- if p.Connected {
+ if p.Connected && !p.SittingOut {
events = append(events, t.menuEvent(p.Name))
}
}
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go
index 49e8fdd..12dc07c 100644
--- a/internal/casino/baccarat_test.go
+++ b/internal/casino/baccarat_test.go
@@ -1,6 +1,7 @@
package casino
import (
+ "strings"
"testing"
)
@@ -266,7 +267,7 @@ func TestBaccaratTiePushesAndPaysJackpotColor(t *testing.T) {
t.Fatalf("payouts alice=%d bob=%d, want 100 (push)/900 (8:1)", wa.paid, wb.paid)
}
for _, event := range events {
- if event.PrivateTo == "bob" && event.PrivateMessage == "You win! +900 credits" && event.Color != "casino_jackpot_win" {
+ if event.PrivateTo == "bob" && strings.Contains(event.PrivateMessage, "WIN | Net +800 credits | 900 returned") && event.Color != "casino_jackpot_win" {
t.Fatalf("tie win color=%q, want casino_jackpot_win", event.Color)
}
}
@@ -354,10 +355,10 @@ func TestBaccaratMenuAndActions(t *testing.T) {
menu = event.Menu
}
}
- if menu == nil || !menu.ShowBalance || menu.Heading != "New baccarat hand! Place your bet." {
+ if menu == nil || !menu.ShowBalance || !strings.Contains(menu.Heading, "Baccarat / Commission | Next hand | Limits") {
t.Fatalf("join menu=%+v, want a baccarat betting menu", menu)
}
- if len(menu.Commands) != 3 || menu.Commands[0].Command != "bet <amount>" || menu.Commands[2].Command != "stop" {
+ if len(menu.Commands) != 4 || menu.Commands[0].Command != "bet 1 banker" || menu.Commands[2].Command != "stop" {
t.Fatalf("baccarat menu commands=%+v", menu.Commands)
}
if events := table.action("alice", "hit"); !hasMessage(events, "no player actions") {
diff --git a/internal/casino/betting.go b/internal/casino/betting.go
index fa2d2dc..54d8c60 100644
--- a/internal/casino/betting.go
+++ b/internal/casino/betting.go
@@ -10,7 +10,11 @@ type countdown struct {
func (c *countdown) start(ticks int) { c.clock, c.deadline = 0, ticks }
func (c *countdown) stop() { c.clock, c.deadline = 0, 0 }
func (c *countdown) tick() { c.clock++ }
-func (c *countdown) expired() bool { return c.deadline > 0 && c.clock >= c.deadline }
+func (c *countdown) remaining() int { return max(0, c.deadline-c.clock) }
+
+// One warning per window, at one quarter of its duration (at least one tick).
+func (c *countdown) warn() bool { return c.deadline > 1 && c.remaining() == max(1, c.deadline/4) }
+func (c *countdown) expired() bool { return c.deadline > 0 && c.clock >= c.deadline }
// bettingWindow is the timed betting phase shared by multiplayer table games.
// The owning table opens it when the first wager lands with multiple seated
@@ -42,7 +46,7 @@ func (w *bettingWindow) advance(seats []*Participant) (sitOuts []string, resolve
func allBet(seats []*Participant) bool {
connected := false
for _, p := range seats {
- if !p.Connected {
+ if !p.Connected || p.SittingOut {
continue
}
connected = true
@@ -68,7 +72,7 @@ func anyBet(seats []*Participant) bool {
func sitOutNames(seats []*Participant) []string {
var names []string
for _, p := range seats {
- if !p.HasBet {
+ if !p.HasBet && !p.SittingOut {
names = append(names, p.Name)
}
}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index a1cb112..2e7d7e9 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -102,11 +102,23 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa
}
func (t *blackjackTable) menuEvent(name string) Event {
- return t.betMenuEvent(name, []MenuCommand{
- {Command: "bet", Desc: "Place a wager (min/max accepted)"},
- {Command: "bet <amount>", Desc: "Place a specific wager"},
- {Command: "stop", Desc: "Leave the table"},
+ if t.phase == blackjackPlaying || t.phase == blackjackInsurance {
+ return t.private(name, "Round in progress. You’ll join the next hand; table shows the current action.")
+ }
+ amount, desc := t.Config.MinBet, "Table minimum"
+ if p := t.players.get(name); p != nil && p.lastBet > 0 {
+ amount, desc = p.lastBet, "Repeat wager"
+ }
+ e := t.betMenuEvent(name, []MenuCommand{
+ {Command: "bet", Desc: fmt.Sprintf("%s: %d credits (also: rebet)", desc, amount)},
+ {Command: "bet <amount>", Desc: "Change wager; min/max accepted"},
+ {Command: "stop", Desc: "Leave; during a round, leave afterward"},
+ {Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"},
})
+ if p := t.players.get(name); p != nil {
+ e.Menu.Repeat = p.lastBet > 0
+ }
+ return e
}
func (t *blackjackTable) join(name string) []Event {
@@ -166,6 +178,7 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve
if err != nil {
return []Event{t.private(name, walletErrorMessage(err))}
}
+ p.SittingOut = false
p.HasBet, p.Wager, p.Wallet, p.lastBet = true, wager, wallet, amount
events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))}
events = t.openBettingLocked(events)
@@ -187,7 +200,15 @@ func (t *blackjackTable) tick() []Event {
}
t.timer.tick()
if !t.timer.expired() {
- return nil
+ var events []Event
+ if t.timer.warn() {
+ for _, p := range t.players.ordered() {
+ if p.HasBet && p.insurance == 0 && !p.declinedInsurance && p.Connected {
+ events = append(events, t.warning(p.Name, fmt.Sprintf("Insurance closes in %d ticks. No response means decline.", t.timer.remaining())))
+ }
+ }
+ }
+ return events
}
return t.finishInsurance()
case blackjackPlaying:
@@ -208,7 +229,7 @@ func (t *blackjackTable) tick() []Event {
t.ceiling.tick()
if t.ceiling.expired() {
active.hands[t.activeHand].stood = true
- events := []Event{t.private(name, "You've been idle too long; standing.")}
+ events := []Event{t.warning(name, "You've been idle too long; standing.")}
// Restart the action timer so the next hand gets a full window.
t.timer.start(t.Rules.ActionTicks)
return append(events, t.finishHand()...)
@@ -216,14 +237,20 @@ func (t *blackjackTable) tick() []Event {
if active.Connected && t.bettorCount() <= 1 {
// A lone connected player is never timed out. A disconnected
// active player always is, so the table can never stall.
+ if t.ceiling.warn() {
+ return []Event{t.warning(name, fmt.Sprintf("%d ticks remaining. No response means stand.", t.ceiling.remaining()))}
+ }
return nil
}
t.timer.tick()
if !t.timer.expired() {
+ if t.timer.warn() {
+ return []Event{t.warning(name, fmt.Sprintf("%d ticks remaining. No response means stand.", t.timer.remaining()))}
+ }
return nil
}
active.hands[t.activeHand].stood = true
- events := []Event{t.private(name, "Your action timed out; standing.")}
+ events := []Event{t.warning(name, "Your action timed out; standing.")}
// Restart the timer so the next active hand gets a full action window
// instead of timing out on the following tick.
t.timer.start(t.Rules.ActionTicks)
@@ -237,12 +264,28 @@ func (t *blackjackTable) tick() []Event {
func (t *blackjackTable) action(name, action string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
+ if events, handled := t.controlLocked(name, action); handled {
+ if t.phase == blackjackBetting && allBet(t.players.participants()) {
+ return append(events, t.startRound()...)
+ }
+ return events
+ }
if t.phase == blackjackInsurance {
return t.insuranceAction(name, action)
}
if t.phase != blackjackPlaying || t.activeName() != name {
return []Event{t.private(name, "It's not your turn.")}
}
+ switch action {
+ case "h":
+ action = "hit"
+ case "st":
+ action = "stand"
+ case "dd":
+ action = "double"
+ case "sp":
+ action = "split"
+ }
p := t.players.get(name)
hand := p.hands[t.activeHand]
// The action window only restarts when a new turn is actually prompted
@@ -259,6 +302,7 @@ func (t *blackjackTable) action(name, action string) []Event {
}
e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1]))
e.Snapshot = t.snapshot(false)
+ e.Detail = true
events := []Event{e}
if total >= 21 {
events = append(events, t.finishHand()...)
@@ -287,6 +331,7 @@ func (t *blackjackTable) action(name, action string) []Event {
}
e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.")
e.Snapshot = t.snapshot(false)
+ e.Detail = true
return append([]Event{e}, t.finishHand()...)
case "split":
return t.splitHand(name, p, hand)
@@ -302,7 +347,7 @@ func (t *blackjackTable) action(name, action string) []Event {
}
// insuranceAction handles commands during the insurance offer window:
-// "insurance" buys cover for half the base bet; any other action declines.
+// "insurance" buys cover for half the base bet; "decline" explicitly declines.
func (t *blackjackTable) insuranceAction(name, action string) []Event {
p := t.players.get(name)
if p == nil || !p.HasBet {
@@ -312,6 +357,9 @@ func (t *blackjackTable) insuranceAction(name, action string) []Event {
return []Event{t.private(name, "You have already decided about insurance.")}
}
var events []Event
+ if action != "insurance" && action != "decline" {
+ return []Event{t.private(name, "Insurance decision first: insurance to buy, decline to continue. No card action has been taken.")}
+ }
if action == "insurance" {
amount := p.hands[0].bet / 2
if amount <= 0 {
@@ -417,7 +465,7 @@ func (t *blackjackTable) startRound() []Event {
p.declinedInsurance = true
}
}
- roundEvent := t.event("", true)
+ roundEvent := t.event("New blackjack hand!", true)
roundEvent.Snapshot = t.snapshot(false)
events := []Event{roundEvent}
if t.dealer.cards[1].rank == "A" {
@@ -428,7 +476,7 @@ func (t *blackjackTable) startRound() []Event {
if !p.HasBet || !p.Connected || p.declinedInsurance {
continue
}
- events = append(events, t.private(p.Name, fmt.Sprintf("Insure your %d-credit bet for %d credits? Type \"insurance\" to buy, or any other action to decline.", p.hands[0].bet, p.hands[0].bet/2)))
+ events = append(events, t.insuranceMenuLocked(p))
}
if t.insuranceSettled() {
events = append(events, t.finishInsurance()...)
@@ -440,6 +488,8 @@ func (t *blackjackTable) startRound() []Event {
return events
}
t.beginPlay()
+ t.skipCompletedHands()
+ events[0].Snapshot = t.snapshot(false)
return append(events, t.finishHand()...)
}
@@ -473,6 +523,7 @@ func (t *blackjackTable) prompt() []Event {
if name == "" {
return t.settle()
}
+ changed := name != t.turnName || t.activeHand != t.turnHand
// The absolute turn ceiling belongs to the turn, not the prompt: restart
// it only when the active (player, hand) changes. A seat event that
// re-prompts the same turn must not reset a hung bettor's deadline.
@@ -481,6 +532,23 @@ func (t *blackjackTable) prompt() []Event {
t.turnName = name
t.turnHand = t.activeHand
}
+ events := []Event{t.turnMenuLocked(name)}
+ if changed {
+ for _, p := range t.players.ordered() {
+ if p.Name == name || !p.Connected {
+ continue
+ }
+ text := name + "’s turn."
+ if p.HasBet && t.players.indexOf(p.Name) > t.activePlayer {
+ text += " You’re waiting for your turn."
+ }
+ events = append(events, t.private(p.Name, text))
+ }
+ }
+ return events
+}
+
+func (t *blackjackTable) turnMenuLocked(name string) Event {
p := t.players.get(name)
h := p.hands[t.activeHand]
ranks := make([]string, len(h.cards))
@@ -490,16 +558,27 @@ func (t *blackjackTable) prompt() []Event {
actions := t.availableActions(p, h)
e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", ")))
e.Color = "casino_menu"
- e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Commands: blackjackActionCommands(actions)}
- return []Event{e}
+ deadline := t.timer.remaining()
+ if p.Connected && t.bettorCount() <= 1 {
+ deadline = t.ceiling.remaining()
+ }
+ e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s) | Hand %d/%d | %s | Wager %d | %d ticks remaining", strings.Join(ranks, ","), t.activeHand+1, len(p.hands), handScore(h), h.bet, deadline), Commands: blackjackActionCommands(actions)}
+ for i := range e.Menu.Commands {
+ c := &e.Menu.Commands[i]
+ if c.Command == "double" || c.Command == "split" {
+ c.Cost = h.bet
+ c.Desc += fmt.Sprintf(" (+%d credits)", h.bet)
+ }
+ }
+ return e
}
func blackjackActionCommands(actions []string) []MenuCommand {
descriptions := map[string]string{
- "hit": "Take another card",
- "stand": "Hold your total",
- "double": "Double your bet and take one card",
- "split": "Split your pair into two hands",
+ "hit": "[h] Take another card",
+ "stand": "[st] Hold your total",
+ "double": "[dd] Double your bet and take one card",
+ "split": "[sp] Split your pair into two hands",
"surrender": "Forfeit half your bet",
}
commands := make([]MenuCommand, 0, len(actions))
@@ -592,19 +671,25 @@ func (t *blackjackTable) settle() []Event {
if !p.HasBet {
continue
}
- for _, hand := range p.hands {
+ roundNet, roundFailed := int64(0), false
+ for i, hand := range p.hands {
result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules)
// One shared settlement path for every game: pay before
// announcing, and leave the stakes pending on any failure so a
// refund can never be paid on top of a payout that was not durable.
if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) {
- publicResult, _ := blackjackResultMessages(p.Name, result, payout)
- settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
+ roundFailed = true
+ settlement := t.playerAction(p.Name, p.Name+"’s settlement is pending recovery.", payoutFailedMessage())
colorizeBlackjackSettlement(&settlement, result)
events = append(events, settlement)
continue
}
- publicResult, privateResult := blackjackResultMessages(p.Name, result, payout)
+ publicResult, _ := blackjackResultMessages(p.Name, result, payout)
+ roundNet += int64(payout) - int64(hand.bet)
+ privateResult := resultText(result, hand.bet, payout)
+ if len(p.hands) > 1 {
+ privateResult = fmt.Sprintf("Hand %d/%d | %s", i+1, len(p.hands), privateResult)
+ }
settlement := t.playerAction(p.Name, publicResult, privateResult)
colorizeBlackjackSettlement(&settlement, result)
events = append(events, settlement)
@@ -616,13 +701,21 @@ func (t *blackjackTable) settle() []Event {
insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout)
}
if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) {
+ roundFailed = true
events = append(events, t.private(p.Name, payoutFailedMessage()))
- } else if insurancePayout > 0 {
- events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout)))
} else {
- events = append(events, t.private(p.Name, "Insurance pays 0 credits."))
+ roundNet += int64(insurancePayout) - int64(p.insurance)
+ events = append(events, t.private(p.Name, resultText("insurance", p.insurance, insurancePayout)))
}
}
+ if !roundFailed {
+ e := t.private(p.Name, fmt.Sprintf("Round complete | Net %+d credits (insurance included)", roundNet))
+ e.ShowBalance = true
+ if roundNet > 0 {
+ e.Color = "casino_big_win"
+ }
+ events = append(events, e)
+ }
// Each hand's stakes were already consumed per-handle by
// settleStakeOutcome (a payout removes them in PayCredits; a loss
// resolves them), and the insurance stake likewise. Deliberately do
@@ -631,8 +724,9 @@ func (t *blackjackTable) settle() []Event {
// refundable (see the Wallet contract).
}
t.resetRound()
+ events = append(events, t.finishControlsLocked()...)
for _, p := range t.players.ordered() {
- if p.Connected {
+ if p.Connected && !p.SittingOut {
events = append(events, t.menuEvent(p.Name))
}
}
@@ -724,8 +818,23 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot {
if !p.HasBet {
continue
}
- for _, hand := range p.hands {
- view := BlackjackPlayerView{Name: p.Name, Score: handScore(hand)}
+ for i, hand := range p.hands {
+ status := "Waiting"
+ switch {
+ case hand.bust:
+ status = "Busted"
+ case hand.surrendered:
+ status = "Surrendered"
+ case hand.stood:
+ status = "Standing"
+ case hand.blackjack(t.Rules):
+ status = "Blackjack"
+ }
+ active := t.phase == blackjackPlaying && t.activeName() == p.Name && t.activeHand == i && !revealDealer
+ if active {
+ status = "Your turn"
+ }
+ view := BlackjackPlayerView{Name: p.Name, Score: handScore(hand), Hand: i + 1, Hands: len(p.hands), Bet: hand.bet, Active: active, Status: status}
for _, card := range hand.cards {
view.Cards = append(view.Cards, CardView{Rank: card.rank, Suit: card.suit})
}
@@ -745,7 +854,7 @@ func handScore(hand *blackjackHand) string {
if !soft || total == 21 {
return fmt.Sprintf("%d", total)
}
- return fmt.Sprintf("%d or %d", total-10, total)
+ return fmt.Sprintf("Soft %d", total)
}
func blackjackResultMessages(name, result string, payout int) (string, string) {
diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go
index e3ffcaf..dd3d83a 100644
--- a/internal/casino/blackjack_test.go
+++ b/internal/casino/blackjack_test.go
@@ -23,8 +23,8 @@ func TestBlackjackHandScoreShowsBlackjackAs21(t *testing.T) {
if got := handScore(&blackjackHand{cards: []card{{rank: "A"}, {rank: "K"}}}); got != "21" {
t.Fatalf("blackjack score=%q, want 21", got)
}
- if got := handScore(&blackjackHand{cards: []card{{rank: "A"}, {rank: "6"}}}); got != "7 or 17" {
- t.Fatalf("soft 17 score=%q, want 7 or 17", got)
+ if got := handScore(&blackjackHand{cards: []card{{rank: "A"}, {rank: "6"}}}); got != "Soft 17" {
+ t.Fatalf("soft 17 score=%q, want Soft 17", got)
}
if got := handScore(&blackjackHand{cards: []card{{rank: "10"}, {rank: "8"}}}); got != "18" {
t.Fatalf("hard 18 score=%q, want 18", got)
@@ -130,7 +130,7 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing.
if event.Message != "Your turn (K,6).\nActions: hit, stand, double" {
t.Fatalf("prompt=%q", event.Message)
}
- if event.Menu == nil || event.Menu.Heading != "Your turn (K,6)" || event.Menu.ShowBalance ||
+ if event.Menu == nil || !strings.Contains(event.Menu.Heading, "Hand 1/1 | 16 | Wager 10") || 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" {
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) != 3 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" {
+ if len(event.Menu.Commands) != 4 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" {
t.Fatalf("bet menu commands=%+v", event.Menu.Commands)
}
}
@@ -175,7 +175,7 @@ func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) {
if w.paid != 15 {
t.Fatalf("insurance payout=%d, want 15 (stake + 2x)", w.paid)
}
- if !hasMessage(events, "Insurance pays 15 credits.") {
+ if !hasMessage(events, "INSURANCE | Net +10 credits | 15 returned") {
t.Fatalf("missing insurance payout message: %+v", events)
}
if table.phase != blackjackWaiting {
@@ -192,7 +192,7 @@ func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) {
})
table.dealer.cards = []card{{rank: "9"}, {rank: "A"}}
table.phase = blackjackInsurance
- events := table.action("alice", "stand")
+ events := table.action("alice", "decline")
if !hasMessage(events, "You decline insurance.") {
t.Fatalf("missing decline message: %+v", events)
}
@@ -285,7 +285,7 @@ func TestBlackjackDisconnectActivePassesPlay(t *testing.T) {
}
// Bob stands; round settles.
settle := table.action("bob", "stand")
- if !hasMessage(settle, "You lose!") {
+ if !hasMessage(settle, "LOSS | Net -10 credits | 0 returned") {
t.Fatalf("bob 16 vs dealer 17: expected loss, got %+v", settle)
}
if wb.paid != 0 {
diff --git a/internal/casino/core.go b/internal/casino/core.go
index 7143841..a927ab1 100644
--- a/internal/casino/core.go
+++ b/internal/casino/core.go
@@ -89,7 +89,15 @@ func (c *tableCore[P]) openBettingLocked(events []Event) []Event {
func (c *tableCore[P]) advanceBettingLocked(resolve func() []Event, reset func()) []Event {
sitOuts, goRound, pending := c.window.advance(c.players.participants())
if pending {
- return nil
+ var events []Event
+ if c.window.timer.warn() {
+ for _, p := range c.players.participants() {
+ if p.Connected && !p.HasBet && !p.SittingOut {
+ events = append(events, c.warning(p.Name, fmt.Sprintf("Betting closes in %d ticks. Bet now or sit out this hand.", c.window.timer.remaining())))
+ }
+ }
+ }
+ return events
}
var events []Event
for _, name := range sitOuts {
diff --git a/internal/casino/table.go b/internal/casino/table.go
index 408e5fe..17732e0 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -19,6 +19,8 @@ type tableGame interface {
hasParticipant(name string) bool
refundAll() []Event
idle() bool
+ status(name string) []Event
+ overview() string
// base returns the table identity shared with the Table handle.
base() *tableBase
}
@@ -52,7 +54,7 @@ func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Eve
// betMenuEvent builds the shared "place your bet" menu heading for a table
// game; only the command list differs per game.
func (b *tableBase) betMenuEvent(name string, commands []MenuCommand) Event {
- heading := fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game)
+ heading := fmt.Sprintf("%s | Next hand | Limits %s", b.Config.Label(), betRangeText(b.Config.MinBet, b.Config.MaxBet))
e := b.private(name, heading)
e.Color = "casino_menu"
e.Menu = &MenuView{Heading: heading, ShowBalance: true, Commands: commands}
@@ -105,3 +107,7 @@ func (t *Table) refundAll() []Event { return t.game.refundAll() }
// idle on the Table wrapper delegates to the game.
func (t *Table) idle() bool { return t.game.idle() }
+
+// Status is a read-only view: it never restarts a deadline or changes a wager.
+func (t *Table) Status(name string) []Event { return t.game.status(name) }
+func (t *Table) Overview() string { return t.game.overview() }
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
new file mode 100644
index 0000000..9543d91
--- /dev/null
+++ b/internal/casino/table_ux.go
@@ -0,0 +1,180 @@
+package casino
+
+import (
+ "fmt"
+ "strings"
+)
+
+func (c TableConfig) Label() string {
+ if c.Game != GameBaccarat {
+ return gameTitle(c.Game)
+ }
+ switch c.Baccarat.Normalize().Variant {
+ case BaccaratSuper6:
+ return "Baccarat / Super 6"
+ case BaccaratEZ:
+ return "Baccarat / EZ"
+ default:
+ return "Baccarat / Commission"
+ }
+}
+
+func (c *tableCore[P]) overview() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.overviewLocked()
+}
+
+func (c *tableCore[P]) overviewLocked() string {
+ state := "Betting open"
+ switch c.phase {
+ case phaseBetting:
+ state = fmt.Sprintf("Betting closes in %d ticks", c.window.timer.remaining())
+ case phasePlaying:
+ state = "Round in progress"
+ case phaseInsurance:
+ state = "Insurance decisions"
+ }
+ return fmt.Sprintf("%s | Limits %s | %d/%d seated | %s", c.Config.Label(), betRangeText(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers, state)
+}
+
+func (c *tableCore[P]) warning(name, text string) Event {
+ e := c.private(name, text)
+ e.Color = "casino_warning"
+ return e
+}
+
+// controlLocked queues departures without changing committed hands or stakes.
+func (c *tableCore[P]) controlLocked(name, action string) ([]Event, bool) {
+ if action != "leave_after" && action != "sitout" {
+ return nil, false
+ }
+ for _, p := range c.players.participants() {
+ if p.Name != name {
+ continue
+ }
+ if action == "leave_after" {
+ p.LeaveAfter = true
+ return []Event{c.private(name, "You’ll leave after this round. Your current hand still needs your actions.")}, true
+ }
+ if p.HasBet {
+ p.SitOutAfter = true
+ return []Event{c.private(name, "You’ll sit out after this round. Your current wager and actions are unchanged.")}, true
+ }
+ p.SittingOut = true
+ return []Event{c.private(name, "You’re sitting out. Place a bet to rejoin; stop to leave.")}, true
+ }
+ return []Event{c.private(name, "You aren’t seated at this table.")}, true
+}
+
+// Called only after resetRound, when seat removal cannot disturb active indices.
+func (c *tableCore[P]) finishControlsLocked() []Event {
+ var events []Event
+ for _, p := range c.players.participants() {
+ if p.LeaveAfter {
+ c.removeSeatLocked(p.Name)
+ events = append(events, c.playerAction(p.Name, p.Name+" stands up.", "Round complete. You stand up."))
+ } else if p.SitOutAfter {
+ p.SitOutAfter = false
+ p.SittingOut = true
+ events = append(events, c.private(p.Name, "You’re now sitting out. Place a bet to rejoin."))
+ }
+ }
+ return c.closeIfEmptyLocked(events)
+}
+
+func (c *tableCore[P]) seatStatusLocked(name string) string {
+ for _, p := range c.players.participants() {
+ if p.Name != name {
+ continue
+ }
+ if p.LeaveAfter {
+ return "Leaving after this round"
+ }
+ if p.SitOutAfter {
+ return "Sitting out after this round"
+ }
+ if p.SittingOut {
+ return "Sitting out | Bet to rejoin"
+ }
+ if p.HasBet {
+ return fmt.Sprintf("Wager committed: %d", p.Wager.Total)
+ }
+ }
+ var waiting []string
+ for _, p := range c.players.participants() {
+ if p.Connected && !p.SittingOut && !p.HasBet {
+ waiting = append(waiting, p.Name)
+ }
+ }
+ if len(waiting) > 0 {
+ return "Waiting for bets: " + strings.Join(waiting, ", ")
+ }
+ return ""
+}
+
+func (t *blackjackTable) status(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ e := t.private(name, t.overviewLocked())
+ if text := t.seatStatusLocked(name); text != "" {
+ e.Message += "\n" + text
+ }
+ events := []Event{e}
+ switch t.phase {
+ case blackjackPlaying:
+ events[0].Snapshot = t.snapshot(false)
+ if t.activeName() == name {
+ events = append(events, t.turnMenuLocked(name))
+ } else {
+ events[0].Message += "\n" + t.activeName() + "’s turn | You are waiting"
+ }
+ case blackjackInsurance:
+ events[0].Snapshot = t.snapshot(false)
+ if p := t.players.get(name); p != nil && p.HasBet && !p.declinedInsurance && p.insurance == 0 {
+ events = append(events, t.insuranceMenuLocked(p))
+ }
+ default:
+ if p := t.players.get(name); p != nil && !p.HasBet {
+ events = append(events, t.menuEvent(name))
+ }
+ }
+ return events
+}
+
+func (t *baccaratTable) status(name string) []Event {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ e := t.private(name, t.overviewLocked())
+ if text := t.seatStatusLocked(name); text != "" {
+ e.Message += "\n" + text
+ }
+ events := []Event{e}
+ if p := t.players.get(name); p != nil && !p.HasBet {
+ events = append(events, t.menuEvent(name))
+ }
+ return events
+}
+
+func (t *blackjackTable) insuranceMenuLocked(p *blackjackPlayer) Event {
+ cost := p.hands[0].bet / 2
+ e := t.private(p.Name, "Insurance decision")
+ e.Color = "casino_menu"
+ e.Menu = &MenuView{Heading: fmt.Sprintf("Insurance | Costs %d | Pays %g:1 | %d ticks remaining", cost, t.Rules.InsurancePayout, t.timer.remaining()), Commands: []MenuCommand{{Command: "insurance", Desc: "Buy insurance", Cost: cost}, {Command: "decline", Desc: "Continue without insurance (also the timeout default)"}}}
+ if cost == 0 {
+ e.Menu.Commands = e.Menu.Commands[1:]
+ e.Menu.Heading = "Bet too small to insure | decline to continue"
+ }
+ return e
+}
+
+func resultText(result string, stake, returned int) string {
+ label := strings.ToUpper(result)
+ switch result {
+ case "blackjack":
+ label = "BLACKJACK"
+ case "bust", "loss", "dealer blackjack":
+ label = "LOSS"
+ }
+ return fmt.Sprintf("%s | Net %+d credits | %d returned (stake included)", label, returned-stake, returned)
+}
diff --git a/internal/casino/table_ux_test.go b/internal/casino/table_ux_test.go
new file mode 100644
index 0000000..7f93951
--- /dev/null
+++ b/internal/casino/table_ux_test.go
@@ -0,0 +1,181 @@
+package casino
+
+import (
+ "strings"
+ "testing"
+)
+
+func uxBlackjack() *blackjackTable {
+ t := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 8}}, newTestRand())
+ t.phase = blackjackPlaying
+ t.dealer.cards = []card{{rank: "10", suit: "spades"}, {rank: "7", suit: "hearts"}}
+ t.players.add("alice", &blackjackPlayer{Participant: Participant{Name: "alice", Connected: true, HasBet: true, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}})
+ t.prompt()
+ return t
+}
+
+func TestTableStatusDoesNotExtendAnyDeadlineOrRevealHoleCard(t *testing.T) {
+ table := uxBlackjack()
+ table.tick()
+ beforeTimer, beforeCeiling := table.timer, table.ceiling
+ for i := 0; i < 3; i++ {
+ events := table.status("alice")
+ snap := events[0].Snapshot.(*BlackjackSnapshot)
+ if !snap.Dealer[0].Hidden || snap.Dealer[0].Rank != "?" {
+ t.Fatal("status revealed hole card")
+ }
+ if !snap.Players[0].Active || snap.Players[0].Hand != 1 || snap.Players[0].Bet != 10 {
+ t.Fatalf("missing active hand: %+v", snap.Players[0])
+ }
+ }
+ if table.timer != beforeTimer || table.ceiling != beforeCeiling {
+ t.Fatal("status extended deadline")
+ }
+}
+
+func TestBlackjackWarnsOnceThenAutoStands(t *testing.T) {
+ for _, multi := range []bool{false, true} {
+ table := uxBlackjack()
+ if multi {
+ table.players.add("bob", &blackjackPlayer{Participant: Participant{Name: "bob", Connected: true, HasBet: true}, hands: []*blackjackHand{{bet: 10, stood: true, cards: []card{{rank: "10"}, {rank: "8"}}}}})
+ }
+ warnings := 0
+ timeout := false
+ for i := 0; i < 81; i++ {
+ for _, e := range table.tick() {
+ if e.PrivateTo == "alice" && strings.Contains(e.Message, "No response means stand") {
+ warnings++
+ if e.Color != "casino_warning" {
+ t.Fatal("warning not colored")
+ }
+ }
+ if strings.Contains(e.Message, "standing.") {
+ timeout = true
+ if e.Color != "casino_warning" {
+ t.Fatal("timeout not colored")
+ }
+ }
+ }
+ }
+ if warnings != 1 || !timeout {
+ t.Fatalf("multi=%v warnings=%d timeout=%v", multi, warnings, timeout)
+ }
+ }
+}
+
+func TestInsuranceRequiresExplicitDecisionAndWarns(t *testing.T) {
+ table := uxBlackjack()
+ table.phase = blackjackInsurance
+ table.timer.start(8)
+ before := len(table.players.get("alice").hands[0].cards)
+ events := table.action("alice", "hit")
+ if !hasMessage(events, "No card action") || table.players.get("alice").declinedInsurance || len(table.players.get("alice").hands[0].cards) != before {
+ t.Fatal("hit silently declined or took card")
+ }
+ warnings := 0
+ for i := 0; i < 8; i++ {
+ for _, e := range table.tick() {
+ if strings.Contains(e.Message, "Insurance closes") {
+ warnings++
+ if e.Color != "casino_warning" {
+ t.Fatal("insurance warning color")
+ }
+ }
+ }
+ }
+ if warnings != 1 || table.phase != blackjackPlaying {
+ t.Fatalf("warnings=%d phase=%s", warnings, table.phase)
+ }
+}
+
+func TestBettingWarningOnlyTargetsPlayersWhoNeedToBet(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat, BettingTicks: 8})
+ for _, n := range []string{"alice", "bob", "carol"} {
+ table.join(n)
+ }
+ table.action("carol", "sitout")
+ table.bet("alice", "banker", 10, &testWallet{credits: 100})
+ warnings := 0
+ for i := 0; i < 8; i++ {
+ for _, e := range table.tick() {
+ if e.Color == "casino_warning" {
+ warnings++
+ if e.PrivateTo != "bob" {
+ t.Fatalf("warning sent to %s", e.PrivateTo)
+ }
+ }
+ }
+ }
+ if warnings != 1 {
+ t.Fatalf("warnings=%d", warnings)
+ }
+}
+
+func TestSitoutDoesNotBlockOthersAndBetRejoins(t *testing.T) {
+ table := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat})
+ table.join("alice")
+ table.join("bob")
+ table.bet("alice", "banker", 10, &testWallet{credits: 100})
+ events := table.action("bob", "sitout")
+ if !hasMessage(events, "New baccarat hand!") || !table.players.get("bob").SittingOut {
+ t.Fatal("sitout held up round")
+ }
+ table.action("alice", "sitout")
+ events = table.bet("bob", "player", 10, &testWallet{credits: 100})
+ if !hasMessage(events, "New baccarat hand!") || table.players.get("bob").SittingOut {
+ t.Fatal("bet failed to rejoin")
+ }
+}
+
+func TestQueuedLeaveAndSitoutPreserveCommittedBlackjackHands(t *testing.T) {
+ for _, action := range []string{"leave_after", "sitout"} {
+ table := uxBlackjack()
+ hand := table.players.get("alice").hands[0]
+ table.action("alice", action)
+ if !table.hasParticipant("alice") || hand.stood || hand.surrendered {
+ t.Fatalf("%s altered committed hand", action)
+ }
+ events := table.action("alice", "stand")
+ if !hasMessage(events, "Net -10 credits") {
+ t.Fatalf("missing settlement: %+v", events)
+ }
+ if action == "leave_after" && table.hasParticipant("alice") {
+ t.Fatal("queued leave retained seat")
+ }
+ if action == "sitout" && !table.players.get("alice").SittingOut {
+ t.Fatal("queued sitout not applied")
+ }
+ }
+}
+
+func TestSplitAndInsuranceRoundNetIncludesEveryStake(t *testing.T) {
+ table := uxBlackjack()
+ p := table.players.get("alice")
+ p.hands = []*blackjackHand{{bet: 20, stood: true, cards: []card{{rank: "10"}, {rank: "10"}}}, {bet: 10, bust: true, cards: []card{{rank: "10"}, {rank: "10"}, {rank: "5"}}}}
+ p.insurance = 5
+ events := table.settle()
+ for _, text := range []string{"Hand 1/2 | WIN | Net +20 credits | 40 returned", "Hand 2/2 | LOSS | Net -10 credits", "INSURANCE | Net -5 credits", "Round complete | Net +5 credits"} {
+ if !hasMessage(events, text) {
+ t.Fatalf("missing %q in %+v", text, events)
+ }
+ }
+}
+
+func TestRebetMenusAndMidRoundJoin(t *testing.T) {
+ table := uxBlackjack()
+ events := table.join("bob")
+ if !hasMessage(events, "Round in progress") {
+ t.Fatal("mid-round join offered betting")
+ }
+ bac := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat})
+ bac.join("alice")
+ first := bac.menuEvent("alice")
+ if first.Menu.Commands[0].Command == "bet <amount>" {
+ t.Fatal("first baccarat bet omitted target")
+ }
+ bac.bet("alice", "banker", 25, &testWallet{credits: 100})
+ menu := bac.menuEvent("alice").Menu
+ if !menu.Repeat || !strings.Contains(menu.Commands[0].Desc, "Repeat 25 on banker") {
+ t.Fatalf("missing rebet details: %+v", menu)
+ }
+}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 5efb43e..4bbc0a8 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -250,6 +250,7 @@ type MenuView struct {
Heading string // prompt heading shown above the command list
ShowBalance bool // prepend the player's chips/credits balance
Commands []MenuCommand
+ Repeat bool // returning bettor: compact clients can show a short reminder
}
// MenuCommand is one selectable command shown in a casino menu: the typed
@@ -257,6 +258,7 @@ type MenuView struct {
type MenuCommand struct {
Command string
Desc string
+ Cost int // additional stake; presentation can mark unaffordable actions
}
type Event struct {
@@ -275,8 +277,10 @@ type Event struct {
// snapshot can never be assigned without implementing the interface, and
// the event filter can test it with a single nil check instead of
// enumerating every game's field.
- Snapshot Snapshot
- Menu *MenuView
+ Snapshot Snapshot
+ Menu *MenuView
+ ShowBalance bool // append the recipient's current available funds
+ Detail bool // action-by-action public output, only for participants/watchers
}
// Snapshot is the marker interface for a game's structured event view. The
@@ -298,9 +302,14 @@ type CardView struct {
}
type BlackjackPlayerView struct {
- Name string
- Cards []CardView
- Score string
+ Name string
+ Cards []CardView
+ Score string
+ Hand int
+ Hands int
+ Bet int
+ Active bool
+ Status string
}
type BlackjackSnapshot struct {
@@ -331,9 +340,12 @@ type BaccaratSnapshot struct {
func (*BaccaratSnapshot) snapshot() {}
type Participant struct {
- Name string
- Connected bool
- HasBet bool
- Wager Wager
- Wallet Wallet
+ Name string
+ Connected bool
+ HasBet bool
+ Wager Wager
+ Wallet Wallet
+ SittingOut bool
+ SitOutAfter bool
+ LeaveAfter bool
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 852a6d6..d765c5b 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -50,39 +50,46 @@ func DefaultColors() ColorsConfig {
"damage_dealt": "B4",
"damage_taken": "A7",
- "xp": "B3",
- "level_up": "B4 bold",
- "item": "6E",
- "player_name": "74",
- "death": "A7 bold",
- "victory": "6C",
- "miss": "F5",
- "error": "A7",
- "say": "FF",
- "global": "49",
- "dialog": "6E",
- "dialog_options": "bold",
- "command": "0A bold",
- "broadcast": "89",
- "sequence": "off",
- "fire": "B4",
- "eat_food": "6C",
- "drop_message": "89",
- "currency_pickup": "B3",
- "visual_tick": "49 dim",
- "visual_first_tick": "B4 bold",
- "battery": "6C",
- "tech_depleted": "A7",
- "science_mod": "8B",
- "assassin_task": "8B",
- "dim": "F5 dim",
- "warning": "B3",
- "farm_grow": "6C",
- "farm_disease": "A7",
- "map_at": "B4 bold",
- "map_blocked": "A7",
- "map_agility": "43",
- "safespot_alert": "A7 bold",
+ "xp": "B3",
+ "level_up": "B4 bold",
+ "item": "6E",
+ "player_name": "74",
+ "death": "A7 bold",
+ "victory": "6C",
+ "miss": "F5",
+ "error": "A7",
+ "say": "FF",
+ "global": "49",
+ "dialog": "6E",
+ "dialog_options": "bold",
+ "command": "0A bold",
+ "broadcast": "89",
+ "sequence": "off",
+ "fire": "B4",
+ "eat_food": "6C",
+ "drop_message": "89",
+ "currency_pickup": "B3",
+ "visual_tick": "49 dim",
+ "visual_first_tick": "B4 bold",
+ "battery": "6C",
+ "tech_depleted": "A7",
+ "science_mod": "8B",
+ "assassin_task": "8B",
+ "dim": "F5 dim",
+ "warning": "B3",
+ "casino_warning": "C4 bold",
+ "casino_action": "6E",
+ "casino_menu": "FA",
+ "casino_chips": "B3",
+ "casino_credits": "B3",
+ "casino_big_win": "0A bold",
+ "casino_jackpot_win": "E2 bold",
+ "farm_grow": "6C",
+ "farm_disease": "A7",
+ "map_at": "B4 bold",
+ "map_blocked": "A7",
+ "map_agility": "43",
+ "safespot_alert": "A7 bold",
}
}
diff --git a/internal/engine/tick.go b/internal/engine/tick.go
index 8c64cdd..e2e7343 100644
--- a/internal/engine/tick.go
+++ b/internal/engine/tick.go
@@ -20,14 +20,15 @@ type subscriber struct {
}
type Engine struct {
- tickMu sync.Mutex // serializes manual ticks and successive ticker runs
- mu sync.Mutex
- subscribers map[uint64]*subscriber
- nextID uint64
- ticker *time.Ticker
- running bool
- stopCh chan struct{}
- loopDone chan struct{}
+ tickMu sync.Mutex // serializes manual ticks and successive ticker runs
+ mu sync.Mutex
+ subscribers map[uint64]*subscriber
+ nextID uint64
+ ticker *time.Ticker
+ running bool
+ tickLengthMs int
+ stopCh chan struct{}
+ loopDone chan struct{}
}
func New() *Engine {
@@ -36,6 +37,16 @@ func New() *Engine {
}
}
+// TickLengthMilliseconds reports the configured cadence for countdown displays.
+func (e *Engine) TickLengthMilliseconds() int {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if e.tickLengthMs == 0 {
+ return 600
+ }
+ return e.tickLengthMs
+}
+
func (e *Engine) Subscribe(interval int, cb Callback) uint64 {
e.mu.Lock()
defer e.mu.Unlock()
@@ -64,6 +75,7 @@ func (e *Engine) Start(tickLengthMs int) {
if tickLengthMs < 50 {
tickLengthMs = 50
}
+ e.tickLengthMs = tickLengthMs
e.ticker = time.NewTicker(time.Duration(tickLengthMs) * time.Millisecond)
e.stopCh = make(chan struct{})
// Capture this run's resources. A quick Stop/Start must not make the
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"},
diff --git a/internal/net/server.go b/internal/net/server.go
index 80710ad..c715074 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -65,8 +65,11 @@ type Session struct {
PendingBackground bool
PendingUndigDir string
PendingUndigRoom int
- promptVisible bool
- writeMu sync.Mutex
+ // Casino spectator preferences are session-local, owner-goroutine only.
+ CasinoWatchRoom int
+ CasinoWatchTable string
+ promptVisible bool
+ writeMu sync.Mutex
// Disconnect bookkeeping is written by the connection goroutine that
// discovered the dead transport (Hub.Remove's deferred path) and read
diff --git a/internal/player/player.go b/internal/player/player.go
index 22ef04a..b296dee 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -140,7 +140,8 @@ var OptionDefs = []OptionDef{
{"show_queued_cmds", OptBool, false, nil, "Show confirmation messages for queued tick actions"},
{"wrap_width", OptInt, 80, nil, "Wrap all output to this many columns (minimum 80)"},
{"unicode", OptBool, true, nil, "Unicode box-drawing characters"},
- {"card_size", OptInt, 9, nil, "Casino card size (1, 5, 7, or 9)"},
+ {"casino_display", OptString, "compact", []string{"compact", "detailed"}, "Table display: compact cards and hints, or detailed card art and menus"},
+ {"card_size", OptInt, 9, nil, "Detailed casino card size (1, 5, 7, or 9)"},
{"card_style", OptString, "dark", []string{"dark", "light"}, "Casino card style (dark or light)"},
{"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"},
{"visual_tick_count", OptInt, 0, nil, "Cycle length for tick counter (0 = no counter)"},