From a8a67482bbff8297a96adff65d2fd7029ed998cc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 21:36:36 -0400 Subject: refactor: replace economy journal with single-file pending-wager wallet --- internal/game/casino_refund_regression_test.go | 141 +++++++++++-------------- 1 file changed, 61 insertions(+), 80 deletions(-) (limited to 'internal/game/casino_refund_regression_test.go') diff --git a/internal/game/casino_refund_regression_test.go b/internal/game/casino_refund_regression_test.go index 90864e2..6842c32 100644 --- a/internal/game/casino_refund_regression_test.go +++ b/internal/game/casino_refund_regression_test.go @@ -1,10 +1,9 @@ package game -// Settlement/refund regressions for the durable-money audit: a settlement -// whose save failed is journaled as owed money, so a shutdown refund must -// NOT also return the round's stake — recovery re-applies the owed payout, -// which already contains the stake's return (table hands) or the round's -// winnings (slots, where the stake was consumed by playing the round). +// Settlement/refund regressions for single-file money: a settlement credits +// the payout and clears the stakes it returns in ONE character save, so a +// failed settlement leaves the stake pending (and refundable) rather than +// paying it twice; an outstanding stake with no settlement is refunded. import ( "path/filepath" @@ -23,12 +22,11 @@ func failingSaveDuring(g *Game, dir string, fn func()) { fn() } -// TestFailedTableSettlementDoesNotDoubleReturnStake: a won hand whose payout -// save fails leaves the settlement journaled and owed. A graceful-shutdown -// refund must not ALSO return the stake; recovery re-applies the payout -// (which returns the stake inside it) exactly once. 100 - 10 + 20 = 110, -// never 120. -func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) { +// TestFailedSettlementLeavesStakeRefundable: a settlement whose save fails +// changes nothing — the credit is reverted and the stake stays pending. A +// restart refunds the stake; the win was never durable, and it is never paid +// on top of a refund. +func TestFailedSettlementLeavesStakeRefundable(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") @@ -43,72 +41,61 @@ func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) { failingSaveDuring(h.g, h.dir, func() { if err := w.PayStakeCover(20, []string{wager.ID}); err == nil { t.Error("expected the settlement save to fail") - return - } - // The shutdown refund path must not return the covered stake. - if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 { - t.Errorf("shutdown refund returned a covered stake: chips=%d credits=%d err=%v", chips, credits, err) - return } }) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 110 { - t.Errorf("balance after recovery = %d, want 110 (stake returned exactly once, inside the re-applied payout)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open after recovery: %+v", open) + if p.Credits != 90 || len(p.PendingWagers) != 1 { + t.Errorf("after failed settlement: credits=%d pending=%+v; want 90 and one stake", + p.Credits, p.PendingWagers) } }) + + // A restart refunds the still-pending stake; nothing double-pays. + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Fatalf("balance after restart = %d, want 100 (failed payout not applied; stake refunded)", loaded.Credits) + } + if len(loaded.PendingWagers) != 0 { + t.Fatalf("pending after recovery = %+v, want none", loaded.PendingWagers) + } } -// TestFailedSlotsPayoutDoesNotDoubleReturnStake: a slot spin's payout is -// winnings only — the stake is consumed by playing the round. When the -// payout save fails, the journaled winnings prove the round resolved: the -// shutdown refund must not return the stake on top of the owed payout. -func TestFailedSlotsPayoutDoesNotDoubleReturnStake(t *testing.T) { +// TestSuccessfulSettlementClearsPendingDurably: the payout and the removal of +// the stake it returns are one save, so the durable file never holds both. +func TestSuccessfulSettlementClearsPendingDurably(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} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - failingSaveDuring(h.g, h.dir, func() { - // Machine payouts carry no stake linkage (nil covers): the - // journaled settlement still resolves the round. - if err := w.PayCredits(20); err == nil { - t.Error("expected the payout save to fail") - return - } - if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 { - t.Errorf("shutdown refund paid a resolved round's stake: chips=%d credits=%d err=%v", chips, credits, err) - return - } - }) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") + wager, err := w.Wager(10) if err != nil { t.Error(err) return } - if loaded.Credits != 110 { - t.Errorf("balance after recovery = %d, want 110 (stake consumed by the round, winnings paid once)", loaded.Credits) + if err := w.PayStakeCover(20, []string{wager.ID}); err != nil { + t.Error(err) } }) + if p.Credits != 110 || len(p.PendingWagers) != 0 { + t.Fatalf("after settlement: credits=%d pending=%+v; want 110 and none", p.Credits, p.PendingWagers) + } + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 110 || len(loaded.PendingWagers) != 0 { + t.Fatalf("durable after settlement: credits=%d pending=%+v; want 110 and none", + loaded.Credits, loaded.PendingWagers) + } } -// TestUncoveredStakeStillRefunds: without a journaled settlement, the -// shutdown refund path keeps returning outstanding stakes (the documented -// refund-on-shutdown policy) — a regression guard that the coverage skip -// never over-triggers. +// TestUncoveredStakeStillRefunds: without a settlement, the refund path +// returns an outstanding stake (the refund-on-shutdown policy). func TestUncoveredStakeStillRefunds(t *testing.T) { h := newHarness(t) h.seedAccount("alice") @@ -125,12 +112,15 @@ func TestUncoveredStakeStillRefunds(t *testing.T) { t.Errorf("uncovered stake refund: chips=%d credits=%d err=%v", chips, credits, err) return } + if p.Credits != 100 || len(p.PendingWagers) != 0 { + t.Errorf("after refund: credits=%d pending=%+v; want 100 and none", p.Credits, p.PendingWagers) + } }) } -// TestCasinoManagerRefundPathUsesCoverage: the same rule through the live -// manager path (baccarat settle → RefundAll), exercised with a probe wallet -// so the settlement save fails mid-round. +// TestCasinoManagerFailedSettlementRefundIsExact drives a full baccarat round +// through the manager with a wallet whose settlement save fails: the stake +// stays pending and is refunded exactly once (never paid on top of a payout). func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) { h := newHarness(t) h.seedAccount("alice") @@ -138,27 +128,18 @@ func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) { h.g.RunOnOwner(func() { p.Credits = 1000 h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice") - var totalPaid int w := casinoWallet{g: h.g, p: p} - wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) { - totalPaid += credits - }} - // One bet: a lone player's bet deals and settles immediately. The - // wallet fails the first settlement's journal-free save via a - // broken SAVE (not journal) so the record stays open and owed. - h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) + // A lone player's bet deals and settles immediately; break the save + // for that settlement so the stake stays pending. + failingSaveDuring(h.g, h.dir, func() { + h.g.Casino.Bet(1, "b", "alice", "player", 10, w) + }) h.g.emitCasinoEvents(h.g.Casino.RefundAll()) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - // The stake was committed durably (990); RefundAll must not return - // it while the settlement payout is owed; recovery re-applies the - // payout: 1000 - 10 + totalPaid = final. - if want := 1000 - 10 + totalPaid; loaded.Credits != want { - t.Errorf("balance after refund+recovery = %d, want %d (wager 10, paid %d)", loaded.Credits, want, totalPaid) - } }) + if p.Credits != 1000 { + t.Fatalf("balance after failed settlement and refund = %d, want 1000", p.Credits) + } + if len(p.PendingWagers) != 0 { + t.Fatalf("pending after refund = %+v, want none", p.PendingWagers) + } } -- cgit v1.2.3