package game // Persistence/economy durability regressions: the economy journal's directory // entry must be synced, recovery must order payout evidence by the record's // creation order (not by its last-written line), the recovery checkpoint // re-anchor must respect the economy cap, the journal-unavailable settlement // fallback must only resolve the stakes it settles, and a bank credit move // whose save failed must be refused instead of leaving memory ahead of the // durable file. import ( "encoding/json" "fmt" "os" "path/filepath" "testing" "time" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/player" "gopkg.in/yaml.v3" ) // waitForConnText blocks until the session's writer flushes text containing // substr; the writer goroutine is asynchronous. func waitForConnText(t *testing.T, conn *chanConn, substr string) { t.Helper() deadline := time.Now().Add(2 * time.Second) for !conn.Contains(substr) && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } if !conn.Contains(substr) { t.Errorf("expected %q in session output; got=%s", substr, conn.Bytes()) } } // TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence: payout evidence for // a stake is ordered by the payout's CREATION sequence, not by the line of its // latest write. A payout created before the stake that is rewritten afterwards // (a settle marker, or a recovery checkpoint re-anchor) must not be read as // the stake's settlement — otherwise recovery settles the stake without // refunding it and the wager is silently lost. func TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.Credits = 1000 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) return } // An older payout record (created first) ... old, err := h.g.Economy.start("payout", p, 0, 20, 1000, 1020, 0, 0) if err != nil { t.Error(err) return } // ... then the stake ... if _, err := h.g.Economy.start("wager", p, 0, 50, 1000, 950, 0, 0); err != nil { t.Error(err) return } // ... and finally a later write of the OLD payout lands after the // stake's line, so line order alone would call it the stake's // settlement. if !h.g.Economy.settleDurable(old) { t.Error("the older payout's settle marker could not be journaled") return } // The stake's debit is durable; the crashed round is recoverable. p.Credits = 950 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) return } h.g.recoverEconomy() }) loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if loaded.Credits != 1000 { t.Errorf("balance after recovery = %d, want 1000: a payout created before the stake must not settle it (the stake is refunded, not silently kept)", loaded.Credits) } if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("records left open after recovery: %+v", open) } } // TestRecoveryReanchorKeepsCheckpointsWithinCreditCap: when the economy cap // blocks a payout's re-application, the re-anchored checkpoints must stay // within the cap. An impossible post-state above the cap can never be read as // durability, and the record is owed at the current balance: it must keep // reading that way across repeated recoveries instead of being settled as // "already durable". Legacy records without the tracked-evidence flag take // this checkpoint-compensating path. func TestRecoveryReanchorKeepsCheckpointsWithinCreditCap(t *testing.T) { dir := t.TempDir() seedWorld(t, dir) hash, _ := player.HashPassword("pw") accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash}) writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData) p := player.New("alice") p.Credits = casino.MaxCredits - 30 p.Account = "alice" pData, _ := yaml.Marshal(p) writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData) // Two identical open payouts of 20 at the cap: the first can be // re-applied, the second cannot. Each record is its own journal line. var journal []byte for i := 0; i < 2; i++ { line, err := json.Marshal(economyRecord{ ID: fmt.Sprintf("unpaid-%d", i), Player: "alice", Kind: "payout", Credit: 20, Status: "open", CreditsBefore: casino.MaxCredits - 30, CreditsAfter: casino.MaxCredits - 10, }) if err != nil { t.Fatal(err) } journal = append(journal, line...) journal = append(journal, '\n') } journalDir := filepath.Join(dir, "economy") if err := os.MkdirAll(journalDir, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(journalDir, "journal.jsonl"), journal, 0600); err != nil { t.Fatal(err) } // The first recovery runs in New. h := harnessFromDir(t, dir) for round := 1; round <= 3; round++ { if round > 1 { h.g.recoverEconomy() } open := h.g.Economy.mustLoadOpen(t) if len(open) != 1 { t.Fatalf("recovery %d: the unpaid liability must stay open exactly once, open=%d: %+v", round, len(open), open) } for _, rec := range open { if rec.CreditsBefore > casino.MaxCredits || rec.CreditsAfter > casino.MaxCredits { t.Fatalf("recovery %d re-anchored %s outside the economy cap: before=%d after=%d cap=%d", round, rec.ID, rec.CreditsBefore, rec.CreditsAfter, casino.MaxCredits) } } } loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if loaded.Credits != casino.MaxCredits-10 { t.Fatalf("balance after repeated recovery = %d, want %d (one 20-credit payout applied, the other still owed)", loaded.Credits, casino.MaxCredits-10) } } // TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes: when the economy // journal is unavailable, the character-file fallback records resolution // evidence only for the stakes the payout settles. A sibling hand's stake is // settled by its own payout, so a crash before that payout must still refund // it. func TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes(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} // Two sibling stakes (a split, or two spots). The sibling is placed // first so its post-debit balance is the durable one. if _, err := w.Wager(10); err != nil { t.Error(err) return } settled, err := w.Wager(10) if err != nil { t.Error(err) return } // The journal breaks for the second hand's push: the payout lands in // the character file only, and may record evidence only for the // stake it settles. failJournalDuring(h, func() { if err := w.PayStakeCover(10, []string{settled.ID}); err != nil { t.Errorf("fallback settlement failed: %v", err) return } }) if p.Credits != 90 { t.Errorf("after the fallback settlement: credits=%d, want 90", p.Credits) return } h.g.recoverEconomy() }) loaded, err := h.g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if loaded.Credits != 100 { t.Errorf("balance after recovery = %d, want 100: the sibling hand's stake must be refunded, not resolved by the other hand's settlement", loaded.Credits) } if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { t.Errorf("records left open after recovery: %+v", open) } } // TestBankCreditTransferRefusedWhenSaveFails: a credit move between the wallet // and the bank that cannot be made durable is reverted and refused. Leaving it // in memory only would put the balance ahead of the character file, and a // wager taken from that phantom balance journals checkpoints recovery can // never match. func TestBankCreditTransferRefusedWhenSaveFails(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") p.Credits = 100 p.BankedCredits = 50 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Fatal(err) } sess, conn := h.connect("alice") h.g.RunOnOwner(func() { failingSaveDuring(h.g, h.dir, func() { h.g.doBankDepositCredits(sess, []string{"deposit", "credits", "40"}) }) }) if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 { t.Errorf("a deposit whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50", sess.Player.Credits, sess.Player.BankedCredits) } waitForConnText(t, conn, "nothing was moved") if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil { t.Error(err) } else if loaded.Credits != 100 || loaded.BankedCredits != 50 { t.Errorf("durable balances after a refused deposit: credits=%d banked=%d, want 100/50", loaded.Credits, loaded.BankedCredits) } h.g.RunOnOwner(func() { failingSaveDuring(h.g, h.dir, func() { h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"}) }) }) if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 { t.Errorf("a withdrawal whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50", sess.Player.Credits, sess.Player.BankedCredits) } waitForConnText(t, conn, "nothing was moved") // A durable move still works. h.g.RunOnOwner(func() { h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"}) }) if sess.Player.Credits != 110 || sess.Player.BankedCredits != 40 { t.Errorf("successful withdrawal: credits=%d banked=%d, want 110/40", sess.Player.Credits, sess.Player.BankedCredits) } if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil { t.Error(err) } else if loaded.Credits != 110 || loaded.BankedCredits != 40 { t.Errorf("durable balances after a successful withdrawal: credits=%d banked=%d, want 110/40", loaded.Credits, loaded.BankedCredits) } }