package casino import "testing" // Free-spin winnings live only in Machine.featureBank until the feature ends, // so a graceful shutdown (or room eviction) that zeroes the bank without // paying it would destroy winnings that had already landed. refundAll must // settle the bank through the wallet in addition to refunding the triggering // stake, and must keep the stake refundable (not double-paid) when either // side of that settlement fails. // TestMachineRefundAllPreservesBankedFeatureWinnings: a nonzero banked bonus // survives RefundAll while the triggering stake is refunded exactly once. func TestMachineRefundAllPreservesBankedFeatureWinnings(t *testing.T) { m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) wallet := &testWallet{credits: 100} m.join("alice") m.beginSpinLocked(10, wallet, "", "") m.spin = &SlotSpin{Payout: 25, ScatterCount: 3} m.finishSpin() // triggers the feature: 25 banked, the stake stays open if m.featureBank != 25 || !m.inFreeSpins { t.Fatalf("setup: featureBank=%v inFreeSpins=%v, want 25/true", m.featureBank, m.inFreeSpins) } if wallet.paid != 0 { t.Fatalf("setup: a payout was journaled mid-feature (%d)", wallet.paid) } events := m.refundAll() // 10 for the refunded stake plus the 25 banked credits. if wallet.paid != 35 { t.Fatalf("refundAll paid %d, want 35 (10 stake + 25 banked winnings)", wallet.paid) } if got := wallet.credits + wallet.paid; got != 125 { t.Fatalf("banked winnings were destroyed: balance %d, want 125", got) } if !hasMessage(events, "25") { t.Fatalf("missing banked-winnings settlement message: %+v", events) } if len(wallet.open) != 0 { t.Fatalf("stake still outstanding after shutdown: %+v", wallet.open) } } // TestMachineRefundAllFailedBankedPayoutKeepsStakeRefunded: if the banked // settlement cannot be persisted, the triggering stake is still refunded // exactly once (not paid twice) and the failure is reported rather than // silently swallowing the win. func TestMachineRefundAllFailedBankedPayoutKeepsStakeRefunded(t *testing.T) { m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) wallet := &failingPayoutWallet{testWallet{credits: 100}} m.join("alice") m.beginSpinLocked(10, wallet, "", "") m.featureBank = 25 events := m.refundAll() if wallet.paid != 10 { t.Fatalf("stake refund paid %d, want 10 once", wallet.paid) } if len(wallet.open) != 0 { t.Fatalf("stake still outstanding: %+v", wallet.open) } if !hasMessage(events, "could not be persisted") { t.Fatalf("a failed banked payout was not reported: %+v", events) } } // failingRefundWallet rejects every refund, mirroring the production wallet's // persistence-failure path: the stakes stay open for recovery. type failingRefundWallet struct { testWallet } func (w *failingRefundWallet) RefundOutstanding() (int, int, error) { return 0, 0, ErrWalletPersistence } // TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery pins the // load-bearing stakeFailed guard in refundAll: resetSeat must not resolve // (forfeit) the stake RefundOutstanding left open, or the recovery refund // contract is broken. Deleting `m.stakeFailed = err != nil` fails this test. func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) { m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) wallet := &failingRefundWallet{testWallet{credits: 100}} m.join("alice") m.beginSpinLocked(10, wallet, "", "") if len(wallet.open) != 1 { t.Fatalf("setup: the stake must be open, got %+v", wallet.open) } m.featureBank = 25 events := m.refundAll() if len(wallet.open) != 1 { t.Fatalf("a failed refund forfeited the open stake; want it preserved for recovery: %+v", wallet.open) } if wallet.paid != 0 { t.Fatalf("a failed refund paid %d; want 0", wallet.paid) } if !hasMessage(events, "stake stays pending") { t.Fatalf("the failed refund was not reported: %+v", events) } } // TestMachineDisconnectSettlesBankedFeatureWinnings: already-landed free-spin // winnings are not part of the forfeited in-flight wager, so a disconnect must // settle them through the wallet rather than silently zeroing featureBank. func TestMachineDisconnectSettlesBankedFeatureWinnings(t *testing.T) { m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) wallet := &testWallet{credits: 100} m.join("alice") m.beginSpinLocked(10, wallet, "", "") m.spin = &SlotSpin{Payout: 25, ScatterCount: 3} m.finishSpin() // triggers the feature: 25 banked, the stake stays open if m.featureBank != 25 || !m.inFreeSpins { t.Fatalf("setup: featureBank=%v inFreeSpins=%v, want 25/true", m.featureBank, m.inFreeSpins) } events := m.markDisconnected("alice") if wallet.paid != 25 { t.Fatalf("disconnect paid %d, want 25 banked free-spin credits", wallet.paid) } if got := wallet.credits + wallet.paid; got != 115 { t.Fatalf("banked winnings were destroyed on disconnect: balance %d, want 115 (100 - 10 forfeited stake + 25 banked)", got) } if !hasMessage(events, "25") { t.Fatalf("missing banked-winnings settlement message: %+v", events) } if len(wallet.open) != 0 { t.Fatalf("stake still outstanding after disconnect: %+v", wallet.open) } if m.owns("alice") { t.Fatal("machine still owns the disconnected player") } }