aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-01 16:26:11 -0400
committerhistoria <[not public]>2026-10-01 16:26:11 -0400
commitd762f8e1360cf38b8e11e69083c2dad59ec7130e (patch)
tree4e684608e02b39b6b085e2a2ab9726bdae063240
parentc94728c929045f7c1bdf0166df152d944e5f99bc (diff)
downloadthehouseoficarus-d762f8e1360cf38b8e11e69083c2dad59ec7130e.tar.gz
fix: trim extra info from games, A2345 support in pai gow, remove bet timers for single player games
-rw-r--r--building_guide/pai_gow.md22
-rw-r--r--data/help/pai_gow.yaml15
-rw-r--r--data/rooms/casino/6195.yaml9
-rw-r--r--data/rooms/casino/6196.yaml4
-rw-r--r--data/rooms/casino/6197.yaml9
-rw-r--r--data/rooms/casino/6198.yaml9
-rw-r--r--dev_guide/pai_gow.md29
-rw-r--r--internal/casino/baccarat_test.go4
-rw-r--r--internal/casino/pai_gow.go299
-rw-r--r--internal/casino/pai_gow_rules.go96
-rw-r--r--internal/casino/pai_gow_test.go163
-rw-r--r--internal/casino/roulette.go7
-rw-r--r--internal/casino/table.go11
-rw-r--r--internal/casino/table_ux.go8
-rw-r--r--internal/game/casino.go47
-rw-r--r--internal/game/casino_look_test.go2
-rw-r--r--internal/game/casino_ux_test.go4
-rw-r--r--internal/game/pai_gow_test.go86
-rw-r--r--internal/game/render_pai_gow.go17
19 files changed, 653 insertions, 188 deletions
diff --git a/building_guide/pai_gow.md b/building_guide/pai_gow.md
index ebc1da9..bf3a3a3 100644
--- a/building_guide/pai_gow.md
+++ b/building_guide/pai_gow.md
@@ -12,17 +12,27 @@ Configure a `casino_tables` entry:
pai_gow:
variant: classic # classic, ez, no_push, face_up
envy: true
+ a2345_second_highest: true # default; false makes A2345 the lowest straight
action_ticks: 60
```
There are six player seats and one dealer. Each round deals seven packets
from a fresh 53-card deck, including unused seats. No player banking.
-`bet 20` places the main wager; `bet 5 fortune` adds the optional side bet.
-`deal` marks a bettor ready; when all bettors are ready, or the betting
-window expires, the cards are dealt. Place side bets before marking ready.
-A Fortune wager requires a main wager. Both use the table's limits; each
-is also bounded to 500,000,000 credits to keep combined payouts below the
-economy's credit cap even on unlimited tables.
+Side bets come first: `bet 5 fortune` (and, on a Face Up table,
+`bet <amount> pushace`) is placed before the main wager. `bet 20` is the
+main wager that commits the round; when every active player has one, the
+cards are dealt immediately. A lone bettor is dealt as soon as the main
+wager lands, with no timed window. Otherwise the betting window opens and
+expires on `betting_ticks`; anyone who has not bet sits out. `unbet main`,
+`unbet fortune`, `unbet pushace` or `unbet all` returns an undealt wager,
+and `stop` before the deal returns every wager and frees the seat. Both
+wagers use the table's limits; each is also bounded to 500,000,000 credits
+to keep combined payouts below the economy's credit cap even on unlimited
+tables.
+
+`a2345_second_highest` defaults to true and controls whether A-2-3-4-5 is
+the second-highest straight (below TJQKA) or the lowest. When enabled the
+rule is printed on the felt.
## Player hand setting
diff --git a/data/help/pai_gow.yaml b/data/help/pai_gow.yaml
index a0e21e5..113fd7a 100644
--- a/data/help/pai_gow.yaml
+++ b/data/help/pai_gow.yaml
@@ -9,10 +9,12 @@ description: |
EZ north, No Push south, Face Up beyond EZ. play pai joins; rules teaches,
table rules displays the exact rules and Fortune payout felt.
- bet 20 places your main wager. Optionally bet 5 fortune BEFORE deal.
- deal marks you ready; the round starts when all bettors are ready or the
- betting window ends. Each player and the dealer receive seven cards from
- a fresh deck with one Joker. Your cards are numbered 1 through 7.
+ 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.
Type 24 to put cards 2 and 4 in your small (two-card) hand.
Type 13456 to put those five cards in your big (five-card) hand instead.
@@ -41,7 +43,7 @@ description: |
aces does not count) automatically pushes every main bet; the optional
Push Ace High side bet pays that push: without Joker 5:1, with Joker
15:1, or 40:1 when BOTH sides are ace-high. bet <amount> pushace,
- up to half your main wager, before deal.
+ placed before your main wager and capped at half of it.
Fortune uses ALL seven cards, independent of your split and main result.
Only the highest listed prize pays, plus your Fortune stake back.
@@ -55,6 +57,7 @@ description: |
bet Fortune. A losing Fortune stake stays lost even when Envy pays.
Without Envy, quads improve from 25:1 to 30:1 to compensate modestly.
- stop leaves between rounds. Committed wagers must finish first.
+ stop before the deal returns your wagers and leaves; once the cards are
+ out you must finish the round.
A failed bank payment stays pending: settle retries the SAME result,
without dealing again. No new round starts until payments finish.
diff --git a/data/rooms/casino/6195.yaml b/data/rooms/casino/6195.yaml
index 7084a13..926ed7c 100644
--- a/data/rooms/casino/6195.yaml
+++ b/data/rooms/casino/6195.yaml
@@ -22,8 +22,9 @@ casino_tables:
action_ticks: 60
objects:
- name: fortune plaque
- description: 'play pai; bet 20, optionally bet 5 fortune, then deal. Set two low cards
- with 24 or five high cards with 13456; house sets automatically. High must beat low.
- Classic wins pay 0.95:1. Fortune pays independently; five credits qualifies for Envy
- on other active players. table rules shows the payout felt.'
+ description: 'play pai; bet 5 fortune first if you want it, then bet 20 to deal (a
+ lone bettor is dealt at once). Set two low cards with 24 or five high cards with 13456;
+ house sets automatically. High must beat low. Classic wins pay 0.95:1. Fortune pays
+ independently; five credits qualifies for Envy on other active players. table rules
+ 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 3f3266c..b9cddf3 100644
--- a/data/rooms/casino/6196.yaml
+++ b/data/rooms/casino/6196.yaml
@@ -21,7 +21,7 @@ casino_tables:
objects:
- name: ez plaque
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 20, optionally bet 5 fortune, then deal.
- Set your low hand with two numbers or high with five; house sets automatically.
+ an ace or completes a straight or flush. bet 5 fortune first if you want it, then bet 20
+ to deal. Set your low hand with two numbers or high with five; house sets automatically.
table rules 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 2290e6a..5f1f21a 100644
--- a/data/rooms/casino/6197.yaml
+++ b/data/rooms/casino/6197.yaml
@@ -18,8 +18,9 @@ casino_tables:
action_ticks: 60
objects:
- name: no push plaque
- description: 'play pai; bet 20, optionally bet 5 fortune, then deal. Two numbers set
- your low hand; five set your high hand; house sets automatically. 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.'
+ description: 'play pai; bet 5 fortune first if you want it, then bet 20 to deal. Two
+ numbers set your low hand; five set your high hand; house sets automatically. 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.'
inroom_description: A two-card plaque explains the tiebreaker.
diff --git a/data/rooms/casino/6198.yaml b/data/rooms/casino/6198.yaml
index 020cac5..7d93687 100644
--- a/data/rooms/casino/6198.yaml
+++ b/data/rooms/casino/6198.yaml
@@ -19,8 +19,9 @@ casino_tables:
action_ticks: 60
objects:
- name: face up plaque
- description: 'play pai; bet 20, optionally bet 5 fortune and/or bet 10 pushace, then
- deal. 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).
- Set two low numbers or five high; house sets automatically. table rules shows the felt.'
+ description: 'play pai; bet 5 fortune and/or bet 10 pushace first, then bet 20 to deal.
+ 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. Set two low numbers or five high; house sets automatically. table
+ rules shows the felt.'
inroom_description: A glass-rail plaque explains the face-up rules.
diff --git a/dev_guide/pai_gow.md b/dev_guide/pai_gow.md
index 9d26871..dd4cd7d 100644
--- a/dev_guide/pai_gow.md
+++ b/dev_guide/pai_gow.md
@@ -8,11 +8,30 @@ settlement and the Joker art.
## Hand evaluation
The Joker is partially wild: an ace, or any missing card completing a
-straight, flush or straight flush. Five aces beats a royal flush. A2345
-is the second-highest straight (and straight flush), below TJQKA but above
-9TJQK. Low hands recognize only pairs and high cards. Suits do not break
-ties. Copies lose to the dealer. The high hand must strictly outrank the
-low hand.
+straight, flush or straight flush. Five aces beats a royal flush. A2345 is
+the second-highest straight (and straight flush), below TJQKA but above
+9TJQK, by default. `PaiGowConfig.A2345High()` reads the optional
+`a2345_second_highest` field (default true); the evaluator is threaded
+through the `...Rules` variants (`pgEvaluateRules`, `pgHouseRules`,
+`pgFortuneRules`, `pgBestRules`, `pgDealerPushRules`,
+`pgMainResultRules`, `pgPushAceClassRules`) so a table can demote the wheel
+to the lowest straight without changing the default wrappers the tests use.
+Low hands recognize only pairs and high cards. Suits do not break ties.
+Copies lose to the dealer. The high hand must strictly outrank the low hand.
+
+## Betting flow
+
+Side bets (`fortune`, and `pushace` on Face Up) are placed first; the main
+wager commits the round. The main bet deals immediately when every active
+(connected) player has one, and a lone bettor is dealt at once, so a single
+seat never waits on a timer. With two or more active players the shared
+betting window (`betting_ticks`) opens and `finishBettingLocked` deals when
+it expires if any main wager is live, refunding side bets on seats that
+never placed one. `unbet main|fortune|pushace|all` returns an undealt stake
+through `PayCredits`, and `leave` before the deal refunds the departing
+seat and re-checks `allBetLocked` so the remaining bettors can be dealt.
+The Push Ace High cap (half the main wager) is enforced when the main wager
+lands, because side bets are already down by then.
## House way
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go
index b767c74..c4ec4aa 100644
--- a/internal/casino/baccarat_test.go
+++ b/internal/casino/baccarat_test.go
@@ -379,8 +379,8 @@ func TestBaccaratMenuAndActions(t *testing.T) {
menu = event.Menu
}
}
- if menu == nil || !menu.ShowBalance || !strings.Contains(menu.Heading, "Baccarat / Commission | Limits") {
- t.Fatalf("join menu=%+v, want a baccarat betting menu", menu)
+ if menu == nil || !menu.ShowBalance || menu.Heading != "" {
+ t.Fatalf("join menu=%+v, want a baccarat betting menu with no game-name heading", menu)
}
if len(menu.Commands) != 6 || menu.Commands[0].Command != "bet 1 banker" || menu.Commands[2].Command != "stop" || menu.Commands[4].Command != "rules" || menu.Commands[5].Command != "list" {
t.Fatalf("baccarat menu commands=%+v", menu.Commands)
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go
index 17d8971..ba3daaa 100644
--- a/internal/casino/pai_gow.go
+++ b/internal/casino/pai_gow.go
@@ -26,6 +26,7 @@ type pgPlayer struct {
cards []card
split pgSplit
ready, disconnected, paid bool
+ leaveAfter bool
payout int
result string
}
@@ -92,18 +93,27 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
return nil
}
commands := []MenuCommand{}
+ faceUp := t.Config.PaiGow.Variant == PaiGowFaceUp
switch t.phase {
case "waiting", "betting":
if p.main.Total == 0 {
- commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; opens a timed betting window"})
- } else {
+ // Side bets are placed before the main wager: the main bet is what
+ // 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 before deal"})
+ 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 t.Config.PaiGow.Variant == PaiGowFaceUp && p.pushace.Total == 0 {
- commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet (max half the main wager)"})
+ 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"})
+ }
}
- commands = append(commands, MenuCommand{Command: "deal", Desc: "Mark ready; deal when all bettors are ready"})
+ 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"})
}
case "setting":
if p.main.Total > 0 && !p.ready {
@@ -157,14 +167,14 @@ func (t *paiGowTable) statusLocked(name string) []Event {
s.Tiebreaker = pgViews(t.tie[:])
}
}
- e := t.private(name, t.Config.Label())
+ e := t.private(name, "")
e.Snapshot = s
// Context-aware hints are attached as a live menu; the shared board
// renderer places them below the felt. Stand-ins (for example a watcher)
// get no menu, so they cannot be told how to bet a game they have not joined.
if commands := t.hintsLocked(name); len(commands) > 0 {
e.Color = "casino_menu"
- e.Menu = &MenuView{Heading: t.Config.Label() + " | " + t.phase, ShowBalance: true, Commands: commands}
+ e.Menu = &MenuView{ShowBalance: true, Commands: commands}
}
return []Event{e}
}
@@ -189,11 +199,24 @@ func (t *paiGowTable) join(name string) []Event {
func (t *paiGowTable) leave(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
- if p := t.player(name); p != nil && p.main.Total > 0 {
- return []Event{t.private(name, "Finish your Pai Gow round before leaving; house sets your hand.")}
+ p := t.player(name)
+ if p == nil {
+ return nil
}
+ // Cards in flight must finish; before the deal the wagers are returned.
+ if t.phase == "setting" || t.phase == "settling" {
+ return []Event{t.private(name, "You're in the middle of a Pai Gow round!")}
+ }
+ if p.wallet != nil && (p.main.Total > 0 || p.fortune.Total > 0 || p.pushace.Total > 0) {
+ if _, _, err := p.wallet.RefundOutstanding(); err != nil {
+ return []Event{t.private(name, "Your stake could not be returned just now; it stays pending for the next startup.")}
+ }
+ }
+ p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
t.remove(name)
- return []Event{t.private(name, "You leave Pai Gow Poker.")}
+ events := []Event{t.private(name, "You leave Pai Gow Poker.")}
+ events = append(events, t.afterSeatChangeLocked()...)
+ return events
}
func (t *paiGowTable) rebet(name string) (string, int) {
t.mu.Lock()
@@ -226,11 +249,14 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
if spot == "main" && p.main.Total > 0 || spot == "fortune" && p.fortune.Total > 0 || spot == "pushace" && p.pushace.Total > 0 {
return fail("That wager is already committed.")
}
- if spot != "main" && (p.main.Total == 0 || p.ready) {
- return fail("Place a main bet first, then side bets before marking ready with deal.")
+ // Side bets settle into the round before the main wager, which is what
+ // actually deals. Once the main bet is down the deal can fire at any
+ // moment, so a later side bet would race the cards.
+ if spot != "main" && p.main.Total > 0 {
+ return fail("Place side bets before your main wager.")
}
- if spot == "pushace" && amount > p.main.Total/2 {
- return fail("Push Ace High is capped at half the 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.")
}
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)) + ".")
@@ -251,20 +277,24 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
p.result = ""
p.ready = false
p.paid = false
- p.fortune = Wager{}
- p.pushace = Wager{}
} else if spot == "pushace" {
p.pushace = stake
} else {
p.fortune = stake
}
- if t.phase == "waiting" {
+ if spot == "main" && t.phase == "waiting" {
t.phase = "betting"
t.ticks = t.Config.BettingTicks
t.dealer = nil
t.dealerSplit = pgSplit{}
}
- return append(fail(fmt.Sprintf("Committed %d on %s. Betting closes in %d ticks; Fortune is optional, then deal.", amount, spot, t.ticks)), t.statusLocked(name)...)
+ events := []Event{t.private(name, fmt.Sprintf("Committed %d on %s.", amount, spot))}
+ // The main wager is the commitment point: when every active player has
+ // one, the cards come out immediately rather than waiting on the timer.
+ if spot == "main" && t.allBetLocked() {
+ return append(events, t.dealLocked()...)
+ }
+ return append(events, t.statusLocked(name)...)
}
func (t *paiGowTable) allReady() bool {
found := false
@@ -278,6 +308,34 @@ func (t *paiGowTable) allReady() bool {
}
return found
}
+
+// allBetLocked reports whether at least one active player has a main wager
+// and every active (connected) player has one. This is the point at which the
+// cards can be dealt without waiting for the betting timer.
+func (t *paiGowTable) allBetLocked() bool {
+ found := false
+ for _, p := range t.players {
+ if p.disconnected {
+ continue
+ }
+ found = true
+ if p.main.Total == 0 {
+ return false
+ }
+ }
+ return found
+}
+
+func (t *paiGowTable) anyMainLocked() bool {
+ for _, p := range t.players {
+ if p.main.Total > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func (t *paiGowTable) a2345High() bool { return t.Config.PaiGow.A2345High() }
func (t *paiGowTable) dealLocked() []Event {
deck := newShoe(1, 0, t.rng)
deck.cards = append(deck.cards, card{"JK", "joker"})
@@ -291,13 +349,13 @@ func (t *paiGowTable) dealLocked() []Event {
}
}
t.dealer = packets[6]
- t.dealerSplit = pgHouse(t.dealer)
+ t.dealerSplit = pgHouseRules(t.dealer, t.a2345High())
t.tie = [2]card{deck.draw(), deck.draw()}
t.phase = "setting"
t.ticks = t.Config.PaiGow.ActionTicks
// Face Up ace-high settles immediately: there is no decision to make.
// Still assign every player's seven cards for independent Fortune/Envy.
- autoPush := t.Config.PaiGow.Variant == PaiGowFaceUp && pgDealerPush(t.dealer, PaiGowFaceUp) != ""
+ autoPush := t.Config.PaiGow.Variant == PaiGowFaceUp && pgDealerPushRules(t.dealer, PaiGowFaceUp, t.a2345High()) != ""
var events []Event
for i, p := range t.players {
if p.main.Total == 0 {
@@ -306,7 +364,7 @@ func (t *paiGowTable) dealLocked() []Event {
p.cards = packets[i]
p.ready = p.disconnected || autoPush
if p.ready {
- p.split = pgHouse(p.cards)
+ p.split = pgHouseRules(p.cards, t.a2345High())
}
if !autoPush {
events = append(events, t.statusLocked(p.name)...)
@@ -331,12 +389,24 @@ func (t *paiGowTable) action(name, action string) []Event {
if t.phase == "settling" && action == "settle" {
return t.payLocked()
}
- if action == "deal" && t.phase == "betting" && p.main.Total > 0 {
- p.ready = true
- if t.allReady() {
+ if action == "leave_after" {
+ if t.phase == "setting" || t.phase == "settling" {
+ p.leaveAfter = true
+ return []Event{t.private(name, "You'll leave after this round. Your current hand still needs your actions.")}
+ }
+ return []Event{t.private(name, "You're not in a round.")}
+ }
+ if action == "sitout" {
+ return []Event{t.private(name, "Pai Gow has no sit-out; use stop to leave after the round.")}
+ }
+ if strings.HasPrefix(action, "unbet") {
+ return t.unbetLocked(p, action)
+ }
+ if action == "deal" && (t.phase == "waiting" || t.phase == "betting") && p.main.Total > 0 {
+ if t.allBetLocked() {
return t.dealLocked()
}
- return []Event{t.private(name, "Ready. Waiting for other bettors or the betting timer.")}
+ return []Event{t.private(name, "Waiting for the other bettors; the window closes on the timer.")}
}
if t.phase != "setting" || p.main.Total == 0 {
return []Event{t.private(name, "Place your bets, then deal; set cards only after they are dealt.")}
@@ -346,7 +416,7 @@ func (t *paiGowTable) action(name, action string) []Event {
}
var split pgSplit
if action == "house" || action == "house way" || action == "set house" {
- split = pgHouse(p.cards)
+ split = pgHouseRules(p.cards, t.a2345High())
} else {
digits, ok := ParsePaiGowDigits(strings.TrimPrefix(action, "set "))
if !ok {
@@ -354,7 +424,7 @@ func (t *paiGowTable) action(name, action string) []Event {
}
split = pgSet(p.cards, digits)
}
- if !split.valid() {
+ if !split.validRules(t.a2345High()) {
return []Event{t.private(name, "FOUL HAND: your five-card high hand must be higher than your two-card low hand. Nothing was set; choose again or use house.")}
}
p.split = split
@@ -370,12 +440,12 @@ func (t *paiGowTable) prepareSettlementLocked() []Event {
if p.main.Total == 0 {
continue
}
- result := pgMainResult(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie)
+ result := pgMainResultRules(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie, t.a2345High())
p.payout = pgMainPay(p.main.Total, result, t.Config.PaiGow.Variant)
p.result = "Main: " + result
if t.Config.PaiGow.Variant == PaiGowFaceUp {
rates, _ := t.Config.PaiGow.pushAceRates()
- if class := pgPushAceClass(t.dealer, p.cards, rates); class.klass != "none" {
+ if class := pgPushAceClassRules(t.dealer, p.cards, rates, t.a2345High()); class.klass != "none" {
p.payout = p.pushace.Total * (class.rate + 1)
p.result += fmt.Sprintf("; Push Ace High: %s (%d:1)", class.desc, class.rate)
} else if p.pushace.Total > 0 {
@@ -383,7 +453,7 @@ func (t *paiGowTable) prepareSettlementLocked() []Event {
}
}
if p.fortune.Total > 0 {
- row, win := pgFortune(p.cards, t.Config.PaiGow.Envy)
+ row, win := pgFortuneRules(p.cards, t.Config.PaiGow.Envy, t.a2345High())
p.result += "; Fortune: " + row.Hand
if win {
p.payout += p.fortune.Total * (row.Odds + 1)
@@ -392,7 +462,7 @@ func (t *paiGowTable) prepareSettlementLocked() []Event {
envy := 0
for _, other := range t.players {
if other != p && other.main.Total > 0 {
- row, _ := pgFortune(other.cards, true)
+ row, _ := pgFortuneRules(other.cards, true, t.a2345High())
envy += row.Envy
}
}
@@ -440,8 +510,9 @@ func (t *paiGowTable) payLocked() []Event {
for _, p := range t.players {
p.main = Wager{}
p.fortune = Wager{}
+ p.pushace = Wager{}
p.ready = false
- if !p.disconnected {
+ if !p.disconnected && !p.leaveAfter {
kept = append(kept, p)
}
}
@@ -457,17 +528,23 @@ func (t *paiGowTable) tick() []Event {
switch t.phase {
case "settling":
return t.payLocked()
- case "betting", "setting":
+ case "betting":
+ if t.allBetLocked() {
+ return t.dealLocked()
+ }
t.ticks--
if t.ticks > 0 {
return nil
}
- if t.phase == "betting" {
- return t.dealLocked()
+ return t.finishBettingLocked()
+ case "setting":
+ t.ticks--
+ if t.ticks > 0 {
+ return nil
}
for _, p := range t.players {
if p.main.Total > 0 && !p.ready {
- p.split = pgHouse(p.cards)
+ p.split = pgHouseRules(p.cards, t.a2345High())
p.ready = true
}
}
@@ -482,15 +559,23 @@ func (t *paiGowTable) markDisconnected(name string) []Event {
if p == nil {
return nil
}
- if p.main.Total == 0 {
+ // Before the deal nothing is revealed, so a disconnect returns the
+ // wagers and frees the seat; the remaining bettors may then be dealt.
+ if t.phase == "waiting" || t.phase == "betting" {
+ if p.wallet != nil {
+ // Nothing has been dealt, so the stake is refunded; a failed refund
+ // leaves it pending for startup recovery, but the seat is still freed.
+ _, _, _ = p.wallet.RefundOutstanding()
+ }
+ p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
t.remove(name)
- return nil
+ return t.afterSeatChangeLocked()
}
p.disconnected = true
p.ready = true
if t.phase == "setting" {
if p.split.high == nil {
- p.split = pgHouse(p.cards)
+ p.split = pgHouseRules(p.cards, t.a2345High())
}
if t.allReady() {
return t.prepareSettlementLocked()
@@ -498,12 +583,144 @@ func (t *paiGowTable) markDisconnected(name string) []Event {
}
return nil
}
+
+// afterSeatChangeLocked re-evaluates the betting window after a seat leaves
+// or disconnects: the cards deal when every remaining active player has bet,
+// and a table with no main wager returns to waiting.
+func (t *paiGowTable) afterSeatChangeLocked() []Event {
+ if t.phase != "betting" {
+ return nil
+ }
+ if t.allBetLocked() {
+ return t.dealLocked()
+ }
+ if !t.anyMainLocked() {
+ return t.finishBettingLocked()
+ }
+ return nil
+}
+
+// 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.
+func (t *paiGowTable) finishBettingLocked() []Event {
+ var events []Event
+ for _, p := range t.players {
+ if p.main.Total > 0 {
+ continue
+ }
+ events = append(events, t.refundSideBetsLocked(p)...)
+ }
+ if !t.anyMainLocked() {
+ t.resetTableLocked()
+ return events
+ }
+ return append(events, t.dealLocked()...)
+}
+
+func (t *paiGowTable) refundSideBetsLocked(p *pgPlayer) []Event {
+ total := p.fortune.Total + p.pushace.Total
+ if total == 0 {
+ return nil
+ }
+ var ids []string
+ if p.fortune.Total > 0 {
+ ids = append(ids, p.fortune.ID)
+ }
+ if p.pushace.Total > 0 {
+ ids = append(ids, p.pushace.ID)
+ }
+ e := t.private(p.name, fmt.Sprintf("Your %d in side bets is returned; no main wager was placed.", total))
+ if p.wallet != nil {
+ if err := p.wallet.PayCredits(total, ids...); err != nil {
+ e.Message = payoutFailedMessage()
+ }
+ }
+ p.fortune, p.pushace = Wager{}, Wager{}
+ return []Event{e}
+}
+
+// resetTableLocked clears a round that closed without a main wager and
+// returns the table to waiting. Callers have already refunded any side bets.
+func (t *paiGowTable) resetTableLocked() {
+ t.phase = "waiting"
+ t.ticks = 0
+ t.dealer = nil
+ t.dealerSplit = pgSplit{}
+ t.tie = [2]card{}
+ for _, p := range t.players {
+ p.main, p.fortune, p.pushace = Wager{}, Wager{}, Wager{}
+ p.cards, p.split, p.ready, p.paid, p.result = nil, pgSplit{}, false, false, ""
+ }
+}
+
+// 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.")}
+ }
+ if t.phase != "waiting" && t.phase != "betting" {
+ return []Event{t.private(p.name, "You can only take back bets before the cards are dealt.")}
+ }
+ total, ids := 0, []string{}
+ take := func(w *Wager) {
+ if w.Total == 0 {
+ return
+ }
+ 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)
+ }
+ if total == 0 {
+ return []Event{t.private(p.name, "Nothing to take back.")}
+ }
+ if p.wallet != nil {
+ if err := p.wallet.PayCredits(total, ids...); err != nil {
+ 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{}
+ }
+ if !t.anyMainLocked() && t.phase == "betting" {
+ t.phase = "waiting"
+ t.ticks = 0
+ }
+ return append([]Event{t.private(p.name, fmt.Sprintf("You take back %d credits.", total))}, t.statusLocked(p.name)...)
+}
func (t *paiGowTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
var events []Event
for _, p := range t.players {
- if p.wallet == nil || p.main.Total == 0 || p.paid {
+ if p.wallet == nil || p.paid {
+ continue
+ }
+ if p.main.Total == 0 && p.fortune.Total == 0 && p.pushace.Total == 0 {
continue
}
if _, _, err := p.wallet.RefundOutstanding(); err != nil {
diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go
index 8a9842c..ced7c47 100644
--- a/internal/casino/pai_gow_rules.go
+++ b/internal/casino/pai_gow_rules.go
@@ -22,8 +22,14 @@ type PaiGowConfig struct {
Envy bool `yaml:"envy,omitempty"`
PushAceHigh string `yaml:"push_ace_high,omitempty"` // Face Up only: 5_15_40 or 5_12_40
ActionTicks int `yaml:"action_ticks,omitempty"`
+ // A2345SecondHighest makes the wheel the second-highest straight, below
+ // Broadway and above a six-high straight. It defaults to true; only an
+ // explicit false demotes A2345 to the lowest straight.
+ A2345SecondHighest *bool `yaml:"a2345_second_highest,omitempty"`
}
+func (c PaiGowConfig) A2345High() bool { return ruleBool(c.A2345SecondHighest, true) }
+
func (c PaiGowConfig) Normalize() PaiGowConfig {
if c.Variant == "" {
c.Variant = PaiGowClassic
@@ -85,7 +91,11 @@ func (c PaiGowConfig) Label() string {
}
func paiGowRules(c TableConfig) string {
r := c.PaiGow.Normalize()
- s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. A2345 is the second-highest straight.\nSet two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. High must strictly outrank low. Copies go to the dealer.\nBoth hands win: win; both lose: loss; one wins: push."
+ wheel := "A2345 is the second-highest straight."
+ if !r.A2345High() {
+ wheel = "A2345 is the lowest straight."
+ }
+ s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. " + wheel + "\nSet two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. High must strictly outrank low. Copies go to the dealer.\nBoth hands win: win; both lose: loss; one wins: push."
switch r.Variant {
case PaiGowClassic:
s += " Wins pay 0.95:1 (profit rounded down, minimum one credit)."
@@ -138,7 +148,14 @@ func pgCompare(a, b pgRank) int {
}
return 0
}
-func pgNatural(cards []card) pgRank {
+// pgNatural keeps the default-rules entry point used by tests and callers that
+// do not thread a table configuration. Production evaluation goes through
+// pgNaturalRules.
+//
+//nolint:unused // default-rules entry point
+func pgNatural(cards []card) pgRank { return pgNaturalRules(cards, true) }
+
+func pgNaturalRules(cards []card, a2345High bool) pgRank {
counts := [15]int{}
flush := len(cards) == 5
for _, c := range cards {
@@ -162,7 +179,11 @@ func pgNatural(cards []card) pgRank {
straight = sorted[4]
}
if sorted[0] == 2 && sorted[1] == 3 && sorted[2] == 4 && sorted[3] == 5 && sorted[4] == 14 {
- straight = 15
+ if a2345High {
+ straight = 15
+ } else {
+ straight = 5
+ }
}
if straight == 14 {
straight = 16
@@ -196,7 +217,9 @@ func pgNatural(cards []card) pgRank {
copy(out[1:], ranks)
return out
}
-func pgEvaluate(cards []card) pgRank {
+func pgEvaluate(cards []card) pgRank { return pgEvaluateRules(cards, true) }
+
+func pgEvaluateRules(cards []card, a2345High bool) pgRank {
j := -1
for i, c := range cards {
if c.rank == "JK" {
@@ -205,11 +228,11 @@ func pgEvaluate(cards []card) pgRank {
}
}
if j < 0 {
- return pgNatural(cards)
+ return pgNaturalRules(cards, a2345High)
}
copyCards := append([]card(nil), cards...)
copyCards[j] = card{"A", "joker"}
- best := pgNatural(copyCards)
+ best := pgNaturalRules(copyCards, a2345High)
if len(cards) != 5 {
return best
}
@@ -226,7 +249,7 @@ func pgEvaluate(cards []card) pgRank {
continue
}
copyCards[j] = replacement
- v := pgNatural(copyCards)
+ v := pgNaturalRules(copyCards, a2345High)
if (v[0] == 4 || v[0] == 5 || v[0] == 8) && pgCompare(v, best) > 0 {
best = v
}
@@ -252,28 +275,32 @@ func pgSet(cards []card, digits []int) pgSplit {
}
return s
}
-func (s pgSplit) valid() bool {
- return len(s.high) == 5 && len(s.low) == 2 && pgCompare(pgEvaluate(s.high), pgEvaluate(s.low)) > 0
+func (s pgSplit) valid() bool { return s.validRules(true) }
+
+func (s pgSplit) validRules(a2345High bool) bool {
+ return len(s.high) == 5 && len(s.low) == 2 && pgCompare(pgEvaluateRules(s.high, a2345High), pgEvaluateRules(s.low, a2345High)) > 0
}
-func pgHouse(cards []card) pgSplit {
+func pgHouse(cards []card) pgSplit { return pgHouseRules(cards, true) }
+
+func pgHouseRules(cards []card, a2345High bool) pgSplit {
var options []pgSplit
best := pgRank{}
for i := 0; i < 7; i++ {
for j := i + 1; j < 7; j++ {
s := pgSet(cards, []int{i, j})
- if !s.valid() {
+ if !s.validRules(a2345High) {
continue
}
options = append(options, s)
- if pgCompare(pgEvaluate(s.high), best) > 0 {
- best = pgEvaluate(s.high)
+ if pgCompare(pgEvaluateRules(s.high, a2345High), best) > 0 {
+ best = pgEvaluateRules(s.high, a2345High)
}
}
}
var chosen pgSplit
var low, high pgRank
for _, s := range options {
- h, l := pgEvaluate(s.high), pgEvaluate(s.low)
+ h, l := pgEvaluateRules(s.high, a2345High), pgEvaluateRules(s.low, a2345High)
eligible := h[0] == best[0]
// Split two pair/full house only for a paired front with at least a pair behind.
if (best[0] == 2 || best[0] == 6) && l[0] == 1 && h[0] >= 1 {
@@ -340,7 +367,9 @@ func pgSevenFlush(cards []card) bool {
}
return false
}
-func pgFortune(cards []card, envy bool) (FortuneRow, bool) {
+func pgFortune(cards []card, envy bool) (FortuneRow, bool) { return pgFortuneRules(cards, envy, true) }
+
+func pgFortuneRules(cards []card, envy, a2345High bool) (FortuneRow, bool) {
rows := FortunePayTable(envy)
joker := false
counts := [15]int{}
@@ -361,7 +390,7 @@ func pgFortune(cards []card, envy bool) (FortuneRow, bool) {
for i := 0; i < 7; i++ {
for j := i + 1; j < 7; j++ {
s := pgSet(cards, []int{i, j})
- v := pgEvaluate(s.high)
+ v := pgEvaluateRules(s.high, a2345High)
index := len(rows)
switch v[0] {
case 9:
@@ -408,11 +437,18 @@ func pgFortune(cards []card, envy bool) (FortuneRow, bool) {
}
return rows[bestIndex], true
}
-func pgBest(cards []card) pgRank {
+// pgBest keeps the default-rules entry point used by tests and callers that do
+// not thread a table configuration. Production evaluation goes through
+// pgBestRules.
+//
+//nolint:unused // default-rules entry point
+func pgBest(cards []card) pgRank { return pgBestRules(cards, true) }
+
+func pgBestRules(cards []card, a2345High bool) pgRank {
var best pgRank
for i := 0; i < 7; i++ {
for j := i + 1; j < 7; j++ {
- v := pgEvaluate(pgSet(cards, []int{i, j}).high)
+ v := pgEvaluateRules(pgSet(cards, []int{i, j}).high, a2345High)
if pgCompare(v, best) > 0 {
best = v
}
@@ -424,10 +460,14 @@ func pgBest(cards []card) pgRank {
// Automatic pushes use the best of all seven dealer cards, not just the
// chosen high hand. Pair/straight/flush hands headed by an ace do not qualify.
func pgDealerPush(cards []card, variant string) string {
+ return pgDealerPushRules(cards, variant, true)
+}
+
+func pgDealerPushRules(cards []card, variant string, a2345High bool) string {
if variant != PaiGowEZ && variant != PaiGowFaceUp {
return ""
}
- v := pgBest(cards)
+ v := pgBestRules(cards, a2345High)
if v[0] == 0 {
if variant == PaiGowEZ && v[1] == 12 {
return "push (dealer queen-high)"
@@ -439,11 +479,15 @@ func pgDealerPush(cards []card, variant string) string {
return ""
}
func pgMainResult(player, dealer pgSplit, dealerCards []card, variant string, tie [2]card) string {
- if push := pgDealerPush(dealerCards, variant); push != "" {
+ return pgMainResultRules(player, dealer, dealerCards, variant, tie, true)
+}
+
+func pgMainResultRules(player, dealer pgSplit, dealerCards []card, variant string, tie [2]card, a2345High bool) string {
+ if push := pgDealerPushRules(dealerCards, variant, a2345High); push != "" {
return push
}
- high := pgCompare(pgEvaluate(player.high), pgEvaluate(dealer.high)) > 0
- low := pgCompare(pgEvaluate(player.low), pgEvaluate(dealer.low)) > 0
+ high := pgCompare(pgEvaluateRules(player.high, a2345High), pgEvaluateRules(dealer.high, a2345High)) > 0
+ low := pgCompare(pgEvaluateRules(player.low, a2345High), pgEvaluateRules(dealer.low, a2345High)) > 0
if high && low {
return "win"
}
@@ -475,7 +519,11 @@ type pgPushAce struct {
}
func pgPushAceClass(dealer, player []card, rates [3]int) pgPushAce {
- if pgBest(dealer)[0] != 0 || pgBest(dealer)[1] != 14 {
+ return pgPushAceClassRules(dealer, player, rates, true)
+}
+
+func pgPushAceClassRules(dealer, player []card, rates [3]int, a2345High bool) pgPushAce {
+ if pgBestRules(dealer, a2345High)[0] != 0 || pgBestRules(dealer, a2345High)[1] != 14 {
return pgPushAce{klass: "none"}
}
for _, c := range dealer {
@@ -483,7 +531,7 @@ func pgPushAceClass(dealer, player []card, rates [3]int) pgPushAce {
return pgPushAce{klass: "joker", rate: rates[1], desc: "dealer ace-high with Joker"}
}
}
- if pgBest(player)[0] == 0 && pgBest(player)[1] == 14 {
+ if pgBestRules(player, a2345High)[0] == 0 && pgBestRules(player, a2345High)[1] == 14 {
return pgPushAce{klass: "both", rate: rates[2], desc: "both ace-high"}
}
return pgPushAce{klass: "plain", rate: rates[0], desc: "dealer ace-high without Joker"}
diff --git a/internal/casino/pai_gow_test.go b/internal/casino/pai_gow_test.go
index f258b04..02ea187 100644
--- a/internal/casino/pai_gow_test.go
+++ b/internal/casino/pai_gow_test.go
@@ -181,17 +181,19 @@ func TestPaiGowLifecyclePrivacyAndUniqueDeck(t *testing.T) {
wallets := map[string]*testWallet{}
for _, name := range []string{"a", "b", "c", "d", "e", "f"} {
tab.join(name)
- w := &testWallet{chips: 100}
- wallets[name] = w
- tab.bet(name, "main", 10, w)
+ wallets[name] = &testWallet{chips: 100}
+ }
+ var events []Event
+ for _, name := range []string{"a", "b", "c", "d", "e", "f"} {
+ w := wallets[name]
+ // Side bets settle before the main wager; the last main bet deals.
tab.bet(name, "fortune", 5, w)
+ events = tab.bet(name, "main", 10, w)
}
tab.join("overflow")
if tab.hasParticipant("overflow") {
t.Fatal("seventh seat")
}
- tab.tick()
- events := tab.tick()
if tab.phase != "setting" {
t.Fatal(tab.phase)
}
@@ -214,6 +216,9 @@ func TestPaiGowLifecyclePrivacyAndUniqueDeck(t *testing.T) {
t.Fatal(len(seen))
}
for _, e := range events {
+ if e.Snapshot == nil {
+ continue
+ }
if e.Public || e.PrivateTo == "" {
t.Fatal("private cards leaked")
}
@@ -265,11 +270,10 @@ func TestPaiGowAtomicRetryAndEnvy(t *testing.T) {
b := &pgFailWallet{testWallet: testWallet{chips: 100}}
tab.join("a")
tab.join("b")
- tab.bet("a", "main", 20, a)
tab.bet("a", "fortune", 5, a)
+ tab.bet("a", "main", 20, a)
+ // b's main wager completes the table and deals immediately.
tab.bet("b", "main", 20, b)
- tab.action("a", "deal")
- tab.action("b", "deal")
// Script the dealt cards before either player sets. a loses Fortune but
// collects b's fixed quad Envy, even though b didn't bet Fortune.
tab.player("a").cards = pgCards("2s 4h 6c 8d Js Qh Ac")
@@ -303,8 +307,8 @@ func TestPaiGowRejectedFoulDoesNotLock(t *testing.T) {
tab := pgTestTable(false)
w := &testWallet{chips: 100}
tab.join("a")
+ // A lone main wager deals immediately, so the cards are already out.
tab.bet("a", "", 10, w)
- tab.action("a", "deal")
p := tab.player("a")
p.cards = pgCards("As Ah 2c 3d 4s 7h 9c")
events := tab.action("a", "12")
@@ -326,16 +330,19 @@ func TestPaiGowEnvyEligibilityAndMultipleAwards(t *testing.T) {
stake, envy int
}{{true, 5, 25}, {true, 4, 0}, {false, 5, 0}} {
tab := pgTestTable(tt.enabled)
+ wallets := map[string]*testWallet{}
for _, name := range []string{"a", "b", "c"} {
tab.join(name)
- w := &testWallet{chips: 100}
- tab.bet(name, "main", 20, w)
+ wallets[name] = &testWallet{chips: 100}
+ }
+ for _, name := range []string{"a", "b", "c"} {
+ w := wallets[name]
if name == "a" {
tab.bet(name, "fortune", tt.stake, w)
}
+ tab.bet(name, "main", 20, w)
}
- tab.tick()
- tab.tick()
+ // The last main wager deals; script the hands before setting.
// Own royal and dealer seven-card straight flush must NOT earn Envy.
tab.player("a").cards = pgCards("10s Js Qs Ks As 2h 3d")
tab.player("b").cards = pgCards("Ks Kh Kc Kd 2h 3c 7h")
@@ -401,16 +408,17 @@ func TestPaiGowFaceUpAndPushAceHigh(t *testing.T) {
w := &testWallet{chips: 500}
tab.join("a")
tab.bet("a", "pushace", 10, w)
- if len(w.open) > 0 {
- t.Fatal("pushace before main")
+ if len(w.open) != 1 {
+ t.Fatal("pushace before main should be accepted")
}
- tab.bet("a", "main", 20, w)
- if events := tab.bet("a", "pushace", 11, w); len(w.open) != 1 || !strings.Contains(events[0].Message, "half the main wager") {
+ tab.bet("a", "fortune", 5, w)
+ // The cap is checked when the main wager lands.
+ if events := tab.bet("a", "main", 10, w); len(events) == 0 || !strings.Contains(events[0].Message, "half the main wager") {
t.Fatal("pushace cap not enforced", events, w.open)
}
- tab.bet("a", "pushace", 10, w)
- tab.bet("a", "fortune", 5, w)
- tab.action("a", "deal")
+ tab.bet("a", "main", 20, w)
+ pushaceStake := tab.player("a").pushace.Total
+ // A lone main wager deals immediately; the dealer is revealed in setting.
snap := tab.status("a")[0].Snapshot.(*PaiGowSnapshot)
// The dealer acts first and the reveal happens in the setting phase.
if len(snap.DealerHigh) != 5 || len(snap.DealerLow) != 2 {
@@ -432,7 +440,7 @@ func TestPaiGowFaceUpAndPushAceHigh(t *testing.T) {
expect += 5 * (row.Odds + 1) // the Fortune stake, before settlement resets it
}
if class := pgPushAceClass(tab.dealer, p.cards, [3]int{5, 15, 40}); class.klass != "none" {
- expect += p.pushace.Total * (class.rate + 1)
+ expect += pushaceStake * (class.rate + 1)
}
if p.payout != expect {
t.Fatalf("payout %d want %d (dealer %v, result %s)", p.payout, expect, tab.dealer, p.result)
@@ -452,13 +460,120 @@ func TestPaiGowConfigurationAndRefund(t *testing.T) {
w := &testWallet{chips: 100}
tab.join("a")
tab.bet("a", "fortune", 5, w)
- if len(w.open) > 0 {
- t.Fatal("side before main")
+ if len(w.open) != 1 {
+ t.Fatal("side before main should be accepted")
}
tab.bet("a", "main", 10, w)
- tab.bet("a", "fortune", 5, w)
tab.refundAll()
if w.chips != 100 || len(w.open) > 0 {
t.Fatal("refund failed")
}
}
+
+func TestPaiGowA2345Option(t *testing.T) {
+ if !(PaiGowConfig{}).A2345High() {
+ t.Fatal("A2345 must default to the second-highest straight")
+ }
+ wheel := pgCards("As 2h 3c 4d 5s")
+ if got := pgEvaluate(wheel); got != (pgRank{4, 15}) {
+ t.Fatalf("default wheel rank %v", got)
+ }
+ if got := pgEvaluateRules(wheel, false); got != (pgRank{4, 5}) {
+ t.Fatalf("demoted wheel rank %v", got)
+ }
+ six := pgCards("2h 3c 4d 5s 6h")
+ if pgCompare(pgEvaluateRules(wheel, false), pgEvaluateRules(six, false)) >= 0 {
+ t.Fatal("a demoted wheel must be the lowest straight")
+ }
+ jokerWheel := pgCards("Ah 2c 3d 4s JK")
+ if got := pgEvaluateRules(jokerWheel, false); got[0] != 4 || got[1] != 5 {
+ t.Fatalf("demoted Joker wheel rank %v", got)
+ }
+}
+
+func TestPaiGowSoloMainDealsAfterSideBets(t *testing.T) {
+ tab := pgTestTable(false)
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ // Side bets come first; they are accepted before any main wager.
+ tab.bet("a", "fortune", 5, w)
+ if len(w.open) != 1 {
+ t.Fatalf("fortune before main rejected: %v", w.open)
+ }
+ if tab.phase != "waiting" {
+ t.Fatalf("a side bet alone opened the betting window: %s", tab.phase)
+ }
+ // The main wager deals a lone table immediately.
+ tab.bet("a", "main", 10, w)
+ if tab.phase != "setting" {
+ t.Fatalf("solo main did not deal: %s", tab.phase)
+ }
+ if events := tab.bet("a", "fortune", 5, w); len(events) == 0 || !hasMessage(events, "Betting is closed") {
+ t.Fatalf("bet accepted after the deal: %+v", events)
+ }
+}
+
+func TestPaiGowWindowDealsWhenEveryBettorIsIn(t *testing.T) {
+ tab := pgTestTable(false)
+ wa, wb := &testWallet{chips: 100}, &testWallet{chips: 100}
+ tab.join("a")
+ tab.join("b")
+ tab.bet("a", "main", 10, wa)
+ if tab.phase != "betting" {
+ t.Fatalf("window did not open for a second seat: %s", tab.phase)
+ }
+ tab.bet("b", "main", 10, wb)
+ if tab.phase != "setting" {
+ t.Fatalf("all-bet table did not deal: %s", tab.phase)
+ }
+}
+
+func TestPaiGowUnbetAndStopRefundBeforeDeal(t *testing.T) {
+ tab := pgTestTable(false)
+ wa := &testWallet{chips: 100}
+ 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 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)
+ }
+ // A side bet placed and then abandoned is returned when the seat leaves.
+ tab.bet("a", "fortune", 5, wa)
+ if wa.chips+wa.paid != 95 {
+ t.Fatalf("fortune not charged: chips=%d paid=%d", wa.chips, wa.paid)
+ }
+ if events := tab.leave("a"); !hasMessage(events, "You leave Pai Gow Poker.") {
+ t.Fatalf("leave during betting: %+v", events)
+ }
+ if wa.chips+wa.paid != 100 || len(wa.open) != 0 || tab.hasParticipant("a") {
+ t.Fatalf("stop did not refund/remove: chips=%d paid=%d seated=%v", wa.chips, wa.paid, tab.hasParticipant("a"))
+ }
+}
+
+func TestPaiGowPushAceCapAtMainWager(t *testing.T) {
+ cfg := TableConfig{ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "5_15_40"}}.Normalize()
+ tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(9))).(*paiGowTable)
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ tab.bet("a", "pushace", 10, w)
+ if events := tab.bet("a", "main", 10, w); len(events) == 0 || !hasMessage(events, "half the main wager") {
+ t.Fatalf("pushace cap not checked when the main lands: %+v", events)
+ }
+ if w.chips != 90 || tab.player("a").main.Total != 0 {
+ t.Fatalf("rejected main changed state: chips=%d main=%d", w.chips, tab.player("a").main.Total)
+ }
+}
+
+func TestPaiGowRefundAllReturnsSideBetsWithoutMain(t *testing.T) {
+ tab := pgTestTable(false)
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ tab.bet("a", "fortune", 5, w)
+ tab.refundAll()
+ if w.chips != 100 || len(w.open) != 0 {
+ t.Fatalf("side-only refund failed: chips=%d open=%d", w.chips, len(w.open))
+ }
+}
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index 1a9f936..761af47 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -708,14 +708,7 @@ func (t *rouletteTable) rebet(name string) (string, int) {
func (t *rouletteTable) status(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
- message := t.overviewLocked()
- if text := t.seatStatusLocked(name); text != "" {
- message += "\n" + text
- }
e := t.menuEvent(name)
- e.Message = message
- e.Menu.Heading = message
- e.Menu.ShowBalance = false
events := []Event{e}
if t.phase == phaseWaiting && t.lastResult != nil {
last := t.private(name, "Last spin:")
diff --git a/internal/casino/table.go b/internal/casino/table.go
index 88ffc62..b1beab9 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -1,7 +1,6 @@
package casino
import (
- "fmt"
"math/rand"
)
@@ -61,13 +60,13 @@ func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Eve
return e
}
-// betMenuEvent builds the shared "place your bet" menu heading for a table
-// game; only the command list differs per game.
+// betMenuEvent builds the shared "place your bet" menu event for a table
+// game; only the command list differs per game. The felt already names the
+// game and its limits, so the menu carries just the balance and commands.
func (b *tableBase) betMenuEvent(name string, commands []MenuCommand) Event {
- heading := fmt.Sprintf("%s | Limits %s", b.Config.Label(), betRangeText(b.Config.MinBet, b.Config.MaxBet))
- e := b.private(name, heading)
+ e := b.private(name, "")
e.Color = "casino_menu"
- e.Menu = &MenuView{Heading: heading, ShowBalance: true, Commands: commands}
+ e.Menu = &MenuView{ShowBalance: true, Commands: commands}
return e
}
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index d0211f3..e255825 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -191,9 +191,9 @@ func (c *tableCore[P]) seatStatusLocked(name string) string {
func (t *blackjackTable) status(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
- e := t.private(name, t.overviewLocked())
+ e := t.private(name, "")
if text := t.seatStatusLocked(name); text != "" {
- e.Message += "\n" + text
+ e.Message = text
}
events := []Event{e}
switch t.phase {
@@ -220,9 +220,9 @@ func (t *blackjackTable) status(name string) []Event {
func (t *baccaratTable) status(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
- e := t.private(name, t.overviewLocked())
+ e := t.private(name, "")
if text := t.seatStatusLocked(name); text != "" {
- e.Message += "\n" + text
+ e.Message = text
}
events := []Event{e}
if p := t.players.get(name); p != nil && !p.HasBet {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index db5bfad..66c90a1 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -595,8 +595,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, g.casinoTimeText(text))
privateMessage = g.renderCasinoMessage(sess, category, event.Menu, privateMessage)
rendered := g.renderCasinoSnapshot(sess, event.Snapshot)
- if header, reminder, ok := g.casinoBoardReminder(sess, category, event.Menu, event.Snapshot, g.casinoTimeText(text)); ok {
- privateMessage = composeTableThenReminder(header, rendered, reminder)
+ if reminder, ok := g.casinoBoardReminder(sess, category, event.Menu, event.Snapshot, g.casinoTimeText(text)); ok {
+ privateMessage = composeTableThenReminder(rendered, reminder)
} else if rendered != "" {
privateMessage += "\n" + rendered
}
@@ -635,35 +635,37 @@ func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message
// casinoBoardReminder splits a persistent-board menu (craps, roulette, video
// poker, Pai Gow) so its instructions print under the board they describe.
// Every other game, and compact displays, keep their menu in one block.
-func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (header, reminder string, ok bool) {
+func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *casino.MenuView, snapshot casino.Snapshot, message string) (reminder string, ok bool) {
if category != "casino_menu" || menu == nil || sess.Player == nil {
- return "", "", false
+ return "", false
}
_, isCraps := snapshot.(*casino.CrapsSnapshot)
_, isPoker := snapshot.(*casino.VideoPokerSnapshot)
_, isPaiGow := snapshot.(*casino.PaiGowSnapshot)
s, isRoulette := snapshot.(*casino.RouletteSnapshot)
if !isPoker && !isCraps && !isPaiGow && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
- return "", "", false
+ return "", false
}
if !isPoker && !isCraps && !isPaiGow && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
// The compact reminder is already a short block; leave it intact.
- return "", "", false
+ return "", false
}
balance, heading, commands := g.casinoMenuParts(sess, menu, message)
- return g.casinoMenuHeader(sess, balance, heading), g.casinoMenuCommands(sess, commands), true
+ // The board replaces the game-name heading, and the balance sits with the
+ // commands underneath it rather than above the graphic.
+ return g.renderCasinoCommands(sess, balance, heading, commands), true
}
-// composeTableThenReminder joins a menu header, the board graphic, and the
-// wager reminder. The board snapshot already leads with a blank line, and the
-// reminder keeps exactly one blank line below the board's last visible row.
-func composeTableThenReminder(header, table, reminder string) string {
- out := header
- if table != "" {
- out += "\n" + strings.TrimRight(table, "\n")
- }
+// composeTableThenReminder joins a board graphic and the wager reminder. The
+// board snapshot already leads with a blank line, and the reminder keeps
+// exactly one blank line below the board's last visible row.
+func composeTableThenReminder(table, reminder string) string {
+ out := strings.TrimRight(table, "\n")
if reminder != "" {
- out += "\n\n" + reminder
+ if out != "" {
+ out += "\n\n"
+ }
+ out += reminder
}
return out
}
@@ -766,18 +768,19 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string,
}
// casinoMenuHeader formats the balance and heading that introduce a casino
-// menu. It is split from the command list so the roulette board can be drawn
-// between them.
+// menu. The heading (if any) comes first so the balance always sits directly
+// above the command list. It is split from the command list so the roulette
+// board can be drawn between them.
func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) string {
var sb strings.Builder
- if balance != "" {
- sb.WriteString(balance)
- sb.WriteString("\n\n")
- }
if heading != "" {
sb.WriteString(g.colorize(sess, "casino_action", heading))
sb.WriteString("\n\n")
}
+ if balance != "" {
+ sb.WriteString(balance)
+ sb.WriteString("\n\n")
+ }
return strings.TrimRight(sb.String(), "\n")
}
diff --git a/internal/game/casino_look_test.go b/internal/game/casino_look_test.go
index 71db40a..4c464f1 100644
--- a/internal/game/casino_look_test.go
+++ b/internal/game/casino_look_test.go
@@ -56,7 +56,7 @@ func TestLookAtTableShowsGameStateAndHints(t *testing.T) {
if !strings.Contains(text, "Simplified Salon") {
t.Fatalf("look lost the room: %q", text)
}
- if !strings.Contains(strings.ToUpper(text), "SIMPLIFIED CRAPS") || !strings.Contains(text, "Limits") {
+ if !strings.Contains(strings.ToUpper(text), "SIMPLIFIED CRAPS") || !strings.Contains(text, "seated") {
t.Fatalf("look missing the table state: %q", text)
}
if !strings.Contains(text, "rules") {
diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go
index 89363b0..5313484 100644
--- a/internal/game/casino_ux_test.go
+++ b/internal/game/casino_ux_test.go
@@ -129,7 +129,7 @@ func TestPlayAliasesJoinGames(t *testing.T) {
if !ok || table == nil || string(table.Config.Game) != game {
t.Fatalf("play %s: participation=%v game=%q", input, ok, game)
}
- if !strings.Contains(stripANSI(string(conn.data)), "Limits between") {
+ if !strings.Contains(stripANSI(string(conn.data)), "Available ") {
t.Fatalf("play %s: missing betting menu, got %q", input, stripANSI(string(conn.data)))
}
}
@@ -165,7 +165,7 @@ func TestTableStatusShowsStateAndRules(t *testing.T) {
g.executePlay(sess, []string{"bj"}, "play bj")
g.executeTableStatus(sess, nil, "table")
text := stripANSI(string(conn.data))
- if !strings.Contains(text, "Limits") || !strings.Contains(text, "seated") {
+ if !strings.Contains(text, "LIMITS") || !strings.Contains(text, "Waiting for bets") {
t.Fatalf("status missing overview: %q", text)
}
conn.data = nil
diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go
index f13655b..af6b749 100644
--- a/internal/game/pai_gow_test.go
+++ b/internal/game/pai_gow_test.go
@@ -25,9 +25,8 @@ func TestPaiGowCommandsLookAndSettlement(t *testing.T) {
t.Fatalf("missing %s: %s", want, text)
}
}
- g.executeBet(sess, []string{"20"}, "bet 20")
g.executeBet(sess, []string{"5", "fortune"}, "bet 5 fortune")
- g.executeTableAction(sess, "deal")
+ g.executeBet(sess, []string{"20"}, "bet 20")
snap := table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot)
if len(snap.Cards) != 7 || len(snap.DealerHigh) != 0 || len(sess.Player.PendingWagers) != 2 {
t.Fatal("deal/side bet")
@@ -132,34 +131,24 @@ func TestPaiGowContextHintsFollowState(t *testing.T) {
g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n min_bet: 5\n max_bet: 1000\n pai_gow:\n envy: true\n")
g.executePlay(sess, []string{"pai"}, "play pai")
- // A fresh bettor is told how to place the main wager.
+ // A fresh bettor is told about the side bet and the main wager, plus the
+ // shared tail.
conn.data = nil
g.executeTableStatus(sess, nil, "table")
text := stripANSI(string(conn.data))
- if !strings.Contains(text, "bet <amount>") {
- t.Fatalf("missing main-bet hint: %s", text)
- }
-
- // After the main wager the live menu offers Fortune and deal, not a second
- // main bet, and the shared tail is present.
- conn.data = nil
- g.executeBet(sess, []string{"20"}, "bet 20")
- text = stripANSI(string(conn.data))
- for _, want := range []string{"bet <amount> fortune", "deal", "table rules", "rules", "stop"} {
+ for _, want := range []string{"bet <amount> fortune", "bet <amount>", "table rules", "rules", "stop"} {
if !strings.Contains(text, want) {
- t.Fatalf("missing %q after main bet: %s", want, text)
+ t.Fatalf("missing %q at a fresh table: %s", want, text)
}
}
- // Setting offers the split selectors and the house-way shortcut.
- g.executeBet(sess, []string{"5", "fortune"}, "bet 5 fortune")
- g.executeTableAction(sess, "deal")
+ // A lone main wager deals immediately; the live menu moves to setting.
conn.data = nil
- g.executeTableStatus(sess, nil, "table")
+ g.executeBet(sess, []string{"20"}, "bet 20")
text = stripANSI(string(conn.data))
for _, want := range []string{"24 / 13456", "house"} {
if !strings.Contains(text, want) {
- t.Fatalf("missing %q while setting: %s", want, text)
+ t.Fatalf("missing %q after the main wager: %s", want, text)
}
}
}
@@ -186,3 +175,62 @@ func TestJokerEmptySuitDoesNotPanic(t *testing.T) {
}
}
}
+
+func pgBoolPtr(b bool) *bool { return &b }
+
+func TestPaiGowFeltEndsAtBottomBorderAndNotesA2345(t *testing.T) {
+ on := renderPaiGowFelt(casino.TableConfig{Game: casino.GamePaiGow}, "none", false)
+ if !strings.Contains(on, "A2345 is second-highest straight.") {
+ t.Fatalf("enabled felt is missing the A2345 rule:\n%s", on)
+ }
+ lines := strings.Split(strings.TrimRight(on, "\n"), "\n")
+ // The bottom border is the final row and the joker note sits directly
+ // above it: the old blank interior row is gone.
+ if last := lines[len(lines)-1]; !strings.HasPrefix(last, "+") {
+ t.Fatalf("felt does not end on its bottom border: %q", last)
+ }
+ if !strings.Contains(lines[len(lines)-2], "Joker:") {
+ t.Fatalf("blank line below the joker note: %q", lines[len(lines)-2])
+ }
+ offFelt := renderPaiGowFelt(casino.TableConfig{Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{A2345SecondHighest: pgBoolPtr(false)}}, "none", false)
+ if strings.Contains(offFelt, "A2345 is second-highest straight.") {
+ t.Fatal("disabled felt still advertises the A2345 rule")
+ }
+}
+
+func TestPaiGowEnvyColumnCentersUnderLabel(t *testing.T) {
+ cfg := casino.TableConfig{Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{Envy: true}}
+ lines := strings.Split(renderPaiGowFelt(cfg, "none", false), "\n")
+ header := lines[5]
+ row := lines[6]
+ hi := strings.Index(header, "ENVY")
+ vi := strings.Index(row, "500")
+ if hi < 0 || vi < 0 {
+ t.Fatalf("missing header/value:\n%s\n%s", header, row)
+ }
+ // A value must sit within the label's four-column span.
+ if vi < hi || vi+len("500") > hi+len("ENVY") {
+ t.Fatalf("ENVY header and value are not aligned: header=%d value=%d\n%s\n%s", hi, vi, header, row)
+ }
+}
+
+func TestPaiGowStopMidRoundQueuesDeparture(t *testing.T) {
+ g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: paigow\n min_bet: 5\n max_bet: 1000\n")
+ g.executePlay(sess, []string{"pai"}, "play pai")
+ // A lone main wager deals at once, so stop reaches the committed-round path.
+ g.executeBet(sess, []string{"20"}, "bet 20")
+ conn.data = nil
+ g.executeCommand(sess, "stop", nil, "stop")
+ text := stripANSI(string(conn.data))
+ if !strings.Contains(text, "leave after this round") {
+ t.Fatalf("stop mid-round did not queue a departure: %s", text)
+ }
+ if _, playing := g.Casino.Participation(sess.Player.Name); !playing {
+ t.Fatal("stop mid-round removed the seat too early")
+ }
+ // Finishing the hand applies the queued departure.
+ g.executeCommand(sess, "house", nil, "house")
+ if _, playing := g.Casino.Participation(sess.Player.Name); playing {
+ t.Fatal("queued stop did not leave after the round")
+ }
+}
diff --git a/internal/game/render_pai_gow.go b/internal/game/render_pai_gow.go
index 239ddb4..d364c61 100644
--- a/internal/game/render_pai_gow.go
+++ b/internal/game/render_pai_gow.go
@@ -9,8 +9,8 @@ import (
)
func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string {
- c := newCrapsCanvas(25)
- c.box(0, 0, 76, 24)
+ c := newCrapsCanvas(24)
+ c.box(0, 0, 76, 23)
c.center(1, 75, 1, strings.ToUpper(cfg.PaiGow.Label()), crapsGold)
c.center(1, 75, 2, "LIMITS "+betLimitsText(cfg.MinBet, cfg.MaxBet), crapsInk)
note := "5% COMMISSION"
@@ -30,12 +30,15 @@ func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string
if cfg.PaiGow.Envy {
envy = "ENVY"
}
- c.text(62, 5, envy, crapsGold)
+ // Center the Envy header and its values over the same narrow field so the
+ // numbers sit under the label instead of hanging off its right edge.
+ const envyLeft, envyRight = 62, 66
+ c.center(envyLeft, envyRight, 5, envy, crapsGold)
for i, row := range casino.FortunePayTable(cfg.PaiGow.Envy) {
c.text(3, 6+i, row.Hand, crapsInk)
c.text(50, 6+i, fmt.Sprintf("%d:1", row.Odds), crapsInk)
if cfg.PaiGow.Envy {
- c.text(65, 6+i, fmt.Sprintf("%d", row.Envy), crapsInk)
+ c.center(envyLeft, envyRight, 6+i, fmt.Sprintf("%d", row.Envy), crapsInk)
}
}
c.center(1, 75, 18, "Highest prize only. Winning Fortune wagers also return their stake.", crapsMuted)
@@ -45,7 +48,11 @@ func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string
}
c.center(1, 75, 20, note, crapsInk)
c.center(1, 75, 21, "High must outrank low. Copies lose.", crapsMuted)
- c.center(1, 75, 22, "Joker: ace or completes straight/flush.", crapsMuted)
+ joker := "Joker: ace or completes straight/flush."
+ if cfg.PaiGow.A2345High() {
+ joker = "Joker: ace or completes straight/flush. A2345 is second-highest straight."
+ }
+ c.center(1, 75, 22, joker, crapsMuted)
return c.render(mode, unicode)
}
func renderPaiGow(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string {