aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy_recovery_identity_test.go
blob: eceeeccca53572f4ed565d8843e06b510827c6a0 (plain)
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", "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()))
	}
}