aboutsummaryrefslogtreecommitdiff
path: root/internal/game
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 /internal/game
parente7848ea79a9a57fdf391d30c48ce649f3d2f6180 (diff)
downloadthehouseoficarus-2c38386484cdb507c4687caceb37e0b963e33594.tar.gz
fix: make slot payout and stake resolution one durable save
Diffstat (limited to 'internal/game')
-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
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")