diff options
| author | historia <[not public]> | 2026-10-01 17:24:54 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-01 17:24:54 -0400 |
| commit | 4e2a95503e4ea4c830510f5aea23fac7948ddc41 (patch) | |
| tree | 690b06df9094480febfaaaf7d9fc71ef7b81848d /internal/casino/pai_gow.go | |
| parent | d762f8e1360cf38b8e11e69083c2dad59ec7130e (diff) | |
| download | thehouseoficarus-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.go | 262 |
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() |
