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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
|
package player
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/persistence"
)
type AccountStore struct {
dataDir string
}
func NewAccountStore(dataDir string) *AccountStore {
return &AccountStore{dataDir: dataDir}
}
func (s *AccountStore) AccountPath(name string) string {
return filepath.Join(s.dataDir, "players", "accounts", name+".yaml")
}
func (s *AccountStore) LoadAccount(name string) (*Account, error) {
path := s.AccountPath(name)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("account not found: %s", name)
}
var acc Account
if err := yaml.Unmarshal(data, &acc); err != nil {
return nil, fmt.Errorf("parse account: %w", err)
}
return &acc, nil
}
func (s *AccountStore) SaveAccount(acc *Account) error {
data, err := s.MarshalAccount(acc)
if err != nil {
return err
}
return writePrivate(s.AccountPath(acc.Name), data)
}
// writePrivate writes data atomically and then forces 0600. persistence.WriteFile
// deliberately preserves an existing file's mode, so an account/character file
// created before this rule (or by an external tool) would otherwise stay
// world-readable through every later save and password rehash.
func writePrivate(path string, data []byte) error {
if err := persistence.WriteFile(path, data, 0600); err != nil {
return err
}
// The file was already renamed into place; a chmod failure is a
// post-replacement durability-uncertain condition, not "nothing
// happened".
if err := os.Chmod(path, 0600); err != nil {
return fmt.Errorf("%w: chmod: %v", persistence.ErrDurabilityUncertain, err)
}
return nil
}
// MarshalAccount serializes an account; callers that own the account (the
// game owner, or transaction code holding it) may use this directly.
func (s *AccountStore) MarshalAccount(acc *Account) ([]byte, error) {
return yaml.Marshal(acc)
}
func (s *AccountStore) AccountExists(name string) bool {
_, err := s.FindAccount(name)
return err == nil
}
// isPathLikeName reports whether name is unsafe to join into a data-file path.
// Callers are expected to validate names (player.ValidName) before calling the
// store; this is a defence-in-depth backstop for the raw path primitive.
func isPathLikeName(name string) bool {
return name == "" || name == "." || name == ".." ||
strings.ContainsAny(name, "/\\") || strings.ContainsRune(name, 0)
}
func (s *AccountStore) FindAccount(name string) (string, error) {
if isPathLikeName(name) {
return "", fmt.Errorf("account not found: %s", name)
}
path := s.AccountPath(name)
if _, err := os.Stat(path); err == nil {
return name, nil
}
dir := filepath.Join(s.dataDir, "players", "accounts")
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("account not found: %s", name)
}
lower := strings.ToLower(name)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
base := strings.TrimSuffix(e.Name(), ".yaml")
if strings.ToLower(base) == lower {
return base, nil
}
}
return "", fmt.Errorf("account not found: %s", name)
}
// characterInventorySlots is the on-disk inventory capacity, matching
// Player.FreeSlots and Player.FirstFreeSlot in player.go.
const characterInventorySlots = MaxInventorySlots
func (s *AccountStore) CharPath(name string) string {
return filepath.Join(s.dataDir, "players", "characters", name+".yaml")
}
func (s *AccountStore) LoadCharacter(name string) (*Player, error) {
path := s.CharPath(name)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("character not found: %s", name)
}
var p Player
if err := yaml.Unmarshal(data, &p); err != nil {
return nil, fmt.Errorf("parse character: %w", err)
}
if p.Skills == nil {
p.Skills = make(map[SkillName]int)
}
if p.Equipment == nil {
p.Equipment = make(map[item.EquipSlot]string)
}
if p.Inventory == nil {
p.Inventory = make(map[int]*InventorySlot)
}
sanitizeInventory(p.Inventory)
sanitizeBank(p.Bank)
if p.MapSymbols == nil {
p.MapSymbols = make(map[int]MapSymbolData)
}
return &p, nil
}
// sanitizeInventory drops inventory entries a hand-edited or corrupt save can
// carry that no gameplay path can reach or free: nil slots, keys outside the
// 0..characterInventorySlots-1 range, and non-positive quantities. FreeSlots
// already ignores invalid entries, but FirstFreeSlot and the on-disk save still
// need the map itself to be clean, so sanitize here as well.
func sanitizeInventory(slots map[int]*InventorySlot) {
for i, slot := range slots {
if slot == nil || slot.Quantity <= 0 || i < 0 || i >= characterInventorySlots {
delete(slots, i)
}
}
}
// sanitizeBank applies the same rule to the bank, which unlike the inventory
// grows past 28 slots (NextBankSlot), so only negative keys are out of range.
func sanitizeBank(slots map[int]*InventorySlot) {
for i, slot := range slots {
if slot == nil || slot.Quantity <= 0 || i < 0 {
delete(slots, i)
}
}
}
func (s *AccountStore) SaveCharacter(p *Player) error {
data, err := s.MarshalCharacter(p)
if err != nil {
return err
}
return writePrivate(s.CharPath(p.Name), data)
}
// MarshalCharacter serializes a character for a same-directory temporary
// atomic write. It serializes a value snapshot so saving god-mode stats
// never temporarily changes the live player; the caller still owns all
// referenced maps (serialize on the owner goroutine).
func (s *AccountStore) MarshalCharacter(p *Player) ([]byte, error) {
snapshot := *p
if snapshot.GodMode && snapshot.GodBackup != nil {
snapshot.Skills = snapshot.GodBackup
if snapshot.HP > snapshot.MaxHP() {
snapshot.HP = snapshot.MaxHP()
}
}
return yaml.Marshal(&snapshot)
}
func (s *AccountStore) CharacterExists(name string) bool {
_, err := s.FindCharacter(name)
return err == nil
}
func (s *AccountStore) FindCharacter(name string) (string, error) {
if isPathLikeName(name) {
return "", fmt.Errorf("character not found: %s", name)
}
path := s.CharPath(name)
if _, err := os.Stat(path); err == nil {
return name, nil
}
dir := filepath.Join(s.dataDir, "players", "characters")
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("character not found: %s", name)
}
lower := strings.ToLower(name)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
base := strings.TrimSuffix(e.Name(), ".yaml")
if strings.ToLower(base) == lower {
return base, nil
}
}
return "", fmt.Errorf("character not found: %s", name)
}
// ListAccountNames returns the stem of each account YAML file.
func (s *AccountStore) ListAccountNames() ([]string, error) {
dir := filepath.Join(s.dataDir, "players", "accounts")
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
name := strings.TrimSuffix(e.Name(), ".yaml")
if name == "" {
continue
}
names = append(names, name)
}
return names, nil
}
// ListCharacterNames returns character YAML stems for offline migrations.
// Callers exclude online characters and update their in-memory state instead.
func (s *AccountStore) ListCharacterNames() ([]string, error) {
dir := filepath.Join(s.dataDir, "players", "characters")
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
name := strings.TrimSuffix(e.Name(), ".yaml")
if name == "" {
continue
}
names = append(names, name)
}
return names, nil
}
|