aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/money_regression_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/money_regression_test.go')
-rw-r--r--internal/casino/money_regression_test.go177
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)
+ }
+}