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