diff options
Diffstat (limited to 'internal/casino/money_regression_test.go')
| -rw-r--r-- | internal/casino/money_regression_test.go | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go new file mode 100644 index 0000000..43bf505 --- /dev/null +++ b/internal/casino/money_regression_test.go @@ -0,0 +1,177 @@ +package casino + +// Casino money-movement regressions: shutdown/eviction refunds cover every +// committed stake (bet, double, split, insurance) exactly once, completed +// spins are not refunded, failed payouts are never announced as paid, and +// the card shoe reshuffles between rounds only. + +import ( + "strings" + "testing" +) + +// failingPayoutWallet rejects every payout with a persistence failure. +type failingPayoutWallet struct { + testWallet +} + +func (w *failingPayoutWallet) PayCredits(int) error { return ErrWalletPersistence } + +// TestBlackjackRefundCoversAllStakes: splitting an eligible pair commits a +// second stake; the shutdown refund must return both, and a completed round +// (stakes resolved) must not be refunded at all. +func TestBlackjackRefundCoversAllStakes(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, // dealer + card{rank: "8"}, card{rank: "8"}, // alice's splittable pair + card{rank: "2"}, card{rank: "3"}, // split draws + ) + wallet := &testWallet{credits: 100} + table.join("alice") + table.bet("alice", "", 10, wallet) + table.action("alice", "split") + if wallet.credits != 80 { + t.Fatalf("setup: split did not commit a second stake (credits=%d)", wallet.credits) + } + + // The round is in flight: the refund must return every committed stake. + table.refundAll() + if total := wallet.credits + wallet.paid; total != 100 { + t.Fatalf("split-then-refund returned %d of 100 committed credits", total) + } + + // A refund must be idempotent: the stakes are resolved after the first. + table.refundAll() + if total := wallet.credits + wallet.paid; total != 100 { + t.Fatalf("second refund double-paid: %d of 100", total) + } +} + +// TestBlackjackDoubleRefundCoversStake: doubling commits a second stake that +// the shutdown refund must return while the round is still in flight. +func TestBlackjackDoubleRefundCoversStake(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, // dealer: 17 + card{rank: "5"}, card{rank: "6"}, // alice: 11, doubles to 15 + card{rank: "10"}, card{rank: "6"}, // bob: 16, still to act + card{rank: "4"}, // alice's double draw: 15, stands + ) + walletA := &testWallet{credits: 100} + walletB := &testWallet{credits: 100} + table.join("alice") + table.join("bob") + table.bet("alice", "", 10, walletA) + table.bet("bob", "", 10, walletB) + table.action("alice", "double") + if walletA.credits != 80 { + t.Fatalf("setup: double did not commit a second stake (credits=%d)", walletA.credits) + } + // The round is still in flight (bob has not acted). + table.refundAll() + if got := walletA.credits + walletA.paid; got != 100 { + t.Fatalf("alice's double-then-refund returned %d of 100", got) + } + if got := walletB.credits + walletB.paid; got != 100 { + t.Fatalf("bob's refund returned %d of 100", got) + } +} + +// TestSlotsRefundOnlyOutstandingStake: a completed losing spin has no +// outstanding stake; refunding a seated player must not pay it again. An +// in-flight spin's stake is refunded exactly once. +func TestSlotsRefundOnlyOutstandingStake(t *testing.T) { + machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand()) + wallet := &testWallet{credits: 100} + machine.join("alice") + machine.setBet("alice", 10, wallet) + machine.spin = &SlotSpin{} // deterministic losing spin + for i := 0; i < 20 && machine.phase != machineReady; i++ { + machine.tick() + } + if machine.phase != machineReady || wallet.paid != 0 { + t.Fatal("setup: the spin must have ended without payout") + } + + // The completed spin's stake was resolved with the round: no refund. + machine.refundAll() + if wallet.paid != 0 { + t.Fatalf("completed losing spin refunded %d credits at shutdown; want 0", wallet.paid) + } + + // An in-flight spin's stake IS refunded. + wallet2 := &testWallet{credits: 100} + machine2 := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 50}, newTestRand()) + machine2.join("alice") + machine2.setBet("alice", 10, wallet2) + machine2.refundAll() + if got := wallet2.credits + wallet2.paid; got != 100 { + t.Fatalf("in-flight spin refund returned %d of 100", got) + } +} + +// TestBlackjackFailedPayoutNotAnnouncedAsPaid: when the wallet cannot make +// the payout durable, the table must not tell the player they were paid. +func TestBlackjackFailedPayoutNotAnnouncedAsPaid(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, // dealer: 17 + card{rank: "10"}, card{rank: "8"}, // alice: 18, stands, wins + ) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + table.join("alice") + table.bet("alice", "", 10, wallet) + events := table.action("alice", "stand") + for _, e := range events { + if strings.Contains(e.PrivateMessage, "You win! +") { + t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage) + } + if strings.Contains(e.PrivateMessage, "persistence failure") { + return // the failure was explained to the player + } + } + t.Fatal("failed payout was neither announced as paid (good) nor explained (bad)") +} + +// TestBaccaratFailedPayoutNotAnnouncedAsPaid: same rule for baccarat. +func TestBaccaratFailedPayoutNotAnnouncedAsPaid(t *testing.T) { + table := newBaccaratTable(1, TableConfig{ID: "bac", Game: GameBaccarat}, newTestRand()) + // Player 9 (natural), banker 0: no tableau draws, player wins. + table.shoe = tableauShoe( + card{rank: "9"}, card{rank: "K"}, // player: 9 + card{rank: "8"}, card{rank: "2"}, // banker: 0 + ) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + table.join("alice") + // With a lone player, the bet starts and deals the round immediately. + events := table.bet("alice", "player", 10, wallet) + for _, e := range events { + if strings.Contains(e.PrivateMessage, "You win! +") { + t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage) + } + } + if got := wallet.credits + wallet.paid; got != 90 { + t.Fatalf("failed payout changed the balance: %d, want 90", got) + } +} + +// TestShoeReshufflesOnlyAtRoundBoundaries: crossing the shuffle threshold +// between individual draws must not replace the shoe mid-hand. +func TestShoeReshufflesOnlyAtRoundBoundaries(t *testing.T) { + shoe := newShoe(1, .25, newTestRand()) + for len(shoe.cards) > 14 { + shoe.draw() + } + shoe.draw() + shoe.draw() + if got := len(shoe.cards); got != 12 { + t.Fatalf("mid-hand draws reshuffled the shoe: %d cards remain, want 12", got) + } + + // At a round boundary the cut-card threshold applies. + shoe.shuffleIfDue() + if got := len(shoe.cards); got <= 12 { + t.Fatalf("shuffleIfDue did not reshuffle at the boundary (%d cards)", got) + } +} |
