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") } }