aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/correct_machine_final_bank_failure_test.go101
-rw-r--r--internal/casino/machine.go14
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 {