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) } }) }