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
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
|
package game
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/player"
)
// Account/character lifecycle operations. Each is one journal transaction
// over the affected files (account list, character files), executed with the
// game's single-owner guarantee: the online check, file mutation, registry
// update, and cross-session refresh happen without any interleaved session
// input, closing the check-then-rename gap. Save/remove errors are
// propagated to the caller, never ignored.
//
// All handlers here are invoked from session input (already on the owner).
// journalDir is the persistence journal directory under the game data dir.
func (g *Game) journalDir() string {
return filepath.Join(g.DataDir, ".journal")
}
// newTxn starts a lifecycle transaction.
func (g *Game) newTxn() *persistence.Transaction {
return persistence.NewTransaction(g.journalDir())
}
// adoptCharacterIdentity backfills stable identity fields on a loaded
// character: a UUID (assigned once at first load and persisted) and the
// owning account name. Legacy characters without these fields get them at
// first connection and are re-saved so subsequent authorizations can use
// them. Called from the owner goroutine only.
func (g *Game) adoptCharacterIdentity(p *player.Player, accountName string) {
assignedID := ""
if p.ID == "" {
p.ID = newCharacterID()
assignedID = p.ID
}
if p.Account == "" {
p.Account = accountName
}
if err := g.AccountStore.SaveCharacter(p); err != nil {
// Identity backfill is best-effort, but a NON-DURABLE ID must never
// become an economy key: a later startup's backfill would assign a
// different ID and strand every journal record written under this
// one. Fall back to the durable character name instead.
if assignedID != "" {
p.ID = ""
}
log.Printf("identity backfill for %s: %v", p.Name, err)
}
}
// newCharacterID returns a random UUID (v4, formatted without dashes is
// unnecessary; standard hyphenated form is used).
func newCharacterID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(fmt.Sprintf("character id generation: %v", err))
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant
s := hex.EncodeToString(b[:])
return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:32]
}
// backfillCharacterIDs migrates every character file at startup: assigns
// missing IDs and owner fields (owner derived from account character lists
// for legacy files) and persists them.
func (g *Game) backfillCharacterIDs() {
names, err := g.AccountStore.ListCharacterNames()
if err != nil {
return
}
owners := map[string]string{} // character (lower) -> owning account
accounts, _ := g.AccountStore.ListAccountNames()
for _, accName := range accounts {
acc, err := g.AccountStore.LoadAccount(accName)
if err != nil {
continue
}
for _, c := range acc.Characters {
owners[strings.ToLower(c)] = acc.Name
}
}
for _, name := range names {
p, err := g.AccountStore.LoadCharacter(name)
if err != nil {
log.Printf("character migration load %s: %v", name, err)
continue
}
changed := false
if p.ID == "" {
p.ID = newCharacterID()
changed = true
}
if p.Account == "" {
if owner, ok := owners[strings.ToLower(p.Name)]; ok {
p.Account = owner
changed = true
}
}
if changed {
if err := g.AccountStore.SaveCharacter(p); err != nil {
log.Printf("character migration save %s: %v", p.Name, err)
}
}
}
}
// canonicChar returns the character name in its canonical on-disk form.
func (g *Game) canonicalChar(name string) (string, error) {
return g.AccountStore.FindCharacter(name)
}
// createCharacterTxn creates the character file and appends it to the
// owning account in a single transaction. Returns the created player.
func (g *Game) createCharacterTxn(accountName, charName string, startingRoom int) (*player.Player, error) {
p := player.New(charName)
p.RoomID = startingRoom
p.Stats.RecordRoomVisit(startingRoom)
p.ID = newCharacterID()
p.Account = accountName
data, err := g.AccountStore.MarshalCharacter(p)
if err != nil {
return nil, err
}
txn := g.newTxn()
if err := txn.Replace(g.AccountStore.CharPath(charName), data, 0600); err != nil {
return nil, err
}
acc, err := g.AccountStore.LoadAccount(accountName)
if err != nil {
return nil, err
}
acc.Characters = append(acc.Characters, charName)
accData, err := g.AccountStore.MarshalAccount(acc)
if err != nil {
return nil, err
}
if err := txn.Replace(g.AccountStore.AccountPath(accountName), accData, 0600); err != nil {
return nil, err
}
return p, txn.Commit()
}
// renameCharacterTxn atomically renames the character file, persists the new
// name inside it, and updates the owning account's character list.
func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error {
txn := g.newTxn()
if err := txn.Move(g.AccountStore.CharPath(oldName), g.AccountStore.CharPath(newName)); err != nil {
return err
}
p, err := g.AccountStore.LoadCharacter(oldName)
if err != nil {
return err
}
p.Name = newName
if p.ID == "" {
// Assign the stable identity at rename time so journal records
// written after this point resolve the renamed character by ID.
p.ID = newCharacterID()
}
data, err := g.AccountStore.MarshalCharacter(p)
if err != nil {
return err
}
if err := txn.Replace(g.AccountStore.CharPath(newName), data, 0600); err != nil {
return err
}
acc, err := g.AccountStore.LoadAccount(accountName)
if err != nil {
return err
}
chars := make([]string, 0, len(acc.Characters))
for _, c := range acc.Characters {
if c == oldName {
chars = append(chars, newName)
} else {
chars = append(chars, c)
}
}
acc.Characters = chars
accData, err := g.AccountStore.MarshalAccount(acc)
if err != nil {
return err
}
if err := txn.Replace(g.AccountStore.AccountPath(accountName), accData, 0600); err != nil {
return err
}
return txn.Commit()
}
// deleteCharacterTxn removes the character file and updates the account list
// atomically.
func (g *Game) deleteCharacterTxn(accountName, charName string) error {
txn := g.newTxn()
if err := txn.Delete(g.AccountStore.CharPath(charName)); err != nil {
return err
}
acc, err := g.AccountStore.LoadAccount(accountName)
if err != nil {
return err
}
var chars []string
for _, c := range acc.Characters {
if c != charName {
chars = append(chars, c)
}
}
acc.Characters = chars
accData, err := g.AccountStore.MarshalAccount(acc)
if err != nil {
return err
}
if err := txn.Replace(g.AccountStore.AccountPath(accountName), accData, 0600); err != nil {
return err
}
return txn.Commit()
}
// purgeAccountTxn removes the account file and every character file in one
// transaction.
func (g *Game) purgeAccountTxn(accountName string, characters []string) error {
txn := g.newTxn()
for _, name := range characters {
if err := txn.Delete(g.AccountStore.CharPath(name)); err != nil {
return err
}
}
if err := txn.Delete(g.AccountStore.AccountPath(accountName)); err != nil {
return err
}
return txn.Commit()
}
// renameAccountTxn moves the account file, rewrites its embedded name,
// and rewrites every owned character file's owner field in one
// transaction. Without the character rewrite the renamed account loses
// access to its characters: membership authorization compares the
// character file's owner against the account name.
func (g *Game) renameAccountTxn(oldName, newName string) error {
txn := g.newTxn()
if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); err != nil {
return err
}
acc, err := g.AccountStore.LoadAccount(oldName)
if err != nil {
return err
}
acc.Name = newName
accData, err := g.AccountStore.MarshalAccount(acc)
if err != nil {
return err
}
if err := txn.Replace(g.AccountStore.AccountPath(newName), accData, 0600); err != nil {
return err
}
// Re-point each owned character at the new account name inside the
// same transaction. Legacy or unreadable character files are skipped:
// membership falls back to the account list, which is already
// rewritten above.
for _, charName := range acc.Characters {
p, err := g.AccountStore.LoadCharacter(charName)
if err != nil || p.Account == "" || !equalsFold(p.Account, oldName) {
continue
}
p.Account = newName
charData, err := g.AccountStore.MarshalCharacter(p)
if err != nil {
return err
}
if err := txn.Replace(g.AccountStore.CharPath(charName), charData, 0600); err != nil {
return err
}
}
return txn.Commit()
}
// isMemberOf reports whether characterName belongs to accountName by
// checking the character file's owner field (falling back to the account's
// character list for legacy files), case-insensitively.
func (g *Game) isMemberOf(accountName, characterName string) bool {
p, err := g.AccountStore.LoadCharacter(characterName)
if err != nil {
return false
}
if p.Account != "" {
return equalsFold(p.Account, accountName)
}
acc, err := g.AccountStore.LoadAccount(accountName)
if err != nil {
return false
}
for _, c := range acc.Characters {
if equalsFold(c, characterName) {
return true
}
}
return false
}
// publishAccountUpdate reloads the account from disk and republishes its
// state to every session of that account, resetting stale pending menu
// selections (pending character names, menu choices) after lifecycle
// mutations. Called from the owner goroutine only.
//
// oldName and newName differ only for an account rename: sessions
// authenticated before the rename still carry the OLD cached account
// name, so both must match or stale sessions would be left bound to a
// name that no longer exists on disk.
func (g *Game) publishAccountUpdate(oldName, newName string) {
for _, sess := range g.allSessionsOfEitherAccount(oldName, newName) {
acc, err := g.AccountStore.LoadAccount(newName)
if err != nil {
// The account file vanished (purge from another session); the
// session is stale and must leave the menu.
sess.Account = nil
sess.PendingChar = ""
sess.PendingMenu = nil
sess.WriteLine("\nYour account no longer exists.")
sess.Close()
continue
}
sess.Account = &player.Account{
Name: acc.Name,
PasswordHash: acc.PasswordHash,
Characters: acc.Characters,
Aliases: acc.Aliases,
Colors: acc.Colors,
Options: acc.Options,
Admin: acc.Admin,
}
if sess.Account.Aliases == nil {
sess.Account.Aliases = make(map[string]string)
}
if sess.Account.Colors == nil {
sess.Account.Colors = make(map[string]string)
}
if sess.Account.Options == nil {
sess.Account.Options = make(map[string]any)
}
// Any pending selection referencing a deleted/renamed character is
// stale now; revalidate before reuse.
sess.PendingMenu = nil
if sess.PendingChar != "" && sess.PendingChar != "connect" {
found := false
for _, c := range sess.Account.Characters {
if equalsFold(c, sess.PendingChar) {
found = true
break
}
}
if !found {
sess.PendingChar = ""
sess.SetState(net.StateMenu)
g.showMenu(sess)
}
}
}
}
// allSessionsOfAccount returns every session authenticated to accountName,
// including sessions sitting in menus (not just in-game characters).
func (g *Game) allSessionsOfAccount(accountName string) []*net.Session {
return g.allSessionsOfEitherAccount(accountName, accountName)
}
// allSessionsOfEitherAccount matches sessions authenticated to either
// account name (an account rename leaves stale sessions under the old
// name until they are republished).
func (g *Game) allSessionsOfEitherAccount(oldName, newName string) []*net.Session {
if g.Hub == nil {
return nil
}
var out []*net.Session
for _, sess := range g.Hub.AllSessions() {
if sess.Account != nil && (equalsFold(sess.Account.Name, oldName) || equalsFold(sess.Account.Name, newName)) {
out = append(out, sess)
}
}
return out
}
// equalsFold is a small case-insensitive string equality helper.
func equalsFold(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if 'A' <= ca && ca <= 'Z' {
ca += 'a' - 'A'
}
if 'A' <= cb && cb <= 'Z' {
cb += 'a' - 'A'
}
if ca != cb {
return false
}
}
return true
}
// recoverJournals rolls back leftover lifecycle journals at startup and
// surfaces unrecoverable ones.
func (g *Game) recoverJournals() {
if err := persistence.Recover(g.journalDir()); err != nil {
fmt.Fprintf(os.Stderr, "lifecycle journal recovery failed: %v\n", err)
os.Exit(1)
}
}
|