package game import ( "fmt" "log" "strings" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) func (g *Game) handleAccountName(sess *net.Session, input string) { name := strings.TrimSpace(input) if name == "" { sess.Write("Account name: ") return } actualName, err := g.AccountStore.FindAccount(name) if err == nil { sess.Account = &player.Account{Name: actualName} sess.SetAuthName(actualName) sess.SetState(net.StatePassword) sess.SetEcho(false) sess.Write("Password: ") } else { if verr := player.ValidName(name); verr != nil { sess.WriteLine(verr.Error()) sess.Write("Account name: ") return } sess.Account = &player.Account{Name: name} sess.SetAuthName(name) sess.SetState(net.StateNewAccountPass) sess.SetEcho(false) sess.WriteLine("\r\nOh, a new visitor to this rock!") sess.Write("Choose password: ") } } func (g *Game) handlePassword(sess *net.Session, input string) { if input == "" { sess.Write("Password: ") return } if len(input) > 128 { sess.SetEcho(true) sess.WriteLine("Wrong password.") sess.SetState(net.StateAccountName) sess.Write("Account name: ") return } acc, err := g.AccountStore.LoadAccount(sess.Account.Name) if err != nil { sess.SetEcho(true) sess.WriteLine("Error loading account.") sess.SetState(net.StateAccountName) sess.Write("Account name: ") return } if !player.CheckPassword(input, acc.PasswordHash) { sess.SetEcho(true) sess.WriteLine("Wrong password.") sess.SetState(net.StateAccountName) sess.Write("Account name: ") return } auth := passwordAuth{ok: true, stored: acc.PasswordHash} // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to // Argon2id after a successful verification. A failed save keeps the // legacy hash and retries on the next login. if player.NeedsRehash(acc.PasswordHash) { if hash, hashErr := player.HashPassword(input); hashErr == nil { auth.rehash = hash } else { log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) } } g.applyPasswordAuth(sess, auth) } // passwordAuth carries the result of the EXPENSIVE password work (Argon2id // verification and any rehash), which runs on the caller's connection // goroutine — never on the owner, where a burst of logins would stall // every command and tick behind password hashing. type passwordAuth struct { ok bool // loadErr marks an account read failure off the owner: the owner task // renders it distinctly from a wrong password. loadErr bool // stored is the hash that was verified; the owner task re-checks the // file against it so a password change, rename, or delete made during // verification cannot authorize a stale login. stored string // rehash is an upgraded hash to persist after a successful check. rehash string } // handlePasswordOffOwner verifies the password on the caller's connection // goroutine and submits one owner task for the (cheap) state transition. // The production entry point; handlePassword stays for direct HandleSession // callers (tests) but hashes on the owner there. func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { if input == "" || len(input) > 128 { // These checks touch no session state, so they are race-free off // the owner; they also keep Argon2id off absurd inputs. tooshort := input == "" g.RunOnOwner(func() { if g.isShuttingDown() { return } if tooshort { // Echo stays disabled: the next attempt is also a password. sess.Write("Password: ") return } sess.SetEcho(true) sess.WriteLine("Wrong password.") sess.SetState(net.StateAccountName) sess.Write("Account name: ") }) return } // The account name comes from the race-free authName snapshot (set by // the owner when the password prompt was offered), NOT from // sess.Account: the owner goroutine can replace Account at any moment // (account republish after a rename/purge), and reading a plain field // the owner concurrently writes is a data race. name := sess.AuthName() if name == "" { // Defensive: password prompts only follow an account-name step. g.RunOnOwner(func() { if g.isShuttingDown() { return } g.handlePassword(sess, input) }) return } auth := passwordAuth{} acc, err := g.AccountStore.LoadAccount(name) switch { case err != nil: auth.loadErr = true case player.CheckPassword(input, acc.PasswordHash): auth.ok, auth.stored = true, acc.PasswordHash // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to // Argon2id after a successful verification. A failed rehash keeps // the legacy hash and retries on the next login. if player.NeedsRehash(acc.PasswordHash) { if hash, hashErr := player.HashPassword(input); hashErr == nil { auth.rehash = hash } else { log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) } } } g.RunOnOwner(func() { if g.isShuttingDown() { return } g.applyPasswordAuth(sess, auth) }) } // applyPasswordAuth completes (or rejects) a password login on the owner. func (g *Game) applyPasswordAuth(sess *net.Session, auth passwordAuth) { sess.SetEcho(true) reject := func(message string) { sess.WriteLine(message) sess.SetState(net.StateAccountName) sess.Write("Account name: ") } if auth.loadErr { reject("Error loading account.") return } if !auth.ok { reject("Wrong password.") return } acc, err := g.AccountStore.LoadAccount(sess.Account.Name) if err != nil { reject("Error loading account.") return } if acc.PasswordHash != auth.stored { // The account changed while the hash was being verified (password // change, rename, or delete): the verified hash is stale. reject("Account changed; sign in again.") return } if auth.rehash != "" { acc.PasswordHash = auth.rehash g.saveAccount(acc) } 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) } g.showMenu(sess) } func (g *Game) handleNewPass(sess *net.Session, input string) { input = strings.TrimSpace(input) if input == "" { sess.SetEcho(true) sess.Account = nil sess.SetState(net.StateAccountName) sess.Write("Account name: ") return } if verr := player.ValidPassword(input); verr != nil { sess.WriteLine(verr.Error()) sess.Write("Choose password: ") return } sess.PendingPass = input sess.SetState(net.StateNewAccount) sess.Write("Confirm password: ") } func (g *Game) handleNewAccount(sess *net.Session, input string) { input = strings.TrimSpace(input) if input == "" { sess.SetEcho(true) sess.Account = nil sess.SetState(net.StateAccountName) sess.Write("Account name: ") return } if input != sess.PendingPass { sess.SetEcho(true) sess.WriteLine("Passwords do not match.") sess.Account = nil sess.SetState(net.StateAccountName) sess.Write("Account name: ") return } hash, err := player.HashPassword(input) g.applyNewAccount(sess, hash, err) } // handleNewAccountOffOwner hashes the confirmed password on the caller's // connection goroutine (Argon2id is expensive) and submits one owner task // for the existence re-check and save. func (g *Game) handleNewAccountOffOwner(sess *net.Session, input string) { input = strings.TrimSpace(input) // Hash unconditionally on the connection goroutine (Argon2id is // expensive); the owner task below re-checks the empty/mismatch cases // against owner-owned session state, so PendingPass is never read off // the owner. A wasted hash only happens on the rare mismatched-confirm // path, where handleNewAccount's own validation rejects it without a // second hash. hash, hashErr := player.HashPassword(input) g.RunOnOwner(func() { if g.isShuttingDown() { return } if input == "" || input != sess.PendingPass { g.handleNewAccount(sess, input) return } g.applyNewAccount(sess, hash, hashErr) }) } // applyNewAccount creates the account on the owner. The password was // already hashed by the caller (off the owner where possible). func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) { if hashErr != nil { sess.SetEcho(true) sess.WriteLine(fmt.Sprintf("Error creating account: %v", hashErr)) sess.Account = nil sess.SetState(net.StateAccountName) sess.Write("Account name: ") return } // Re-check existence as part of the same owner step as the save. The // name was validated as new when it was typed, but another session (or // the admin API) may have created it in the meantime; saving without // this check would overwrite that account's password and characters. if _, findErr := g.AccountStore.FindAccount(sess.Account.Name); findErr == nil { sess.SetEcho(true) sess.Account = nil sess.SetState(net.StateAccountName) sess.WriteLine("That account name was just taken; please sign in instead.") sess.Write("Account name: ") return } acc := &player.Account{ Name: sess.Account.Name, PasswordHash: hash, } if err := g.AccountStore.SaveAccount(acc); err != nil { sess.SetEcho(true) sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) sess.Account = nil sess.SetState(net.StateAccountName) sess.Write("Account name: ") return } sess.SetEcho(true) sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, Aliases: make(map[string]string), Colors: make(map[string]string), Options: make(map[string]any), } sess.PendingPass = "" sess.SetState(net.StateColorChoice) sess.Write("\nShould I disable color? [y/N] ") } func (g *Game) handleColorChoice(sess *net.Session, input string) { input = strings.ToLower(strings.TrimSpace(input)) if input == "y" || input == "yes" { sess.Account.Options["color"] = "none" acc, err := g.AccountStore.LoadAccount(sess.Account.Name) if err == nil { if acc.Options == nil { acc.Options = make(map[string]any) } acc.Options["color"] = "none" g.saveAccount(acc) } } g.showMenu(sess) } func menuColorMode(sess *net.Session) string { if sess != nil && sess.Account != nil && sess.Account.Options != nil { if v, ok := sess.Account.Options["color"]; ok { if s, ok := v.(string); ok && s != "" { return s } } } return "xterm256" } func (g *Game) showMenu(sess *net.Session) { sess.SetState(net.StateMenu) mode := menuColorMode(sess) lines := []string{ "", fmt.Sprintf("Welcome back to Vesta, %s.", sess.Account.Name), "", } if len(sess.Account.Characters) > 0 { lines = append(lines, color.ExpandTags(mode, " {03}(C){/}{04}onnect character to THOI{/}"), "", color.ExpandTags(mode, " {03}(L){/}{04}ist characters{/}"), color.ExpandTags(mode, " {03}(R){/}{04}ename character{/}"), color.ExpandTags(mode, " {03}(D){/}{04}elete character{/}"), ) } lines = append(lines, color.ExpandTags(mode, " {03}(N){/}{04}ew character{/}"), color.ExpandTags(mode, " {03}(P){/}{04}urge account{/}"), color.ExpandTags(mode, " {03}(A){/}{04}ccount rename{/}"), color.ExpandTags(mode, " {03}(Q){/}{04}uit{/}"), "", ) sess.WriteLines(lines...) sess.Write("> ") } func (g *Game) handleMenu(sess *net.Session, input string) { switch strings.ToLower(strings.TrimSpace(input)) { case "": sess.Write("> ") case "c": if len(sess.Account.Characters) == 0 { sess.WriteLine("\nHey, make a character with 'N' first!") sess.Write("\n> ") return } if len(sess.Account.Characters) == 1 { g.connectCharacter(sess, sess.Account.Characters[0]) return } sess.WriteLine("\nSelect character:") for i, name := range sess.Account.Characters { sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) } sess.SetState(net.StateNewCharName) sess.PendingChar = "connect" sess.Write("\n> ") case "l": if len(sess.Account.Characters) == 0 { sess.WriteLine("\nYou don't have any characters! Make one with 'N'!") } else { sess.WriteLine("\nCharacters:") for _, name := range sess.Account.Characters { sess.WriteLine(fmt.Sprintf(" - %s", name)) } } sess.Write("\n> ") case "n": sess.SetState(net.StateNewCharName) sess.PendingChar = "" sess.Write("What's your character name?: ") case "a": sess.SetState(net.StateRenameAccount) sess.Write("New account name: ") case "r": if len(sess.Account.Characters) == 0 { sess.WriteLine("\nRename who? You don't have any characters!") sess.Write("\n> ") return } if len(sess.Account.Characters) == 1 { sess.PendingChar = sess.Account.Characters[0] sess.SetState(net.StateRenameCharName) sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) sess.Write("New name: ") return } sess.SetState(net.StateRenameChar) sess.WriteLine("\nRename which character?") for i, name := range sess.Account.Characters { sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) } sess.Write("\n> ") case "d": if len(sess.Account.Characters) == 0 { sess.WriteLine("\nNo characters to delete.") sess.Write("\n> ") return } if len(sess.Account.Characters) == 1 { sess.PendingChar = sess.Account.Characters[0] } else { sess.SetState(net.StateDeleteChar) sess.WriteLine("\nDelete which character?") for i, name := range sess.Account.Characters { sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) } sess.Write("\n> ") return } g.showDeleteConfirm(sess) case "p": sess.SetState(net.StatePurgeAccount) sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name)) sess.Write("\n> ") case "q": sess.WriteLine("Later.") sess.Close() return default: sess.Write("> ") } } func (g *Game) handleRenameAccount(sess *net.Session, input string) { newName := strings.TrimSpace(input) if newName == "" { sess.Write("New account name: ") return } if verr := player.ValidName(newName); verr != nil { sess.WriteLine(verr.Error()) sess.Write("New account name: ") return } oldName := sess.Account.Name if newName == oldName { sess.WriteLine("That's already your account name.") g.showMenu(sess) return } if g.AccountStore.AccountExists(newName) { sess.WriteLine("An account with that name already exists.") sess.Write("New account name: ") return } // One journal transaction: file move + embedded name rewrite. Any // failure rolls back, so the account is never half-renamed. if err := g.renameAccountTxn(oldName, newName); err != nil { sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) g.showMenu(sess) return } // Republish account state to EVERY session of this account: other // authenticated sessions must not retain stale account objects or // pending menu selections bound to the old name. Stale sessions still // carry the old cached name, so match both. g.publishAccountUpdate(oldName, newName) sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) g.showMenu(sess) } func (g *Game) handlePurgeAccount(sess *net.Session, input string) { input = strings.TrimSpace(input) expected := "PURGE " + sess.Account.Name if !strings.EqualFold(input, expected) { sess.WriteLine("Purge cancelled.") g.showMenu(sess) return } // Refuse to purge while any character is connected: a live session // would keep playing (and saving) a character whose file is about to // vanish. The online check and the purge run as one owner step, so no // connection can start a character between check and deletion. for _, name := range sess.Account.Characters { if g.characterOnline(name) { sess.WriteLine(fmt.Sprintf("%s is logged in. Log all characters out before purging.", name)) g.showMenu(sess) return } } if err := g.purgeAccountTxn(sess.Account.Name, sess.Account.Characters); err != nil { sess.WriteLine(fmt.Sprintf("Error purging account: %v", err)) g.showMenu(sess) return } sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name)) // Other authenticated sessions of this account are stale now; tell // them and close them (they cannot safely keep using cached state). for _, other := range g.allSessionsOfAccount(sess.Account.Name) { if other == sess { continue } other.Account = nil other.PendingChar = "" other.PendingMenu = nil other.WriteLine("\nYour account has been purged from another session.") other.Close() } sess.Close() }