diff options
| author | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
| commit | 0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch) | |
| tree | 2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/game/account_txn.go | |
| parent | 396c3facb8287b9a34076cae8f3d7b300d366d99 (diff) | |
| download | thehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz | |
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/game/account_txn.go')
| -rw-r--r-- | internal/game/account_txn.go | 380 |
1 files changed, 380 insertions, 0 deletions
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go new file mode 100644 index 0000000..488a5f6 --- /dev/null +++ b/internal/game/account_txn.go @@ -0,0 +1,380 @@ +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) + } +} |
