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