diff options
| author | historia <[not public]> | 2026-09-18 21:50:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 21:50:17 -0400 |
| commit | 2c38386484cdb507c4687caceb37e0b963e33594 (patch) | |
| tree | b25ecf9f509a48d8126f5a4b3a0482de42676239 /internal/game | |
| parent | e7848ea79a9a57fdf391d30c48ce649f3d2f6180 (diff) | |
| download | thehouseoficarus-2c38386484cdb507c4687caceb37e0b963e33594.tar.gz | |
fix: make slot payout and stake resolution one durable save
Diffstat (limited to 'internal/game')
| -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 |
3 files changed, 47 insertions, 20 deletions
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") |
