From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- internal/casino/correct_casino_refund_bank_test.go | 101 +++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 internal/casino/correct_casino_refund_bank_test.go (limited to 'internal/casino/correct_casino_refund_bank_test.go') diff --git a/internal/casino/correct_casino_refund_bank_test.go b/internal/casino/correct_casino_refund_bank_test.go new file mode 100644 index 0000000..fd90ae1 --- /dev/null +++ b/internal/casino/correct_casino_refund_bank_test.go @@ -0,0 +1,101 @@ +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, "recorded for recovery") { + t.Fatalf("the failed refund was not reported: %+v", events) + } +} -- cgit v1.2.3