diff options
| author | historia <[not public]> | 2026-09-16 13:52:58 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 13:52:58 -0400 |
| commit | c054b27f628f23fecc505b9e09e78721738f3c4e (patch) | |
| tree | ea197165bc454ee811dd22b9ceef5dc94bb0b279 /internal/game/economy_recovery_identity_test.go | |
| parent | f2d44f10f52d3fe68fdb19b518a330f7af9e1ed2 (diff) | |
| download | thehouseoficarus-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.go | 128 |
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())) + } +} |
