diff options
| author | historia <[not public]> | 2026-09-16 19:57:45 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 19:57:45 -0400 |
| commit | 6ef40a5739f1723ec26cc32dfc48fe764f776dca (patch) | |
| tree | e215a4dc96ea639bcea07bb589aae3e31c3609bb /internal/game/journal_failure_regression_test.go | |
| parent | 1dad15698845d370e998f7cc5feb8aece3fab58f (diff) | |
| download | thehouseoficarus-6ef40a5739f1723ec26cc32dfc48fe764f776dca.tar.gz | |
fix: durable payouts survive journal failure, cap bank withdrawals, hash passwords off the owner loop.
Diffstat (limited to 'internal/game/journal_failure_regression_test.go')
| -rw-r--r-- | internal/game/journal_failure_regression_test.go | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/internal/game/journal_failure_regression_test.go b/internal/game/journal_failure_regression_test.go new file mode 100644 index 0000000..683b996 --- /dev/null +++ b/internal/game/journal_failure_regression_test.go @@ -0,0 +1,228 @@ +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) 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()) + } +} |
