diff options
Diffstat (limited to 'internal/casino/hardening_casino_refund_test.go')
| -rw-r--r-- | internal/casino/hardening_casino_refund_test.go | 145 |
1 files changed, 145 insertions, 0 deletions
diff --git a/internal/casino/hardening_casino_refund_test.go b/internal/casino/hardening_casino_refund_test.go new file mode 100644 index 0000000..7b98be8 --- /dev/null +++ b/internal/casino/hardening_casino_refund_test.go @@ -0,0 +1,145 @@ +package casino + +import ( + "math/rand" + "testing" +) + +// RefundAll (graceful shutdown / room eviction) must not leave a refunded +// round alive: a round that settles after its stake was returned pays the +// same round twice and mints credits. + +// TestRefundAllDiscardsTheRefundedMachineRound: the review's repro — bet, +// refund everything, then keep ticking. The machine must not pay the spin +// whose stake the refund returned, and a second RefundAll must not refund +// again. +func TestRefundAllDiscardsTheRefundedMachineRound(t *testing.T) { + m := NewManager(1) + cfg := MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 50} + m.JoinMachine(1, cfg, "alice") + wallet := &testWallet{credits: 100} + if events := m.SetMachineBet(1, "slots", "alice", 10, wallet); len(events) == 0 { + t.Fatal("setting a bet produced no events") + } + // Force a paying spin, so a live round WOULD pay after the refund. + machine := m.machines[tableKey(1, "slots")] + machine.mu.Lock() + machine.spin = &SlotSpin{Payout: 20} + machine.mu.Unlock() + + m.RefundAll() + if wallet.paid != 10 { + t.Fatalf("refund paid %d, want the 10-credit stake", wallet.paid) + } + // Drive the machine to (what used to be) the end of its spin. + for i := 0; i < 60; i++ { + m.Tick() + } + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("refunded spin paid again: balance %d, want 100 (10 refunded once, no 20-credit payout)", got) + } + if machine.phase != machineReady { + t.Fatalf("refunded machine kept its round: phase=%s", machine.phase) + } + + // A second shutdown (or eviction) must not double-pay either. + m.RefundAll() + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("second RefundAll double-paid: balance %d", got) + } +} + +// TestRefundAllDiscardsTheRefundedTableRound: same rule at a card table. The +// refunded round is reset, so the later settlement/split path cannot pay the +// stake the refund already returned. +func TestRefundAllDiscardsTheRefundedTableRound(t *testing.T) { + m := NewManager(1) + cfg := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 2, BettingTicks: 5} + m.Join(1, cfg, "alice") + m.Join(1, cfg, "bob") + wallet := &testWallet{credits: 100} + // Only alice bets: the window stays open with no cards dealt. + m.Bet(1, "bj", "alice", "", 10, wallet) + + m.RefundAll() + table := m.Table(1, "bj").game.(*blackjackTable) + if table.phase != blackjackWaiting || table.players.get("alice").HasBet { + t.Fatalf("refunded table kept its round: phase=%s hasBet=%v", table.phase, table.players.get("alice").HasBet) + } + // The window can no longer deal (the wager is gone) and nothing pays. + for i := 0; i < 20; i++ { + m.Tick() + } + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("refunded table round paid again: balance %d, want 100", got) + } +} + +// Free-spin bookkeeping: recovery reads ANY journaled payout for the player +// as proof that the round resolved (Wallet.PayCredits carries no stake +// linkage), so a feature that paid each spin as it landed would make a crash +// mid-bonus silently keep the triggering stake. The machine must journal +// nothing until the feature — and with it the round — completes. + +// TestSlotsFeatureWinningsAreBankedNotJournaled: the triggering stake stays +// outstanding and no payout at all is made while free spins remain. +func TestSlotsFeatureWinningsAreBankedNotJournaled(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + wallet := &testWallet{credits: 100} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + m.spin = &SlotSpin{Payout: 25, ScatterCount: 3} + events := m.finishSpin() + if !hasMessage(events, "trigger") { + t.Fatalf("setup: feature did not trigger: %+v", events) + } + if wallet.paid != 0 { + t.Fatalf("a payout was journaled mid-feature (%d credits): recovery would read it as the round resolving", wallet.paid) + } + if len(wallet.open) != 1 { + t.Fatalf("the triggering stake must stay outstanding through the feature, open=%+v", wallet.open) + } + if m.featureBank != 25 { + t.Fatalf("featureBank=%v, want the trigger spin's 25 credits banked", m.featureBank) + } + + // A free spin wins too and is banked as well. + m.spin = &SlotSpin{Payout: 40} + m.freeSpins = 1 + m.finishSpin() + if wallet.paid != 0 || len(wallet.open) != 1 { + t.Fatalf("mid-feature spin paid or resolved the stake: paid=%d open=%+v", wallet.paid, wallet.open) + } + if m.featureBank != 65 { + t.Fatalf("featureBank=%v, want 65 banked", m.featureBank) + } +} + +// TestSlotsFeaturePaysOneSettlementThenResolvesTheStake: the banked feature +// winnings are paid in a single settlement when the last free spin stops, and +// only then is the triggering stake resolved. +func TestSlotsFeaturePaysOneSettlementThenResolvesTheStake(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + wallet := &testWallet{credits: 100} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + // The last free spin: the feature (and the round) resolves with it. + m.inFreeSpins = true + m.freeSpins = 0 + m.featureBank = 25 + m.spin = &SlotSpin{Payout: 40, ScatterCount: 3} + + events := m.finishSpin() + if wallet.paid != 65 { + t.Fatalf("feature settlement paid %d, want one 65-credit payout", wallet.paid) + } + if !hasMessage(events, "The free spins pay 65 credits.") { + t.Fatalf("missing feature settlement message: %+v", events) + } + if len(wallet.open) != 0 { + t.Fatalf("stake still outstanding after the feature completed: %+v", wallet.open) + } + if m.inFreeSpins || m.freeSpins != 0 || m.featureBank != 0 { + t.Fatalf("feature state leaked: inFreeSpins=%v freeSpins=%d bank=%v", m.inFreeSpins, m.freeSpins, m.featureBank) + } +} |
