aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_economy_durability_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:36:36 -0400
committerhistoria <[not public]>2026-09-18 21:36:36 -0400
commita8a67482bbff8297a96adff65d2fd7029ed998cc (patch)
treee84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc /internal/game/hardening_economy_durability_test.go
parentff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff)
downloadthehouseoficarus-a8a67482bbff8297a96adff65d2fd7029ed998cc.tar.gz
refactor: replace economy journal with single-file pending-wager wallet
Diffstat (limited to 'internal/game/hardening_economy_durability_test.go')
-rw-r--r--internal/game/hardening_economy_durability_test.go272
1 files changed, 0 insertions, 272 deletions
diff --git a/internal/game/hardening_economy_durability_test.go b/internal/game/hardening_economy_durability_test.go
deleted file mode 100644
index 55fff50..0000000
--- a/internal/game/hardening_economy_durability_test.go
+++ /dev/null
@@ -1,272 +0,0 @@
-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)
- }
-}