package casino import ( "math/rand" "reflect" "strings" "testing" ) // pokerTestWallet models atomic settlement failure without filesystem I/O. type pokerTestWallet struct { testWallet fail bool bank *pokerTestBank } func (w *pokerTestWallet) LoadPokerBanks(int, string) (map[string][]int, error) { return nil, nil } func (w *pokerTestWallet) PayPoker(n int, id string, room int, cabinet string, banks map[string][]int) error { if err := w.PayCredits(n, id); err != nil { return err } return w.bank.save(room, cabinet, banks) } func (w *pokerTestWallet) PayCredits(n int, ids ...string) error { if w.fail { return ErrWalletPersistence } return w.testWallet.PayCredits(n, ids...) } func (w *pokerTestWallet) RefundStakes(ids ...string) error { if w.fail { return ErrWalletPersistence } return w.testWallet.RefundStakes(ids...) } func (e Event) MessageStarts(prefix string) bool { return strings.HasPrefix(e.Message, prefix) } type pokerTestBank struct{ writes int } func (b *pokerTestBank) save(int, string, map[string][]int) error { b.writes++; return nil } func pokerTestCabinet(t *testing.T, cfg VideoPokerConfig) (*videoPokerTable, *pokerTestWallet, *pokerTestBank) { t.Helper() tableCfg := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: cfg} if err := tableCfg.Validate(); err != nil { t.Fatal(err) } table := newVideoPokerTable(1, tableCfg.Normalize(), rand.New(rand.NewSource(1))).(*videoPokerTable) w := &pokerTestWallet{testWallet: testWallet{credits: 10000000}} bank := &pokerTestBank{} w.bank = bank table.join("alice") return table, w, bank } func pokerDeal(t *testing.T, table *videoPokerTable, w Wallet) { t.Helper() table.action("alice", "coins max") table.bet("alice", "deal", 0, w) if !table.players["alice"].active { t.Fatal("deal failed") } } func TestPokerCabinetOccupancyAndHints(t *testing.T) { table, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) table.join("bob") if table.hasParticipant("bob") { t.Fatal("cabinet had two occupants") } if table.hints("bob") != nil { t.Fatal("non-occupant saw hints") } for _, h := range table.hints("alice") { if h.Command == "list" { t.Fatal("irrelevant list hint") } } if table.overview() == "" { t.Fatal("empty overview") } } func TestPokerCoinsDenominationAndRoyal(t *testing.T) { table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) p := table.players["alice"] for want := 1; want <= 2; want++ { events := table.action("alice", "coins 1") snap := events[0].Snapshot.(*VideoPokerSnapshot) if p.loaded != want || !snap.ShowPayTable || snap.Coins != want { t.Fatalf("load: coins=%d %+v", p.loaded, snap) } } if len(w.open) != 0 || p.remaining != nil { t.Fatal("coin loading charged or dealt") } table.action("alice", "denom 10") table.bet("alice", "deal", 0, w) if p.wager.Total != 20 { t.Fatalf("cost %d", p.wager.Total) } p.cards = pokerHand("Ah Kh Qh Jh 10h") table.action("alice", "12345") table.action("alice", "draw") if w.paid != 5000 { t.Fatalf("two-coin royal: %d", w.paid) } events := table.action("alice", "coins") if p.loaded != 2 || events[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { t.Fatal("bare bet did not repeat silently") } // A second coin adds to the loaded wager and reprints the pay table. events = table.action("alice", "coins 1") if p.loaded != 3 || !events[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { t.Fatal("second coin did not reprint the pay table") } table.action("alice", "coins max") table.bet("alice", "deal", 0, w) p.cards = pokerHand("Ah Kh Qh Jh 10h") table.action("alice", "12345") table.action("alice", "draw") if w.paid != 45000 { t.Fatalf("max royal total %d", w.paid) } rows := p.config.PayRows(1) if rows[0].Coins != [5]int{250, 500, 750, 1000, 4000} { t.Fatalf("royal columns %+v", rows[0]) } // An uninitialized cabinet repeats the max wager on the first bare bet. table2, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) table2.players["alice"].loaded = 0 table2.action("alice", "coins") if table2.players["alice"].loaded != 5 { t.Fatal("initial bare bet not max") } } func TestPokerHoldToggleAndMultiHand(t *testing.T) { table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) table.action("alice", "hands 100") pokerDeal(t, table, w) p := table.players["alice"] original := p.cards table.action("alice", "531") if !p.held[0] || p.held[1] || !p.held[2] || p.held[3] || !p.held[4] { t.Fatalf("first toggle: %+v", p.held) } table.action("alice", "1") if p.held[0] { t.Fatal("digit did not toggle back off") } table.action("alice", "1") table.status("alice") table.action("alice", "paytable") if p.remaining == nil || !p.active { t.Fatal("read commands changed deal") } table.action("alice", "denom 50") table.action("alice", "game jacks") if p.denomination != 1 || p.variant != "jacks_or_better" { t.Fatal("settings changed during a hand") } table.action("alice", "draw") if len(p.lines) != 100 || len(w.open) != 0 || p.active { t.Fatal("bad multi-hand settlement") } unique := map[string]bool{} for _, line := range p.lines { for _, i := range []int{0, 2, 4} { if line.Cards[i].Rank != original[i].rank || line.Cards[i].Suit != original[i].suit { t.Fatal("hold lost") } } for _, i := range []int{1, 3} { for _, c := range original { if line.Cards[i].Rank == c.rank && line.Cards[i].Suit == c.suit { t.Fatal("original card returned") } } } if line.Cards[1] == line.Cards[3] { t.Fatal("duplicate within one hand") } unique[line.Cards[1].Rank+line.Cards[1].Suit] = true } if len(unique) < 10 { t.Fatal("lines did not draw independently") } paid := w.paid table.action("alice", "draw") if w.paid != paid { t.Fatal("second draw paid again") } for _, e := range table.status("alice") { if e.Public || e.PrivateTo != "alice" { t.Fatal("private cards leaked") } } // Settings change freely between rounds. table.action("alice", "denom 50") table.action("alice", "hands 1") if p.denomination != 50 || p.hands != 1 { t.Fatal("settings refused between rounds") } table.leave("alice") if !table.idle() { t.Fatal("seat not released") } } func TestPokerGameSwitching(t *testing.T) { table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic, Payout: "low"}) p := table.players["alice"] // Payout is house-owned: the retired verb no longer changes the schedule. if !table.action("alice", "payout full")[0].MessageStarts("Use bet") || p.config.Payout != "low" { t.Fatal("payout action still changes the schedule") } if !table.action("alice", "game jacks")[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { t.Fatal("explicit switch did not show the pay table") } table.action("alice", "game ddb") if p.config.Variant != "double_double_bonus" || p.config.PayTable != "8/5" { t.Fatalf("alias switch: %+v", p.config) } table.action("alice", "game joker") if p.config.PayTable != "50/15/7/5/3" { t.Fatalf("tier lost switching to joker: %+v", p.config) } events := table.action("alice", "game unknown") if !events[0].MessageStarts("Use bet") || p.variant != "joker_poker" { t.Fatal("unknown game accepted") } table.action("alice", "coins max") pokerDeal(t, table, w) if !table.action("alice", "game jacks")[0].MessageStarts("Finish this hand first.") { t.Fatal("game switched during a hand") } table.action("alice", "draw") // A hundred-hand classic Jacks or Better edition is reachable. table.action("alice", "game jacks") table.action("alice", "hands 100") if p.hands != 100 { t.Fatal("hundred-play unavailable") } table.action("alice", "hands 3") if p.hands != 3 { t.Fatal("multi-hand unavailable") } } func TestPokerFeatureFeesAndHandCounts(t *testing.T) { for _, tt := range []struct { cabinet string payTable string feature string hands, max int }{{CabinetUXSingle, "9/6", PokerUltimateX, 1, 1}, {CabinetUXMulti, "8/6", PokerUltimateX, 3, 10}, {CabinetSuperTimesPay, "9/6", PokerSuperTimesPay, 1, 10}, {CabinetHotRoll, "9/6", PokerHotRoll, 3, 10}} { table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: tt.cabinet}) p := table.players["alice"] table.action("alice", "hands 100") if p.hands != tt.hands { t.Fatal("unsupported hand count accepted") } table.action("alice", "coins 1") table.bet("alice", "deal", 0, w) if p.active || len(w.open) != 0 { t.Fatal("feature allowed a partial bet") } pokerDeal(t, table, w) fee := 5 if tt.feature == PokerSuperTimesPay { fee = 1 } if p.wager.Total != (5+fee)*tt.hands { t.Fatalf("%s fee %d", tt.feature, p.wager.Total) } if p.config.Feature != tt.feature || p.config.PayTable != tt.payTable { t.Fatalf("%s cabinet: %+v", tt.cabinet, p.config) } table.action("alice", "draw") table.action("alice", "feature off") pokerDeal(t, table, w) if p.wager.Total != 5*tt.hands { t.Fatal("feature off still charged a fee") } } } func TestPokerSettlementRetry(t *testing.T) { table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) pokerDeal(t, table, w) p := table.players["alice"] p.cards = pokerHand("Ah Kh Qh Jh 10h") table.action("alice", "12345") w.fail = true table.action("alice", "draw") if !p.active || !p.drawn || len(w.open) != 1 { t.Fatal("failed payout lost stake") } final := append([]PokerLine(nil), p.lines...) table.action("alice", "draw") if !reflect.DeepEqual(final, p.lines) || w.paid != 0 { t.Fatal("failed final hands changed or paid") } w.fail = false table.action("alice", "draw") table.action("alice", "draw") if w.paid != 4000 || len(w.open) != 0 || p.active { t.Fatalf("retry: paid=%d", w.paid) } } func TestPokerDisconnectAndRefund(t *testing.T) { table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) pokerDeal(t, table, w) table.markDisconnected("alice") if table.hasParticipant("alice") || len(w.open) != 0 { t.Fatal("disconnect failed to settle") } table, w, _ = pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) pokerDeal(t, table, w) table.refundAll() table.refundAll() if w.paid != 5 || len(w.open) != 0 { t.Fatal("refund not exactly once") } } func TestPokerJokerDeck(t *testing.T) { table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Variant: "joker_poker"}) pokerDeal(t, table, w) p := table.players["alice"] seen := map[card]bool{} for _, c := range append(append([]card(nil), p.cards[:]...), p.remaining...) { seen[c] = true } if len(seen) != 53 || !seen[card{rank: "JK", suit: "joker"}] { t.Fatal("not a 53-card deck") } }