diff options
| -rw-r--r-- | building_guide/concurrency.md | 11 | ||||
| -rw-r--r-- | internal/casino/core.go | 20 | ||||
| -rw-r--r-- | internal/casino/machine.go | 11 | ||||
| -rw-r--r-- | internal/casino/manager_test.go | 13 | ||||
| -rw-r--r-- | internal/casino/money_regression_test.go | 4 | ||||
| -rw-r--r-- | internal/casino/paycredits_atomic_test.go | 38 | ||||
| -rw-r--r-- | internal/casino/types.go | 28 | ||||
| -rw-r--r-- | internal/game/casino.go | 27 | ||||
| -rw-r--r-- | internal/game/casino_refund_regression_test.go | 4 | ||||
| -rw-r--r-- | internal/game/wallet_test.go | 36 |
10 files changed, 126 insertions, 66 deletions
diff --git a/building_guide/concurrency.md b/building_guide/concurrency.md index f0b8b06..40be4d9 100644 --- a/building_guide/concurrency.md +++ b/building_guide/concurrency.md @@ -125,11 +125,12 @@ there is no cross-file crash window to reconcile. - **Wager** — debits chips then credits and appends a `PendingWager` (its wallet handle plus the exact chips/credits debited) in the SAME save. The entry stays until the round resolves. -- **Settlement** — `PayStakeCover` credits the payout and removes the stakes - it returns in the same save (a table hand includes its stake return in the - payout). `PayCredits` credits winnings that carry no stake linkage (a slot - spin: the stake was consumed by the round) and the game then resolves the - stake with `ResolveStake`/`ResolveStakes`. +- **Settlement** — `PayCredits(amount, stakeIDs...)` credits the payout and + removes the listed stakes in the same save. A table passes the hand's stake + IDs (its payout includes their return); a slot win passes its triggering + stake (its payout is winnings only). Either way the credit and the stake + consumption are atomic, so a crash can never pay the win and refund the + stake. - **Loss/forfeit** — `ResolveStake`/`ResolveStakes` consume the pending stakes in one save; the round resolved and the stake is gone. - **Refund** — `RefundOutstanding` returns every pending stake (chips in 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). diff --git a/internal/game/casino.go b/internal/game/casino.go index f6592ac..c52e5eb 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -23,7 +23,7 @@ type casinoWallet struct { // Wager debits the stake and records it in PendingWagers in the SAME atomic // character save. The entry stays until the round resolves: a settlement -// removes the stakes it returns (PayStakeCover) or consumes them +// removes the stakes it returns (PayCredits) or consumes them // (ResolveStake/ResolveStakes), and startup refunds anything still listed // (see recoverPendingWagers). A failed save reverts the in-memory debit and // ledger entry, so a player is never charged for a stake that is not durable. @@ -72,20 +72,15 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil } -// PayCredits applies winnings that do not return a stake (a slot spin's -// payout: the stake was consumed by playing the round and is resolved -// separately). The credit and the save are the commit point; a failed save -// reverts the credit and reports persistence failure so the caller does not -// announce a win that is not durable. -func (w casinoWallet) PayCredits(amount int) error { - return w.payCredits(amount, nil) -} - -// PayStakeCover applies a settlement whose amount INCLUDES the return of the -// listed stake wager IDs (a table hand pays its stake back inside the -// payout). The credit and the removal of those stakes happen in ONE atomic -// save, so a crash can never pay the payout AND refund the returned stake. -func (w casinoWallet) 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 +// free-spin bank already refunded by RefundOutstanding). A table settlement +// passes the hand's stake IDs so the credit and the stake return are one +// atomic step; a slot win passes its triggering stake for the same reason. +// Either way a crash can never pay the payout AND refund the same stake. A +// failed save reverts both the credit and the ledger and reports persistence +// failure, so the caller never announces a win that is not durable. +func (w casinoWallet) PayCredits(amount int, stakeIDs ...string) error { return w.payCredits(amount, stakeIDs) } @@ -129,7 +124,7 @@ func (w casinoWallet) ResolveStakes() { // ResolveStake consumes ONE pending stake by wallet handle: its hand lost or // was paid by a cover, so it must never be refunded. A settlement that -// already removed it (PayStakeCover) makes this a no-op, so no redundant +// already removed it (PayCredits) makes this a no-op, so no redundant // save is paid. func (w casinoWallet) ResolveStake(stakeID string) { if stakeID == "" || !removePendingWager(w.p, stakeID) { diff --git a/internal/game/casino_refund_regression_test.go b/internal/game/casino_refund_regression_test.go index 6842c32..db0b337 100644 --- a/internal/game/casino_refund_regression_test.go +++ b/internal/game/casino_refund_regression_test.go @@ -39,7 +39,7 @@ func TestFailedSettlementLeavesStakeRefundable(t *testing.T) { return } failingSaveDuring(h.g, h.dir, func() { - if err := w.PayStakeCover(20, []string{wager.ID}); err == nil { + if err := w.PayCredits(20, wager.ID); err == nil { t.Error("expected the settlement save to fail") } }) @@ -77,7 +77,7 @@ func TestSuccessfulSettlementClearsPendingDurably(t *testing.T) { t.Error(err) return } - if err := w.PayStakeCover(20, []string{wager.ID}); err != nil { + if err := w.PayCredits(20, wager.ID); err != nil { t.Error(err) } }) diff --git a/internal/game/wallet_test.go b/internal/game/wallet_test.go index 218ce36..f20f96f 100644 --- a/internal/game/wallet_test.go +++ b/internal/game/wallet_test.go @@ -96,9 +96,9 @@ func TestWagerDebitsDurablyAndLeavesPendingStake(t *testing.T) { t.Fatalf("durable state after wager = credits %d, pending %+v; want 90 and one stake", loaded.Credits, loaded.PendingWagers) } - // A settlement that covers the stake credits and clears it in one save. + // A settlement that resolves the stake credits and clears it in one save. h.g.RunOnOwner(func() { - if err := w.PayStakeCover(10, []string{wager.ID}); err != nil { + if err := w.PayCredits(10, wager.ID); err != nil { t.Error(err) } }) @@ -108,6 +108,38 @@ func TestWagerDebitsDurablyAndLeavesPendingStake(t *testing.T) { } } +// TestWinningPayoutConsumesStakeInSameSave: PayCredits credits the payout and +// removes the stakes it resolves in ONE character save, so the durable file +// never holds a paid win with the stake still pending (which startup would +// refund on top of the win). +func TestWinningPayoutConsumesStakeInSameSave(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + w := casinoWallet{g: h.g, p: p} + wager, err := w.Wager(10) + if err != nil { + t.Error(err) + return + } + if err := w.PayCredits(20, wager.ID); err != nil { + t.Error(err) + } + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 110 { + t.Fatalf("credits after winning payout = %d, want 110", loaded.Credits) + } + if len(loaded.PendingWagers) != 0 { + t.Fatalf("durable pending wagers after a winning payout = %+v, want none (credited and consumed in one save)", loaded.PendingWagers) + } +} + func TestFailedWagerSaveReverts(t *testing.T) { h := newHarness(t) h.seedAccount("alice") |
