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/journal_failure_regression_test.go | 243 ----------------------- 1 file changed, 243 deletions(-) delete mode 100644 internal/game/journal_failure_regression_test.go (limited to 'internal/game/journal_failure_regression_test.go') diff --git a/internal/game/journal_failure_regression_test.go b/internal/game/journal_failure_regression_test.go deleted file mode 100644 index 043afeb..0000000 --- a/internal/game/journal_failure_regression_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package game - -// Journal-failure regressions: a broken economy journal (persistent append -// failure) must never lose money silently. Settlements pay through the -// character file, stake resolutions are recorded as file evidence, and an -// unreadable journal aborts recovery/compaction instead of erasing records. - -import ( - "os" - "strings" - "testing" - "time" - - "thehouseoficarus/internal/casino" - "thehouseoficarus/internal/player" -) - -// failJournalDuring redirects the economy journal path for the duration of -// fn, simulating a persistent append failure, and restores it afterwards. -func failJournalDuring(h *harness, fn func()) { - path := h.g.Economy.path - h.g.Economy.path = h.dir - defer func() { h.g.Economy.path = path }() - fn() -} - -// TestSettlementFallbackKeepsWholeBalance: a full table round whose payout -// journal append fails still ends with the player whole — the settlement is -// paid through the character file, the stake resolution is recorded as -// evidence, and recovery leaves the balance exact. -func TestSettlementFallbackKeepsWholeBalance(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 - failed := false - w := casinoWallet{g: h.g, p: p} - // Wrap the wallet so exactly one settlement hits a broken journal. - wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) { - totalPaid += credits - if failedCall { - failed = true - } - }} - rounds := 0 - for rounds < 100 && !failed { - rounds++ - h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) - } - if !failed { - t.Fatal("probe never observed a settlement payout") - return - } - // One more full round after the failure: the table keeps working. - rounds++ - h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - want := 1000 - 10*rounds + totalPaid - if loaded.Credits != want { - t.Errorf("balance after journal-failure round: credits=%d, want %d (wagers %d, paid %d)", - loaded.Credits, want, 10*rounds, totalPaid) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open: %+v", open) - } - }) -} - -// fallbackProbeWallet routes PayCredits through the real wallet once with a -// broken journal (the first settlement), then normally. -type fallbackProbeWallet struct { - harness *harness - inner casinoWallet - onPay func(credits int, failedCall bool) - broke bool -} - -func (w *fallbackProbeWallet) Wager(amount int) (casino.Wager, error) { return w.inner.Wager(amount) } - -func (w *fallbackProbeWallet) PayCredits(amount int) error { - if w.broke { - w.onPay(amount, false) - return w.inner.PayCredits(amount) - } - w.broke = true - w.onPay(amount, true) - failJournalDuring(w.harness, func() { - _ = w.inner.PayCredits(amount) - }) - return nil -} - -func (w *fallbackProbeWallet) ResolveStakes() { w.inner.ResolveStakes() } - -func (w *fallbackProbeWallet) PayStakeCover(amount int, stakeIDs []string) error { - if w.broke { - w.onPay(amount, false) - return w.inner.PayStakeCover(amount, stakeIDs) - } - w.broke = true - w.onPay(amount, true) - failJournalDuring(w.harness, func() { - _ = w.inner.PayStakeCover(amount, stakeIDs) - }) - return nil -} - -func (w *fallbackProbeWallet) ResolveStake(stakeID string) { w.inner.ResolveStake(stakeID) } -func (w *fallbackProbeWallet) RefundOutstanding() (int, int, error) { - return w.inner.RefundOutstanding() -} - -// TestChipStakeSettlementFallbackNoDoublePay: a chip-funded stake whose win -// is paid through the character-file fallback must not ALSO get its chips -// returned in kind by recovery — the settlement returned the stake as -// credits inside the payout. -func TestChipStakeSettlementFallbackNoDoublePay(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - // The journal is broken for BOTH the settlement and the resolution: - // the win lands in the character file, the resolution in evidence. - failJournalDuring(h, func() { - if err := w.PayCredits(20); err != nil { - t.Errorf("fallback settlement failed: %v", err) - return - } - w.ResolveStakes() - }) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 120 || countChips(loaded) != 0 { - t.Errorf("after recovery: %d credits / %d chips, want 120 / 0 (chip stake debited, win of 20 paid once in credits; no in-kind chip return on top)", - loaded.Credits, countChips(loaded)) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("open records: %+v", open) - } - }) -} - -// TestCorruptJournalAbortsRecoveryWithoutErasure: an unreadable journal -// region must abort compensation and compaction — the owed payout record -// beyond the corruption stays in the file for operator inspection instead -// of being compacted away. -func TestCorruptJournalAbortsRecoveryWithoutErasure(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - // Corrupt the journal with an oversized line, then journal an owed - // payout BEYOND it (as appends would after the corruption). - f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) - if err != nil { - t.Error(err) - return - } - _, werr := f.WriteString(strings.Repeat("x", 1024*1024+1) + "\n") - f.Close() - if werr != nil { - t.Error(werr) - return - } - if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil { - t.Errorf("payout could not be journaled: %v", err) - return - } - h.g.recoverEconomy() - // The owed payout record is preserved verbatim: no compensation ran - // (credits unchanged) and no compaction erased the record. - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - data, err := os.ReadFile(h.g.Economy.path) - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 100 { - t.Errorf("aborted recovery paid the payout: credits=%d, want 100 (preserved for inspection)", loaded.Credits) - } - if !strings.Contains(string(data), `"kind":"payout"`) { - t.Error("corrupt journal recovery erased the owed payout record instead of preserving it") - } - }) -} - -// TestBankWithdrawalHonorsCreditCap: a withdrawal that would push the -// on-hand balance past the shared economy cap is rejected; neither balance -// changes. -func TestBankWithdrawalHonorsCreditCap(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - p.Credits = casino.MaxCredits - p.BankedCredits = 10 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Fatal(err) - } - sess, conn := h.connect("alice") - h.g.RunOnOwner(func() { - h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"}) - }) - if sess.Player.Credits != casino.MaxCredits || sess.Player.BankedCredits != 10 { - t.Errorf("rejected withdrawal changed balances: credits=%d banked=%d", - sess.Player.Credits, sess.Player.BankedCredits) - } - // The session writer goroutine flushes asynchronously; wait for the - // rejection to arrive instead of racing it. - deadline := time.Now().Add(2 * time.Second) - for !conn.Contains("cannot hold") && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) - } - if !conn.Contains("cannot hold") { - t.Errorf("withdrawal rejection not reported; output=%s", conn.Bytes()) - } -} -- cgit v1.2.3