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
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
|
package game
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"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 persistence.JournalDir(g.DataDir)
}
// 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 not
// survive in memory: a later startup's backfill would assign a
// different one, and any reference cached under this ID would point
// at nothing. 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)
}
// validateAccountName and validateCharacterName reject names that any
// AccountPath/CharPath join would interpret as a path (separators, dots) or
// that no account/character in this store may carry. Every lifecycle
// transaction validates its names BEFORE it touches the filesystem: a
// caller-supplied or account-file-supplied name such as "../../x" would
// otherwise make AccountPath/CharPath read or delete files outside
// players/, and a transaction is not the place to discover that.
func validateAccountName(name string) error {
if err := player.ValidName(name); err != nil {
return fmt.Errorf("invalid account name %q: %w", name, err)
}
return nil
}
func validateCharacterName(name string) error {
if err := player.ValidName(name); err != nil {
return fmt.Errorf("invalid character name %q: %w", name, err)
}
return nil
}
// 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) {
if err := validateAccountName(accountName); err != nil {
return nil, err
}
if err := validateCharacterName(charName); err != nil {
return nil, err
}
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 {
if err := validateAccountName(accountName); err != nil {
return err
}
if err := validateCharacterName(oldName); err != nil {
return err
}
if err := validateCharacterName(newName); err != nil {
return err
}
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 cached references
// (and the owner/membership checks) resolve the renamed character
// by ID rather than the new filename.
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 {
if err := validateAccountName(accountName); err != nil {
return err
}
if err := validateCharacterName(charName); err != nil {
return err
}
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. The account's character list arrives from the account file,
// so every name in it is validated before the first CharPath join: a
// hand-edited or corrupt entry must not turn a purge into a delete outside
// players/characters/.
func (g *Game) purgeAccountTxn(accountName string, characters []string) error {
if err := validateAccountName(accountName); err != nil {
return err
}
for _, name := range characters {
if err := validateCharacterName(name); err != nil {
return err
}
}
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.
//
// Both names and every entry of the account's character list are validated
// up front: each one is joined into AccountPath/CharPath below, and a
// path-like name would escape players/.
func (g *Game) renameAccountTxn(oldName, newName string) error {
if err := validateAccountName(oldName); err != nil {
return err
}
if err := validateAccountName(newName); err != nil {
return err
}
acc, err := g.AccountStore.LoadAccount(oldName)
if err != nil {
return err
}
for _, charName := range acc.Characters {
if err := validateCharacterName(charName); err != nil {
return err
}
}
txn := g.newTxn()
if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); 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. A session whose account no longer exists on disk is reset to the
// account-name prompt (resetToAccountName) and closed. 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 or a raw admin delete from
// another session); the session is stale and must leave the menu.
// resetToAccountName clears PendingPass and the pending state
// alongside Account: a stale confirm-password still matching
// PendingPass would otherwise reach a handler that expects an
// account bound to the session.
sess.WriteLine("\nYour account no longer exists.")
g.resetToAccountName(sess)
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 and surfaces
// unrecoverable ones. It scans every journal in the directory, so it must run
// only where no other transaction can be in flight: at startup, or on the game
// owner goroutine (which serializes every transaction). The mid-game caller in
// video_poker_banks.go is on the owner.
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)
}
}
|