From 32a3c59cebf2a16277e54d715a67adfdbe00b456 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 17 Sep 2026 22:00:33 -0400 Subject: fix: exact casino settlement/refund coverage, journal damage handling, craft output ordering, aggregate ingredient planning --- internal/game/casino_refund_regression_test.go | 164 +++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 internal/game/casino_refund_regression_test.go (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 new file mode 100644 index 0000000..90864e2 --- /dev/null +++ b/internal/game/casino_refund_regression_test.go @@ -0,0 +1,164 @@ +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). + +import ( + "path/filepath" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/player" +) + +// failingSaveDuring swaps the account store for one that cannot save, +// simulating a character-save failure, and restores it afterwards. +func failingSaveDuring(g *Game, dir string, fn func()) { + realStore := g.AccountStore + g.AccountStore = player.NewAccountStore(filepath.Join(dir, "unwritable")) + defer func() { g.AccountStore = realStore }() + 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) { + 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 + } + 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) + } + }) +} + +// 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) { + 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") + 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) + } + }) +} + +// 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. +func TestUncoveredStakeStillRefunds(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 + } + chips, credits, err := w.RefundOutstanding() + if err != nil || chips != 0 || credits != 10 { + t.Errorf("uncovered stake refund: chips=%d credits=%d err=%v", chips, credits, err) + return + } + }) +} + +// TestCasinoManagerRefundPathUsesCoverage: the same rule through the live +// manager path (baccarat settle → RefundAll), exercised with a probe wallet +// so the settlement save fails mid-round. +func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + 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) + 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) + } + }) +} -- cgit v1.2.3