1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
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")
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.mustLoadOpen(t); 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")
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.mustLoadOpen(t)))
}
}
|