aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
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/casino
parentd114c747a7672c7e4daab3a5b5f1b2f066ebc912 (diff)
downloadthehouseoficarus-13db3e50cb6e29eedbf25416e5284bf08b0adefb.tar.gz
feat: casino table ux: aliases, rebet hints, deadline notifications, spectator mode
Diffstat (limited to 'internal/casino')
-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
10 files changed, 578 insertions, 59 deletions
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
}