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) } }