aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/machine.go43
-rw-r--r--internal/casino/money_regression_test.go72
2 files changed, 105 insertions, 10 deletions
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index caff1bf..f156fe7 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -35,6 +35,10 @@ type Machine struct {
suspense bool
autoBet bool
autoCountdown int
+ // stakeFailed marks the in-flight stake whose payout could not be
+ // persisted: the stake record stays OPEN as recovery evidence, so the
+ // seat must not resolve (forfeit) it when the player leaves.
+ stakeFailed bool
}
func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine {
@@ -107,6 +111,7 @@ func (m *Machine) beginSpinLocked(name string, amount int, wallet Wallet, public
m.bet = amount
m.spinBet = amount
m.player.Wallet = wallet
+ m.stakeFailed = false
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
m.reel, m.clock, m.suspense = 0, 0, false
m.phase = machineSpinning
@@ -209,6 +214,7 @@ func (m *Machine) autoBetTick() []Event {
return m.private(walletErrorMessage(err, "Autobet stopped: you don't have enough chips and credits for that bet."))
}
m.spinBet = m.bet
+ m.stakeFailed = false
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
m.reel, m.clock, m.suspense = 0, 0, false
m.phase = machineSpinning
@@ -233,20 +239,17 @@ func (m *Machine) finishSpin() []Event {
if err := m.player.Wallet.PayCredits(credits); err != nil {
// The payout could not be made durable; recovery will re-apply
// the owed amount from the open journal record. Do not announce
- // the win as paid, stop any autobet chain, and leave the stake
- // open so recovery settles it against the payout record.
+ // the win as paid, stop any autobet chain, and keep the stake
+ // OPEN (stakeFailed): resolving it here would erase the refund
+ // evidence for the owed settlement.
payFailed = true
+ m.stakeFailed = true
m.autoBet, m.autoCountdown = false, 0
failedEvents := m.private(payoutFailedMessage())
failedEvents[0].Color = "casino_payout"
events = append(events, failedEvents...)
}
}
- if !payFailed && m.player.Wallet != nil {
- // The paid spin resolved (free spins that follow wager nothing):
- // the stake is no longer outstanding.
- m.player.Wallet.ResolveStakes()
- }
if credits > 0 && !payFailed {
winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits))
winEvents[0].Color = "casino_payout"
@@ -270,6 +273,10 @@ func (m *Machine) finishSpin() []Event {
events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...)
}
if m.freeSpins > 0 {
+ // A feature is in progress: the triggering stake stays OUTSTANDING
+ // until the bonus completes, so a graceful shutdown (or crash) while
+ // free spins remain refunds the stake through RefundOutstanding /
+ // startup recovery instead of silently eating the feature.
m.freeSpins--
m.phase = machineSpinning
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
@@ -277,6 +284,13 @@ func (m *Machine) finishSpin() []Event {
events = append(events, m.public(fmt.Sprintf("%d free spins remain.", m.freeSpins))...)
} else {
m.inFreeSpins = false
+ if !payFailed && m.player.Wallet != nil {
+ // The paid round fully resolved (the feature either never
+ // started or just completed; free spins wager nothing): the
+ // stake is no longer outstanding.
+ m.player.Wallet.ResolveStakes()
+ m.stakeFailed = false
+ }
if !m.autoBet {
events = append(events, m.menuEvents("")...)
}
@@ -300,9 +314,12 @@ func (m *Machine) leave(name string) []Event {
// resetSeat frees the machine, discarding any in-flight spin and autobet
// state. A wager already taken for a spin in progress is forfeited: its
-// stake is resolved so startup recovery does not refund it.
+// stake is resolved so startup recovery does not refund it. The exception
+// is a stake whose payout could not be persisted (stakeFailed): the open
+// stake record is the only evidence of the owed settlement, so it is left
+// unresolved for recovery (or the shutdown refund) to complete.
func (m *Machine) resetSeat() {
- if m.player != nil && m.player.Wallet != nil {
+ if m.player != nil && m.player.Wallet != nil && !m.stakeFailed {
m.player.Wallet.ResolveStakes()
}
m.player = nil
@@ -315,6 +332,7 @@ func (m *Machine) resetSeat() {
m.inFreeSpins = false
m.payoutRemainder = 0
m.autoBet, m.autoCountdown = false, 0
+ m.stakeFailed = false
}
// markDisconnected removes the player from the machine, forfeiting any
@@ -409,7 +427,12 @@ func (m *Machine) menuEvents(prefix string) []Event {
// refundAll refunds any outstanding stake at graceful shutdown/eviction.
// The wallet resolves every outstanding stake exactly once; a completed
-// spin has no outstanding stake and is not refunded again. Locked.
+// spin has no outstanding stake and is not refunded again. A stake whose
+// payout failed to persist (stakeFailed) is refunded here as well — its
+// owed settlement remains open separately and startup recovery re-applies
+// it. A bonus in progress keeps its triggering stake outstanding, so a
+// shutdown during the feature returns the stake (the remaining free-spin
+// EV is forfeit, never the wager). Locked.
func (m *Machine) refundAll() []Event {
m.mu.Lock()
defer m.mu.Unlock()
diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go
index 43bf505..e6b3ef8 100644
--- a/internal/casino/money_regression_test.go
+++ b/internal/casino/money_regression_test.go
@@ -175,3 +175,75 @@ func TestShoeReshufflesOnlyAtRoundBoundaries(t *testing.T) {
t.Fatalf("shuffleIfDue did not reshuffle at the boundary (%d cards)", got)
}
}
+
+// TestSlotsLeaveAfterFailedPayoutKeepsStakeOpen: a spin whose payout could
+// not be persisted leaves its stake record OPEN as recovery evidence for
+// the owed settlement. Standing up afterwards must not resolve (forfeit)
+// that stake — doing so would erase the refund evidence and silently drop
+// the owed win.
+func TestSlotsLeaveAfterFailedPayoutKeepsStakeOpen(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &failingPayoutWallet{testWallet{credits: 100}}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{Payout: 20}
+ for i := 0; i < 20 && machine.phase != machineReady; i++ {
+ machine.tick()
+ }
+ if len(wallet.open) != 1 {
+ t.Fatal("setup: a failed payout must leave the stake open")
+ }
+ machine.leave("alice")
+ if len(wallet.open) != 1 {
+ t.Errorf("leaving after a failed payout erased the outstanding stake; want it preserved for recovery/refund")
+ }
+}
+
+// TestSlotsShutdownDuringBonusRefundsStake: free spins keep the triggering
+// stake OUTSTANDING until the bonus completes. A graceful shutdown mid-feature
+// therefore refunds the stake; the remaining feature EV is forfeit, but the
+// player never loses the wager they paid for the round.
+func TestSlotsShutdownDuringBonusRefundsStake(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &testWallet{credits: 100}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{ScatterCount: 3} // deterministic bonus trigger
+ for i := 0; i < 20 && !machine.inFreeSpins; i++ {
+ machine.tick()
+ }
+ if !machine.inFreeSpins || machine.freeSpins != 7 {
+ t.Fatalf("setup: expected the first of eight free spins (inFreeSpins=%v freeSpins=%d)", machine.inFreeSpins, machine.freeSpins)
+ }
+ machine.refundAll()
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Errorf("shutdown during the bonus left %d of the 100-credit balance; want the stake refunded", got)
+ }
+}
+
+// TestSlotsStakeResolvesOnlyAfterFullRound: without any failure and without
+// a feature, a completed spin resolves its stake (no shutdown refund), and a
+// bonus that RUNS TO COMPLETION also resolves the stake at the end.
+func TestSlotsStakeResolvesOnlyAfterFullRound(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &testWallet{credits: 100}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{ScatterCount: 3} // trigger, then run all free spins
+ for i := 0; i < 200 && (machine.phase != machineReady || machine.inFreeSpins); i++ {
+ machine.tick()
+ }
+ if machine.inFreeSpins || machine.freeSpins != 0 {
+ t.Fatalf("setup: bonus must have completed (inFreeSpins=%v freeSpins=%d)", machine.inFreeSpins, machine.freeSpins)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("stake still outstanding after the feature completed: %+v", wallet.open)
+ }
+ // A completed round has nothing outstanding: the shutdown refund must
+ // not pay anything extra.
+ paidBefore := wallet.paid
+ machine.refundAll()
+ if wallet.paid != paidBefore {
+ t.Errorf("refundAll double-paid a completed round: paid %d -> %d", paidBefore, wallet.paid)
+ }
+}