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) { if p.ID == "" { p.ID = newCharacterID() } if p.Account == "" { p.Account = accountName } if err := g.AccountStore.SaveCharacter(p); err != nil { // Identity backfill is best-effort: the character remains usable, // but membership validation falls back to the account list. 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) } } } } // canonicAccount returns the account name in its canonical on-disk form. func (g *Game) canonicalAccount(name string) (string, error) { return g.AccountStore.FindAccount(name) } // 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 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 and rewrites its embedded name in // one transaction. 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 } 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. func (g *Game) publishAccountUpdate(accountName string) { for _, sess := range g.allSessionsOfAccount(accountName) { acc, err := g.AccountStore.LoadAccount(accountName) 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.State = 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 { if g.Hub == nil { return nil } var out []*net.Session for _, sess := range g.Hub.AllSessions() { if sess.Account != nil && equalsFold(sess.Account.Name, accountName) { 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) } }