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) wallets[name] = &testWallet{chips: 100} } var events []Event for _, name := range []string{"a", "b", "c", "d", "e", "f"} { w := wallets[name] // Side bets settle before the main wager; the last main bet deals. tab.bet(name, "fortune", 5, w) events = tab.bet(name, "main", 10, w) } tab.join("overflow") if tab.hasParticipant("overflow") { t.Fatal("seventh seat") } 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.Snapshot == nil { continue } 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", "fortune", 5, a) tab.bet("a", "main", 20, a) // b's main wager completes the table and deals immediately. tab.bet("b", "main", 20, b) // 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") // 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 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 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") // A lone main wager deals immediately, so the cards are already out. tab.bet("a", "", 10, w) 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 || p.pending.high != nil || !strings.Contains(events[0].Message, "FOUL HAND") { t.Fatal(events) } tab.action("a", "34567") 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") } } 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) wallets := map[string]*testWallet{} for _, name := range []string{"a", "b", "c"} { tab.join(name) wallets[name] = &testWallet{chips: 100} } for _, name := range []string{"a", "b", "c"} { w := wallets[name] if name == "a" { tab.bet(name, "fortune", tt.stake, w) } tab.bet(name, "main", 20, w) } // The last main wager deals; script the hands before setting. // 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") tab.action(name, "set") } p := tab.player("a") main := pgMainPay(20, pgMainResult(p.split, tab.dealerSplit, tab.dealer, PaiGowClassic, tab.tie), PaiGowClassic) 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) } } } 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) != 1 { t.Fatal("pushace before main should be accepted") } tab.bet("a", "fortune", 5, w) // The cap is checked when the main wager lands. if events := tab.bet("a", "main", 10, w); len(events) == 0 || !strings.Contains(events[0].Message, "half the main wager") { t.Fatal("pushace cap not enforced", events, w.open) } tab.bet("a", "main", 20, w) pushaceStake := tab.player("a").pushace.Total // A lone main wager deals immediately; the dealer is revealed in setting. 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") tab.action("a", "set") 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 += pushaceStake * (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) != 1 { t.Fatal("side before main should be accepted") } tab.bet("a", "main", 10, w) tab.refundAll() if w.chips != 100 || len(w.open) > 0 { t.Fatal("refund failed") } } func TestPaiGowA2345Option(t *testing.T) { if !(PaiGowConfig{}).A2345High() { t.Fatal("A2345 must default to the second-highest straight") } wheel := pgCards("As 2h 3c 4d 5s") if got := pgEvaluate(wheel); got != (pgRank{4, 15}) { t.Fatalf("default wheel rank %v", got) } if got := pgEvaluateRules(wheel, false); got != (pgRank{4, 5}) { t.Fatalf("demoted wheel rank %v", got) } six := pgCards("2h 3c 4d 5s 6h") if pgCompare(pgEvaluateRules(wheel, false), pgEvaluateRules(six, false)) >= 0 { t.Fatal("a demoted wheel must be the lowest straight") } jokerWheel := pgCards("Ah 2c 3d 4s JK") if got := pgEvaluateRules(jokerWheel, false); got[0] != 4 || got[1] != 5 { t.Fatalf("demoted Joker wheel rank %v", got) } } 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} tab.join("a") // Side bets come first; they are accepted before any main wager. tab.bet("a", "fortune", 5, w) if len(w.open) != 1 { t.Fatalf("fortune before main rejected: %v", w.open) } if tab.phase != "waiting" { t.Fatalf("a side bet alone opened the betting window: %s", tab.phase) } // The main wager deals a lone table immediately. tab.bet("a", "main", 10, w) if tab.phase != "setting" { t.Fatalf("solo main did not deal: %s", tab.phase) } if events := tab.bet("a", "fortune", 5, w); len(events) == 0 || !hasMessage(events, "Betting is closed") { t.Fatalf("bet accepted after the deal: %+v", events) } } func TestPaiGowWindowDealsWhenEveryBettorIsIn(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) if tab.phase != "betting" { t.Fatalf("window did not open for a second seat: %s", tab.phase) } tab.bet("b", "main", 10, wb) if tab.phase != "setting" { t.Fatalf("all-bet table did not deal: %s", tab.phase) } } func TestPaiGowUnbetAndStopRefundBeforeDeal(t *testing.T) { tab := pgTestTable(false) wa := &testWallet{chips: 100} tab.join("a") tab.join("b") tab.bet("a", "main", 10, wa) if events := tab.action("a", "unbet"); !hasMessage(events, "take back") { t.Fatalf("unbet failed: %+v", events) } if wa.chips+wa.paid != 100 || len(wa.open) != 0 || tab.phase != "waiting" || tab.player("a").main.Total != 0 { t.Fatalf("unbet left state: chips=%d paid=%d phase=%s", wa.chips, wa.paid, tab.phase) } // A side bet placed and then abandoned is returned when the seat leaves. tab.bet("a", "fortune", 5, wa) if wa.chips+wa.paid != 95 { t.Fatalf("fortune not charged: chips=%d paid=%d", wa.chips, wa.paid) } if events := tab.leave("a"); !hasMessage(events, "You leave Pai Gow Poker.") { t.Fatalf("leave during betting: %+v", events) } if wa.chips+wa.paid != 100 || len(wa.open) != 0 || tab.hasParticipant("a") { t.Fatalf("stop did not refund/remove: chips=%d paid=%d seated=%v", wa.chips, wa.paid, tab.hasParticipant("a")) } } func TestPaiGowPushAceCapAtMainWager(t *testing.T) { cfg := TableConfig{ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "5_15_40"}}.Normalize() tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(9))).(*paiGowTable) w := &testWallet{chips: 100} tab.join("a") tab.bet("a", "pushace", 10, w) if events := tab.bet("a", "main", 10, w); len(events) == 0 || !hasMessage(events, "half the main wager") { t.Fatalf("pushace cap not checked when the main lands: %+v", events) } if w.chips != 90 || tab.player("a").main.Total != 0 { t.Fatalf("rejected main changed state: chips=%d main=%d", w.chips, tab.player("a").main.Total) } } func TestPaiGowRefundAllReturnsSideBetsWithoutMain(t *testing.T) { tab := pgTestTable(false) w := &testWallet{chips: 100} tab.join("a") tab.bet("a", "fortune", 5, w) tab.refundAll() if w.chips != 100 || len(w.open) != 0 { t.Fatalf("side-only refund failed: chips=%d open=%d", w.chips, len(w.open)) } } func TestPaiGowDealtCardsSortedLowToHigh(t *testing.T) { tab := pgTestTable(false) w := &testWallet{chips: 100} tab.join("a") tab.bet("a", "", 10, w) p := tab.player("a") if len(p.cards) != 7 { t.Fatalf("no seven-card deal: %v", p.cards) } for i := 1; i < len(p.cards); i++ { if pgSortRank(p.cards[i-1]) > pgSortRank(p.cards[i]) { t.Fatalf("cards not sorted small to large: %v", p.cards) } } } func TestPaiGowNewSideBetClearsLastRound(t *testing.T) { tab := pgTestTable(false) w := &testWallet{chips: 100} tab.join("a") tab.bet("a", "", 10, w) // a lone bettor deals at once tab.action("a", "house") tab.action("a", "set") p := tab.player("a") if p.showdown == nil { t.Fatal("setup: round did not settle") } // A side bet for the next round must not re-show the previous result. tab.bet("a", "fortune", 5, w) if p.showdown != nil || p.result != "" || len(p.cards) != 0 { t.Fatalf("stale round leaked into a new bet: showdown=%v result=%q cards=%d", p.showdown != nil, p.result, len(p.cards)) } } func TestPaiGowUnbetRemovesEveryWager(t *testing.T) { tab := pgTestTable(false) wa, _ := &testWallet{chips: 100}, &testWallet{chips: 100} tab.join("a") tab.join("b") tab.bet("a", "fortune", 5, wa) tab.bet("a", "main", 10, wa) // b has not bet, so the round is still in the betting window. if tab.phase != "betting" { t.Fatalf("setup: wanted betting phase, got %s", tab.phase) } if events := tab.action("a", "unbet"); !hasMessage(events, "take back") { t.Fatalf("unbet failed: %+v", events) } p := tab.player("a") if wa.chips+wa.paid != 100 || len(wa.open) != 0 || p.main.Total != 0 || p.fortune.Total != 0 { t.Fatalf("unbet left state: chips=%d paid=%d open=%d main=%d fortune=%d", wa.chips, wa.paid, len(wa.open), p.main.Total, p.fortune.Total) } } func TestPaiGowSoloSettingNeverTimesOut(t *testing.T) { cfg := TableConfig{ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{ActionTicks: 1}}.Normalize() tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(3))).(*paiGowTable) w := &testWallet{chips: 100} tab.join("a") tab.bet("a", "", 10, w) if tab.phase != "setting" { t.Fatalf("setup: wanted setting, got %s", tab.phase) } for i := 0; i < 5; i++ { tab.tick() } if tab.phase != "setting" || tab.player("a").ready { t.Fatalf("a lone player was timed out: phase=%s ready=%v", tab.phase, tab.player("a").ready) } } func TestPaiGowMultiplayerTimeoutWarnsAndSets(t *testing.T) { tab := pgTestTable(false) // ActionTicks 2 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) // the second main wager deals if tab.phase != "setting" { t.Fatalf("setup: wanted setting, got %s", tab.phase) } tab.action("a", "house") tab.action("a", "set") warned := false for i := 0; i < 3; i++ { for _, e := range tab.tick() { if e.Color == "casino_warning" && strings.Contains(e.Message, "timed out") { warned = true } } } if !warned { t.Fatal("multiplayer timeout did not announce the auto-set") } if tab.phase != "waiting" || tab.player("b").showdown == nil { t.Fatal("timed-out hand was not set and settled") } } func TestPaiGowBettingWindowTimeoutNotifiesNonBettor(t *testing.T) { tab := pgTestTable(false) // BettingTicks 2 wa, wb := &testWallet{chips: 100}, &testWallet{chips: 100} tab.join("a") tab.join("b") tab.join("c") tab.bet("a", "main", 10, wa) tab.bet("b", "main", 10, wb) notified := false for i := 0; i < 3; i++ { for _, e := range tab.tick() { if e.PrivateTo == "c" && strings.Contains(e.Message, "betting window closed") { notified = true } } } if !notified { t.Fatal("non-bettor was not told the betting window closed") } }