diff options
48 files changed, 573 insertions, 332 deletions
diff --git a/data/help/baccarat.yaml b/data/help/baccarat.yaml index 951e2f2..f5e4aad 100644 --- a/data/help/baccarat.yaml +++ b/data/help/baccarat.yaml @@ -16,7 +16,7 @@ description: | Super 6 tables: Banker pays 1:1, except winning with 6 pays 1:2. EZ tables: Banker pays 1:1, except winning with a three-card 7 pushes. - Player bets still lose on those Banker wins. Check table rules. + Player bets still lose on those Banker wins. Check the table felt. Optional bets (only where offered; payouts are profit odds): player_pair / banker_pair First two cards have the same rank: 11:1. @@ -26,5 +26,5 @@ description: | Side bets settle independently; a pair can win even on a tie. Higher payouts do not mean better value: rare events win less often. - Typing rules at the table shows this page again; table rules shows - this table's exact payouts and available bets. + Typing rules at the table shows this page again; table shows the felt + and this table's exact payouts and available bets. diff --git a/data/help/blackjack.yaml b/data/help/blackjack.yaml index 1f34b1a..2863d33 100644 --- a/data/help/blackjack.yaml +++ b/data/help/blackjack.yaml @@ -21,7 +21,7 @@ description: | normally an ordinary win. For a 10-credit bet, 3:2 returns 25 total. Win chances depend on cards, rules and your decisions, not fixed odds. - table rules lists the actual blackjack payout, insurance payout, + table shows the blackjack felt with the actual payout, insurance payout, dealer soft-17 rule, splitting restrictions and surrender availability. Typing rules at the table shows this page again; help casino explains wagers and returns. diff --git a/data/help/casino.yaml b/data/help/casino.yaml index 84ea49f..5f4e527 100644 --- a/data/help/casino.yaml +++ b/data/help/casino.yaml @@ -9,11 +9,11 @@ description: | bet <amount> Wager chips first, then credits. bet 10 on <target> Choose a roulette, baccarat or craps bet. bet / rebet Repeat your last table wager. - table Show the table; roulette also shows payout tables. + table Show the table felt; roulette also shows the racetrack, + history and payout tables. rules The help page for the game you're playing — every house edition has its own short page. list Every bet accepted right now, with payouts. - table rules Check this table's exact rules and payouts. stop Leave (after a committed table round). Profit odds exclude your returned stake: a 10-credit win at 2:1 diff --git a/data/help/crapless_craps.yaml b/data/help/crapless_craps.yaml index f05364a..ffb80a0 100644 --- a/data/help/crapless_craps.yaml +++ b/data/help/crapless_craps.yaml @@ -17,7 +17,7 @@ description: | bet 10 on field / hard 6 / seven / eleven / horn / hop 2 3 roll / autoroll Shoot now / roll on your turn automatically table / list / rules Felt / bets and payouts / this page - unbet place 6 Return optional bets or odds before the roll + unbet Return every optional bet and all free odds stop / sitout Leave / sit out once bets settle No don't bets and no Fire/All/Tall/Small bonuses: with no losing diff --git a/data/help/craps.yaml b/data/help/craps.yaml index 2e1c826..214abfc 100644 --- a/data/help/craps.yaml +++ b/data/help/craps.yaml @@ -12,7 +12,7 @@ description: | play craps Join the tail of the shooting queue watch craps Watch without betting - table / table rules Felt, dice, wagers / exact rules and payouts + table Felt, dice, wagers and every accepted bet bet 10 on pass Come-out 7/11 wins; 2/3/12 loses bet 10 on dontpass Opposite side, with 12 a push bet 10 on come A personal point (table point must be ON) @@ -38,9 +38,7 @@ description: | roll Shoot now (solo) or after the 10-second call autoroll Repeat on your turn until your bets finish stop Cancel autoroll and resume manual play - unbet place 6 Return an optional wager before bets close - unbet odds come 6 Return free odds, retaining the contract - unbet all Take down every optional bet at once + unbet Return every optional bet and all free odds stop / sitout In manual mode, leave / sit out once bets resolve New players join last in shooting order. Making a point keeps the @@ -65,7 +63,7 @@ description: | and come odds are OFF on come-out. Lay and don't odds always work. Exact payout units are required. Commission rounds up to a credit. Default odds: 3-4-5x, with 6x laid on don't bets. Field 12 pays 2:1 - or 3:1 according to table rules. Pass/come and bonus contracts cannot + or 3:1 according to the table felt. Pass/come and bonus contracts cannot be removed. Disconnecting forfeits live bets; shutdown refunds them. HOUSE EDITIONS have their own help pages — simplified craps, high diff --git a/data/help/pai_gow.yaml b/data/help/pai_gow.yaml index a99cee2..b252278 100644 --- a/data/help/pai_gow.yaml +++ b/data/help/pai_gow.yaml @@ -7,14 +7,14 @@ description: | Find Pai Gow east of the Video Poker Parlor. Classic is in the main salon, EZ north, No Push south, Face Up beyond EZ. play pai joins; rules teaches, - table rules displays the exact rules and Fortune payout felt. + table displays the Fortune payout felt. Place any side bets FIRST (bet 5 fortune). bet 20 is the main wager and commits the round: a lone bettor is dealt at once, and with several players the cards come out as soon as every active seat has bet (or the - betting window ends). unbet main|fortune|pushace|all takes an undealt - wager back. Each player and the dealer receive seven cards from a fresh - deck with one Joker. Your cards are numbered 1 through 7. + betting window ends). unbet takes every undealt wager back. Each player + and the dealer receive seven cards from a fresh deck with one Joker. Your + cards are sorted small to large and numbered 1 through 7. Type 24 to put cards 2 and 4 in your small (two-card) hand, or 13456 to put those five cards in your big (five-card) hand. Spaces and order do @@ -24,8 +24,9 @@ description: | high cards, a gap, then the two low cards with HIGH/LOW labels; type set to lock it and reveal the dealer. house arranges the house way and still needs set. autoset on locks any legal arrangement at once; autoset off - restores the confirmation, and autoset alone toggles. Timeout or - disconnect uses the house way. + restores the confirmation, and autoset alone toggles. A multiplayer + timeout or a disconnect uses the house way; a lone player is never timed + out. At the showdown the high hands are compared first (your high, then the dealer's), then the low hands. Each hand strength (for example PAIR OF @@ -34,7 +35,7 @@ description: | House way preserves the best high-hand category, maximizing low within that category. Two pair/full houses may be split to put pairs in both. The dealer uses this same deterministic house way. It is not a claim - of optimal strategy. Full details appear in table rules. + of optimal strategy. Full details appear in rules. The Joker acts as an ace, or completes a straight, flush or straight flush. A2345 is the second-highest straight. Low hands have only pairs/high cards. diff --git a/data/help/video_poker.yaml b/data/help/video_poker.yaml index 808edd2..067d95b 100644 --- a/data/help/video_poker.yaml +++ b/data/help/video_poker.yaml @@ -48,6 +48,6 @@ description: | Payouts are total coins including stake, not profit. Multiply by denomination for credits. Natural royals pay 250/500/750/1000 coins for 1-4 base coins, or 4000 at five. Only the highest-paying category wins on each line. - look redisplays your hand; rules opens the cabinet guide; table rules shows + look redisplays your hand; rules opens the cabinet guide; table shows the pay table. stop leaves between hands. Disconnect finishes current holds. If payment fails, draw retries the SAME final hands, never a fresh draw. diff --git a/data/rooms/casino/6195.yaml b/data/rooms/casino/6195.yaml index 12fee29..e743ddf 100644 --- a/data/rooms/casino/6195.yaml +++ b/data/rooms/casino/6195.yaml @@ -25,6 +25,6 @@ objects: description: 'play pai; bet 5 fortune first if you want it, then bet 20 to deal (a lone bettor is dealt at once). Arrange two low cards with 24 or five high cards with 13456, then set; house arranges the house way, autoset locks at once. High must beat low. Classic wins pay 0.95:1. Fortune pays - independently; five credits qualifies for Envy on other active players. table rules + independently; five credits qualifies for Envy on other active players. table shows the payout felt.' inroom_description: A jade fortune plaque explains the table. diff --git a/data/rooms/casino/6196.yaml b/data/rooms/casino/6196.yaml index 3ccb56f..1ebbedf 100644 --- a/data/rooms/casino/6196.yaml +++ b/data/rooms/casino/6196.yaml @@ -23,5 +23,5 @@ objects: description: 'play pai; main wins pay 1:1, but a dealer queen-high pushes. The Joker is an ace or completes a straight or flush. bet 5 fortune first if you want it, then bet 20 to deal. Arrange your low hand with two numbers or high with five, then set; house - arranges the house way, autoset locks at once. table rules shows Fortune payouts and fixed Envy awards.' + arranges the house way, autoset locks at once. table shows Fortune payouts and fixed Envy awards.' inroom_description: A queen-shaped plaque marks the EZ rules. diff --git a/data/rooms/casino/6197.yaml b/data/rooms/casino/6197.yaml index ab899ad..0045a2d 100644 --- a/data/rooms/casino/6197.yaml +++ b/data/rooms/casino/6197.yaml @@ -22,5 +22,5 @@ objects: numbers arrange your low hand; five arrange your high hand; then set to lock. house arranges the house way and autoset locks at once. Main wins pay 1:1. Split results compare two extra cards: player must be higher, ties lose, Joker is ace. Envy is off - here, so Fortune quads improve to 30:1. table rules shows all prizes.' + here, so Fortune quads improve to 30:1. table shows all prizes.' inroom_description: A two-card plaque explains the tiebreaker. diff --git a/data/rooms/casino/6198.yaml b/data/rooms/casino/6198.yaml index 8385004..b499fd1 100644 --- a/data/rooms/casino/6198.yaml +++ b/data/rooms/casino/6198.yaml @@ -23,5 +23,5 @@ objects: The dealer sets and reveals first; dealer ace-high pai gow pushes main bets. Push Ace High pays 5:1 (no Joker), 15:1 (with Joker) or 40:1 (both ace-high), and is capped at half your main wager. Arrange two low numbers or five high, then set; house arranges - the house way and autoset locks at once. table rules shows the felt.' + the house way and autoset locks at once. table shows the felt.' inroom_description: A glass-rail plaque explains the face-up rules. 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 { diff --git a/internal/game/casino_commands_test.go b/internal/game/casino_commands_test.go index 5e05d4e..cd558ea 100644 --- a/internal/game/casino_commands_test.go +++ b/internal/game/casino_commands_test.go @@ -12,9 +12,9 @@ import ( func TestCasinoCommandsWrapBeforeSessionWithoutLosingIndent(t *testing.T) { commands := []casino.MenuCommand{ - {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; table rules lists targets"}, + {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; list gives targets"}, {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"}, - {Command: "unbet <target> / unbet all", Desc: "Remove one bet / every optional bet"}, + {Command: "unbet", Desc: "Remove all your bets"}, {Command: "table", Desc: "View the felt"}, {Command: "insurance", Desc: "Buy insurance; a deliberately long description which requires a hanging continuation line"}, {Command: "decline", Desc: "Continue without insurance (also the timeout default)"}, @@ -58,7 +58,7 @@ func TestCasinoCommandsHangingIndentAndShortDescription(t *testing.T) { sess := &net.Session{Player: player.New("a")} g := &Game{} commands := []casino.MenuCommand{ - {Command: "bet <amount> on <target>", Desc: "Place a wager; targets: table rules"}, + {Command: "bet <amount> on <target>", Desc: "Place a wager; targets: list"}, {Command: "table", Desc: "View the felt"}, } text := plainRoulette(g.casinoMenuCommands(sess, commands)) diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 1d66f43..6d666f7 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -109,14 +109,26 @@ func (g *Game) showCasinoChoices(sess *net.Session, choices []casinoChoice) { sess.WriteLine(g.renderCasinoCommands(sess, "", "Choose a game (use an unambiguous name or abbreviation):", commands)) } +// renderCasinoFelt draws a table's felt (layout, payouts, board) without the +// prose rule text. The table command shows this; rules opens the help page. +// Craps and roulette already render their felt through the live status +// snapshot, so they return nothing here. +func (g *Game) renderCasinoFelt(sess *net.Session, cfg casino.TableConfig) string { + switch cfg.Game { + case casino.GameBlackjack: + return g.renderCasinoSnapshot(sess, &casino.BlackjackRulesView{Config: cfg}) + case casino.GameBaccarat: + return g.renderCasinoSnapshot(sess, &casino.BaccaratRulesView{Config: cfg}) + case casino.GamePaiGow: + return renderPaiGowFelt(cfg, g.colorMode(sess), sess.Player.OptionBool("unicode")) + } + return "" +} + 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 { if len(args) > 0 && args[0] == "help" { @@ -132,41 +144,36 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { } return } - if len(args) > 0 && args[0] == "rules" { - // The felt and the exact, per-table wording stay under table rules; - // bare rules opens the game's own help page. - if t.Config.Game == casino.GameVideoPoker { - g.executeTableAction(sess, "paytable") - } else { - sess.WriteLine(g.renderCasinoRules(sess, t.Config)) - } - return - } + // table help remains a shortcut to the seated game's help page; the old + // "table rules" subcommand is gone, so bare table always shows the felt. if len(args) > 0 && args[0] == "help" { g.doHelp(sess, t.HelpTopic()) - } else { - g.emitCasinoEvents(t.Status(sess.Player.Name)) - if t.Config.Game == casino.GameRoulette { - sess.WriteLine(g.rouletteReminder(sess, t.Config)) - } else { - sess.WriteLine(g.colorize(sess, "command", "Learn to play: rules. Exact payouts: table rules.")) + if t.Config.Game != casino.GameVideoPoker { + commands := []casino.MenuCommand{ + {Command: "rules", Desc: "Help page for this game variant"}, + {Command: "list", Desc: "Every bet available right now, with payouts"}, + {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, + {Command: "help casino", Desc: "Quick guides to all casino games"}, + } + sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands)) } + return } - if len(args) > 0 { - // Reuse the game's current-state menu instead of advertising actions - // (insurance, split, rebet, etc.) that may not be legal right now. - g.emitCasinoEvents(t.Status(sess.Player.Name)) - if t.Config.Game == casino.GameVideoPoker { - return - } - commands := []casino.MenuCommand{ - {Command: "rules", Desc: "Help page for this game variant"}, - {Command: "list", Desc: "Every bet available right now, with payouts"}, - {Command: "table rules", Desc: "This table’s exact rules and payouts"}, - {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, - {Command: "help casino", Desc: "Quick guides to all casino games"}, - } - sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands)) + // The table command always shows the live felt. The rules command opens + // the game's help page; there is no separate "table rules" command. + if t.Config.Game == casino.GameVideoPoker { + // A video poker cabinet's felt is its paytable. + g.executeTableAction(sess, "paytable") + return + } + g.emitCasinoEvents(t.Status(sess.Player.Name)) + if felt := g.renderCasinoFelt(sess, t.Config); felt != "" { + sess.WriteLine(felt) + } + if t.Config.Game == casino.GameRoulette { + sess.WriteLine(g.rouletteReminder(sess, t.Config)) + } else { + sess.WriteLine(g.colorize(sess, "command", "Learn to play: rules.")) } } diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go index 5313484..415df63 100644 --- a/internal/game/casino_ux_test.go +++ b/internal/game/casino_ux_test.go @@ -168,10 +168,10 @@ func TestTableStatusShowsStateAndRules(t *testing.T) { if !strings.Contains(text, "LIMITS") || !strings.Contains(text, "Waiting for bets") { t.Fatalf("status missing overview: %q", text) } - conn.data = nil - g.executeTableStatus(sess, []string{"rules"}, "table rules") - if !strings.Contains(stripANSI(string(conn.data)), "BLACKJACK PAYS") { - t.Fatalf("table rules missing: %q", stripANSI(string(conn.data))) + // The table command carries the blackjack felt; the rules command (not a + // "table rules" subcommand) opens the help page. + if !strings.Contains(text, "BLACKJACK PAYS") { + t.Fatalf("table felt missing: %q", text) } } diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index acac68b..8aad30b 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -56,8 +56,8 @@ var commandRegistry = map[string]commandDef{ }, ClassInstant}, "roll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "roll") }, ClassActive}, "autoroll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "autoroll") }, ClassInstant}, - "unbet": {func(g *Game, s *net.Session, args []string, _ string) { - g.executeTableAction(s, "unbet "+strings.Join(args, " ")) + "unbet": {func(g *Game, s *net.Session, _ []string, _ string) { + g.executeTableAction(s, "unbet") }, ClassActive}, "table": {(*Game).executeTableStatus, ClassInstant}, "status": {(*Game).executeTableStatus, ClassInstant}, diff --git a/internal/game/craps_test.go b/internal/game/craps_test.go index 569b4db..fd2e4b9 100644 --- a/internal/game/craps_test.go +++ b/internal/game/craps_test.go @@ -5,12 +5,14 @@ import ( "testing" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" + "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" ) func TestCrapsCommandsAndRendering(t *testing.T) { - g := &Game{Casino: casino.NewManager(1)} + g := &Game{Casino: casino.NewManager(1), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}} cfg := casino.TableConfig{ID: "craps", Game: casino.GameCraps} g.Casino.Join(1, cfg, "alice") conn := &casinoTestConn{} @@ -24,8 +26,8 @@ func TestCrapsCommandsAndRendering(t *testing.T) { } } conn.data = nil - g.executeTableStatus(sess, []string{"rules"}, "table rules") - if !strings.Contains(string(conn.data), "Bank Craps") || !strings.Contains(string(conn.data), "PASS LINE") { + g.executeTableStatus(sess, nil, "table") + if !strings.Contains(string(conn.data), "PASS LINE") { t.Fatal(string(conn.data)) } for _, cmd := range []string{"roll", "autoroll", "unbet"} { diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go index 6fa2959..1e91ced 100644 --- a/internal/game/look_casino.go +++ b/internal/game/look_casino.go @@ -93,7 +93,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi } // casinoTableSummary is the compact per-table rule description used by the -// look details block. The full wording stays in RulesText. +// look details block. The full wording lives in the rules help page. func casinoTableSummary(cfg casino.TableConfig) string { switch cfg.Game { case casino.GameBlackjack: diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go index 9b37a68..b8a9c22 100644 --- a/internal/game/pai_gow_test.go +++ b/internal/game/pai_gow_test.go @@ -18,7 +18,7 @@ func TestPaiGowCommandsLookAndSettlement(t *testing.T) { t.Fatal("alias failed") } conn.data = nil - g.executeTableStatus(sess, []string{"rules"}, "table rules") + g.executeTableStatus(sess, nil, "table") text := stripANSI(string(conn.data)) for _, want := range []string{"FORTUNE", "ENVY", "750:1", "Three pair"} { if !strings.Contains(text, want) { @@ -147,6 +147,15 @@ func TestPaiGowShowdownOrderNamesAndColors(t *testing.T) { t.Fatalf("showdown missing %q:\n%s", want, clean) } } + // The hand labels are left-justified normal text, never centered over the + // card art. + for _, line := range strings.Split(clean, "\n") { + if strings.Contains(line, "Your high") || strings.Contains(line, "Dealer high") { + if strings.HasPrefix(line, " ") { + t.Fatalf("showdown label is indented, not left justified: %q", line) + } + } + } // Winning hand strengths are bold yellow; losing ones are bold white. if !strings.Contains(raw, "38;5;220m") { t.Fatalf("winning strength not yellow:\n%q", raw) @@ -296,7 +305,7 @@ func TestPaiGowContextHintsFollowState(t *testing.T) { conn.data = nil g.executeTableStatus(sess, nil, "table") text := stripANSI(string(conn.data)) - for _, want := range []string{"bet <amount> fortune", "bet <amount>", "table rules", "rules", "stop"} { + for _, want := range []string{"bet <amount> fortune", "bet <amount>", "table", "rules", "stop"} { if !strings.Contains(text, want) { t.Fatalf("missing %q at a fresh table: %s", want, text) } diff --git a/internal/game/render_casino_help.go b/internal/game/render_casino_help.go index 26927ae..248a6dd 100644 --- a/internal/game/render_casino_help.go +++ b/internal/game/render_casino_help.go @@ -40,7 +40,7 @@ func (g *Game) casinoBetTable(sess *net.Session, title string, columns []string, return strings.Join(t.Render(unicode, width), "\n") } -// rouletteReminder is shown by table/table rules, not on ambient boards. +// rouletteReminder is shown by the table command, not on ambient boards. // Hit chances count equally likely pockets; all ratios are NET profit odds. func (g *Game) rouletteReminder(sess *net.Session, cfg casino.TableConfig) string { pockets, basketCount, basketOdds := 37, 4, "8:1" @@ -83,6 +83,6 @@ func (g *Game) rouletteReminder(sess *net.Session, cfg casino.TableConfig) strin "Profit excludes your returned stake: 1:1 on 10 returns 20.\n"+ "Low: 1-18; high: 19-36. Zero pockets lose outside bets.\n"+ "Use bet <amount> on <target>, e.g. bet 10 on split 1 2.\n"+ - "Learn more: help roulette / help casino; table rules for this table.") + "Learn more: help roulette / help casino; table for the felt.") return strings.Join(blocks, "\n") } diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go index 12714dc..80f05ee 100644 --- a/internal/game/render_craps.go +++ b/internal/game/render_craps.go @@ -359,7 +359,7 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty } c := newCrapsCanvas(height) c.box(0, 0, 76, height-1) - c.center(1, 75, 1, "HOUSE EDITION — table rules for payouts", crapsGold) + c.center(1, 75, 1, "HOUSE EDITION — rules teaches this edition", crapsGold) switch variant { case "crapless": c.center(1, 75, 3, "POINTS: 2 3 4 5 6 8 9 10 11 12 | PASS / COME / ODDS", crapsInk) diff --git a/internal/game/render_help.go b/internal/game/render_help.go index 36f73f7..6887e3e 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -64,7 +64,7 @@ var commandList = []cmdEntry{ {"look / l", "Instant", "Look around or examine things"}, {"map", "Instant", "Display an ASCII map of the area"}, {"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"}, + {"table / status", "Instant", "Redisplay the table felt; rules for the help page"}, {"rules", "Instant", "Help page for the casino game you're playing (one per house edition)"}, {"watch", "Instant", "Watch detailed table action; watch off to stop"}, {"sitout", "Instant", "Sit out future table rounds; bet to rejoin"}, diff --git a/internal/game/render_pai_gow.go b/internal/game/render_pai_gow.go index 4b31373..b125a68 100644 --- a/internal/game/render_pai_gow.go +++ b/internal/game/render_pai_gow.go @@ -72,8 +72,9 @@ func paiGowNumberLabels(nums []int, mode string) []string { return labels } -// paiGowCardBlock renders one labeled run of cards: an optional centered -// header over the card art and, when nums is set, position numbers below. +// paiGowCardBlock renders one labeled run of cards: an optional header over +// the card art and, when nums is set, position numbers below. The header is +// plain left-justified text, not centered, so the hand labels read normally. func paiGowCardBlock(header string, cards []casino.CardView, nums []int, mode string, unicode bool, size int, style string, store *cardart.Store) []string { if len(cards) == 0 { return nil @@ -86,11 +87,7 @@ func paiGowCardBlock(header string, cards []casino.CardView, nums []int, mode st if header == "" { return lines } - width := 0 - for _, line := range lines { - width = max(width, color.VisibleLen(line)) - } - return append([]string{rouletteCenter(header, width)}, lines...) + return append([]string{header}, lines...) } // paiGowJoinBlocks sets two card blocks side by side with a gutter between diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index 9ffd355..218a91f 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -9,7 +9,6 @@ import ( "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" - "thehouseoficarus/internal/net" ) // Roulette art is rendered per recipient, just like cards. Never embed ANSI @@ -595,48 +594,3 @@ func renderRouletteResult(pockets [5]string, mode string, unicode bool, size int } return strings.Join(lines, "\n") } - -func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string { - if cfg.Game == casino.GameBlackjack { - // The whole rule card is a rendered felt. - return g.renderCasinoSnapshot(sess, &casino.BlackjackRulesView{Config: cfg}) - } - if cfg.Game == casino.GamePaiGow { - return cfg.RulesText() + "\n" + renderPaiGowFelt(cfg, g.colorMode(sess), sess.Player.OptionBool("unicode")) - } - if cfg.Game == casino.GameBaccarat { - return g.renderCasinoSnapshot(sess, &casino.BaccaratRulesView{Config: cfg}) - } - rules := g.colorize(sess, "casino_action", cfg.RulesText()) - if cfg.Game == casino.GameCraps { - snap := &casino.CrapsSnapshot{Phase: "waiting"} - if g.Casino != nil && sess.Player != nil { - if table := g.Casino.Table(sess.Player.RoomID, cfg.ID); table != nil { - for _, event := range table.Status(sess.Player.Name) { - if view, ok := event.Snapshot.(*casino.CrapsSnapshot); ok { - snap = view - break - } - } - } - } - return rules + "\n" + g.renderCasinoSnapshot(sess, snap) - } - if cfg.Game == casino.GameRoulette { - snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)} - // Rules views draw a betting board too; use the live table's history - // rather than presenting an empty tracker after completed spins. - if g.Casino != nil && sess.Player != nil { - if table := g.Casino.Table(sess.Player.RoomID, cfg.ID); table != nil { - for _, event := range table.Status(sess.Player.Name) { - if view, ok := event.Snapshot.(*casino.RouletteSnapshot); ok { - snap.History = view.History - break - } - } - } - } - rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess, cfg) - } - return rules -} diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index fcfd9c7..72da225 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -406,7 +406,7 @@ func TestRouletteSettlementShowsBalanceOnce(t *testing.T) { if action != "" { g.Casino.Action(1, "r", p.Name, action) } - g.Casino.Tick() + // A lone player's spin starts on the bet, so the first tick settles. g.emitCasinoEvents(g.Casino.Tick()) if got := strings.Count(string(conn.data), "Available "); got != 1 { t.Fatalf("action=%q: balance printed %d times\n%s", action, got, conn.data) diff --git a/internal/game/render_roulette_tracker_test.go b/internal/game/render_roulette_tracker_test.go index a629b57..b05a2be 100644 --- a/internal/game/render_roulette_tracker_test.go +++ b/internal/game/render_roulette_tracker_test.go @@ -105,7 +105,7 @@ func TestRouletteTrackerAboveBettingBoardsOnly(t *testing.T) { } } -func TestRouletteRulesUseLiveHistory(t *testing.T) { +func TestRouletteTableShowsLiveHistory(t *testing.T) { g := &Game{Casino: casino.NewManager(7)} p := player.New("alice") p.RoomID, p.Credits = 1, 100 @@ -116,8 +116,20 @@ func TestRouletteRulesUseLiveHistory(t *testing.T) { g.Casino.Bet(1, "r", p.Name, "red", 1, casinoWallet{g: g, p: p, saveCharacter: func(*player.Player) error { return nil }}) g.Casino.Tick() g.Casino.Tick() - art := g.renderCasinoRules(sess, cfg) + // The table command renders the live status snapshot, which carries the + // tracker's completed-spin history. + var snap *casino.RouletteSnapshot + for _, event := range g.Casino.Table(1, "r").Status(p.Name) { + if view, ok := event.Snapshot.(*casino.RouletteSnapshot); ok && view.Phase != "result" { + snap = view + break + } + } + if snap == nil { + t.Fatal("no live roulette snapshot") + } + art := g.renderCasinoSnapshot(sess, snap) if !strings.Contains(art, "History: last 1 spin") || strings.Contains(art, "awaiting the first spin") { - t.Fatalf("rules did not show live history:\n%s", art) + t.Fatalf("table did not show live history:\n%s", art) } } diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go index 0c5b62e..9eaea8e 100644 --- a/internal/game/roulette_test.go +++ b/internal/game/roulette_test.go @@ -5,13 +5,15 @@ import ( "testing" "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" ) func TestRouletteNumericTargetsAndRendering(t *testing.T) { for _, double := range []bool{false, true} { - g := &Game{Casino: casino.NewManager(1)} + g := &Game{Casino: casino.NewManager(1), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}} cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{DoubleZero: double}} g.Casino.Join(1, cfg, "alice") conn := &casinoTestConn{} @@ -29,9 +31,9 @@ func TestRouletteNumericTargetsAndRendering(t *testing.T) { } } conn.data = nil - g.executeTableStatus(sess, []string{"rules"}, "table rules") - if !strings.Contains(string(conn.data), "Straight numbers pay 35:1") || strings.Contains(string(conn.data), "Blackjack") { - t.Fatal("wrong rules", string(conn.data)) + g.executeTableStatus(sess, nil, "table") + if !strings.Contains(string(conn.data), "35:1") || strings.Contains(string(conn.data), "Blackjack") { + t.Fatal("wrong felt", string(conn.data)) } snapshot := &casino.RouletteSnapshot{DoubleZero: double, Phase: "playing", Players: []string{"alice"}} if got := g.renderCasinoSnapshot(sess, snapshot); got != renderRouletteSnapshot(snapshot, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size")) { |
