aboutsummaryrefslogtreecommitdiff
path: root/internal/game/account_txn.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 15:35:52 -0400
committerhistoria <[not public]>2026-09-15 15:35:52 -0400
commit0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch)
tree2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/game/account_txn.go
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-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.go380
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)
+ }
+}