aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy_recovery_identity_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 13:52:58 -0400
committerhistoria <[not public]>2026-09-16 13:52:58 -0400
commitc054b27f628f23fecc505b9e09e78721738f3c4e (patch)
treeea197165bc454ee811dd22b9ceef5dc94bb0b279 /internal/game/economy_recovery_identity_test.go
parentf2d44f10f52d3fe68fdb19b518a330f7af9e1ed2 (diff)
downloadthehouseoficarus-c054b27f628f23fecc505b9e09e78721738f3c4e.tar.gz
fix: shutdown admission, recovery identity/order, rollback journal retention, pickup merge
Diffstat (limited to 'internal/game/economy_recovery_identity_test.go')
-rw-r--r--internal/game/economy_recovery_identity_test.go128
1 files changed, 128 insertions, 0 deletions
diff --git a/internal/game/economy_recovery_identity_test.go b/internal/game/economy_recovery_identity_test.go
new file mode 100644
index 0000000..eceeecc
--- /dev/null
+++ b/internal/game/economy_recovery_identity_test.go
@@ -0,0 +1,128 @@
+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()))
+ }
+}