aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:50:17 -0400
committerhistoria <[not public]>2026-09-18 21:50:17 -0400
commit2c38386484cdb507c4687caceb37e0b963e33594 (patch)
treeb25ecf9f509a48d8126f5a4b3a0482de42676239
parente7848ea79a9a57fdf391d30c48ce649f3d2f6180 (diff)
downloadthehouseoficarus-2c38386484cdb507c4687caceb37e0b963e33594.tar.gz
fix: make slot payout and stake resolution one durable save
-rw-r--r--building_guide/concurrency.md11
-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
-rw-r--r--internal/game/casino.go27
-rw-r--r--internal/game/casino_refund_regression_test.go4
-rw-r--r--internal/game/wallet_test.go36
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")