diff options
| author | historia <[not public]> | 2026-09-18 21:36:36 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 21:36:36 -0400 |
| commit | a8a67482bbff8297a96adff65d2fd7029ed998cc (patch) | |
| tree | e84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc /internal/game/hardening_economy_durability_test.go | |
| parent | ff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff) | |
| download | thehouseoficarus-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.go | 272 |
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) - } -} |
