aboutsummaryrefslogtreecommitdiff
path: root/internal/casino
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino')
-rw-r--r--internal/casino/core.go20
-rw-r--r--internal/casino/machine.go11
-rw-r--r--internal/casino/manager_test.go13
-rw-r--r--internal/casino/money_regression_test.go4
-rw-r--r--internal/casino/paycredits_atomic_test.go38
-rw-r--r--internal/casino/types.go28
6 files changed, 73 insertions, 41 deletions
diff --git a/internal/casino/core.go b/internal/casino/core.go
index 4177094..7ac728f 100644
--- a/internal/casino/core.go
+++ b/internal/casino/core.go
@@ -130,13 +130,13 @@ func (c *tableCore[P]) refundAllLocked() []Event {
// settleStakeOutcome applies one wager's outcome through the wallet and
// reports whether the settlement could NOT be made durable. It carries the
// money-critical ordering shared by every table game (documented in
-// building_guide/concurrency.md): compute the payout, pay it via
-// PayStakeCover BEFORE announcing the win (that one save both credits the
-// payout and removes the stakes it returns), and only then stand the hand
-// down. On a persistence failure the stakes stay pending, so a later shutdown
-// refund or startup recovery returns them exactly once — the payout was never
-// durable, so it can never be paid on top of the refund. The caller must not
-// announce a paid win and must not run the player-wide ResolveStakes.
+// building_guide/concurrency.md): compute the payout, pay it via PayCredits
+// BEFORE announcing the win — that one save credits the payout and resolves
+// the stakes it returns, so a crash can never pay the payout AND refund the
+// same stake — and only then stand the hand down. On a persistence failure
+// the stakes stay pending, so a later shutdown refund or startup recovery
+// returns them exactly once. The caller must not announce a paid win and must
+// not run the player-wide ResolveStakes.
//
// payoutErr is a payout-computation error (for example an overflowed
// multiplier): it is treated exactly like a persistence failure so a
@@ -150,10 +150,10 @@ func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error
return false
}
if payout > 0 {
- if err := w.PayStakeCover(payout, stakeIDs); err != nil {
- return true
- }
+ return w.PayCredits(payout, stakeIDs...) != nil
}
+ // A zero payout is a loss: consume the stakes so recovery cannot refund a
+ // round that resolved.
for _, id := range stakeIDs {
if id != "" {
w.ResolveStake(id)
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 5c73b6f..97b846d 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -277,7 +277,9 @@ func (m *Machine) finishSpin() []Event {
payFailed := false
if payNow > 0 && m.player.Wallet != nil {
- if err := m.player.Wallet.PayCredits(payNow); err != nil {
+ // Credit the payout and consume the triggering stake in ONE save: a
+ // crash can then never pay the win AND refund the same stake.
+ if err := m.player.Wallet.PayCredits(payNow, m.stakeID); err != nil {
// The payout could not be made durable. Do not announce the win as
// paid, stop any autobet chain, and keep the stake PENDING
// (stakeFailed): the settlement was never durable, so the stake is
@@ -334,10 +336,9 @@ func (m *Machine) finishSpin() []Event {
} 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): consume
- // ONLY this round's stake, leaving an older failed-payout stake
- // pending.
+ // A win already consumed this round's stake inside PayCredits; a
+ // loss (payNow == 0) has not, so consume it here. Either way the
+ // round is over and the stake must not be refunded.
m.player.Wallet.ResolveStake(m.stakeID)
m.stakeID = ""
m.stakeFailed = false
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index 6601b52..9b5d35a 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -23,17 +23,16 @@ func (w *testWallet) Wager(amount int) (Wager, error) {
return wager, nil
}
-func (w *testWallet) PayCredits(amount int) error {
+// PayCredits mirrors the production wallet: it banks the payout and, in the
+// same step, consumes the stakes the settlement resolves.
+func (w *testWallet) PayCredits(amount int, stakeIDs ...string) error {
w.paid += amount
+ for _, id := range stakeIDs {
+ w.ResolveStake(id)
+ }
return nil
}
-// PayStakeCover routes through PayCredits so a wallet rejecting payouts
-// (failingPayoutWallet) rejects stake-covering settlements identically.
-func (w *testWallet) PayStakeCover(amount int, _ []string) error {
- return w.PayCredits(amount)
-}
-
// ResolveStake settles one outstanding stake by handle, mirroring the
// production wallet.
func (w *testWallet) ResolveStake(stakeID string) {
diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go
index bb8263e..3f1359e 100644
--- a/internal/casino/money_regression_test.go
+++ b/internal/casino/money_regression_test.go
@@ -15,9 +15,7 @@ type failingPayoutWallet struct {
testWallet
}
-func (w *failingPayoutWallet) PayCredits(int) error { return ErrWalletPersistence }
-
-func (w *failingPayoutWallet) PayStakeCover(int, []string) error { return ErrWalletPersistence }
+func (w *failingPayoutWallet) PayCredits(int, ...string) error { return ErrWalletPersistence }
// TestBlackjackRefundCoversAllStakes: splitting an eligible pair commits a
// second stake; the shutdown refund must return both, and a completed round
diff --git a/internal/casino/paycredits_atomic_test.go b/internal/casino/paycredits_atomic_test.go
new file mode 100644
index 0000000..b08415e
--- /dev/null
+++ b/internal/casino/paycredits_atomic_test.go
@@ -0,0 +1,38 @@
+package casino
+
+import "testing"
+
+// recordingWallet records the stake IDs passed to PayCredits so a winning
+// spin's atomicity can be asserted directly.
+type recordingWallet struct {
+ testWallet
+ payStakes []string
+}
+
+func (w *recordingWallet) PayCredits(amount int, stakeIDs ...string) error {
+ w.payStakes = append(w.payStakes, stakeIDs...)
+ return w.testWallet.PayCredits(amount, stakeIDs...)
+}
+
+// TestWinningSpinConsumesStakeInPayoutSave: a winning paid spin must pass its
+// triggering stake to the SAME PayCredits call, so the credit and the stake
+// consumption are one durable save. Resolving the stake in a separate later
+// call leaves a crash window in which the payout is durable but the stake is
+// still pending — startup would then refund it on top of the win.
+func TestWinningSpinConsumesStakeInPayoutSave(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
+ w := &recordingWallet{testWallet: testWallet{credits: 100}}
+ m.join("alice")
+ m.beginSpinLocked(10, w, "", "")
+ wagerID := m.stakeID
+
+ m.spin = &SlotSpin{Payout: 25}
+ m.finishSpin()
+
+ if len(w.payStakes) != 1 || w.payStakes[0] != wagerID {
+ t.Fatalf("winning spin passed stakes %v to PayCredits; want [%q] (one atomic payout+resolve)", w.payStakes, wagerID)
+ }
+ if len(w.open) != 0 {
+ t.Fatalf("winning spin left %d stake(s) pending: %+v", len(w.open), w.open)
+ }
+}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index a5e1415..0400df4 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -191,9 +191,9 @@ type Wager struct {
Total int
Chips int
Credits int
- // ID is the wallet's opaque handle for the committed stake. Table
- // settlements pass the hand's stake IDs to PayStakeCover so the wallet
- // removes exactly the stakes the payout returns.
+ // ID is the wallet's opaque handle for the committed stake. It names the
+ // stake when a settlement resolves it (PayCredits) or consumes it
+ // (ResolveStake).
ID string
}
@@ -202,20 +202,16 @@ type Wallet interface {
// is a normal rejected bet; ErrWalletPersistence means the debit could
// not be made durable and the game must not proceed as if it succeeded.
Wager(amount int) (Wager, error)
- // PayCredits pays winnings durably, returning an error when the credit
- // could not be persisted (the game must not report a win that is not
- // durable). The payout carries no stake linkage: the stake was consumed
- // by playing the round, so the game resolves it separately.
- PayCredits(amount int) error
- // PayStakeCover pays a settlement whose amount INCLUDES the return of
- // the listed stake wager IDs (a table hand pays back its stake inside
- // the payout). The credit and the removal of those stakes are one
- // durable step, so a failed settlement leaves the stakes pending —
- // refunded at the next startup, never paid on top of the refund.
- PayStakeCover(amount int, stakeIDs []string) error
+ // PayCredits banks a payout durably and, in the same save, resolves the
+ // listed stakes (stakeIDs may be empty, for a payout that returns no
+ // stake). A table passes the hand's stake IDs so the payout and the
+ // stake return are one durable step; a slot win passes its triggering
+ // stake. A failed save leaves the stakes pending — refunded at the next
+ // startup, never paid on top of the refund — and the game must not
+ // announce the win.
+ PayCredits(amount int, stakeIDs ...string) error
// ResolveStakes consumes every pending stake for this player: the round
- // resolved as a loss, a forfeit, or an unlinked payout, so the stakes
- // must not be refunded.
+ // resolved as a loss or a forfeit, so the stakes must not be refunded.
ResolveStakes()
// ResolveStake consumes ONE pending stake by wallet handle (a single
// hand paid or lost while a sibling hand's round is still in flight).