diff options
Diffstat (limited to 'internal/casino')
| -rw-r--r-- | internal/casino/machine.go | 43 | ||||
| -rw-r--r-- | internal/casino/money_regression_test.go | 72 |
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) + } +} |
