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 | |
| 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')
| -rw-r--r-- | internal/casino/pai_gow.go | 262 | ||||
| -rw-r--r-- | internal/casino/pai_gow_rules.go | 79 | ||||
| -rw-r--r-- | internal/casino/pai_gow_test.go | 151 |
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} |
