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 } func (w *failingPayoutWallet) PayStakeCover(int, []string) 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) } } // TestSlotsLeaveAfterFailedPayoutKeepsStakeOpen: a spin whose payout could // not be persisted leaves its stake record OPEN as recovery evidence for // the owed settlement. Standing up afterwards must not resolve (forfeit) // that stake — doing so would erase the refund evidence and silently drop // the owed win. func TestSlotsLeaveAfterFailedPayoutKeepsStakeOpen(t *testing.T) { machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand()) wallet := &failingPayoutWallet{testWallet{credits: 100}} machine.join("alice") machine.setBet("alice", 10, wallet) machine.spin = &SlotSpin{Payout: 20} for i := 0; i < 20 && machine.phase != machineReady; i++ { machine.tick() } if len(wallet.open) != 1 { t.Fatal("setup: a failed payout must leave the stake open") } machine.leave("alice") if len(wallet.open) != 1 { t.Errorf("leaving after a failed payout erased the outstanding stake; want it preserved for recovery/refund") } } // TestSlotsShutdownDuringBonusRefundsStake: free spins keep the triggering // stake OUTSTANDING until the bonus completes. A graceful shutdown mid-feature // therefore refunds the stake; the remaining feature EV is forfeit, but the // player never loses the wager they paid for the round. func TestSlotsShutdownDuringBonusRefundsStake(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{ScatterCount: 3} // deterministic bonus trigger for i := 0; i < 20 && !machine.inFreeSpins; i++ { machine.tick() } if !machine.inFreeSpins || machine.freeSpins != 7 { t.Fatalf("setup: expected the first of eight free spins (inFreeSpins=%v freeSpins=%d)", machine.inFreeSpins, machine.freeSpins) } machine.refundAll() if got := wallet.credits + wallet.paid; got != 100 { t.Errorf("shutdown during the bonus left %d of the 100-credit balance; want the stake refunded", got) } } // TestSlotsStakeResolvesOnlyAfterFullRound: without any failure and without // a feature, a completed spin resolves its stake (no shutdown refund), and a // bonus that RUNS TO COMPLETION also resolves the stake at the end. func TestSlotsStakeResolvesOnlyAfterFullRound(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{ScatterCount: 3} // trigger, then run all free spins for i := 0; i < 200 && (machine.phase != machineReady || machine.inFreeSpins); i++ { machine.tick() } if machine.inFreeSpins || machine.freeSpins != 0 { t.Fatalf("setup: bonus must have completed (inFreeSpins=%v freeSpins=%d)", machine.inFreeSpins, machine.freeSpins) } if len(wallet.open) != 0 { t.Fatalf("stake still outstanding after the feature completed: %+v", wallet.open) } // A completed round has nothing outstanding: the shutdown refund must // not pay anything extra. paidBefore := wallet.paid machine.refundAll() if wallet.paid != paidBefore { t.Errorf("refundAll double-paid a completed round: paid %d -> %d", paidBefore, wallet.paid) } }