aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/pai_gow.go
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/pai_gow.go
parentd762f8e1360cf38b8e11e69083c2dad59ec7130e (diff)
downloadthehouseoficarus-4e2a95503e4ea4c830510f5aea23fac7948ddc41.tar.gz
feat: set and autoset in pai gow, better showdown arrangement, better formatting
Diffstat (limited to 'internal/casino/pai_gow.go')
-rw-r--r--internal/casino/pai_gow.go262
1 files changed, 219 insertions, 43 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()