package game import ( "fmt" "log" "strings" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) func (g *Game) handleNewCharName(sess *net.Session, input string) { if sess.Account == nil { g.resetToAccountName(sess) return } name := strings.TrimSpace(input) if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { sess.PendingChar = "" g.connectCharacter(sess, sess.Account.Characters[idx-1]) return } sess.WriteLine("Invalid choice.") sess.Write("\n> ") return } if name == "" { sess.Write("Character name: ") return } if verr := player.ValidName(name); verr != nil { sess.WriteLine(verr.Error()) sess.Write("Character name: ") return } // Everything below runs on the owner goroutine (session input): the // existence check, file creation, and account publication are one // transaction with no interleaved session input, so a concurrent // creation of the same name cannot slip through the check. if g.AccountStore.CharacterExists(name) { sess.WriteLine("A character with that name already exists.") sess.Write("Character name: ") return } p, err := g.createCharacterTxn(sess.Account.Name, name, g.StartingRoom) if err != nil { sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) sess.SetState(net.StateMenu) g.showMenu(sess) return } _ = p sess.Account.Characters = append(sess.Account.Characters, name) g.publishAccountUpdate(sess.Account.Name, sess.Account.Name) g.connectCharacter(sess, name) if sess.Player != nil { g.runEnterSteps(sess, sess.Player.RoomID) } } func (g *Game) connectCharacter(sess *net.Session, name string) { if sess.Account == nil { g.resetToAccountName(sess) return } // This whole function runs as one owner step (invoked from session // input): membership validation, the online check, registry // publication, and room entry are serialized against every other // lifecycle operation, closing the check-then-register gap. // Authorize by ownership, not just existence: the character file's // owner must match this account, so a stale cached name cannot connect // a character reassigned to another account. actualName, err := g.canonicalChar(name) if err != nil { sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) sess.SetState(net.StateMenu) g.showMenu(sess) return } if !g.isMemberOf(sess.Account.Name, actualName) { sess.WriteLine("That character does not belong to this account.") sess.SetState(net.StateMenu) g.showMenu(sess) return } name = actualName g.charsMu.Lock() if existing := g.loggedInChars[name]; existing != nil { g.charsMu.Unlock() sess.WriteLine("This character is logged in elsewhere.") sess.SetState(net.StateMenu) g.showMenu(sess) return } p, err := g.AccountStore.LoadCharacter(name) if err != nil { g.charsMu.Unlock() sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) sess.SetState(net.StateMenu) g.showMenu(sess) return } g.adoptCharacterIdentity(p, sess.Account.Name) if g.Hub != nil { g.Hub.SetSessionPlayer(sess, p) } else { sess.Player = p } sess.SetState(net.StateGame) g.loggedInChars[name] = sess g.charsMu.Unlock() if sess.Account != nil && sess.Account.Options != nil { p.Options = make(map[string]any) for k, v := range sess.Account.Options { p.Options[k] = v } } // Rescue a character whose saved room no longer exists (the room was // deleted, or a code-referenced teleport target was removed). Persisting // a nonexistent RoomID soft-locks the character on every later login // because movement and every room lookup fail. if _, err := g.World.LoadRoom(p.RoomID); err != nil { log.Printf("character %s: saved room %d no longer exists; relocating to starting room %d", p.Name, p.RoomID, g.StartingRoom) p.RoomID = g.StartingRoom if _, err := g.World.LoadRoom(p.RoomID); err != nil { log.Printf("character %s: starting room %d also missing; leaving room as %d", p.Name, g.StartingRoom, p.RoomID) } } p.Stats.RecordRoomVisit(p.RoomID) g.World.SeedGroundItems(p.RoomID) g.seedRoomMobs(p.RoomID) g.seedRoomObjects(p.RoomID) if g.Hub != nil { g.Hub.EnterRoom(sess, p.RoomID) } g.doLook(sess) g.checkAggro(sess) // Resume an interrupted LOCKED trigger sequence saved across disconnect. g.resumePendingSequence(sess, p) sess.WritePrompt("> ") } func (g *Game) handleRenameChar(sess *net.Session, input string) { if sess.Account == nil { g.resetToAccountName(sess) return } name := strings.TrimSpace(input) if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { sess.PendingChar = sess.Account.Characters[idx-1] } else { sess.PendingChar = name } found := false for _, c := range sess.Account.Characters { if c == sess.PendingChar { found = true break } } if !found { sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) g.showMenu(sess) return } sess.SetState(net.StateRenameCharName) sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) sess.Write("New name: ") } // characterOnline reports whether the named character is currently connected // through a live session. Menu operations that rename or delete character // files must refuse while the character is online, otherwise the in-memory // player later overwrites or resurrects the moved/removed file. // // The lookup is case-insensitive: loggedInChars is keyed by the canonical // on-disk character name, but callers pass names taken from hand-editable // account files and admin file paths ("ALICE"), and character names keep the // case their owner chose. Character identity in this game is otherwise // case-insensitive (see isMemberOf), so liveness must be too - an exact-match // lookup lets an online character's file be edited or deleted. func (g *Game) characterOnline(name string) bool { g.charsMu.Lock() defer g.charsMu.Unlock() if g.loggedInChars[name] != nil { return true } for logged, sess := range g.loggedInChars { if sess != nil && strings.EqualFold(logged, name) { return true } } return false } func (g *Game) handleRenameCharName(sess *net.Session, input string) { if sess.Account == nil { g.resetToAccountName(sess) return } newName := strings.TrimSpace(input) oldName := sess.PendingChar if newName == "" { sess.Write("New name: ") return } if verr := player.ValidName(newName); verr != nil { sess.WriteLine(verr.Error()) sess.Write("New name: ") return } if newName == oldName { sess.WriteLine("That's already the character's name.") g.showMenu(sess) return } // Checks and mutation run as one owner step: no session input can // interleave between the membership/online checks and the file // rename, and the rename is a single journal transaction. actualOld, err := g.canonicalChar(oldName) if err != nil { sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) g.showMenu(sess) return } if !g.isMemberOf(sess.Account.Name, actualOld) { sess.WriteLine(fmt.Sprintf("No character named %s on this account.", oldName)) sess.PendingChar = "" g.showMenu(sess) return } if newName != actualOld && g.AccountStore.CharacterExists(newName) { sess.WriteLine("A character with that name already exists.") sess.Write("New name: ") return } if g.characterOnline(actualOld) { sess.WriteLine("That character is logged in elsewhere. Log them out first.") sess.PendingChar = "" g.showMenu(sess) return } if err := g.renameCharacterTxn(sess.Account.Name, actualOld, newName); err != nil { sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) g.showMenu(sess) return } sess.PendingChar = "" sess.WriteLine(fmt.Sprintf("%s renamed to %s.", actualOld, newName)) // Refresh every session of this account so no stale character list or // pending selection survives the rename. g.publishAccountUpdate(sess.Account.Name, sess.Account.Name) g.showMenu(sess) } func (g *Game) showDeleteConfirm(sess *net.Session) { sess.SetState(net.StateDeleteChar) sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) } func (g *Game) handleDeleteChar(sess *net.Session, input string) { if sess.Account == nil { g.resetToAccountName(sess) return } if sess.PendingChar == "" { name := strings.TrimSpace(input) if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { sess.PendingChar = sess.Account.Characters[idx-1] } else { sess.PendingChar = name } found := false for _, c := range sess.Account.Characters { if c == sess.PendingChar { found = true break } } if !found { sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) sess.PendingChar = "" g.showMenu(sess) return } g.showDeleteConfirm(sess) return } input = strings.TrimSpace(input) expected := "DELETE " + sess.PendingChar if !strings.EqualFold(input, expected) { sess.WriteLine("Delete cancelled.") sess.PendingChar = "" g.showMenu(sess) return } // Checks and mutation run as one owner step; the delete is a single // journal transaction (character file + account list). actualName, err := g.canonicalChar(sess.PendingChar) if err != nil { sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) sess.PendingChar = "" g.showMenu(sess) return } if !g.isMemberOf(sess.Account.Name, actualName) { sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) sess.PendingChar = "" g.showMenu(sess) return } if g.characterOnline(actualName) { sess.WriteLine("That character is logged in elsewhere. Log them out first.") sess.PendingChar = "" g.showMenu(sess) return } if err := g.deleteCharacterTxn(sess.Account.Name, actualName); err != nil { sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) sess.PendingChar = "" g.showMenu(sess) return } sess.WriteLine(fmt.Sprintf("%s has been deleted.", actualName)) sess.PendingChar = "" g.publishAccountUpdate(sess.Account.Name, sess.Account.Name) g.showMenu(sess) }