aboutsummaryrefslogtreecommitdiff
path: root/internal/game/casino_refund_regression_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-17 22:00:33 -0400
committerhistoria <[not public]>2026-09-17 22:00:33 -0400
commit32a3c59cebf2a16277e54d715a67adfdbe00b456 (patch)
tree9bc87011513ef92f0613d9339fc55689f130915d /internal/game/casino_refund_regression_test.go
parentfea2cc6839ec3ee17940a2dea69622dc6f4deabf (diff)
downloadthehouseoficarus-32a3c59cebf2a16277e54d715a67adfdbe00b456.tar.gz
fix: exact casino settlement/refund coverage, journal damage handling, craft output ordering, aggregate ingredient planning
Diffstat (limited to 'internal/game/casino_refund_regression_test.go')
-rw-r--r--internal/game/casino_refund_regression_test.go164
1 files changed, 164 insertions, 0 deletions
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)
+ }
+ })
+}