aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_economy_durability_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/hardening_economy_durability_test.go')
-rw-r--r--internal/game/hardening_economy_durability_test.go272
1 files changed, 272 insertions, 0 deletions
diff --git a/internal/game/hardening_economy_durability_test.go b/internal/game/hardening_economy_durability_test.go
new file mode 100644
index 0000000..55fff50
--- /dev/null
+++ b/internal/game/hardening_economy_durability_test.go
@@ -0,0 +1,272 @@
+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)
+ }
+}