aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/pai_gow_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/pai_gow_test.go')
-rw-r--r--internal/casino/pai_gow_test.go464
1 files changed, 464 insertions, 0 deletions
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")
+ }
+}