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.State = 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.State = 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.State = 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.State = net.StateAccountName sess.Write("Account name: ") return } if !player.CheckPassword(input, acc.PasswordHash) { sess.SetEcho(true) sess.WriteLine("Wrong password.") sess.State = net.StateAccountName sess.Write("Account name: ") return } // 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 { acc.PasswordHash = hash if err := g.AccountStore.SaveAccount(acc); err != nil { log.Printf("login: password rehash save for %s: %v", acc.Name, err) } } else { log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) } } sess.SetEcho(true) 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.State = 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.State = 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.State = net.StateAccountName sess.Write("Account name: ") return } if input != sess.PendingPass { sess.SetEcho(true) sess.WriteLine("Passwords do not match.") sess.Account = nil sess.State = net.StateAccountName sess.Write("Account name: ") return } hash, err := player.HashPassword(input) if err != nil { sess.SetEcho(true) sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) sess.Account = nil sess.State = 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.State = 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.State = 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.State = 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.State = 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.State = 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.State = net.StateNewCharName sess.PendingChar = "" sess.Write("What's your character name?: ") case "a": sess.State = 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.State = net.StateRenameCharName sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) sess.Write("New name: ") return } sess.State = 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.State = 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.State = 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() }