package game // Identity-resolution regressions for economy recovery: a record carrying a // stable character ID must never be resolved — paid or resolved — through a // display name that a different character later reused, and money recovery // must run only AFTER lifecycle-journal rollback (a rolled-back rename // restores an older character before-image that must not erase compensation // already applied). import ( "os" "path/filepath" "testing" "thehouseoficarus/internal/persistence" ) // TestRecoveryDoesNotPayReusedCharacterName: an owed payout journaled for // identity A (named "alice") must not be paid to identity B after B reuses // the name, and A's unresolved liability must stay open for operator // inspection instead of being silently settled against B's balance. func TestRecoveryDoesNotPayReusedCharacterName(t *testing.T) { h := newHarness(t) h.seedAccount("alice", "pw", false) p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.ID = "original-character" p.Credits = 90 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) return } // A failed payout save left the owed money journaled for recovery. if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { t.Error(err) return } // Delete the original character, then reuse its name for a // different identity. p.ID = "replacement-character" 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 != 90 { t.Errorf("replacement identity received someone else's payout: credits=%d, want 90", loaded.Credits) } if open := h.g.Economy.loadOpen(); len(open) != 1 { t.Errorf("original identity's unresolved liability was discarded: open records=%d", len(open)) } } // TestEconomyRecoveryRunsAfterLifecycleRollback: a leftover lifecycle // transaction (a rename that never reached its commit point) restores the // character's before-image at startup, and must do so BEFORE money // compensation — otherwise the compensation's settled/compacted records are // gone and the rollback silently erases a payout that was already applied. func TestEconomyRecoveryRunsAfterLifecycleRollback(t *testing.T) { h := newHarness(t) h.seedAccount("alice", "pw", false) p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.ID = "stable-character" p.Credits = 90 if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) return } // A failed payout save left the owed money journaled for recovery. if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { t.Error(err) return } // Simulate a crash mid-rename: apply the move and the renamed // character file directly, then leave the lifecycle journal on // disk so the next startup rolls it back. oldPath := h.g.AccountStore.CharPath("alice") newPath := h.g.AccountStore.CharPath("renamed") txn := persistence.NewTransaction(h.g.journalDir()) if err := txn.Move(oldPath, newPath); err != nil { t.Error(err) return } p.Name = "renamed" data, err := h.g.AccountStore.MarshalCharacter(p) if err != nil { t.Error(err) return } if err := txn.Replace(newPath, data, 0600); err != nil { t.Error(err) return } journal, err := txn.MarshalJournal() if err != nil { t.Error(err) return } if err := os.WriteFile(filepath.Join(h.g.journalDir(), "txn-audit.json"), journal, 0600); err != nil { t.Error(err) return } if err := os.Rename(oldPath, newPath); err != nil { t.Error(err) return } if err := h.g.AccountStore.SaveCharacter(p); err != nil { t.Error(err) return } }) // A fresh Game over the saved files simulates the restart. restarted := harnessFromDir(t, h.dir) loaded, err := restarted.g.AccountStore.LoadCharacter("alice") if err != nil { t.Fatal(err) } if loaded.Credits != 110 { t.Errorf("lifecycle rollback erased recovered payout: credits=%d, want 110; open liabilities=%d", loaded.Credits, len(restarted.g.Economy.loadOpen())) } }