From 0ba388bbe229dd1ccfcb0831d21a5542ae0935f1 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 1 Oct 2026 04:26:20 -0400 Subject: feat: video poker machines added to casino --- internal/casino/video_poker_lifecycle_test.go | 327 ++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 internal/casino/video_poker_lifecycle_test.go (limited to 'internal/casino/video_poker_lifecycle_test.go') diff --git a/internal/casino/video_poker_lifecycle_test.go b/internal/casino/video_poker_lifecycle_test.go new file mode 100644 index 0000000..3107a93 --- /dev/null +++ b/internal/casino/video_poker_lifecycle_test.go @@ -0,0 +1,327 @@ +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 (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 TestPokerGameAndPayoutSwitching(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + p := table.players["alice"] + 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 != "10/6" { + t.Fatalf("alias switch: %+v", p.config) + } + table.action("alice", "payout low") + if p.config.PayTable != "8/5" { + t.Fatalf("tier not applied to the current game: %+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") + } +} -- cgit v1.2.3