aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/pai_gow.md28
-rw-r--r--data/help/pai_gow.yaml20
-rw-r--r--data/rooms/casino/6195.yaml4
-rw-r--r--data/rooms/casino/6196.yaml4
-rw-r--r--data/rooms/casino/6197.yaml8
-rw-r--r--data/rooms/casino/6198.yaml4
-rw-r--r--dev_guide/pai_gow.md26
-rw-r--r--internal/casino/pai_gow.go262
-rw-r--r--internal/casino/pai_gow_rules.go79
-rw-r--r--internal/casino/pai_gow_test.go151
-rw-r--r--internal/game/casino.go48
-rw-r--r--internal/game/cmd_registry.go2
-rw-r--r--internal/game/pai_gow_test.go170
-rw-r--r--internal/game/render_pai_gow.go205
-rw-r--r--internal/game/render_video_poker.go35
15 files changed, 920 insertions, 126 deletions
diff --git a/building_guide/pai_gow.md b/building_guide/pai_gow.md
index bf3a3a3..93e9594 100644
--- a/building_guide/pai_gow.md
+++ b/building_guide/pai_gow.md
@@ -12,6 +12,7 @@ Configure a `casino_tables` entry:
pai_gow:
variant: classic # classic, ez, no_push, face_up
envy: true
+ autoset: false # default; true locks every legal arrangement without "set"
a2345_second_highest: true # default; false makes A2345 the lowest straight
action_ticks: 60
```
@@ -36,13 +37,28 @@ rule is printed on the felt.
## Player hand setting
-`24` or `set 24` selects positions 2 and 4 for the two-card low hand.
-`13456` selects those five positions for the high hand. Spaces and order
+The seven dealt cards are shown with their position numbers underneath.
+`24` or `set 24` arranges positions 2 and 4 into the two-card low hand;
+`13456` arranges those five positions into the high hand. Spaces and order
are immaterial; duplicates, out-of-range positions and other counts are
-rejected. Setting locks the hand. Fouls are rejected with a clear warning
-and leave the hand unset. `house` (`house way`, `set house`) sets
-automatically. Timeout or disconnect sets an unfinished hand the house
-way; a committed round cannot be abandoned.
+rejected. Fouls are rejected with a clear warning and leave the hand
+unarranged.
+
+An arrangement is only a preview: the board redraws the five-card high
+hand, a gap, then the two-card low hand (with `HIGH` and `LOW` labels and
+position numbers) and waits. `set` locks the hand and reveals the dealer.
+`house` (`house way`, `set house`) arranges the house way and still needs
+`set`. `autoset on` locks any legal arrangement the moment it is made, so
+the preview and `set` steps are skipped; `autoset off` restores them, and
+`autoset` alone toggles. The table's `autoset` setting only seeds a new
+seat. Timeout or disconnect sets an unfinished hand the house way; a
+committed round cannot be abandoned.
+
+When every seat has set, the dealer reveals and the showdown compares the
+high hands first (your high, then the dealer's high) and then the low hands
+(your low, then the dealer's low). Each hand prints its strength in
+parentheses, bold yellow when it wins its comparison and bold white when it
+loses. The summary names the winner of each hand and then the round.
## Variants
diff --git a/data/help/pai_gow.yaml b/data/help/pai_gow.yaml
index 113fd7a..a99cee2 100644
--- a/data/help/pai_gow.yaml
+++ b/data/help/pai_gow.yaml
@@ -16,12 +16,20 @@ description: |
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.
- Spaces and order do not matter. set 24 also works. No repeated numbers.
- Your five-card hand must outrank your two-card hand: a foul produces a
- warning and leaves your hand unset. A valid selection LOCKS your hand.
- house sets automatically; timeout or disconnect also uses the house way.
+ 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
+ not matter, set 24 also works, and repeated numbers are rejected. Your
+ five-card hand must outrank your two-card hand: a foul produces a warning
+ and leaves your hand unarranged. A legal arrangement reprints the five
+ 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.
+
+ 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
+ ACES) is bold, yellow when that hand wins and white when it loses.
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.
diff --git a/data/rooms/casino/6195.yaml b/data/rooms/casino/6195.yaml
index 926ed7c..12fee29 100644
--- a/data/rooms/casino/6195.yaml
+++ b/data/rooms/casino/6195.yaml
@@ -23,8 +23,8 @@ casino_tables:
objects:
- name: fortune plaque
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
+ 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
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 b9cddf3..3ccb56f 100644
--- a/data/rooms/casino/6196.yaml
+++ b/data/rooms/casino/6196.yaml
@@ -22,6 +22,6 @@ 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 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.'
+ 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.'
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 5f1f21a..ab899ad 100644
--- a/data/rooms/casino/6197.yaml
+++ b/data/rooms/casino/6197.yaml
@@ -19,8 +19,8 @@ casino_tables:
objects:
- name: no push plaque
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.'
+ 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.'
inroom_description: A two-card plaque explains the tiebreaker.
diff --git a/data/rooms/casino/6198.yaml b/data/rooms/casino/6198.yaml
index 7d93687..8385004 100644
--- a/data/rooms/casino/6198.yaml
+++ b/data/rooms/casino/6198.yaml
@@ -22,6 +22,6 @@ objects:
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.'
+ 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.'
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 dd4cd7d..3e0bc88 100644
--- a/dev_guide/pai_gow.md
+++ b/dev_guide/pai_gow.md
@@ -33,6 +33,32 @@ 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.
+## Player setting and showdown
+
+An arrangement made with `24`/`13456`/`house` is stored on the player as
+`pending`; it does not lock the hand or reveal the dealer. `set` moves
+`pending` into `split` and marks the seat ready. When `autoset` is on (the
+table default or a per-seat `autoset on`) a legal arrangement is committed
+straight to `split`. A seat's `autoset` is seeded from
+`PaiGowConfig.Autoset` at join.
+
+When every active seat is ready, `prepareSettlementLocked` evaluates both
+sides, builds a `PaiGowShowdown`, and hands it to `payLocked`. The showdown
+always orders `Hands` player-high, dealer-high, player-low, dealer-low.
+Each `PaiGowHandView` carries the label, the `pgRankName` strength, the
+cards, and whether that hand won its comparison. The presentation layer
+derives the colours (bold yellow for a winning strength, bold white for a
+losing one) from those flags, so the casino package stays colour-free. The
+`PaiGowShowdown` also carries the main result word, Fortune and Push Ace
+outcomes, the Envy amount (only when another active player is seated), the
+total stake, the payout, and the paid/pending state.
+
+The dealt seven cards are only put on the snapshot before a seat has
+arranged anything. A manual seat shows its `pending`/committed split as the
+`Preview` (high then low, gap between, position numbers); an autoset seat
+skips the preview and shows only the set/waiting note. `pgCardNums` maps
+each hand card back to its 1-7 dealt position.
+
## House way
This is a **local, deterministic house way**, not a claim to reproduce a
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go
index ba3daaa..b853829 100644
--- a/internal/casino/pai_gow.go
+++ b/internal/casino/pai_gow.go
@@ -7,28 +7,74 @@ import (
"sync"
)
+// PaiGowHandView is one hand in the showdown: its label, evaluated
+// strength, cards and whether it won its comparison. Winning is only
+// meaningful when HasWin is true (a showdown), so Face Up's mid-round
+// dealer reveal does not paint the dealer as a winner.
+type PaiGowHandView struct {
+ Label string
+ Strength string
+ Cards []CardView
+ Winning bool
+ HasWin bool
+}
+
+// PaiGowShowdown carries the comparison and settlement pieces the renderer
+// turns into the coloured results block. Hands always run player high,
+// dealer high, player low, dealer low.
+type PaiGowShowdown struct {
+ Hands []PaiGowHandView
+ Result string
+ Fortune string
+ FortuneWin bool
+ PushAce string
+ PushAceWin bool
+ Envy int
+ ShowEnvy bool
+ Stake int
+ Payout int
+ Paid bool
+ Pending bool
+}
+
type PaiGowSnapshot struct {
- Config TableConfig
- Phase string
- Cards, High, Low, DealerHigh, DealerLow []CardView
- Tiebreaker []CardView
- Result string
+ Config TableConfig
+ Phase string
+ ShowFelt bool
+ Cards, DealerHigh, DealerLow []CardView
+ Tiebreaker []CardView
+ // Preview carries an arranged-but-unset (or locked) hand: the five-card
+ // high hand followed by the two-card low hand. PreviewHighNums and
+ // PreviewLowNums are the original 1-7 positions under each card.
+ Preview bool
+ PreviewHigh, PreviewLow []CardView
+ PreviewHighNums, PreviewLowNums []int
+ // DealerHighName/DealerLowName label a Face Up reveal that happens before
+ // the showdown.
+ DealerHighName, DealerLowName string
+ Showdown *PaiGowShowdown
+ Result string
}
func (*PaiGowSnapshot) snapshot() {}
type pgPlayer struct {
- name string
- wallet Wallet
- main, fortune Wager
- pushace Wager
- last int
- cards []card
- split pgSplit
+ name string
+ wallet Wallet
+ main, fortune Wager
+ pushace Wager
+ last int
+ cards []card
+ split pgSplit
+ // pending holds a legal arrangement the player has previewed but not yet
+ // committed with "set". Autoset players never populate it.
+ pending pgSplit
+ autoset bool
ready, disconnected, paid bool
leaveAfter bool
payout int
result string
+ showdown *PaiGowShowdown
}
type paiGowTable struct {
*tableBase
@@ -117,7 +163,18 @@ func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
}
case "setting":
if p.main.Total > 0 && !p.ready {
- commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose two low cards OR five high cards; locks the hand"}, MenuCommand{Command: "house", Desc: "Set your hand the house way"})
+ if p.pending.high != nil {
+ commands = append(commands, MenuCommand{Command: "set", Desc: "Lock this arrangement and reveal the dealer"})
+ commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose a different two-low or five-high arrangement"})
+ } else {
+ commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose two low cards or five high cards to arrange"})
+ }
+ commands = append(commands, MenuCommand{Command: "house", Desc: "Arrange your hand the house way"})
+ if p.autoset {
+ commands = append(commands, MenuCommand{Command: "autoset off", Desc: "Require set before the dealer reveals"})
+ } else {
+ commands = append(commands, MenuCommand{Command: "autoset on", Desc: "Set a legal arrangement immediately"})
+ }
}
case "settling":
commands = append(commands, MenuCommand{Command: "settle", Desc: "Retry the same pending payment"})
@@ -149,13 +206,53 @@ func pgViews(cards []card) []CardView {
}
return out
}
-func (t *paiGowTable) statusLocked(name string) []Event {
- s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase}
+
+// 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 {
+ used := make([]bool, len(all))
+ var nums []int
+ for _, h := range hand {
+ for i, c := range all {
+ if !used[i] && c == h {
+ used[i] = true
+ nums = append(nums, i+1)
+ break
+ }
+ }
+ }
+ return nums
+}
+
+func (t *paiGowTable) statusLocked(name string, showFelt bool) []Event {
+ s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase, ShowFelt: showFelt}
if p := t.player(name); p != nil {
- s.Cards = pgViews(p.cards)
- s.High = pgViews(p.split.high)
- s.Low = pgViews(p.split.low)
+ // The dealt hand only appears before the player has arranged anything;
+ // afterwards the board shows the arrangement or the waiting message.
+ if !p.ready && p.pending.high == nil {
+ s.Cards = pgViews(p.cards)
+ }
+ if p.showdown != nil {
+ s.Showdown = p.showdown
+ }
+ // A pending arrangement previews before "set"; a committed one previews
+ // until every seat is in and the showdown replaces it. Autoset players
+ // skip the preview and go straight to the set/waiting state.
+ split := p.split
+ if p.pending.high != nil {
+ split = p.pending
+ }
+ if split.high != nil && !(p.autoset && p.pending.high == nil) {
+ s.Preview = true
+ s.PreviewHigh = pgViews(split.high)
+ s.PreviewLow = pgViews(split.low)
+ s.PreviewHighNums = pgCardNums(split.high, p.cards)
+ s.PreviewLowNums = pgCardNums(split.low, p.cards)
+ }
s.Result = p.result
+ if p.ready && t.phase == "setting" && p.showdown == nil {
+ s.Result = "Hand set — waiting for the other players."
+ }
if t.phase == "settling" && !p.paid && p.main.Total > 0 {
s.Result += "; PAYMENT PENDING (not yet credited)"
}
@@ -163,6 +260,12 @@ func (t *paiGowTable) statusLocked(name string) []Event {
if t.phase == "settling" || t.phase == "waiting" || t.phase == "setting" && t.Config.PaiGow.Variant == PaiGowFaceUp {
s.DealerHigh = pgViews(t.dealerSplit.high)
s.DealerLow = pgViews(t.dealerSplit.low)
+ if len(t.dealerSplit.high) > 0 {
+ s.DealerHighName = pgRankName(pgEvaluateRules(t.dealerSplit.high, t.a2345High()))
+ }
+ if len(t.dealerSplit.low) > 0 {
+ s.DealerLowName = pgRankName(pgEvaluateRules(t.dealerSplit.low, t.a2345High()))
+ }
if len(t.dealer) > 0 && t.Config.PaiGow.Variant == PaiGowNoPush {
s.Tiebreaker = pgViews(t.tie[:])
}
@@ -181,20 +284,20 @@ func (t *paiGowTable) statusLocked(name string) []Event {
func (t *paiGowTable) status(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
- return t.statusLocked(name)
+ return t.statusLocked(name, false)
}
func (t *paiGowTable) join(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
if p := t.player(name); p != nil {
p.disconnected = false
- return t.statusLocked(name)
+ return t.statusLocked(name, true)
}
if len(t.players) >= t.Config.MaxPlayers {
return []Event{t.private(name, "This Pai Gow table is full.")}
}
- t.players = append(t.players, &pgPlayer{name: name})
- return t.statusLocked(name)
+ t.players = append(t.players, &pgPlayer{name: name, autoset: t.Config.PaiGow.Autoset})
+ return t.statusLocked(name, true)
}
func (t *paiGowTable) leave(name string) []Event {
t.mu.Lock()
@@ -274,6 +377,8 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
p.last = amount
p.cards = nil
p.split = pgSplit{}
+ p.pending = pgSplit{}
+ p.showdown = nil
p.result = ""
p.ready = false
p.paid = false
@@ -294,7 +399,7 @@ func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
if spot == "main" && t.allBetLocked() {
return append(events, t.dealLocked()...)
}
- return append(events, t.statusLocked(name)...)
+ return append(events, t.statusLocked(name, false)...)
}
func (t *paiGowTable) allReady() bool {
found := false
@@ -367,7 +472,7 @@ func (t *paiGowTable) dealLocked() []Event {
p.split = pgHouseRules(p.cards, t.a2345High())
}
if !autoPush {
- events = append(events, t.statusLocked(p.name)...)
+ events = append(events, t.statusLocked(p.name, false)...)
}
}
if t.allReady() {
@@ -384,7 +489,7 @@ func (t *paiGowTable) action(name, action string) []Event {
}
action = strings.TrimSpace(strings.ToLower(action))
if action == "paytable" || action == "status" {
- return t.statusLocked(name)
+ return t.statusLocked(name, false)
}
if t.phase == "settling" && action == "settle" {
return t.payLocked()
@@ -399,6 +504,27 @@ func (t *paiGowTable) action(name, action string) []Event {
if action == "sitout" {
return []Event{t.private(name, "Pai Gow has no sit-out; use stop to leave after the round.")}
}
+ if action == "autoset" || strings.HasPrefix(action, "autoset ") {
+ if t.phase != "waiting" && t.phase != "betting" && t.phase != "setting" {
+ return []Event{t.private(name, "Autoset can only change before the showdown.")}
+ }
+ rest := strings.TrimSpace(strings.TrimPrefix(action, "autoset"))
+ switch rest {
+ case "", "toggle":
+ p.autoset = !p.autoset
+ case "on":
+ p.autoset = true
+ case "off":
+ p.autoset = false
+ default:
+ return []Event{t.private(name, "Use autoset on or autoset off.")}
+ }
+ state := "off"
+ if p.autoset {
+ state = "on"
+ }
+ return append([]Event{t.private(name, "Autoset is "+state+".")}, t.statusLocked(name, false)...)
+ }
if strings.HasPrefix(action, "unbet") {
return t.unbetLocked(p, action)
}
@@ -414,6 +540,20 @@ func (t *paiGowTable) action(name, action string) []Event {
if p.ready {
return []Event{t.private(name, "Your hand is locked; waiting for the other players.")}
}
+ // "set" commits a previewed arrangement. Without autoset, the numeric
+ // choices only arrange; the player confirms before the dealer reveals.
+ if action == "set" || action == "set hand" {
+ if p.pending.high == nil {
+ return []Event{t.private(name, "Arrange your hand first with 24 or 13456, or house.")}
+ }
+ p.split = p.pending
+ p.pending = pgSplit{}
+ p.ready = true
+ if t.allReady() {
+ return t.prepareSettlementLocked()
+ }
+ return t.statusLocked(name, false)
+ }
var split pgSplit
if action == "house" || action == "house way" || action == "set house" {
split = pgHouseRules(p.cards, t.a2345High())
@@ -427,12 +567,17 @@ func (t *paiGowTable) action(name, action string) []Event {
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
- p.ready = true
- if t.allReady() {
- return t.prepareSettlementLocked()
+ if p.autoset {
+ p.split = split
+ p.pending = pgSplit{}
+ p.ready = true
+ if t.allReady() {
+ return t.prepareSettlementLocked()
+ }
+ return t.statusLocked(name, false)
}
- return t.statusLocked(name)
+ p.pending = split
+ return t.statusLocked(name, false)
}
func (t *paiGowTable) prepareSettlementLocked() []Event {
t.phase = "settling"
@@ -442,34 +587,59 @@ func (t *paiGowTable) prepareSettlementLocked() []Event {
}
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
+ p.result = result
+ highRank := pgEvaluateRules(p.split.high, t.a2345High())
+ lowRank := pgEvaluateRules(p.split.low, t.a2345High())
+ dealerHighRank := pgEvaluateRules(t.dealerSplit.high, t.a2345High())
+ dealerLowRank := pgEvaluateRules(t.dealerSplit.low, t.a2345High())
+ highWin := pgCompare(highRank, dealerHighRank) > 0
+ lowWin := pgCompare(lowRank, dealerLowRank) > 0
+ sd := &PaiGowShowdown{
+ Result: result,
+ Hands: []PaiGowHandView{
+ {Label: "Your high", Strength: pgRankName(highRank), Cards: pgViews(p.split.high), Winning: highWin, HasWin: true},
+ {Label: "Dealer high", Strength: pgRankName(dealerHighRank), Cards: pgViews(t.dealerSplit.high), Winning: !highWin, HasWin: true},
+ {Label: "Your low", Strength: pgRankName(lowRank), Cards: pgViews(p.split.low), Winning: lowWin, HasWin: true},
+ {Label: "Dealer low", Strength: pgRankName(dealerLowRank), Cards: pgViews(t.dealerSplit.low), Winning: !lowWin, HasWin: true},
+ },
+ }
if t.Config.PaiGow.Variant == PaiGowFaceUp {
rates, _ := t.Config.PaiGow.pushAceRates()
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)
+ p.payout += p.pushace.Total * (class.rate + 1)
+ sd.PushAce = fmt.Sprintf("%s (%d:1)", class.desc, class.rate)
+ sd.PushAceWin = true
} else if p.pushace.Total > 0 {
- p.result += "; Push Ace High: lost (dealer not ace-high)"
+ sd.PushAce = "dealer not ace-high"
}
}
if p.fortune.Total > 0 {
row, win := pgFortuneRules(p.cards, t.Config.PaiGow.Envy, t.a2345High())
- p.result += "; Fortune: " + row.Hand
+ sd.Fortune = row.Hand
+ sd.FortuneWin = win
if win {
p.payout += p.fortune.Total * (row.Odds + 1)
}
if t.Config.PaiGow.Envy && p.fortune.Total >= 5 {
- envy := 0
+ envy, others := 0, 0
for _, other := range t.players {
if other != p && other.main.Total > 0 {
+ others++
row, _ := pgFortuneRules(other.cards, true, t.a2345High())
envy += row.Envy
}
}
- p.payout += envy
- p.result += fmt.Sprintf("; Envy: %d", envy)
+ // Envy only means something with someone else at the table.
+ if others > 0 {
+ sd.ShowEnvy = true
+ sd.Envy = envy
+ p.payout += envy
+ }
}
}
+ sd.Stake = p.main.Total + p.fortune.Total + p.pushace.Total
+ sd.Payout = p.payout
+ p.showdown = sd
}
return t.payLocked()
}
@@ -492,16 +662,21 @@ func (t *paiGowTable) payLocked() []Event {
// returns. Freeze the round on failure; retries never redeal or pay twice.
if err := p.wallet.PayCredits(p.payout, ids...); err != nil {
pending = true
+ if p.showdown != nil {
+ p.showdown.Pending = true
+ }
events = append(events, t.private(p.name, payoutFailedMessage()+" Use settle to retry the same result."))
continue
}
p.paid = true
- p.result += fmt.Sprintf("; total returned %d credits", p.payout)
+ if p.showdown != nil {
+ p.showdown.Paid = true
+ }
newlyPaid = append(newlyPaid, p.name)
}
if pending {
for _, name := range newlyPaid {
- events = append(events, t.statusLocked(name)...)
+ events = append(events, t.statusLocked(name, false)...)
}
return events
}
@@ -518,7 +693,7 @@ func (t *paiGowTable) payLocked() []Event {
}
t.players = kept
for _, p := range t.players {
- events = append(events, t.statusLocked(p.name)...)
+ events = append(events, t.statusLocked(p.name, false)...)
}
return events
}
@@ -650,7 +825,8 @@ func (t *paiGowTable) resetTableLocked() {
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, ""
+ p.cards, p.split, p.pending, p.ready, p.paid, p.result = nil, pgSplit{}, pgSplit{}, false, false, ""
+ p.showdown = nil
}
}
@@ -710,7 +886,7 @@ func (t *paiGowTable) unbetLocked(p *pgPlayer, action string) []Event {
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)...)
+ return append([]Event{t.private(p.name, fmt.Sprintf("You take back %d credits.", total))}, t.statusLocked(p.name, false)...)
}
func (t *paiGowTable) refundAll() []Event {
t.mu.Lock()
diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go
index ced7c47..3a1f4b5 100644
--- a/internal/casino/pai_gow_rules.go
+++ b/internal/casino/pai_gow_rules.go
@@ -21,7 +21,11 @@ type PaiGowConfig struct {
Variant string `yaml:"variant,omitempty"`
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"`
+ // Autoset makes a legal arrangement lock immediately instead of waiting
+ // for the player's "set" confirmation. Players may toggle it per seat with
+ // "autoset on|off"; this field only seeds the default at join.
+ Autoset bool `yaml:"autoset,omitempty"`
+ 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.
@@ -95,7 +99,7 @@ func paiGowRules(c TableConfig) string {
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."
+ s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. " + wheel + "\nArrange two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. Your five-card hand must strictly outrank your two-card hand. The board then shows the five high cards and the two low cards, and set locks the hand and reveals the dealer; 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)."
@@ -106,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. Use house or set <digits>; set locks your hand. 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). 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."
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 {
@@ -148,6 +152,7 @@ func pgCompare(a, b pgRank) int {
}
return 0
}
+
// pgNatural keeps the default-rules entry point used by tests and callers that
// do not thread a table configuration. Production evaluation goes through
// pgNaturalRules.
@@ -258,6 +263,73 @@ func pgEvaluateRules(cards []card, a2345High bool) pgRank {
return best
}
+// pgRankWords names card ranks for the hand-strength labels shown at the
+// showdown. Index 14 is ACE so a pair of aces reads "PAIR OF ACES".
+var pgRankWords = [...]string{"", "", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN", "JACK", "QUEEN", "KING", "ACE"}
+
+func pgRankWord(v int) string {
+ if v >= 2 && v <= 14 {
+ return pgRankWords[v]
+ }
+ return "?"
+}
+
+func pgRankPlural(v int) string {
+ word := pgRankWord(v)
+ switch word {
+ case "SIX":
+ return "SIXES"
+ case "ACE":
+ return "ACES"
+ default:
+ return word + "S"
+ }
+}
+
+func pgStraightName(v int) string {
+ switch v {
+ case 16:
+ return "ACE-HIGH"
+ case 15:
+ return "FIVE-HIGH"
+ default:
+ return pgRankWord(v) + "-HIGH"
+ }
+}
+
+// pgRankName renders a hand strength for the showdown, for example
+// "PAIR OF ACES", "TWO PAIR ACES AND KINGS" or "STRAIGHT ACE-HIGH".
+func pgRankName(r pgRank) string {
+ switch r[0] {
+ case 9:
+ return "FIVE OF A KIND " + pgRankPlural(r[1])
+ case 8:
+ if r[1] == 16 {
+ return "ROYAL FLUSH"
+ }
+ return "STRAIGHT FLUSH " + pgStraightName(r[1])
+ case 7:
+ return "FOUR OF A KIND " + pgRankPlural(r[1])
+ case 6:
+ return "FULL HOUSE " + pgRankPlural(r[1]) + " FULL OF " + pgRankPlural(r[2])
+ case 5:
+ return "FLUSH " + pgRankWord(r[1]) + "-HIGH"
+ case 4:
+ return "STRAIGHT " + pgStraightName(r[1])
+ case 3:
+ return "THREE OF A KIND " + pgRankPlural(r[1])
+ case 2:
+ return "TWO PAIR " + pgRankPlural(r[1]) + " AND " + pgRankPlural(r[2])
+ case 1:
+ return "PAIR OF " + pgRankPlural(r[1])
+ default:
+ if r[1] > 0 {
+ return "HIGH CARD " + pgRankWord(r[1])
+ }
+ return "HIGH CARD"
+ }
+}
+
type pgSplit struct{ high, low []card }
func pgSet(cards []card, digits []int) pgSplit {
@@ -437,6 +509,7 @@ func pgFortuneRules(cards []card, envy, a2345High bool) (FortuneRow, bool) {
}
return rows[bestIndex], true
}
+
// pgBest keeps the default-rules entry point used by tests and callers that do
// not thread a table configuration. Production evaluation goes through
// pgBestRules.
diff --git a/internal/casino/pai_gow_test.go b/internal/casino/pai_gow_test.go
index 02ea187..3936704 100644
--- a/internal/casino/pai_gow_test.go
+++ b/internal/casino/pai_gow_test.go
@@ -278,14 +278,17 @@ func TestPaiGowAtomicRetryAndEnvy(t *testing.T) {
// 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")
tab.player("b").cards = pgCards("Ks Kh Kc Kd 2h 3c 7h")
+ // Autoset is off by default, so each player previews then confirms.
tab.action("a", "house")
tab.action("b", "house")
+ tab.action("a", "set")
+ tab.action("b", "set")
if tab.phase != "settling" || len(a.open) != 2 || len(b.open) != 0 || b.calls != 1 {
t.Fatal("failed settlement wasn't held atomically")
}
p := tab.player("a")
- if !strings.Contains(p.result, "Envy: 5") {
- t.Fatal(p.result)
+ if p.showdown == nil || !p.showdown.ShowEnvy || p.showdown.Envy != 5 {
+ t.Fatalf("envy not reported: %+v", p.showdown)
}
cards := append([]card(nil), p.cards...)
amount := p.payout
@@ -312,14 +315,18 @@ func TestPaiGowRejectedFoulDoesNotLock(t *testing.T) {
p := tab.player("a")
p.cards = pgCards("As Ah 2c 3d 4s 7h 9c")
events := tab.action("a", "12")
- if p.ready || p.split.high != nil || !strings.Contains(events[0].Message, "FOUL HAND") {
+ if p.ready || p.split.high != nil || p.pending.high != nil || !strings.Contains(events[0].Message, "FOUL HAND") {
t.Fatal(events)
}
tab.action("a", "34567")
- if p.ready {
+ if p.ready || p.pending.high != nil {
t.Fatal("complement foul should fail")
}
tab.action("a", "67")
+ if p.ready || p.pending.high == nil {
+ t.Fatal("a legal arrangement should preview, not lock")
+ }
+ tab.action("a", "set")
if tab.phase != "waiting" {
t.Fatal("legal set did not settle")
}
@@ -351,10 +358,15 @@ func TestPaiGowEnvyEligibilityAndMultipleAwards(t *testing.T) {
tab.dealerSplit = pgHouse(tab.dealer)
for _, name := range []string{"a", "b", "c"} {
tab.action(name, "house")
+ tab.action(name, "set")
}
p := tab.player("a")
main := pgMainPay(20, pgMainResult(p.split, tab.dealerSplit, tab.dealer, PaiGowClassic, tab.tie), PaiGowClassic)
- if want := main + tt.stake*151 + tt.envy; p.payout != want {
+ wantEnvy := 0
+ if tt.enabled && tt.stake >= 5 {
+ wantEnvy = tt.envy
+ }
+ if want := main + tt.stake*151 + wantEnvy; p.payout != want {
t.Fatalf("%+v payout=%d want=%d", tt, p.payout, want)
}
}
@@ -429,6 +441,7 @@ func TestPaiGowFaceUpAndPushAceHigh(t *testing.T) {
t.Fatal("round resolved before player set")
}
tab.action("a", "house")
+ tab.action("a", "set")
if tab.phase != "waiting" || len(w.open) != 0 {
t.Fatal("Face Up settlement did not finish", tab.phase, w.open)
}
@@ -491,6 +504,134 @@ func TestPaiGowA2345Option(t *testing.T) {
}
}
+func TestPaiGowRankNames(t *testing.T) {
+ cases := []struct {
+ cards string
+ name string
+ }{
+ {"As Ah", "PAIR OF ACES"},
+ {"Ks Qh", "HIGH CARD KING"},
+ {"As Ah Kc Kd 2s", "TWO PAIR ACES AND KINGS"},
+ {"As Kh Qc Jd 10s", "STRAIGHT ACE-HIGH"},
+ {"2s 3h 4c 5d 6s", "STRAIGHT SIX-HIGH"},
+ {"As 2h 3c 4d 5s", "STRAIGHT FIVE-HIGH"},
+ {"10s Js Qs Ks As", "ROYAL FLUSH"},
+ {"Ks Kh Kd Kc 2s", "FOUR OF A KIND KINGS"},
+ {"Ks Kh Kd Qc Qs", "FULL HOUSE KINGS FULL OF QUEENS"},
+ }
+ for _, tt := range cases {
+ if got := pgRankName(pgEvaluate(pgCards(tt.cards))); got != tt.name {
+ t.Errorf("%s = %q want %q", tt.cards, got, tt.name)
+ }
+ }
+}
+
+func TestPaiGowManualSetConfirmsBeforeReveal(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)
+ tab.bet("b", "main", 10, wb)
+ p := tab.player("a")
+ p.cards = pgCards("As Ah 2c 3d 4s 7h 9c")
+ events := tab.action("a", "house")
+ if p.ready || p.pending.high == nil {
+ t.Fatalf("house should preview, not lock: ready=%v pending=%+v", p.ready, p.pending)
+ }
+ snap := events[len(events)-1].Snapshot.(*PaiGowSnapshot)
+ if !snap.Preview || len(snap.PreviewHigh) != 5 || len(snap.PreviewLow) != 2 {
+ t.Fatalf("arranged preview missing: %+v", snap)
+ }
+ if len(snap.DealerHigh) != 0 {
+ t.Fatal("dealer revealed before set")
+ }
+ // "set" locks the arrangement but the round stays open for b.
+ events = tab.action("a", "set")
+ if !p.ready || tab.phase != "setting" {
+ t.Fatalf("set did not lock: ready=%v phase=%s", p.ready, tab.phase)
+ }
+ snap = events[len(events)-1].Snapshot.(*PaiGowSnapshot)
+ if snap.Showdown != nil {
+ t.Fatal("showdown before every seat set")
+ }
+ // b finishing the house way settles both.
+ tab.action("b", "house")
+ tab.action("b", "set")
+ if tab.phase != "waiting" || p.showdown == nil || len(p.showdown.Hands) != 4 {
+ t.Fatalf("round did not settle: phase=%s showdown=%+v", tab.phase, p.showdown)
+ }
+ // The showdown compares high hands first, then low hands.
+ labels := []string{p.showdown.Hands[0].Label, p.showdown.Hands[1].Label, p.showdown.Hands[2].Label, p.showdown.Hands[3].Label}
+ want := []string{"Your high", "Dealer high", "Your low", "Dealer low"}
+ if !reflect.DeepEqual(labels, want) {
+ t.Fatalf("showdown order %v want %v", labels, want)
+ }
+}
+
+func TestPaiGowPreviewKeepsOriginalPositions(t *testing.T) {
+ tab := pgTestTable(false)
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ tab.bet("a", "", 10, w)
+ p := tab.player("a")
+ p.cards = pgCards("As Ah 2c 3d 4s 7h 9c")
+ events := tab.action("a", "24")
+ snap := events[len(events)-1].Snapshot.(*PaiGowSnapshot)
+ if !snap.Preview {
+ t.Fatal("a legal arrangement should preview")
+ }
+ if !reflect.DeepEqual(snap.PreviewLowNums, []int{2, 4}) {
+ t.Fatalf("low numbers %v", snap.PreviewLowNums)
+ }
+ if !reflect.DeepEqual(snap.PreviewHighNums, []int{1, 3, 5, 6, 7}) {
+ t.Fatalf("high numbers %v", snap.PreviewHighNums)
+ }
+}
+
+func TestPaiGowAutosetLocksWithoutPreview(t *testing.T) {
+ cfg := TableConfig{ID: "pg", Game: GamePaiGow, BettingTicks: 2, PaiGow: PaiGowConfig{Autoset: true, ActionTicks: 60}}.Normalize()
+ tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(3))).(*paiGowTable)
+ wa, wb := &testWallet{chips: 100}, &testWallet{chips: 100}
+ tab.join("a")
+ tab.join("b")
+ if !tab.player("a").autoset {
+ t.Fatal("table config did not seed the seat's autoset")
+ }
+ tab.bet("a", "main", 10, wa)
+ tab.bet("b", "main", 10, wb)
+ p := tab.player("a")
+ p.cards = pgCards("As Ah 2c 3d 4s 7h 9c")
+ events := tab.action("a", "house")
+ if !p.ready || p.pending.high != nil {
+ t.Fatalf("autoset did not lock: ready=%v pending=%+v", p.ready, p.pending)
+ }
+ snap := events[len(events)-1].Snapshot.(*PaiGowSnapshot)
+ if snap.Preview || len(snap.Cards) != 0 {
+ t.Fatalf("autoset re-showed the hand: preview=%v cards=%d", snap.Preview, len(snap.Cards))
+ }
+ if !strings.Contains(snap.Result, "waiting") {
+ t.Fatalf("waiting note missing: %q", snap.Result)
+ }
+}
+
+func TestPaiGowAutosetToggle(t *testing.T) {
+ tab := pgTestTable(false)
+ w := &testWallet{chips: 100}
+ tab.join("a")
+ if events := tab.action("a", "autoset on"); !hasMessage(events, "Autoset is on.") {
+ t.Fatalf("autoset on failed: %+v", events)
+ }
+ tab.bet("a", "", 10, w)
+ p := tab.player("a")
+ p.cards = pgCards("As Ah 2c 3d 4s 7h 9c")
+ tab.action("a", "24")
+ // A lone bettor settles at once, so a locked hand shows as a showdown.
+ if p.showdown == nil && (!p.ready || p.pending.high != nil) {
+ t.Fatalf("toggled autoset did not lock: ready=%v pending=%+v", p.ready, p.pending)
+ }
+}
+
func TestPaiGowSoloMainDealsAfterSideBets(t *testing.T) {
tab := pgTestTable(false)
w := &testWallet{chips: 100}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 66c90a1..b15c965 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -897,6 +897,54 @@ func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, sty
return lines
}
+// renderCardRow lays cards side by side, each column padded to its own art
+// or label width, and appends an optional centered label row. minWidth lets
+// the caller force a floor (video poker's HELD labels need it) while Pai Gow
+// passes 0. It is the shared "cards with numbers underneath" presentation.
+func renderCardRow(cards []casino.CardView, labels []string, minWidth int, mode string, unicode bool, size int, style string, art *cardart.Store) []string {
+ if len(cards) == 0 {
+ return nil
+ }
+ artLines := make([][]string, len(cards))
+ widths := make([]int, len(cards))
+ height := 0
+ for i, card := range cards {
+ artLines[i] = cardsLine(mode, []casino.CardView{card}, size, unicode, style, art)
+ height = max(height, len(artLines[i]))
+ widths[i] = minWidth
+ for _, line := range artLines[i] {
+ widths[i] = max(widths[i], color.VisibleLen(line))
+ }
+ if i < len(labels) {
+ widths[i] = max(widths[i], color.VisibleLen(labels[i]))
+ }
+ }
+ lines := make([]string, 0, height+1)
+ for row := 0; row < height; row++ {
+ parts := make([]string, len(cards))
+ for i := range cards {
+ text := ""
+ if row < len(artLines[i]) {
+ text = artLines[i][row]
+ }
+ parts[i] = rouletteCenter(text, widths[i])
+ }
+ lines = append(lines, strings.Join(parts, " "))
+ }
+ if len(labels) > 0 {
+ parts := make([]string, len(cards))
+ for i := range cards {
+ text := ""
+ if i < len(labels) {
+ text = labels[i]
+ }
+ parts[i] = rouletteCenter(text, widths[i])
+ }
+ lines = append(lines, strings.Join(parts, " "))
+ }
+ return lines
+}
+
func cardRenderSize(unicode bool, size int) int {
if size == 1 {
return 1
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index d8f6b76..acac68b 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -300,7 +300,7 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
if p != nil && g.Casino != nil {
if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GamePaiGow {
numeric := cmd != "" && cmd[0] >= '0' && cmd[0] <= '9'
- if numeric || cmd == "set" || cmd == "house" || cmd == "settle" {
+ if numeric || cmd == "set" || cmd == "house" || cmd == "settle" || cmd == "autoset" {
g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " "))
return
}
diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go
index af6b749..9b37a68 100644
--- a/internal/game/pai_gow_test.go
+++ b/internal/game/pai_gow_test.go
@@ -39,19 +39,179 @@ func TestPaiGowCommandsLookAndSettlement(t *testing.T) {
if classifyCommand("67") != ClassInstant || classifyCommand("13456") != ClassInstant {
t.Fatal("numeric classification")
}
- // A numeric selection either locks a valid split or gives a clear foul;
- // either way it must reach Pai Gow rather than the generic unknown verb.
+ // A numeric selection either previews a valid arrangement or gives a clear
+ // foul; either way it must reach Pai Gow rather than the generic unknown
+ // verb, and must not lock the hand before "set".
conn.data = nil
g.executeCommand(sess, "67", nil, "67")
text = string(conn.data)
- if table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot).Phase == "setting" && !strings.Contains(text, "FOUL HAND") {
+ snap = table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot)
+ if snap.Phase == "setting" && !strings.Contains(text, "FOUL HAND") && !strings.Contains(text, "arranged hand") {
t.Fatal(text)
}
g.executeCommand(sess, "house", nil, "house")
+ g.executeCommand(sess, "set", nil, "set")
if table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot).Phase != "waiting" || len(sess.Player.PendingWagers) != 0 {
t.Fatal("settlement did not finish")
}
}
+func pgTestSnap() *casino.PaiGowSnapshot {
+ cv := func(rank, suit string) casino.CardView { return casino.CardView{Rank: rank, Suit: suit} }
+ return &casino.PaiGowSnapshot{
+ Config: casino.TableConfig{Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{Envy: true}},
+ Phase: "setting",
+ Cards: []casino.CardView{cv("A", "diamonds"), cv("K", "clubs"), cv("9", "clubs"), cv("A", "spades"), cv("Q", "spades"), cv("2", "diamonds"), cv("6", "hearts")},
+ }
+}
+
+func TestPaiGowFeltOnlyOnJoin(t *testing.T) {
+ s := pgTestSnap()
+ s.ShowFelt = true
+ withFelt := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil))
+ if !strings.Contains(withFelt, "FORTUNE") {
+ t.Fatalf("join did not show the felt:\n%s", withFelt)
+ }
+ s.ShowFelt = false
+ without := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil))
+ if strings.Contains(without, "FORTUNE") || strings.Contains(without, "COMMISSION") {
+ t.Fatalf("felt leaked into an action:\n%s", without)
+ }
+}
+
+func TestPaiGowCardsShowNumbersUnderneath(t *testing.T) {
+ s := pgTestSnap()
+ out := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil))
+ if !strings.Contains(out, "Your seven cards") {
+ t.Fatalf("missing dealt-hand header:\n%s", out)
+ }
+ // The old side-by-side position key must be gone; the numbers now sit
+ // under the cards, one per column.
+ if strings.Contains(out, "1=A") || strings.Contains(out, "=") {
+ t.Fatalf("stale position key still present:\n%s", out)
+ }
+ for _, n := range []string{"1", "2", "3", "4", "5", "6", "7"} {
+ if !strings.Contains(out, n) {
+ t.Fatalf("missing position %s:\n%s", n, out)
+ }
+ }
+}
+
+func TestPaiGowArrangedPreviewHighThenLow(t *testing.T) {
+ s := pgTestSnap()
+ s.Preview = true
+ s.PreviewHigh = s.Cards[:5]
+ s.PreviewLow = s.Cards[5:]
+ s.PreviewHighNums = []int{1, 2, 3, 4, 5}
+ s.PreviewLowNums = []int{6, 7}
+ out := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil))
+ if !strings.Contains(out, "HIGH") || !strings.Contains(out, "LOW") {
+ t.Fatalf("missing HIGH/LOW labels:\n%s", out)
+ }
+ // The five-card high hand precedes the two-card low hand, and its number
+ // row still keys back to the dealt positions.
+ hi := strings.Index(out, "HIGH")
+ lo := strings.Index(out, "LOW")
+ if hi < 0 || lo < 0 || hi > lo {
+ t.Fatalf("HIGH/LOW order wrong:\n%s", out)
+ }
+ if !strings.Contains(out, "arranged hand") {
+ t.Fatalf("missing set prompt:\n%s", out)
+ }
+}
+
+func TestPaiGowShowdownOrderNamesAndColors(t *testing.T) {
+ cv := func(rank, suit string) casino.CardView { return casino.CardView{Rank: rank, Suit: suit} }
+ s := pgTestSnap()
+ s.Phase = "waiting"
+ s.Showdown = &casino.PaiGowShowdown{
+ Result: "loss",
+ Hands: []casino.PaiGowHandView{
+ {Label: "Your high", Strength: "STRAIGHT ACE-HIGH", Cards: []casino.CardView{cv("A", "diamonds"), cv("K", "clubs"), cv("Q", "clubs"), cv("J", "spades"), cv("10", "spades")}, Winning: true, HasWin: true},
+ {Label: "Dealer high", Strength: "TWO PAIR ACES AND KINGS", Cards: []casino.CardView{cv("A", "clubs"), cv("A", "hearts"), cv("K", "diamonds"), cv("K", "spades"), cv("9", "clubs")}, Winning: false, HasWin: true},
+ {Label: "Your low", Strength: "PAIR OF ACES", Cards: []casino.CardView{cv("A", "diamonds"), cv("2", "diamonds")}, Winning: false, HasWin: true},
+ {Label: "Dealer low", Strength: "HIGH CARD KING", Cards: []casino.CardView{cv("K", "clubs"), cv("9", "hearts")}, Winning: true, HasWin: true},
+ },
+ Stake: 25, Payout: 50, Paid: true,
+ }
+ raw := renderPaiGow(s, "xterm256", true, 7, "dark", nil)
+ clean := stripANSI(raw)
+ high := strings.Index(clean, "Your high")
+ dealerHigh := strings.Index(clean, "Dealer high")
+ low := strings.Index(clean, "Your low")
+ dealerLow := strings.Index(clean, "Dealer low")
+ if !(high >= 0 && high < dealerHigh && dealerHigh < low && low < dealerLow) {
+ t.Fatalf("showdown order wrong:\n%s", clean)
+ }
+ for _, want := range []string{"STRAIGHT ACE-HIGH", "TWO PAIR ACES AND KINGS", "PAIR OF ACES", "HIGH CARD KING", "DEALER WINS THE ROUND", "Net +25 credits."} {
+ if !strings.Contains(clean, want) {
+ t.Fatalf("showdown missing %q:\n%s", want, clean)
+ }
+ }
+ // 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)
+ }
+ if !strings.Contains(raw, "38;5;15m") {
+ t.Fatalf("losing strength not white:\n%q", raw)
+ }
+}
+
+func TestPaiGowOutcomeOmitsEnvyWithoutOthers(t *testing.T) {
+ s := pgTestSnap()
+ s.Phase = "waiting"
+ s.Showdown = &casino.PaiGowShowdown{
+ Result: "win",
+ Hands: []casino.PaiGowHandView{
+ {Label: "Your high", Strength: "PAIR OF KINGS", Winning: true, HasWin: true},
+ {Label: "Dealer high", Strength: "HIGH CARD ACE", Winning: false, HasWin: true},
+ {Label: "Your low", Strength: "PAIR OF ACES", Winning: true, HasWin: true},
+ {Label: "Dealer low", Strength: "HIGH CARD KING", Winning: false, HasWin: true},
+ },
+ Payout: 20, Paid: true,
+ }
+ out := stripANSI(renderPaiGow(s, "none", true, 7, "dark", nil))
+ if strings.Contains(out, "Envy") {
+ t.Fatalf("envy shown without other players:\n%s", out)
+ }
+ if !strings.Contains(out, "YOU WIN THE ROUND") {
+ t.Fatalf("overall win missing:\n%s", out)
+ }
+}
+
+func TestPaiGowSetCommandLocksPreview(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")
+ g.executeBet(sess, []string{"20"}, "bet 20")
+ table, _ := g.Casino.Participation(sess.Player.Name)
+ // house only arranges; the hand stays unset until set.
+ conn.data = nil
+ g.executeCommand(sess, "house", nil, "house")
+ snap := table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot)
+ if !snap.Preview || table.State() != "setting" {
+ t.Fatalf("house should preview, not settle: preview=%v phase=%s", snap.Preview, table.State())
+ }
+ // set locks and, for a lone bettor, settles the round.
+ g.executeCommand(sess, "set", nil, "set")
+ if table.State() != "waiting" {
+ t.Fatalf("set did not settle the round: %s", table.State())
+ }
+}
+
+func TestPaiGowAutosetConfigSkipsPreview(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 autoset: true\n")
+ g.executePlay(sess, []string{"pai"}, "play pai")
+ g.executeBet(sess, []string{"20"}, "bet 20")
+ conn.data = nil
+ g.executeCommand(sess, "house", nil, "house")
+ text := stripANSI(string(conn.data))
+ if strings.Contains(text, "arranged hand") {
+ t.Fatalf("autoset showed the arrangement preview:\n%s", text)
+ }
+ if !strings.Contains(text, "DEALER WINS") && !strings.Contains(text, "YOU WIN") && !strings.Contains(text, "PUSH") {
+ t.Fatalf("autoset did not reach the showdown:\n%s", text)
+ }
+}
+
func TestPaiGowFeltAndJokerAllModes(t *testing.T) {
store := cardart.Load("../../data/cards.yaml")
for _, size := range []int{1, 5, 7, 9} {
@@ -228,8 +388,10 @@ func TestPaiGowStopMidRoundQueuesDeparture(t *testing.T) {
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.
+ // Finishing the hand applies the queued departure. Autoset is off, so
+ // house arranges and set commits.
g.executeCommand(sess, "house", nil, "house")
+ g.executeCommand(sess, "set", nil, "set")
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 d364c61..4b31373 100644
--- a/internal/game/render_pai_gow.go
+++ b/internal/game/render_pai_gow.go
@@ -6,6 +6,13 @@ import (
"thehouseoficarus/internal/cardart"
"thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+var (
+ paiGowWin = color.ColorSpec{Fg: color.NearestXterm256(80, 220, 100), Bold: true}
+ paiGowLoss = color.ColorSpec{Fg: color.NearestXterm256(240, 80, 80), Bold: true}
+ paiGowPush = color.ColorSpec{Fg: 15, Bold: true}
)
func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string {
@@ -55,30 +62,190 @@ func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string
c.center(1, 75, 22, joker, crapsMuted)
return c.render(mode, unicode)
}
+
+// paiGowNumberLabels centers the card's original position number under it.
+func paiGowNumberLabels(nums []int, mode string) []string {
+ labels := make([]string, len(nums))
+ for i, n := range nums {
+ labels[i] = color.Render(mode, color.ColorSpec{Fg: 15, Bold: true}, fmt.Sprintf("%d", n))
+ }
+ 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.
+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
+ }
+ var labels []string
+ if len(nums) > 0 {
+ labels = paiGowNumberLabels(nums, mode)
+ }
+ lines := renderCardRow(cards, labels, 0, mode, unicode, size, style, store)
+ if header == "" {
+ return lines
+ }
+ width := 0
+ for _, line := range lines {
+ width = max(width, color.VisibleLen(line))
+ }
+ return append([]string{rouletteCenter(header, width)}, lines...)
+}
+
+// paiGowJoinBlocks sets two card blocks side by side with a gutter between
+// them, so the high hand and the low hand read as separate groups.
+func paiGowJoinBlocks(left, right []string, gutter string) []string {
+ height := max(len(left), len(right))
+ leftWidth := 0
+ for _, line := range left {
+ leftWidth = max(leftWidth, color.VisibleLen(line))
+ }
+ lines := make([]string, 0, height)
+ for i := 0; i < height; i++ {
+ l, r := "", ""
+ if i < len(left) {
+ l = left[i]
+ }
+ if i < len(right) {
+ r = right[i]
+ }
+ lines = append(lines, l+strings.Repeat(" ", leftWidth-color.VisibleLen(l))+gutter+r)
+ }
+ return lines
+}
+
+// paiGowStrength paints a hand's name yellow when it won its comparison and
+// white when it did not.
+func paiGowStrength(mode, strength string, winning bool) string {
+ spec := color.ColorSpec{Fg: 15, Bold: true}
+ if winning {
+ spec = color.ColorSpec{Fg: pokerYellow, Bold: true}
+ }
+ return color.Render(mode, spec, strength)
+}
+
+// paiGowArranged renders the preview: the five-card high hand, a gap, then the
+// two-card low hand, each with its original position numbers underneath.
+func paiGowArranged(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) []string {
+ high := paiGowCardBlock(color.Render(mode, paiGowPush, "HIGH"), s.PreviewHigh, s.PreviewHighNums, mode, unicode, size, style, store)
+ low := paiGowCardBlock(color.Render(mode, paiGowPush, "LOW"), s.PreviewLow, s.PreviewLowNums, mode, unicode, size, style, store)
+ lines := []string{"Your arranged hand — high first, then low. Type set to lock it, or choose again:"}
+ return append(lines, paiGowJoinBlocks(high, low, " ")...)
+}
+
+// paiGowOutcome renders the comparison narrative and settlement summary.
+func paiGowOutcome(sd *casino.PaiGowShowdown, mode string) []string {
+ var lines []string
+ if len(sd.Hands) >= 4 {
+ switch {
+ case sd.Hands[0].Winning && sd.Hands[2].Winning:
+ lines = append(lines, "You take both hands.")
+ case !sd.Hands[0].Winning && !sd.Hands[2].Winning:
+ lines = append(lines, "The dealer takes both hands.")
+ default:
+ high, low := "You take the high hand", "you take the low hand"
+ if !sd.Hands[0].Winning {
+ high = "The dealer takes the high hand"
+ }
+ if !sd.Hands[2].Winning {
+ low = "the dealer takes the low hand"
+ }
+ lines = append(lines, high+"; "+low+".")
+ }
+ }
+ spec, label := paiGowLoss, "DEALER WINS THE ROUND"
+ switch {
+ case strings.HasPrefix(sd.Result, "win"):
+ spec, label = paiGowWin, "YOU WIN THE ROUND"
+ case strings.HasPrefix(sd.Result, "push"):
+ spec, label = paiGowPush, "PUSH — YOUR WAGER IS RETURNED"
+ if detail := strings.Trim(strings.TrimPrefix(sd.Result, "push"), " ()"); detail != "" {
+ label = "PUSH (" + strings.ToUpper(detail) + ") — YOUR WAGER IS RETURNED"
+ }
+ }
+ lines = append(lines, color.Render(mode, spec, label))
+ if sd.Fortune != "" {
+ if sd.FortuneWin {
+ lines = append(lines, "Fortune: "+color.Render(mode, paiGowWin, strings.ToUpper(sd.Fortune)+" — WIN"))
+ } else {
+ lines = append(lines, "Fortune: no win.")
+ }
+ }
+ if sd.PushAce != "" {
+ if sd.PushAceWin {
+ lines = append(lines, "Push Ace High: "+color.Render(mode, paiGowWin, sd.PushAce+" — WIN"))
+ } else {
+ lines = append(lines, "Push Ace High: "+sd.PushAce+".")
+ }
+ }
+ if sd.ShowEnvy && sd.Envy > 0 {
+ lines = append(lines, "Envy: "+color.Render(mode, paiGowWin, fmt.Sprintf("+%d credits", sd.Envy)))
+ }
+ switch {
+ case sd.Pending:
+ lines = append(lines, color.Render(mode, paiGowLoss, "PAYMENT PENDING — type settle to retry the same result."))
+ case sd.Paid:
+ net := sd.Payout - sd.Stake
+ netSpec, netText := paiGowPush, "Net 0 credits."
+ if net > 0 {
+ netSpec, netText = paiGowWin, fmt.Sprintf("Net +%d credits.", net)
+ } else if net < 0 {
+ netSpec, netText = paiGowLoss, fmt.Sprintf("Net %d credits.", net)
+ }
+ lines = append(lines, color.Render(mode, netSpec, netText))
+ }
+ return lines
+}
+
+func paiGowShowdown(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) []string {
+ sd := s.Showdown
+ var lines []string
+ for i, hand := range sd.Hands {
+ header := hand.Label
+ if hand.Strength != "" {
+ header += " (" + paiGowStrength(mode, hand.Strength, hand.HasWin && hand.Winning) + ")"
+ }
+ lines = append(lines, paiGowCardBlock(header, hand.Cards, nil, mode, unicode, size, style, store)...)
+ if i == 1 {
+ lines = append(lines, "")
+ }
+ }
+ lines = append(lines, "")
+ return append(lines, paiGowOutcome(sd, mode)...)
+}
+
func renderPaiGow(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string {
size = cardRenderSize(unicode, size)
- lines := []string{renderPaiGowFelt(s.Config, mode, unicode)}
- if len(s.Cards) > 0 {
+ var lines []string
+ if s.ShowFelt {
+ lines = append(lines, renderPaiGowFelt(s.Config, mode, unicode))
+ }
+ switch {
+ case s.Showdown != nil:
+ lines = append(lines, paiGowShowdown(s, mode, unicode, size, style, store)...)
+ case s.Preview:
+ lines = append(lines, paiGowArranged(s, mode, unicode, size, style, store)...)
+ case len(s.Cards) > 0:
lines = append(lines, "Your seven cards (positions 1-7):")
- lines = append(lines, cardsLine(mode, s.Cards, size, unicode, style, store)...)
- // Position labels use each card's actual rendered width; compact cards are
- // variable-width, so print a separate unambiguous position-to-card key.
- var positions []string
- for i, c := range s.Cards {
- positions = append(positions, fmt.Sprintf("%d=%s", i+1, cardSymbol(c)))
- }
- lines = append(lines, strings.Join(positions, " "))
- }
- for _, h := range []struct {
- label string
- cards []casino.CardView
- }{{"Your high", s.High}, {"Your low", s.Low}, {"Dealer high", s.DealerHigh}, {"Dealer low", s.DealerLow}, {"Tiebreaker: player / dealer", s.Tiebreaker}} {
- if len(h.cards) > 0 {
- lines = append(lines, h.label+":")
- lines = append(lines, cardsLine(mode, h.cards, size, unicode, style, store)...)
+ nums := make([]int, len(s.Cards))
+ for i := range nums {
+ nums[i] = i + 1
}
+ lines = append(lines, paiGowCardBlock("", s.Cards, nums, mode, unicode, size, style, store)...)
+ }
+ // Face Up reveals the dealer before the player's decision; the showdown
+ // replaces this block once it exists.
+ if s.Showdown == nil && len(s.DealerHigh) > 0 {
+ lines = append(lines, "")
+ lines = append(lines, paiGowCardBlock("Dealer high ("+s.DealerHighName+")", s.DealerHigh, nil, mode, unicode, size, style, store)...)
+ lines = append(lines, paiGowCardBlock("Dealer low ("+s.DealerLowName+")", s.DealerLow, nil, mode, unicode, size, style, store)...)
+ }
+ if len(s.Tiebreaker) > 0 {
+ lines = append(lines, "Tiebreaker — player / dealer:")
+ lines = append(lines, paiGowCardBlock("", s.Tiebreaker, nil, mode, unicode, size, style, store)...)
}
- if s.Result != "" {
+ if s.Showdown == nil && s.Result != "" {
lines = append(lines, s.Result)
}
return strings.Join(lines, "\n")
diff --git a/internal/game/render_video_poker.go b/internal/game/render_video_poker.go
index b8baef8..b8262c8 100644
--- a/internal/game/render_video_poker.go
+++ b/internal/game/render_video_poker.go
@@ -87,43 +87,20 @@ func renderPokerCards(cards []casino.CardView, held *[5]bool, mode string, unico
if len(cards) == 0 {
return nil
}
- art := make([][]string, len(cards))
- widths := make([]int, len(cards))
- height := 0
- for i, c := range cards {
- art[i] = cardsLine(mode, []casino.CardView{c}, size, unicode, style, store)
- height = max(height, len(art[i]))
- for _, line := range art[i] {
- widths[i] = max(widths[i], color.VisibleLen(line))
- }
- if held != nil {
- widths[i] = max(widths[i], 6)
- }
- }
- var lines []string
- for r := 0; r < height; r++ {
- parts := make([]string, len(cards))
- for i := range cards {
- text := ""
- if r < len(art[i]) {
- text = art[i][r]
- }
- parts[i] = rouletteCenter(text, widths[i])
- }
- lines = append(lines, strings.Join(parts, " "))
- }
+ var labels []string
+ minWidth := 0
if held != nil {
- labels := make([]string, len(cards))
+ labels = make([]string, len(cards))
+ minWidth = 6
for i := range cards {
text := fmt.Sprint(i + 1)
if held[i] {
text = color.Render(mode, color.ColorSpec{Fg: pokerYellow, Bg: -1, Bold: true}, text+" HELD")
}
- labels[i] = rouletteCenter(text, widths[i])
+ labels[i] = text
}
- lines = append(lines, strings.Join(labels, " "))
}
- return lines
+ return renderCardRow(cards, labels, minWidth, mode, unicode, size, style, store)
}
func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string {