aboutsummaryrefslogtreecommitdiff
path: root/internal/game/journal_failure_regression_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/journal_failure_regression_test.go')
-rw-r--r--internal/game/journal_failure_regression_test.go228
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())
+ }
+}