aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-01 17:24:54 -0400
committerhistoria <[not public]>2026-10-01 17:24:54 -0400
commit4e2a95503e4ea4c830510f5aea23fac7948ddc41 (patch)
tree690b06df9094480febfaaaf7d9fc71ef7b81848d /internal/casino
parentd762f8e1360cf38b8e11e69083c2dad59ec7130e (diff)
downloadthehouseoficarus-4e2a95503e4ea4c830510f5aea23fac7948ddc41.tar.gz
feat: set and autoset in pai gow, better showdown arrangement, better formatting
Diffstat (limited to 'internal/casino')
-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
3 files changed, 441 insertions, 51 deletions
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}