diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/correct_machine_final_bank_failure_test.go | 101 | ||||
| -rw-r--r-- | internal/casino/machine.go | 14 |
2 files changed, 114 insertions, 1 deletions
diff --git a/internal/casino/correct_machine_final_bank_failure_test.go b/internal/casino/correct_machine_final_bank_failure_test.go new file mode 100644 index 0000000..5613705 --- /dev/null +++ b/internal/casino/correct_machine_final_bank_failure_test.go @@ -0,0 +1,101 @@ +package casino + +import "testing" + +// Regression: when the LAST free spin ends the feature, finishSpin folded +// featureBank into payNow and zeroed the bank BEFORE the wallet save. A +// settlement whose save failed therefore destroyed already-landed free-spin +// winnings with no recovery path and no explicit forfeiture notice, even +// though the triggering stake was correctly kept pending. The bank must stay +// on the machine until its payout is durable, so the shutdown/eviction +// settlement (which pays the bank after refunding the stake) can still +// return it. + +// TestMachineFinalFeatureBankSurvivesFailedSettlement: the feature pays as +// one settlement when it ends, but if that payout cannot be persisted the +// banked winnings stay in featureBank instead of vanishing, and the failure +// is explained to the player rather than presented as a mere stake refund. +func TestMachineFinalFeatureBankSurvivesFailedSettlement(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + // The final settling spin of a feature: freeSpins has already counted + // down to 0 while inFreeSpins is still true (the count reached 0 through + // finishSpin's tail decrement; this spin resolves the feature). + m.freeSpins = 0 + m.inFreeSpins = true + m.featureBank = 40 // winnings already landed during earlier free spins + m.spin = &SlotSpin{Payout: 15, ScatterCount: 3, Wins: nil} + + events := m.finishSpin() + + if m.freeSpins != 0 || m.inFreeSpins { + t.Fatalf("the feature did not end: freeSpins=%d inFreeSpins=%v", m.freeSpins, m.inFreeSpins) + } + if wallet.paid != 0 { + t.Fatalf("a failed settlement must not be reported as paid: paid=%d", wallet.paid) + } + if m.featureBank != 40 { + t.Fatalf("featureBank=%v, want 40: the banked winnings must survive a failed settlement", m.featureBank) + } + if !m.stakeFailed { + t.Fatal("the triggering stake must stay pending (stakeFailed) after a failed payout") + } + if !hasMessage(events, "could not process your payout") { + t.Fatalf("no failed-payout notice: %+v", events) + } + if !hasMessage(events, "banked free-spin winnings") { + t.Fatalf("the preserved bank was not explained: %+v", events) + } + + // The stake's pending wager was NOT consumed by the failed settle, so the + // shutdown refund path still returns it exactly once (the banked settlement + // fails again on this wallet, which refundAll reports explicitly). + refundEvents := m.refundAll() + if len(wallet.open) != 0 { + t.Fatalf("outstanding stakes after shutdown refund: %+v", wallet.open) + } + // After the failed settle: 100 - 10 wagered = 90 credits remain, and the + // shutdown refund returns the 10-credit stake through the refund path + // (testWallet records refunds of credits in `paid`). The banked + // settlement failed again on this wallet, so refundAll reports (never + // pays) it. + if got := wallet.credits; got != 90 { + t.Fatalf("credits after shutdown = %d, want 90", got) + } + if got := wallet.paid; got != 10 { + t.Fatalf("shutdown refund returned %d of the stake, want 10 exactly once", got) + } + if !hasMessage(refundEvents, "could not be persisted") { + t.Fatalf("refundAll did not report the failed bank settlement: %+v", refundEvents) + } +} + +// TestMachineFinalFeatureBankClearedOnSuccessfulSettlement: the ordinary +// path still clears the bank exactly once when the payout persists. +func TestMachineFinalFeatureBankClearedOnSuccessfulSettlement(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) + wallet := &testWallet{credits: 100} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + m.freeSpins = 0 + m.inFreeSpins = true + m.featureBank = 40 + m.spin = &SlotSpin{Payout: 15, ScatterCount: 3} + + events := m.finishSpin() + + if paid := 40 + 15; wallet.paid != paid { + t.Fatalf("paid=%d, want %d (bank + final free spin)", wallet.paid, paid) + } + if m.featureBank != 0 { + t.Fatalf("featureBank=%v, want 0 after a durable settlement", m.featureBank) + } + if len(wallet.open) != 0 { + t.Fatalf("triggering stake was not consumed with the payout: %+v", wallet.open) + } + if !hasMessage(events, "The free spins pay 55 credits") { + t.Fatalf("missing combined feature payout message: %+v", events) + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 36576d0..aad6352 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -272,7 +272,6 @@ func (m *Machine) finishSpin() []Event { payNow = 0 case featureEnds: payNow = credits + int(m.featureBank) - m.featureBank = 0 } payFailed := false @@ -290,8 +289,21 @@ func (m *Machine) finishSpin() []Event { failedEvents := m.private(payoutFailedMessage()) failedEvents[0].Color = "casino_payout" events = append(events, failedEvents...) + if featureEnds && int(m.featureBank) > 0 { + bankEvents := m.private("The house could not credit your banked free-spin winnings either; they stay banked and are paid with your next completed free-spin feature, or when the casino closes.") + bankEvents[0].Color = "casino_action" + events = append(events, bankEvents...) + } } } + if featureEnds && !payFailed { + // The bank is now part of the settled (or absent-wallet) payout. It is + // cleared only once the save succeeded, so a failed settlement leaves + // the banked winnings on the machine instead of destroying them: the + // next free-spin feature pays them again, and shutdown/refund paths + // (refundAll, markDisconnected) still settle or report them. + m.featureBank = 0 + } if payNow > 0 && !payFailed { message := fmt.Sprintf("\nYou win %d credits.", payNow) switch { |
