aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-01 19:30:38 -0400
committerhistoria <[not public]>2026-10-01 19:30:38 -0400
commit6d9908b29c8187196d23b126b1165c7e0bd0fc76 (patch)
treeddbc02357ed92646b801687b90b1a06c71a15d71 /internal/casino
parent8a94f21bd47c0a00bc415395232dba22fa958837 (diff)
downloadthehouseoficarus-6d9908b29c8187196d23b126b1165c7e0bd0fc76.tar.gz
fix: casino pai gow sorting, universal unbet, table/rules split, remove solo timers
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/baccarat.go4
-rw-r--r--internal/casino/blackjack.go56
-rw-r--r--internal/casino/command_hints_test.go16
-rw-r--r--internal/casino/craps.go50
-rw-r--r--internal/casino/craps_clock_test.go6
-rw-r--r--internal/casino/craps_regression_test.go2
-rw-r--r--internal/casino/craps_rules.go3
-rw-r--r--internal/casino/craps_test.go4
-rw-r--r--internal/casino/craps_ux.go4
-rw-r--r--internal/casino/craps_variants.go4
-rw-r--r--internal/casino/hardening_casino_locking_test.go31
-rw-r--r--internal/casino/hardening_fixes_batch2_test.go17
-rw-r--r--internal/casino/pai_gow.go141
-rw-r--r--internal/casino/pai_gow_rules.go2
-rw-r--r--internal/casino/pai_gow_test.go126
-rw-r--r--internal/casino/roulette.go45
-rw-r--r--internal/casino/roulette_history_test.go10
-rw-r--r--internal/casino/roulette_test.go3
-rw-r--r--internal/casino/table_ux.go36
-rw-r--r--internal/casino/table_ux_test.go14
-rw-r--r--internal/casino/unbet_test.go59
-rw-r--r--internal/casino/video_poker.go12
22 files changed, 452 insertions, 193 deletions
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 122deed..e6af047 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -148,6 +148,10 @@ func (t *baccaratTable) tick() []Event {
func (t *baccaratTable) action(name, action string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
+ if action == "unbet" {
+ events, _ := t.unbetLocked(name)
+ return events
+ }
if events, handled := t.controlLocked(name, action); handled {
if t.phase == baccaratBetting && allBet(t.players.participants()) {
return append(events, t.deal()...)
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index a047c71..3bc70b4 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -81,12 +81,11 @@ type blackjackTable struct {
}
// loneTurnGrace multiplies the action window into an absolute per-turn
-// ceiling. A lone connected player is deliberately never timed out by the
-// action timer (a solo player's think time must not end the hand), but
-// "never" cannot mean forever: a hung-but-connected client would hold the
-// hand, the seat, and the stake open with no automatic resolution, since
-// "leave" refuses mid-round. After this many ticks on one turn the table
-// stands the hand for the player and plays on.
+// ceiling. A lone connected player has no time limit at all (a solo player's
+// think time must not end the hand), but a multiplayer turn is still bounded:
+// a hung client must not hold the table, the other seats, and their stakes
+// open, since "leave" refuses mid-round. After this many ticks on one turn
+// the table stands the hand for the player and plays on.
const loneTurnGrace = 10
func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTable {
@@ -212,6 +211,10 @@ func (t *blackjackTable) tick() []Event {
if t.insuranceSettled() {
return t.finishInsurance()
}
+ // A lone bettor is never timed out into declining insurance.
+ if t.bettorCount() <= 1 {
+ return nil
+ }
t.timer.tick()
if !t.timer.expired() {
var events []Event
@@ -224,7 +227,14 @@ func (t *blackjackTable) tick() []Event {
}
return events
}
- return t.finishInsurance()
+ // Announce the timeout itself, never silently declining the offer.
+ var events []Event
+ for _, p := range t.players.ordered() {
+ if p.HasBet && p.insurance == 0 && !p.declinedInsurance && p.Connected {
+ events = append(events, t.warning(p.Name, "Insurance timed out; continuing without it."))
+ }
+ }
+ return append(events, t.finishInsurance()...)
case blackjackPlaying:
name := t.activeName()
if name == "" {
@@ -236,24 +246,20 @@ func (t *blackjackTable) tick() []Event {
t.activePlayer++
return t.finishHand()
}
- // The absolute turn ceiling bounds every turn, including a lone
- // connected player's: the action timer below deliberately never
- // expires for a solo player, so without this a hung-but-connected
- // client would hold the stake and the phase open forever.
- t.ceiling.tick()
- if t.ceiling.expired() {
- active.hands[t.activeHand].stood = true
- 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()...)
+ // A lone bettor has no time limit at all. With two or more bettors
+ // the absolute turn ceiling bounds a hung client; a disconnected
+ // active player is always timed out so the table can never stall.
+ if t.bettorCount() > 1 {
+ t.ceiling.tick()
+ if t.ceiling.expired() {
+ active.hands[t.activeHand].stood = true
+ 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()...)
+ }
}
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()
@@ -278,6 +284,10 @@ func (t *blackjackTable) tick() []Event {
func (t *blackjackTable) action(name, action string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
+ if action == "unbet" {
+ events, _ := t.unbetLocked(name)
+ return events
+ }
if events, handled := t.controlLocked(name, action); handled {
if t.phase == blackjackBetting && allBet(t.players.participants()) {
return append(events, t.startRound()...)
diff --git a/internal/casino/command_hints_test.go b/internal/casino/command_hints_test.go
index 1de70bd..353d292 100644
--- a/internal/casino/command_hints_test.go
+++ b/internal/casino/command_hints_test.go
@@ -31,7 +31,7 @@ func TestCrapsHintsFollowPlayerState(t *testing.T) {
if !hintBet(commands) || !hintHas(commands, "stop") {
t.Fatal(v, "missing idle actions", commands)
}
- for _, c := range []string{"roll", "autoroll", "unbet <target> / unbet all"} {
+ for _, c := range []string{"roll", "autoroll", "unbet"} {
if hintHas(commands, c) {
t.Fatal(v, "idle", c)
}
@@ -44,7 +44,7 @@ func TestCrapsHintsFollowPlayerState(t *testing.T) {
if v == "simplified" {
// Simplified craps rolls by itself: no manual shooting actions
// while the pass contract is live.
- if hintHas(commands, "roll") || hintHas(commands, "autoroll") || hintHas(commands, "unbet <target> / unbet all") || hintBet(commands) {
+ if hintHas(commands, "roll") || hintHas(commands, "autoroll") || hintHas(commands, "unbet") || hintBet(commands) {
t.Fatal(v, "manual actions", commands)
}
if !hintHas(commands, "stop") {
@@ -52,7 +52,7 @@ func TestCrapsHintsFollowPlayerState(t *testing.T) {
}
continue
}
- if !hintHas(commands, "roll") || !hintHas(commands, "autoroll") || hintHas(commands, "unbet <target> / unbet all") {
+ if !hintHas(commands, "roll") || !hintHas(commands, "autoroll") || hintHas(commands, "unbet") {
t.Fatal(v, "contract actions", commands)
}
if !tb.Config.Craps.allows("buy") && hintBet(commands) {
@@ -80,19 +80,19 @@ func TestCrapsHintsOnlyOfferRemovableWagers(t *testing.T) {
w := &testWallet{chips: 1000}
tb.bet("a", "pass", 10, w)
tb.resolveRoll(2, 2)
- if hintHas(tb.commands("a"), "unbet <target> / unbet all") {
+ if hintHas(tb.commands("a"), "unbet") {
t.Fatal("contract removable")
}
tb.bet("a", "odds pass", 10, w)
- if !hintHas(tb.commands("a"), "unbet <target> / unbet all") {
+ if !hintHas(tb.commands("a"), "unbet") {
t.Fatal("odds not removable")
}
- tb.action("a", "unbet odds pass")
- if hintHas(tb.commands("a"), "unbet <target> / unbet all") {
+ tb.action("a", "unbet")
+ if hintHas(tb.commands("a"), "unbet") {
t.Fatal("removed odds still advertised")
}
tb.bet("a", "place 6", 12, w)
- if !hintHas(tb.commands("a"), "unbet <target> / unbet all") {
+ if !hintHas(tb.commands("a"), "unbet") {
t.Fatal("place removal missing")
}
}
diff --git a/internal/casino/craps.go b/internal/casino/craps.go
index e9b87ec..95d3c21 100644
--- a/internal/casino/craps.go
+++ b/internal/casino/craps.go
@@ -250,7 +250,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event {
}
kind, n, ok := parseCrapsTarget(spot)
if !ok || !t.Config.Craps.allows(kind) || ((kind == "place" || kind == "buy" || kind == "lay") && !t.Config.Craps.number(n)) {
- return reject("Unknown or unavailable craps target. Use table rules.")
+ return reject("Unknown or unavailable craps target. Use table to see the felt or rules for the guide.")
}
if (kind == "pass" || kind == "dontpass") && t.point != 0 {
return reject("Line bets are accepted only on come-out.")
@@ -484,52 +484,10 @@ func (t *crapsTable) action(name, action string) []Event {
}
return append(events, t.roll()...)
}
- if strings.HasPrefix(action, "unbet ") {
- target := strings.TrimPrefix(action, "unbet ")
- if strings.TrimSpace(strings.TrimPrefix(target, "odds ")) == "all" {
- return append(t.unbetAllLocked(p), t.finishCrapsControls()...)
- }
- oddsOnly := strings.HasPrefix(target, "odds ")
- target = strings.TrimPrefix(target, "odds ")
- if kind, n, ok := parseCrapsTarget(target); ok && kind == "hop" {
- target = crapsTarget(&crapsBet{kind: kind, number: n})
- }
- for i, b := range p.bets {
- if crapsTarget(b) != target && !(b.kind == target && (target == "pass" || target == "dontpass")) {
- continue
- }
- if oddsOnly {
- if b.odds.Total == 0 {
- continue
- }
- e := t.private(name, "Odds returned.")
- if err := p.Wallet.PayCredits(b.odds.Total, b.odds.ID); err != nil {
- e.Message = payoutFailedMessage()
- }
- b.odds = Wager{}
- return []Event{e, t.betSummary(p), t.reminder(name)}
- }
- if crapsContract(b.kind) {
- return []Event{t.private(name, "That contract bet cannot be removed.")}
- }
- line, failed := t.pay(p, b, 0, 1, 0, true)
- var lines []settlementLine
- if !failed {
- lines = append(lines, line)
- }
- e := t.settlementEvent(p, lines, failed)
- p.bets = append(p.bets[:i], p.bets[i+1:]...)
- p.HasBet = len(p.bets) > 0
- events := append([]Event{e}, t.finishCrapsControls()...)
- events = append(events, t.syncRoundClock()...)
- if t.players.has(name) {
- events = append(events, t.betSummary(p), t.reminder(name))
- }
- return events
- }
- return []Event{t.private(name, "No matching removable bet.")}
+ if action == "unbet" {
+ return append(t.unbetAllLocked(p), t.finishCrapsControls()...)
}
- return []Event{t.private(name, "Use table rules for craps commands.")}
+ return []Event{t.private(name, "Use table for the felt or rules for the craps guide.")}
}
func (t *crapsTable) rebet(name string) (string, int) {
t.mu.Lock()
diff --git a/internal/casino/craps_clock_test.go b/internal/casino/craps_clock_test.go
index 518d18d..8f606f9 100644
--- a/internal/casino/craps_clock_test.go
+++ b/internal/casino/craps_clock_test.go
@@ -46,7 +46,7 @@ func TestCrapsMultiplayerClockAndLateBets(t *testing.T) {
if len(w.open) != 2 {
t.Fatal("late bet rejected")
}
- tb.action("alice", "unbet field")
+ tb.action("alice", "unbet")
if len(tb.players.get("alice").bets) != 1 {
t.Fatal("late removal rejected")
}
@@ -239,7 +239,7 @@ func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) {
tb.bet("alice", "place 4", 10, w)
tb.bet("alice", "field", 10, w)
before := w.paid
- events := tb.action("alice", "unbet all")
+ events := tb.action("alice", "unbet")
text := crapsEventText(events)
if !strings.Contains(text, "take down {casino_chips}40{/}") || !strings.Contains(text, "contracts stay up") {
t.Fatal(text)
@@ -254,7 +254,7 @@ func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) {
if tb.hasLiveBets() != true || len(w.open) != 2 {
t.Fatal("pass and fire stakes must stay pending", w.open)
}
- tb.action("alice", "unbet all")
+ tb.action("alice", "unbet")
if w.paid != before+40 {
t.Fatal("second unbet all moved money", w.paid)
}
diff --git a/internal/casino/craps_regression_test.go b/internal/casino/craps_regression_test.go
index 322325c..0bf8ba6 100644
--- a/internal/casino/craps_regression_test.go
+++ b/internal/casino/craps_regression_test.go
@@ -27,7 +27,7 @@ func TestCrapsHopIsUnordered(t *testing.T) {
if len(w.open) != 1 || w.chips != 90 {
t.Fatal("duplicate unordered hop accepted")
}
- tb.action("a", "unbet hop 3 2")
+ tb.action("a", "unbet")
if len(w.open) != 0 || w.paid != 10 || w.chips != 90 {
t.Fatal("reverse-order removal failed")
}
diff --git a/internal/casino/craps_rules.go b/internal/casino/craps_rules.go
index 8d2f7d4..6292b4a 100644
--- a/internal/casino/craps_rules.go
+++ b/internal/casino/craps_rules.go
@@ -96,8 +96,7 @@ func (c TableConfig) CrapsRulesText() string {
"Solo: roll anytime. Multiplayer: 10 seconds to bet, then 60 seconds for the shooter.\n"+
"Bets remain open until the roll. Dice rotate on seven-out.\n"+
"autoroll: repeat every 3 seconds on your turn (multiplayer waits 10 seconds).\n"+
- "unbet <target>: remove non-contract bets/odds; pass/come contracts stay.\n"+
- "unbet all: return every optional bet and odds at once.\n"+
+ "unbet: remove every non-contract bet and all free odds; pass/come contracts stay.\n"+
"stop cancels autoroll without leaving; in manual mode stop/sitout wait for bets.\n"+
"Disconnect forfeits live bets.\n"+
"Winning bets return stake and are removed (no automatic pressing).", odds, r.Field12)
diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go
index f49b5d0..603aedb 100644
--- a/internal/casino/craps_test.go
+++ b/internal/casino/craps_test.go
@@ -288,7 +288,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) {
t.Fatal("refund not idempotent")
}
tb.bet("a", "place 6", 12, w)
- tb.action("a", "unbet place 6")
+ tb.action("a", "unbet")
if w.paid != 12 || tb.players.get("a").HasBet {
t.Fatal("optional removal")
}
@@ -396,7 +396,7 @@ func TestCrapsFailureRecoveryAndSnapshots(t *testing.T) {
good := &testWallet{chips: 100}
tb.bet("a", "place 6", 12, good)
tb.action("a", "leave_after")
- tb.action("a", "unbet place 6")
+ tb.action("a", "unbet")
if tb.hasParticipant("a") {
t.Fatal("unbet stranded queued departure")
}
diff --git a/internal/casino/craps_ux.go b/internal/casino/craps_ux.go
index b14354e..8cd5098 100644
--- a/internal/casino/craps_ux.go
+++ b/internal/casino/craps_ux.go
@@ -27,7 +27,7 @@ func (t *crapsTable) commands(name string) []MenuCommand {
if !t.Config.Craps.allows("buy") {
target = "pass"
}
- commands = append(commands, MenuCommand{Command: "bet <amount> on " + target, Desc: "Place a wager; targets: table rules"})
+ commands = append(commands, MenuCommand{Command: "bet <amount> on " + target, Desc: "Place a wager; targets: list"})
}
}
if p.HasBet {
@@ -41,7 +41,7 @@ func (t *crapsTable) commands(name string) []MenuCommand {
}
for _, b := range p.bets {
if !crapsContract(b.kind) || b.odds.Total > 0 {
- commands = append(commands, MenuCommand{Command: "unbet <target> / unbet all", Desc: "Return optional bets or odds"})
+ commands = append(commands, MenuCommand{Command: "unbet", Desc: "Return every optional bet and free odds"})
break
}
}
diff --git a/internal/casino/craps_variants.go b/internal/casino/craps_variants.go
index 4818231..dcaf1cd 100644
--- a/internal/casino/craps_variants.go
+++ b/internal/casino/craps_variants.go
@@ -220,7 +220,7 @@ func (c CrapsConfig) variantRulesText() string {
}
cadence := "Solo roll anytime; multiplayer waits 10 seconds, then dealer rolls after 60 seconds."
autoroll := "Bets stay open until the roll. autoroll repeats every 3 seconds, respecting the betting window."
- betLine := "Bet: bet <amount> on pass; table rules is authoritative for this edition."
+ betLine := "Bet: bet <amount> on pass; the felt is authoritative for this edition."
stopLine := "stop cancels autoroll; manual stop/sitout waits for live bets."
if c.variant() == "simplified" {
betLine = "Bet: bet <amount> — always on pass; bare bet rebets your last wager."
@@ -231,7 +231,7 @@ func (c CrapsConfig) variantRulesText() string {
lines := []string{c.title() + " — house rules", rules, betLine,
fmt.Sprintf("Free odds: %d (0 = 3-4-5x on ordinary points, don't 6x laid). No odds on Simplified, High Point or Die Rich.", c.Odds),
"Each wager obeys table limits and exact payout units. Wins return stake and remove the bet.",
- "Pass/come contracts cannot be removed; optional bets/odds can: unbet <target> / unbet all.",
+ "Pass/come contracts cannot be removed; optional bets/odds can: unbet.",
cadence, stopLine + " Disconnect forfeits; shutdown refunds."}
if autoroll != "" {
lines = append(lines[:7], append([]string{autoroll}, lines[7:]...)...)
diff --git a/internal/casino/hardening_casino_locking_test.go b/internal/casino/hardening_casino_locking_test.go
index 080d941..f48b6c2 100644
--- a/internal/casino/hardening_casino_locking_test.go
+++ b/internal/casino/hardening_casino_locking_test.go
@@ -42,12 +42,12 @@ func assertRebetWaitsForLock(t *testing.T, name string, mu *sync.Mutex, rebet fu
}
}
-// A lone connected player is deliberately never timed out by the action
-// window. The absolute turn ceiling must therefore end that turn, or a
-// hung-but-connected client holds the hand, the seat, and the stake forever
-// (leave refuses mid-round).
+// A lone player at the table is deliberately never timed out: neither the
+// action window nor the turn ceiling advances, so the hand waits for the
+// player's own decision. (Multiplayer turns are still bounded; see
+// TestBlackjackWarnsOnceThenAutoStands.)
-func TestLoneBettorTurnCeilingSettlesTheHand(t *testing.T) {
+func TestLoneBettorHasNoTurnTimer(t *testing.T) {
table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack,
Blackjack: BlackjackConfig{ActionTicks: 2}}, newTestRand())
table.shoe = tableauShoe(
@@ -61,24 +61,19 @@ func TestLoneBettorTurnCeilingSettlesTheHand(t *testing.T) {
t.Fatalf("setup: expected a dealt hand (phase=%s)", table.phase)
}
- // The action timer alone would have expired after two ticks, but the
- // lone bettor gets the full grace period: 10x the action window.
+ // Far past both the action window and the old turn ceiling, the hand must
+ // still be waiting: neither deadline runs for a lone player.
ceiling := table.Rules.ActionTicks * loneTurnGrace
- for i := 0; i < ceiling-1; i++ {
+ for i := 0; i < ceiling+5; i++ {
table.tick()
}
if table.phase != blackjackPlaying {
- t.Fatalf("lone bettor's hand ended before the turn ceiling (phase=%s, ceiling=%d)", table.phase, ceiling)
+ t.Fatalf("lone player's hand was settled by a timer (phase=%s)", table.phase)
}
-
- events := table.tick()
- if table.phase != blackjackWaiting {
- t.Fatalf("turn ceiling did not settle the hand: phase=%s events=%+v", table.phase, events)
- }
- if !hasMessage(events, "idle too long") {
- t.Fatalf("missing turn-ceiling message: %+v", events)
+ if table.ceiling.clock != 0 || table.timer.clock != 0 {
+ t.Fatalf("a lone player's timer advanced: ceiling=%d timer=%d", table.ceiling.clock, table.timer.clock)
}
- if len(wallet.open) != 0 {
- t.Fatalf("stake still committed after the ceiling stood the hand: %+v", wallet.open)
+ if len(wallet.open) != 1 {
+ t.Fatalf("stake resolved without player action: %+v", wallet.open)
}
}
diff --git a/internal/casino/hardening_fixes_batch2_test.go b/internal/casino/hardening_fixes_batch2_test.go
index 1391143..54c7def 100644
--- a/internal/casino/hardening_fixes_batch2_test.go
+++ b/internal/casino/hardening_fixes_batch2_test.go
@@ -70,10 +70,10 @@ func TestSlotProfileRejectsWeightlessTrigger(t *testing.T) {
}
}
-// TestDisconnectingNonBettorDoesNotResetLoneTurnCeiling: a third party's
-// seat event re-prompts the same turn and must not extend a hung lone
-// bettor's absolute deadline.
-func TestDisconnectingNonBettorDoesNotResetLoneTurnCeiling(t *testing.T) {
+// TestNonBettorDoesNotStartLoneBettorTimer: a spectator at the table must
+// not put a lone bettor on the clock. With only one live wager the action
+// timer and turn ceiling both stay stopped, so the hand waits for the player.
+func TestNonBettorDoesNotStartLoneBettorTimer(t *testing.T) {
table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 3,
Blackjack: BlackjackConfig{ActionTicks: 2}}, newTestRand())
table.shoe = tableauShoe(
@@ -96,13 +96,12 @@ func TestDisconnectingNonBettorDoesNotResetLoneTurnCeiling(t *testing.T) {
for i := 0; i < 3; i++ {
table.tick()
}
- before := table.ceiling.clock
- if before == 0 {
- t.Fatal("setup: turn ceiling did not advance")
+ if table.ceiling.clock != 0 || table.timer.clock != 0 {
+ t.Fatalf("a spectator started a timer for a lone bettor: ceiling=%d timer=%d", table.ceiling.clock, table.timer.clock)
}
table.markDisconnected("bob")
- if table.ceiling.clock != before {
- t.Fatalf("a non-bettor's disconnect reset the turn ceiling: %d -> %d", before, table.ceiling.clock)
+ if table.phase != blackjackPlaying || table.ceiling.clock != 0 || table.timer.clock != 0 {
+ t.Fatalf("non-bettor disconnect changed the lone bettor's timers: phase=%s ceiling=%d timer=%d", table.phase, table.ceiling.clock, table.timer.clock)
}
}
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go
index b853829..44893c1 100644
--- a/internal/casino/pai_gow.go
+++ b/internal/casino/pai_gow.go
@@ -3,6 +3,7 @@ package casino
import (
"fmt"
"math/rand"
+ "sort"
"strings"
"sync"
)
@@ -147,19 +148,14 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
// deals, so there is no later chance to add them.
if p.fortune.Total == 0 {
commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet; place before your main bet"})
- } else {
- commands = append(commands, MenuCommand{Command: "unbet fortune", Desc: "Take back your Fortune side bet"})
}
- if faceUp {
- if p.pushace.Total == 0 {
- commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet; place before your main bet"})
- } else {
- commands = append(commands, MenuCommand{Command: "unbet pushace", Desc: "Take back your Push Ace High side bet"})
- }
+ if faceUp && p.pushace.Total == 0 {
+ commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet; place before your main bet"})
}
commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; deals once every bettor is in"})
- } else {
- commands = append(commands, MenuCommand{Command: "unbet main", Desc: "Take back your main wager before the deal"})
+ }
+ if p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0 {
+ commands = append(commands, MenuCommand{Command: "unbet", Desc: "Take back all your wagers before the deal"})
}
case "setting":
if p.main.Total > 0 && !p.ready {
@@ -179,7 +175,7 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
case "settling":
commands = append(commands, MenuCommand{Command: "settle", Desc: "Retry the same pending payment"})
}
- return append(commands, MenuCommand{Command: "table rules", Desc: "Rules and Fortune felt"}, MenuCommand{Command: "rules", Desc: "Learn Pai Gow"}, MenuCommand{Command: "stop", Desc: "Leave after finishing your wager"})
+ return append(commands, MenuCommand{Command: "table", Desc: "View the felt"}, MenuCommand{Command: "rules", Desc: "Learn Pai Gow"}, MenuCommand{Command: "stop", Desc: "Leave after finishing your wager"})
}
func (t *paiGowTable) hints(name string) []MenuCommand {
t.mu.Lock()
@@ -193,7 +189,7 @@ func (t *paiGowTable) wagers(name string) []WagerRow {
if p == nil || t.phase == "setting" || t.phase == "settling" {
return nil
}
- rows := []WagerRow{{Bet: "main", Payout: "See table rules", Win: "Beat both dealer hands"}, {Bet: "fortune", Payout: "See Fortune felt", Win: "Best seven-card bonus; independent of main result"}}
+ rows := []WagerRow{{Bet: "main", Payout: "See the felt", Win: "Beat both dealer hands"}, {Bet: "fortune", Payout: "See Fortune felt", Win: "Best seven-card bonus; independent of main result"}}
if t.Config.PaiGow.Variant == PaiGowFaceUp {
rows = append(rows, WagerRow{Bet: "pushace", Payout: "40/15/5:1", Win: "Dealer ace-high pai gow; both 40:1"})
}
@@ -207,6 +203,25 @@ func pgViews(cards []card) []CardView {
return out
}
+// pgSortHand orders a freshly dealt hand small to large so the numbered
+// positions read naturally: by rank first, then by suit. The Joker sorts last.
+func pgSortHand(cards []card) {
+ sort.SliceStable(cards, func(i, j int) bool {
+ ri, rj := pgSortRank(cards[i]), pgSortRank(cards[j])
+ if ri != rj {
+ return ri < rj
+ }
+ return cards[i].suit < cards[j].suit
+ })
+}
+
+func pgSortRank(c card) int {
+ if c.rank == "JK" {
+ return 15
+ }
+ return pokerRank(c)
+}
+
// pgCardNums maps each card in hand to its 1-based position in all. Dealt
// cards are unique, so the match is unambiguous and preserves hand order.
func pgCardNums(hand, all []card) []int {
@@ -359,7 +374,7 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
return fail("Place side bets before your main wager.")
}
if spot == "main" && p.pushace.Total > amount/2 {
- return fail("Push Ace High is capped at half the main wager; take it back with unbet pushace first.")
+ return fail("Push Ace High is capped at half the main wager; take it back with unbet first.")
}
if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > pgMaxWager {
return fail("Wager must be " + betRangeText(t.Config.MinBet, min(t.Config.MaxBet, pgMaxWager)) + ".")
@@ -372,16 +387,19 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
return fail(walletErrorMessage(err))
}
p.wallet = w
+ // A fresh wager starts a new round in the seat's view. Without this a side
+ // bet placed before the next main wager would leave the previous round's
+ // cards and showdown on screen.
+ p.cards = nil
+ p.split = pgSplit{}
+ p.pending = pgSplit{}
+ p.showdown = nil
+ p.result = ""
+ p.ready = false
+ p.paid = false
if spot == "main" {
p.main = stake
p.last = amount
- p.cards = nil
- p.split = pgSplit{}
- p.pending = pgSplit{}
- p.showdown = nil
- p.result = ""
- p.ready = false
- p.paid = false
} else if spot == "pushace" {
p.pushace = stake
} else {
@@ -467,6 +485,7 @@ func (t *paiGowTable) dealLocked() []Event {
continue
}
p.cards = packets[i]
+ pgSortHand(p.cards)
p.ready = p.disconnected || autoPush
if p.ready {
p.split = pgHouseRules(p.cards, t.a2345High())
@@ -526,7 +545,7 @@ func (t *paiGowTable) action(name, action string) []Event {
return append([]Event{t.private(name, "Autoset is "+state+".")}, t.statusLocked(name, false)...)
}
if strings.HasPrefix(action, "unbet") {
- return t.unbetLocked(p, action)
+ return t.unbetLocked(p)
}
if action == "deal" && (t.phase == "waiting" || t.phase == "betting") && p.main.Total > 0 {
if t.allBetLocked() {
@@ -707,23 +726,35 @@ func (t *paiGowTable) tick() []Event {
if t.allBetLocked() {
return t.dealLocked()
}
+ // A lone player has no time limit; wait for their bet instead.
+ if t.activePlayersLocked() <= 1 {
+ return nil
+ }
t.ticks--
if t.ticks > 0 {
return nil
}
return t.finishBettingLocked()
case "setting":
+ // A lone player has no time limit; wait for them to set.
+ if t.activePlayersLocked() <= 1 {
+ return nil
+ }
t.ticks--
if t.ticks > 0 {
return nil
}
+ var events []Event
for _, p := range t.players {
if p.main.Total > 0 && !p.ready {
p.split = pgHouseRules(p.cards, t.a2345High())
p.ready = true
+ e := t.private(p.name, "Your hand timed out and was set the house way.")
+ e.Color = "casino_warning"
+ events = append(events, e)
}
}
- return t.prepareSettlementLocked()
+ return append(events, t.prepareSettlementLocked()...)
}
return nil
}
@@ -776,17 +807,23 @@ func (t *paiGowTable) afterSeatChangeLocked() []Event {
}
// finishBettingLocked closes the betting window on the timer: side bets on
-// seats that never placed a main wager are returned, and the round deals when
-// at least one main wager is live. With no main wager the table resets.
+// seats that never placed a main wager are returned, every non-bettor is told
+// the window closed (no silent timer), and the round deals when at least one
+// main wager is live. With no main wager the table resets.
func (t *paiGowTable) finishBettingLocked() []Event {
var events []Event
+ deals := t.anyMainLocked()
for _, p := range t.players {
if p.main.Total > 0 {
continue
}
- events = append(events, t.refundSideBetsLocked(p)...)
+ if side := t.refundSideBetsLocked(p); len(side) > 0 {
+ events = append(events, side...)
+ } else if deals {
+ events = append(events, t.private(p.name, "The betting window closed; you did not place a main wager and sit out this round."))
+ }
}
- if !t.anyMainLocked() {
+ if !deals {
t.resetTableLocked()
return events
}
@@ -830,17 +867,23 @@ func (t *paiGowTable) resetTableLocked() {
}
}
-// unbetLocked takes back one or all of a player's wagers before the deal.
-// Refunds go through PayCredits with the original stake handle so the ledger
-// entry resolves in the same save; wagers are only cleared on success.
-func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event {
- target := strings.TrimSpace(strings.TrimPrefix(action, "unbet"))
- if target == "" {
- target = "all"
- }
- if target != "all" && target != "main" && target != "fortune" && target != "pushace" {
- return []Event{t.private(p.name, "Unbet what? Use unbet main, unbet fortune, unbet pushace, or unbet all.")}
+// activePlayersLocked counts the connected players with a live main wager.
+// A lone bettor never runs on a clock, so every timeout path checks this
+// before decrementing. A seated spectator does not start a timer for them.
+func (t *paiGowTable) activePlayersLocked() int {
+ n := 0
+ for _, p := range t.players {
+ if !p.disconnected && p.main.Total > 0 {
+ n++
+ }
}
+ return n
+}
+
+// unbetLocked takes back every wager before the deal. Refunds go through
+// PayCredits with the original stake handles so the ledger entries resolve in
+// the same save; wagers are only cleared on success.
+func (t *paiGowTable) unbetLocked(p *pgPlayer) []Event {
if t.phase != "waiting" && t.phase != "betting" {
return []Event{t.private(p.name, "You can only take back bets before the cards are dealt.")}
}
@@ -852,18 +895,9 @@ func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event {
total += w.Total
ids = append(ids, w.ID)
}
- switch target {
- case "main":
- take(&p.main)
- case "fortune":
- take(&p.fortune)
- case "pushace":
- take(&p.pushace)
- default:
- take(&p.main)
- take(&p.fortune)
- take(&p.pushace)
- }
+ take(&p.main)
+ take(&p.fortune)
+ take(&p.pushace)
if total == 0 {
return []Event{t.private(p.name, "Nothing to take back.")}
}
@@ -872,16 +906,7 @@ func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event {
return []Event{t.private(p.name, payoutFailedMessage())}
}
}
- switch target {
- case "main":
- p.main = Wager{}
- case "fortune":
- p.fortune = Wager{}
- case "pushace":
- p.pushace = Wager{}
- default:
- p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
- }
+ p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
if !t.anyMainLocked() && t.phase == "betting" {
t.phase = "waiting"
t.ticks = 0
diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go
index 3a1f4b5..0539c9f 100644
--- a/internal/casino/pai_gow_rules.go
+++ b/internal/casino/pai_gow_rules.go
@@ -110,7 +110,7 @@ func paiGowRules(c TableConfig) string {
case PaiGowNoPush:
s += " No commission; split results use two extra cards: player must beat dealer, ties lose; Joker is ace."
}
- s += "\nHouse way: preserve the best high-hand category, maximize low within it; split two pair, full houses and trips-pairs when this improves low to a pair. Never foul. house arranges the house way and still needs set; autoset on locks any legal arrangement immediately (autoset off restores the confirmation). Timeout/disconnect uses house way.\nBet main first, optionally bet <amount> fortune before dealing. Fortune uses all seven cards regardless of split; highest listed prize only, profit odds plus stake returned."
+ s += "\nHouse way: preserve the best high-hand category, maximize low within it; split two pair, full houses and trips-pairs when this improves low to a pair. Never foul. house arranges the house way and still needs set; autoset on locks any legal arrangement immediately (autoset off restores the confirmation). A multiplayer timeout or disconnect uses the house way; a lone player is never timed out.\nBet main first, optionally bet <amount> fortune before dealing; unbet takes every undealt wager back. Fortune uses all seven cards regardless of split; highest listed prize only, profit odds plus stake returned."
if r.Envy {
s += "\nEnvy: Fortune stake of at least 5 qualifies for fixed bonuses on OTHER active players' hands, never your own or dealer; multiple bonuses add."
} else {
diff --git a/internal/casino/pai_gow_test.go b/internal/casino/pai_gow_test.go
index 3936704..7be9e8a 100644
--- a/internal/casino/pai_gow_test.go
+++ b/internal/casino/pai_gow_test.go
@@ -675,11 +675,11 @@ func TestPaiGowUnbetAndStopRefundBeforeDeal(t *testing.T) {
tab.join("a")
tab.join("b")
tab.bet("a", "main", 10, wa)
- if events := tab.action("a", "unbet main"); !hasMessage(events, "take back") {
- t.Fatalf("unbet main failed: %+v", events)
+ if events := tab.action("a", "unbet"); !hasMessage(events, "take back") {
+ t.Fatalf("unbet failed: %+v", events)
}
if wa.chips+wa.paid != 100 || len(wa.open) != 0 || tab.phase != "waiting" || tab.player("a").main.Total != 0 {
- t.Fatalf("unbet main left state: chips=%d paid=%d phase=%s", wa.chips, wa.paid, tab.phase)
+ t.Fatalf("unbet left state: chips=%d paid=%d phase=%s", wa.chips, wa.paid, tab.phase)
}
// A side bet placed and then abandoned is returned when the seat leaves.
tab.bet("a", "fortune", 5, wa)
@@ -718,3 +718,123 @@ func TestPaiGowRefundAllReturnsSideBetsWithoutMain(t *testing.T) {
t.Fatalf("side-only refund failed: chips=%d open=%d", w.chips, len(w.open))
}
}
+
+func TestPaiGowDealtCardsSortedLowToHigh(t *testing.T) {
+ tab := pgTestTable(false)
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ tab.bet("a", "", 10, w)
+ p := tab.player("a")
+ if len(p.cards) != 7 {
+ t.Fatalf("no seven-card deal: %v", p.cards)
+ }
+ for i := 1; i < len(p.cards); i++ {
+ if pgSortRank(p.cards[i-1]) > pgSortRank(p.cards[i]) {
+ t.Fatalf("cards not sorted small to large: %v", p.cards)
+ }
+ }
+}
+
+func TestPaiGowNewSideBetClearsLastRound(t *testing.T) {
+ tab := pgTestTable(false)
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ tab.bet("a", "", 10, w) // a lone bettor deals at once
+ tab.action("a", "house")
+ tab.action("a", "set")
+ p := tab.player("a")
+ if p.showdown == nil {
+ t.Fatal("setup: round did not settle")
+ }
+ // A side bet for the next round must not re-show the previous result.
+ tab.bet("a", "fortune", 5, w)
+ if p.showdown != nil || p.result != "" || len(p.cards) != 0 {
+ t.Fatalf("stale round leaked into a new bet: showdown=%v result=%q cards=%d", p.showdown != nil, p.result, len(p.cards))
+ }
+}
+
+func TestPaiGowUnbetRemovesEveryWager(t *testing.T) {
+ tab := pgTestTable(false)
+ wa, _ := &testWallet{chips: 100}, &testWallet{chips: 100}
+ tab.join("a")
+ tab.join("b")
+ tab.bet("a", "fortune", 5, wa)
+ tab.bet("a", "main", 10, wa)
+ // b has not bet, so the round is still in the betting window.
+ if tab.phase != "betting" {
+ t.Fatalf("setup: wanted betting phase, got %s", tab.phase)
+ }
+ if events := tab.action("a", "unbet"); !hasMessage(events, "take back") {
+ t.Fatalf("unbet failed: %+v", events)
+ }
+ p := tab.player("a")
+ if wa.chips+wa.paid != 100 || len(wa.open) != 0 || p.main.Total != 0 || p.fortune.Total != 0 {
+ t.Fatalf("unbet left state: chips=%d paid=%d open=%d main=%d fortune=%d", wa.chips, wa.paid, len(wa.open), p.main.Total, p.fortune.Total)
+ }
+}
+
+func TestPaiGowSoloSettingNeverTimesOut(t *testing.T) {
+ cfg := TableConfig{ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{ActionTicks: 1}}.Normalize()
+ tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(3))).(*paiGowTable)
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ tab.bet("a", "", 10, w)
+ if tab.phase != "setting" {
+ t.Fatalf("setup: wanted setting, got %s", tab.phase)
+ }
+ for i := 0; i < 5; i++ {
+ tab.tick()
+ }
+ if tab.phase != "setting" || tab.player("a").ready {
+ t.Fatalf("a lone player was timed out: phase=%s ready=%v", tab.phase, tab.player("a").ready)
+ }
+}
+
+func TestPaiGowMultiplayerTimeoutWarnsAndSets(t *testing.T) {
+ tab := pgTestTable(false) // ActionTicks 2
+ wa, wb := &testWallet{chips: 100}, &testWallet{chips: 100}
+ tab.join("a")
+ tab.join("b")
+ tab.bet("a", "main", 10, wa)
+ tab.bet("b", "main", 10, wb) // the second main wager deals
+ if tab.phase != "setting" {
+ t.Fatalf("setup: wanted setting, got %s", tab.phase)
+ }
+ tab.action("a", "house")
+ tab.action("a", "set")
+ warned := false
+ for i := 0; i < 3; i++ {
+ for _, e := range tab.tick() {
+ if e.Color == "casino_warning" && strings.Contains(e.Message, "timed out") {
+ warned = true
+ }
+ }
+ }
+ if !warned {
+ t.Fatal("multiplayer timeout did not announce the auto-set")
+ }
+ if tab.phase != "waiting" || tab.player("b").showdown == nil {
+ t.Fatal("timed-out hand was not set and settled")
+ }
+}
+
+func TestPaiGowBettingWindowTimeoutNotifiesNonBettor(t *testing.T) {
+ tab := pgTestTable(false) // BettingTicks 2
+ wa, wb := &testWallet{chips: 100}, &testWallet{chips: 100}
+ tab.join("a")
+ tab.join("b")
+ tab.join("c")
+ tab.bet("a", "main", 10, wa)
+ tab.bet("b", "main", 10, wb)
+ notified := false
+ for i := 0; i < 3; i++ {
+ for _, e := range tab.tick() {
+ if e.PrivateTo == "c" && strings.Contains(e.Message, "betting window closed") {
+ notified = true
+ }
+ }
+ }
+ if !notified {
+ t.Fatal("non-bettor was not told the betting window closed")
+ }
+}
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index 761af47..163ed20 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -482,6 +482,19 @@ func newRouletteTable(room int, cfg TableConfig, rng *rand.Rand) *rouletteTable
cfg = cfg.Normalize()
return &rouletteTable{tableCore: newTableCore[*roulettePlayer](&tableBase{RoomID: room, Config: cfg}), rng: rng}
}
+
+// activePlayersLocked counts connected, non-sitting-out seats. A lone player
+// never has a betting deadline; the wheel spins as soon as they wager.
+func (t *rouletteTable) activePlayersLocked() int {
+ n := 0
+ for _, p := range t.players.ordered() {
+ if p.Connected && !p.SittingOut {
+ n++
+ }
+ }
+ return n
+}
+
func (t *rouletteTable) view() *RouletteSnapshot {
s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Racetrack: t.Config.Roulette.Racetrack, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero), History: append([]string(nil), t.history...)}
for _, p := range t.players.ordered() {
@@ -569,15 +582,24 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event {
}
p.HasBet, p.Wager, p.Wallet, p.lastSpot, p.lastBet, p.SittingOut = true, wager, w, spot, amount, false
events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d on %s.", name, amount, spot), fmt.Sprintf("You bet %d on %s.", amount, spot))}
- // Always offer a real betting phase, even with only one player. The
- // board is not attached here: it is sent once per round by the menu event
- // (join, settlement, or an explicit "table"), so placing a wager never
- // redraws it.
+ // With two or more players a timed betting window lets the table bet
+ // together. A lone player has no time limit: once their wager lands,
+ // the wheel spins. The board is not attached here: it is sent once per
+ // round by the menu event (join, settlement, or an explicit "table"), so
+ // placing a wager never redraws it.
if t.phase == phaseWaiting {
- t.phase = phaseBetting
- t.window.open(t.Config.BettingTicks)
- e := t.event(fmt.Sprintf("Roulette betting is open for %d ticks.", t.Config.BettingTicks), true)
- events = append(events, e)
+ if t.activePlayersLocked() > 1 {
+ t.phase = phaseBetting
+ t.window.open(t.Config.BettingTicks)
+ e := t.event(fmt.Sprintf("Roulette betting is open for %d ticks.", t.Config.BettingTicks), true)
+ events = append(events, e)
+ } else {
+ t.phase = phasePlaying
+ t.spinRemaining = t.Config.Roulette.SpinTicks
+ e := t.event("No more bets! The roulette wheel spins...", true)
+ e.Snapshot = t.view()
+ events = append(events, e)
+ }
}
return events
}
@@ -692,6 +714,13 @@ func (t *rouletteTable) resetRound() {
func (t *rouletteTable) action(name, action string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
+ if action == "unbet" {
+ events, _ := t.unbetLocked(name)
+ if !anyBet(t.players.participants()) {
+ t.resetRound()
+ }
+ return events
+ }
if events, ok := t.controlLocked(name, action); ok {
return events
}
diff --git a/internal/casino/roulette_history_test.go b/internal/casino/roulette_history_test.go
index fbd2f65..8ac84a7 100644
--- a/internal/casino/roulette_history_test.go
+++ b/internal/casino/roulette_history_test.go
@@ -22,8 +22,9 @@ func TestRouletteHistoryTracksCompletedSpinsOnly(t *testing.T) {
if !reflect.DeepEqual(before.History, expected) {
t.Fatalf("history changed on bet: %v", before.History)
}
- // Two betting ticks, then two spinning ticks. No sample until the last.
- for j := 0; j < 3; j++ {
+ // A lone player's spin starts at once; the sample only appears on
+ // the settling tick, never before.
+ for j := 0; j < table.Config.Roulette.SpinTicks-1; j++ {
table.tick()
if !reflect.DeepEqual(table.view().History, expected) {
t.Fatal("history revealed before settlement")
@@ -67,9 +68,10 @@ func TestRouletteHistoryTracksCompletedSpinsOnly(t *testing.T) {
func TestRouletteCancelledSpinDoesNotEnterHistory(t *testing.T) {
table := rouletteFixture()
table.join("a")
+ table.join("idle")
table.bet("a", "red", 1, &testWallet{chips: 100})
- table.tick()
- table.tick()
+ // With a second seat the wager sits in the timed betting window, so the
+ // spin can still be cancelled by a disconnect.
table.markDisconnected("a")
table.tick()
table.tick()
diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go
index 9a62236..97bff2a 100644
--- a/internal/casino/roulette_test.go
+++ b/internal/casino/roulette_test.go
@@ -277,6 +277,7 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) {
wheel := rouletteWheel(double)
index := rand.New(rand.NewSource(7)).Intn(len(wheel))
table.join("a")
+ table.join("idle")
w := &testWallet{chips: 100}
table.bet("a", wheel[index], 10, w)
table.bet("a", "red", 10, w)
@@ -325,6 +326,7 @@ func TestRouletteTimedPhasesAndSettlement(t *testing.T) {
func TestRouletteBetDoesNotRedrawBoard(t *testing.T) {
table := rouletteFixture()
table.join("a")
+ table.join("idle")
events := table.bet("a", "red", 10, &testWallet{chips: 100})
for _, e := range events {
if e.Snapshot != nil {
@@ -344,6 +346,7 @@ func TestRouletteRefundAndPersistenceFailure(t *testing.T) {
for _, spinning := range []bool{false, true} {
table := rouletteFixture()
table.join("a")
+ table.join("idle")
w := &testWallet{chips: 100}
table.bet("a", "red", 10, w)
if spinning {
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index e255825..4251e4f 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -115,10 +115,46 @@ func (c *tableCore[P]) committedMenu(name string) Event {
{Command: "sitout", Desc: "Sit out after this round"},
{Command: "stop", Desc: "Leave after this round"},
}
+ // Until the round is dealt or spun, the committed wager can still be
+ // taken back with the same universal unbet command every game uses.
+ if c.phase == phaseBetting {
+ commands = append([]MenuCommand{{Command: "unbet", Desc: "Take back all your wagers before the round"}}, commands...)
+ }
e.Menu = &MenuView{Heading: e.Message, Commands: withTableHints(commands, nil)}
return e
}
+// unbetLocked refunds a seated player's pre-round wager in the shared betting
+// phase. Every table game routes the single unbet command through it; a stake
+// already in play (cards dealt, wheel spinning) is refused. Games that keep
+// their own stake metadata clear it after this returns.
+func (c *tableCore[P]) unbetLocked(name string) ([]Event, bool) {
+ for _, p := range c.players.participants() {
+ if p.Name != name {
+ continue
+ }
+ if c.phase != phaseWaiting && c.phase != phaseBetting {
+ return []Event{c.private(name, "You can only take back bets before the round starts.")}, true
+ }
+ if !p.HasBet || p.Wager.Total == 0 {
+ return []Event{c.private(name, "Nothing to take back.")}, true
+ }
+ total := p.Wager.Total
+ var ids []string
+ if p.Wager.ID != "" {
+ ids = append(ids, p.Wager.ID)
+ }
+ if p.Wallet != nil {
+ if err := p.Wallet.PayCredits(total, ids...); err != nil {
+ return []Event{c.private(name, payoutFailedMessage())}, true
+ }
+ }
+ p.HasBet, p.Wager = false, Wager{}
+ return []Event{c.private(name, fmt.Sprintf("You take back %d credits.", total))}, true
+ }
+ return []Event{c.private(name, "You aren't seated at this table.")}, true
+}
+
// 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" {
diff --git a/internal/casino/table_ux_test.go b/internal/casino/table_ux_test.go
index cb13300..ea8bc04 100644
--- a/internal/casino/table_ux_test.go
+++ b/internal/casino/table_ux_test.go
@@ -57,14 +57,24 @@ func TestBlackjackWarnsOnceThenAutoStands(t *testing.T) {
}
}
}
- if warnings != 1 || !timeout {
- t.Fatalf("multi=%v warnings=%d timeout=%v", multi, warnings, timeout)
+ if multi {
+ if warnings != 1 || !timeout {
+ t.Fatalf("multi warnings=%d timeout=%v", warnings, timeout)
+ }
+ continue
+ }
+ // A lone player has no timer: no warning, no forced stand.
+ if warnings != 0 || timeout || table.phase != blackjackPlaying {
+ t.Fatalf("solo warnings=%d timeout=%v phase=%s", warnings, timeout, table.phase)
}
}
}
func TestInsuranceRequiresExplicitDecisionAndWarns(t *testing.T) {
table := uxBlackjack()
+ // A second seated player keeps the multi-player insurance timer running;
+ // they have already declined so only alice is still deciding.
+ table.players.add("bob", &blackjackPlayer{Participant: Participant{Name: "bob", Connected: true, HasBet: true, Wager: Wager{Total: 10}}, declinedInsurance: true})
table.phase = blackjackInsurance
table.timer.start(8)
before := len(table.players.get("alice").hands[0].cards)
diff --git a/internal/casino/unbet_test.go b/internal/casino/unbet_test.go
new file mode 100644
index 0000000..d132b58
--- /dev/null
+++ b/internal/casino/unbet_test.go
@@ -0,0 +1,59 @@
+package casino
+
+import "testing"
+
+// The single unbet command must take back every pre-round wager at every
+// table game; games no longer accept per-target "unbet <spot>" spellings.
+func TestUnbetRefundsPreRoundWagersEveryGame(t *testing.T) {
+ t.Run("baccarat", func(t *testing.T) {
+ tab := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat, BettingTicks: 10})
+ tab.join("a")
+ tab.join("b")
+ w := &testWallet{chips: 100}
+ tab.bet("a", "banker", 10, w)
+ if events := tab.action("a", "unbet"); !hasMessage(events, "take back") {
+ t.Fatalf("unbet: %+v", events)
+ }
+ if w.chips+w.paid != 100 || len(w.open) != 0 {
+ t.Fatalf("wallet not refunded: chips=%d paid=%d open=%d", w.chips, w.paid, len(w.open))
+ }
+ })
+ t.Run("blackjack", func(t *testing.T) {
+ tab := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack, BettingTicks: 10,
+ Blackjack: BlackjackConfig{Decks: 8, ActionTicks: 10}}, newTestRand())
+ tab.join("a")
+ tab.join("b")
+ w := &testWallet{chips: 100}
+ tab.bet("a", "", 10, w)
+ if events := tab.action("a", "unbet"); !hasMessage(events, "take back") {
+ t.Fatalf("unbet: %+v", events)
+ }
+ if w.chips+w.paid != 100 || len(w.open) != 0 {
+ t.Fatalf("wallet not refunded: chips=%d paid=%d open=%d", w.chips, w.paid, len(w.open))
+ }
+ })
+ t.Run("roulette", func(t *testing.T) {
+ tab := rouletteFixture()
+ tab.join("a")
+ tab.join("idle")
+ w := &testWallet{chips: 100}
+ tab.bet("a", "red", 10, w)
+ if events := tab.action("a", "unbet"); !hasMessage(events, "take back") {
+ t.Fatalf("unbet: %+v", events)
+ }
+ if w.chips+w.paid != 100 || len(w.open) != 0 {
+ t.Fatalf("wallet not refunded: chips=%d paid=%d open=%d", w.chips, w.paid, len(w.open))
+ }
+ if tab.phase != phaseWaiting {
+ t.Fatalf("empty roulette table did not reset: %s", tab.phase)
+ }
+ })
+}
+
+func TestUnbetWithNothingCommitted(t *testing.T) {
+ tab := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat, BettingTicks: 10})
+ tab.join("a")
+ if events := tab.action("a", "unbet"); !hasMessage(events, "Nothing to take back") {
+ t.Fatalf("unbet with no wager: %+v", events)
+ }
+}
diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go
index e5012f5..cc4ccfa 100644
--- a/internal/casino/video_poker.go
+++ b/internal/casino/video_poker.go
@@ -358,6 +358,16 @@ func (t *videoPokerTable) action(name, action string) []Event {
return t.statusLocked(name, false)
}
switch cmd {
+ case "unbet":
+ // Coins loaded but not yet dealt are the only removable wager; once
+ // the hand is dealt the stake is in play.
+ if p.loaded == 0 {
+ return []Event{t.private(name, "Nothing to take back.")}
+ }
+ p.loaded = 0
+ p.lines, p.remaining, p.result, p.current, p.next = nil, nil, "", nil, nil
+ p.multiplier, p.dice = 1, [2]int{}
+ return t.statusLocked(name, true)
case "coins":
if len(fields) > 2 {
break
@@ -431,7 +441,7 @@ func (t *videoPokerTable) action(name, action string) []Event {
p.featureOn = fields[1] == "on"
return t.statusLocked(name, false)
}
- return []Event{t.private(name, "Use bet [1-4|max], deal, game <name>, denom 1|10|50, hands <count>, paytable, or feature on|off on a bonus cabinet.")}
+ return []Event{t.private(name, "Use bet [1-4|max], unbet, deal, game <name>, denom 1|10|50, hands <count>, paytable, or feature on|off on a bonus cabinet.")}
}
func (t *videoPokerTable) drawLocked(name string, p *pokerPlayer) []Event {