From 1082f3336085c15992ca83d5553c0ba52208d70a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 1 Oct 2026 14:38:08 -0400 Subject: feat: pai gow poker added to the casino --- internal/casino/pai_gow_test.go | 464 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 internal/casino/pai_gow_test.go (limited to 'internal/casino/pai_gow_test.go') diff --git a/internal/casino/pai_gow_test.go b/internal/casino/pai_gow_test.go new file mode 100644 index 0000000..f258b04 --- /dev/null +++ b/internal/casino/pai_gow_test.go @@ -0,0 +1,464 @@ +package casino + +import ( + "math" + "math/rand" + "reflect" + "strings" + "testing" +) + +func pgCards(s string) []card { + var out []card + for _, v := range strings.Fields(s) { + if v == "JK" { + out = append(out, card{"JK", "joker"}) + continue + } + suit := map[byte]string{'s': "spades", 'h': "hearts", 'd': "diamonds", 'c': "clubs"}[v[len(v)-1]] + out = append(out, card{v[:len(v)-1], suit}) + } + return out +} +func TestPaiGowParsingAndFouls(t *testing.T) { + for _, s := range []string{"24", "42", "2 4", "13456", "6 5 4 3 1"} { + d, ok := ParsePaiGowDigits(s) + if !ok { + t.Fatal(s) + } + split := pgSet(pgCards("As Ah 2c 3d 4s 7h 9c"), d) + if len(split.high) != 5 || len(split.low) != 2 { + t.Fatal(split) + } + } + for _, s := range []string{"", "2", "123", "1234", "123456", "1234567", "22", "11234", "08", "28", "-24", "2x4"} { + if _, ok := ParsePaiGowDigits(s); ok { + t.Fatalf("accepted %q", s) + } + } + cards := pgCards("As Ah 2c 3d 4s 7h 9c") + if pgSet(cards, []int{0, 1}).valid() { + t.Fatal("pair low above high-card high") + } + if !pgSet(cards, []int{5, 6}).valid() { + t.Fatal("legal pair high") + } + if !reflect.DeepEqual(pgSet(cards, []int{1, 3}), pgSet(cards, []int{0, 2, 4, 5, 6})) { + t.Fatal("complement selection differs") + } +} +func TestPaiGowRanksAndBug(t *testing.T) { + cases := []struct { + cards string + rank pgRank + }{ + {"As Ah", pgRank{1, 14}}, {"JK Kh", pgRank{0, 14, 13}}, + {"Ks Qh Jc 10d 9s", pgRank{4, 13}}, {"As 2h 3c 4d 5s", pgRank{4, 15}}, {"As Kh Qc Jd 10s", pgRank{4, 16}}, + {"Ks Kh Kd JK 2s", pgRank{3, 13, 14, 2}}, // Joker cannot become a fourth king. + {"As Ah Ad Ac JK", pgRank{9, 14}}, + {"2h 5h 8h Kh JK", pgRank{5, 14, 13, 8, 5, 2}}, + {"10s Js Qs Ks JK", pgRank{8, 16}}, {"As 2h 3c 4d JK", pgRank{4, 15}}, + } + for _, tt := range cases { + if got := pgEvaluate(pgCards(tt.cards)); got != tt.rank { + t.Errorf("%s = %v want %v", tt.cards, got, tt.rank) + } + } +} +func TestPaiGowFortuneCategories(t *testing.T) { + cases := []struct { + cards, hand string + odds int + }{ + {"2s 3s 4s 5s 6s 7s 8s", "Natural seven-card straight flush", 1000}, + {"As 2s 3s 4s 5s 6s 7s", "Natural seven-card straight flush", 1000}, + {"10s Js Qs Ks As Kh Qh", "Royal flush + royal match", 750}, + {"10s Js Qs Ks JK Kh Qh", "Royal flush + royal match", 750}, + {"2s 3s 4s 5s 6s 8s JK", "Wild seven-card straight flush", 500}, + {"As Ah Ac Ad JK 2c 3s", "Five aces", 250}, + {"10s Js Qs Ks As 2h 3d", "Royal flush", 150}, + {"2s 3s 4s 5s 6s 8h 9d", "Straight flush", 50}, + {"Ks Kh Kc Kd 2s 3c JK", "Four of a kind", 25}, + {"Ks Kh Kc Qd Qs 3c 8h", "Full house", 5}, + {"2s 4s 6s 8s Ks 3h 9d", "Flush", 4}, + {"2s 3h 4c 5d 6s 6h 6c", "Three of a kind", 3}, // trips outrank straight for Fortune + {"2s 3h 4c 5d 6s 9h Kc", "Straight", 2}, + {"As Ah Kc Kd 2s 2h 7c", "Three pair", 1}, + {"2s 4h 6c 8d Js Qh Ac", "No Fortune win", 0}, + } + for _, tt := range cases { + row, win := pgFortune(pgCards(tt.cards), true) + if row.Hand != tt.hand || row.Odds != tt.odds || win != (tt.odds > 0) { + t.Errorf("%s: %+v %v", tt.cards, row, win) + } + } + rows := FortunePayTable(false) + if rows[6].Odds != 30 { + t.Fatal(rows) + } + for _, r := range rows { + if r.Envy != 0 { + t.Fatal(r) + } + } +} + +// Published category combination counts also check that the effective +// schedules and the documented theoretical edges cannot silently drift. +func TestPaiGowFortunePublishedEdge(t *testing.T) { + counts := []int{32, 72, 196, 1128, 26020, 184644, 307472, 4188528, 6172088, 7672500, 11034204, 2862000} + for _, envy := range []bool{false, true} { + profit := -121694196 + for i, row := range FortunePayTable(envy) { + profit += row.Odds * counts[i] + } + edge := -float64(profit) / 154143080 + want := 0.034318 + if envy { + want = 0.044292 + } + if math.Abs(edge-want) > 0.000001 { + t.Fatalf("envy %v edge %g", envy, edge) + } + } +} +func TestPaiGowHouseNeverFouls(t *testing.T) { + rng := rand.New(rand.NewSource(21)) + for n := 0; n < 500; n++ { + deck := newShoe(1, 0, rng) + cards := append([]card(nil), deck.cards[:7]...) + if n%2 == 0 { + cards[0] = card{"JK", "joker"} + } + s := pgHouse(cards) + if !s.valid() { + t.Fatalf("foul %v: %+v", cards, s) + } + if !reflect.DeepEqual(s, pgHouse(cards)) { + t.Fatal("nondeterministic house way") + } + } + s := pgHouse(pgCards("As Ah Ks Kh 2s 4h 7c")) + if pgEvaluate(s.low)[0] != 1 || pgEvaluate(s.high)[0] != 1 { + t.Fatal("house should split two pair", s) + } +} +func TestPaiGowVariants(t *testing.T) { + dealer := pgSplit{high: pgCards("Ks Kh 8s 7d 2c"), low: pgCards("Qs Jh")} + win := pgSplit{high: pgCards("As Ah 8h 6d 3c"), low: pgCards("Ks Qh")} + split := pgSplit{high: win.high, low: pgCards("10s 9h")} + for _, variant := range []string{PaiGowClassic, PaiGowEZ, PaiGowNoPush} { + if pgMainResult(win, dealer, append(append([]card(nil), dealer.high...), dealer.low...), variant, [2]card{}) != "win" { + t.Fatal(variant) + } + } + if pgMainResult(dealer, dealer, nil, PaiGowClassic, [2]card{}) != "loss" { + t.Fatal("copies must lose") + } + if pgMainResult(split, dealer, nil, PaiGowClassic, [2]card{}) != "push" { + t.Fatal("split should push") + } + for _, tt := range []struct{ cards, result string }{{"Ks Qh", "win (tiebreaker)"}, {"Ks Kh", "loss (tiebreaker)"}, {"JK As", "loss (tiebreaker)"}, {"JK Ks", "win (tiebreaker)"}} { + cards := pgCards(tt.cards) + if got := pgMainResult(split, dealer, nil, PaiGowNoPush, [2]card{cards[0], cards[1]}); got != tt.result { + t.Fatal(tt, got) + } + } + queen := pgCards("Qs Jh 9c 8d 6s 4h 2c") + if got := pgMainResult(win, dealer, queen, PaiGowEZ, [2]card{}); got != "push (dealer queen-high)" { + t.Fatal(got) + } + if pgMainPay(20, "win", PaiGowClassic) != 39 || pgMainPay(20, "win", PaiGowEZ) != 40 || pgMainPay(20, "push", PaiGowEZ) != 20 || pgMainPay(20, "loss", PaiGowClassic) != 0 { + t.Fatal("wrong payout") + } +} +func pgTestTable(envy bool) *paiGowTable { + cfg := TableConfig{ID: "pg", Game: GamePaiGow, BettingTicks: 2, PaiGow: PaiGowConfig{Envy: envy, ActionTicks: 2}}.Normalize() + return newPaiGowTable(1, cfg, rand.New(rand.NewSource(3))).(*paiGowTable) +} +func TestPaiGowLifecyclePrivacyAndUniqueDeck(t *testing.T) { + tab := pgTestTable(true) + wallets := map[string]*testWallet{} + for _, name := range []string{"a", "b", "c", "d", "e", "f"} { + tab.join(name) + w := &testWallet{chips: 100} + wallets[name] = w + tab.bet(name, "main", 10, w) + tab.bet(name, "fortune", 5, w) + } + tab.join("overflow") + if tab.hasParticipant("overflow") { + t.Fatal("seventh seat") + } + tab.tick() + events := tab.tick() + if tab.phase != "setting" { + t.Fatal(tab.phase) + } + seen := map[card]bool{} + for _, p := range tab.players { + for _, c := range p.cards { + if seen[c] { + t.Fatal("duplicate", c) + } + seen[c] = true + } + } + for _, c := range append(append([]card(nil), tab.dealer...), tab.tie[:]...) { + if seen[c] { + t.Fatal("duplicate", c) + } + seen[c] = true + } + if len(seen) != 51 { + t.Fatal(len(seen)) + } + for _, e := range events { + if e.Public || e.PrivateTo == "" { + t.Fatal("private cards leaked") + } + s := e.Snapshot.(*PaiGowSnapshot) + if len(s.DealerHigh) > 0 || len(s.Tiebreaker) > 0 { + t.Fatal("dealer revealed before set") + } + } + tab.leave("a") + if !tab.hasParticipant("a") { + t.Fatal("left committed round") + } + before := wallets["a"].chips + tab.bet("a", "fortune", 5, wallets["a"]) + if wallets["a"].chips != before { + t.Fatal("late bet charged") + } + tab.markDisconnected("a") + tab.tick() + tab.tick() + if tab.phase != "waiting" || tab.hasParticipant("a") { + t.Fatal("disconnect/timeout did not finish") + } + for _, w := range wallets { + if len(w.open) > 0 { + t.Fatal("unresolved stakes") + } + } +} + +type pgFailWallet struct { + testWallet + fail bool + calls int + ids []string +} + +func (w *pgFailWallet) PayCredits(n int, ids ...string) error { + w.calls++ + if w.fail { + return ErrWalletPersistence + } + w.ids = append([]string(nil), ids...) + return w.testWallet.PayCredits(n, ids...) +} +func TestPaiGowAtomicRetryAndEnvy(t *testing.T) { + tab := pgTestTable(true) + a := &pgFailWallet{testWallet: testWallet{chips: 100}, fail: true} + b := &pgFailWallet{testWallet: testWallet{chips: 100}} + tab.join("a") + tab.join("b") + tab.bet("a", "main", 20, a) + tab.bet("a", "fortune", 5, a) + tab.bet("b", "main", 20, b) + tab.action("a", "deal") + tab.action("b", "deal") + // Script the dealt cards before either player sets. a loses Fortune but + // 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") + tab.action("a", "house") + tab.action("b", "house") + 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) + } + cards := append([]card(nil), p.cards...) + amount := p.payout + tab.action("a", "house") + if !reflect.DeepEqual(cards, p.cards) { + t.Fatal("redrew pending result") + } + a.fail = false + tab.action("a", "settle") + if tab.phase != "waiting" || a.paid != amount || len(a.ids) != 2 || len(a.open) != 0 || b.calls != 1 { + t.Fatal("retry did not settle exactly once") + } + tab.tick() + if a.calls != 2 || b.calls != 1 { + t.Fatal("double payout") + } +} +func TestPaiGowRejectedFoulDoesNotLock(t *testing.T) { + tab := pgTestTable(false) + w := &testWallet{chips: 100} + tab.join("a") + tab.bet("a", "", 10, w) + tab.action("a", "deal") + 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") { + t.Fatal(events) + } + tab.action("a", "34567") + if p.ready { + t.Fatal("complement foul should fail") + } + tab.action("a", "67") + if tab.phase != "waiting" { + t.Fatal("legal set did not settle") + } +} +func TestPaiGowEnvyEligibilityAndMultipleAwards(t *testing.T) { + for _, tt := range []struct { + enabled bool + stake, envy int + }{{true, 5, 25}, {true, 4, 0}, {false, 5, 0}} { + tab := pgTestTable(tt.enabled) + for _, name := range []string{"a", "b", "c"} { + tab.join(name) + w := &testWallet{chips: 100} + tab.bet(name, "main", 20, w) + if name == "a" { + tab.bet(name, "fortune", tt.stake, w) + } + } + tab.tick() + tab.tick() + // Own royal and dealer seven-card straight flush must NOT earn Envy. + tab.player("a").cards = pgCards("10s Js Qs Ks As 2h 3d") + tab.player("b").cards = pgCards("Ks Kh Kc Kd 2h 3c 7h") + tab.player("c").cards = pgCards("2s 3s 4s 5s 6s 8h 9d") + tab.dealer = pgCards("2h 3h 4h 5h 6h 7h 8h") + tab.dealerSplit = pgHouse(tab.dealer) + for _, name := range []string{"a", "b", "c"} { + tab.action(name, "house") + } + 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 { + t.Fatalf("%+v payout=%d want=%d", tt, p.payout, want) + } + } +} +func TestPaiGowFaceUpAndPushAceHigh(t *testing.T) { + // A pair of aces or an ace-headed flush is NOT an ace-high pai gow; only + // a genuine best-five=ace-high triggers the automatic push. + aceHigh := pgCards("As Ks Qh 9c 7d 4s 3h") + pairAces := pgCards("As Ah Ks Qh 9c 7d 3s") + flushAce := pgCards("Ah Kh Qh Jh 9h 3d 2c") + for _, c := range []struct { + cards []card + push bool + }{{aceHigh, true}, {pairAces, false}, {flushAce, false}} { + if got := pgDealerPush(c.cards, PaiGowFaceUp) != ""; got != c.push { + t.Fatalf("%v push=%v", c.cards, got) + } + } + if pgDealerPush(aceHigh, PaiGowEZ) != "" { + t.Fatal("ace-high must not trigger the EZ queen-high push") + } + if pgDealerPush(pgCards("Qs Jh 9c 8d 6s 4h 2c"), PaiGowEZ) == "" { + t.Fatal("EZ queen-high detection drifted") + } + rates, _ := (PaiGowConfig{PushAceHigh: "5_12_40"}).pushAceRates() + if rates[0] != 5 || rates[1] != 12 { + t.Fatal(rates) + } + // Side-bet classes: plain, with-Joker, both-ace-high, and no win. + for _, tt := range []struct { + dealer, player []card + klass string + rate int + }{{ + pgCards("As Kh Qd 9c 7d 4s 3h"), pgCards("2h 3c 4d 5s 6h 7d 8c"), "plain", 5, + }, { + pgCards("JK Kh Qd 9c 7d 4s 2h"), pgCards("2h 3c 4d 5s 6h 7d 8c"), "joker", 15, + }, { + pgCards("Ah Kh Qd 9c 7d 4s 3h"), pgCards("Ad Kd Qh Jc 9s 5d 2c"), "both", 40, + }, { + pgCards("2h 3c 4d 5s 6h 7d 8c"), pgCards("As Kh Qd 9c 7d 4s 3h"), "none", 0, + }} { + got := pgPushAceClass(tt.dealer, tt.player, [3]int{5, 15, 40}) + if got.klass != tt.klass || got.rate != tt.rate { + t.Fatalf("class %v want %v/%d", got, tt.klass, tt.rate) + } + } + // Full engine pass on a Face Up table with the side bet. + cfg := TableConfig{ID: "pg", Game: GamePaiGow, BettingTicks: 1, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "5_15_40", Envy: true, ActionTicks: 1}}.Normalize() + tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(5))).(*paiGowTable) + w := &testWallet{chips: 500} + tab.join("a") + tab.bet("a", "pushace", 10, w) + if len(w.open) > 0 { + t.Fatal("pushace before main") + } + tab.bet("a", "main", 20, w) + if events := tab.bet("a", "pushace", 11, w); len(w.open) != 1 || !strings.Contains(events[0].Message, "half the main wager") { + t.Fatal("pushace cap not enforced", events, w.open) + } + tab.bet("a", "pushace", 10, w) + tab.bet("a", "fortune", 5, w) + tab.action("a", "deal") + snap := tab.status("a")[0].Snapshot.(*PaiGowSnapshot) + // The dealer acts first and the reveal happens in the setting phase. + if len(snap.DealerHigh) != 5 || len(snap.DealerLow) != 2 { + t.Fatal("Face Up dealer was not revealed during setting") + } + // The player must still set (or time out) unless the round auto-pushed. + if tab.phase == "waiting" { + t.Fatal("round resolved before player set") + } + tab.action("a", "house") + if tab.phase != "waiting" || len(w.open) != 0 { + t.Fatal("Face Up settlement did not finish", tab.phase, w.open) + } + // Reconstruct the outcome class from the announced result. + p := tab.player("a") + main := pgMainPay(20, pgMainResult(p.split, tab.dealerSplit, tab.dealer, PaiGowFaceUp, tab.tie), PaiGowFaceUp) + expect := main + if row, win := pgFortune(p.cards, true); win { + expect += 5 * (row.Odds + 1) // the Fortune stake, before settlement resets it + } + if class := pgPushAceClass(tab.dealer, p.cards, [3]int{5, 15, 40}); class.klass != "none" { + expect += p.pushace.Total * (class.rate + 1) + } + if p.payout != expect { + t.Fatalf("payout %d want %d (dealer %v, result %s)", p.payout, expect, tab.dealer, p.result) + } +} +func TestPaiGowConfigurationAndRefund(t *testing.T) { + for _, c := range []TableConfig{{ID: "pg", Game: GamePaiGow, MaxPlayers: 7}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: "fake"}}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{ActionTicks: -1}}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp}}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "9_9_9"}}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowEZ, PushAceHigh: "5_15_40"}}, {ID: "bj", Game: GameBlackjack, PaiGow: PaiGowConfig{Envy: true}}} { + if c.Validate() == nil { + t.Fatal("accepted", c) + } + } + c := TableConfig{ID: "pg", Game: GamePaiGow} + if c.Validate() != nil || c.Normalize().MaxPlayers != 6 { + t.Fatal("defaults") + } + tab := pgTestTable(false) + w := &testWallet{chips: 100} + tab.join("a") + tab.bet("a", "fortune", 5, w) + if len(w.open) > 0 { + t.Fatal("side before main") + } + tab.bet("a", "main", 10, w) + tab.bet("a", "fortune", 5, w) + tab.refundAll() + if w.chips != 100 || len(w.open) > 0 { + t.Fatal("refund failed") + } +} -- cgit v1.2.3