aboutsummaryrefslogtreecommitdiff
path: root/internal/game/core_login_char.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/core_login_char.go
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/game/core_login_char.go')
-rw-r--r--internal/game/core_login_char.go132
1 files changed, 71 insertions, 61 deletions
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index 9d7c211..68a0d58 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -2,7 +2,6 @@ package game
import (
"fmt"
- "os"
"strings"
"thehouseoficarus/internal/net"
@@ -34,33 +33,26 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
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 := player.New(name)
- p.RoomID = g.StartingRoom
- p.Stats.RecordRoomVisit(g.StartingRoom)
-
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
- sess.State = net.StateMenu
- g.showMenu(sess)
- return
- }
-
- acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ p, err := g.createCharacterTxn(sess.Account.Name, name, g.StartingRoom)
if err != nil {
sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
sess.State = net.StateMenu
g.showMenu(sess)
return
}
- acc.Characters = append(acc.Characters, name)
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = acc.Characters
+ _ = p
+ sess.Account.Characters = append(sess.Account.Characters, name)
+ g.publishAccountUpdate(sess.Account.Name)
g.connectCharacter(sess, name)
@@ -70,6 +62,29 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
}
func (g *Game) connectCharacter(sess *net.Session, name string) {
+ // 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.State = net.StateMenu
+ g.showMenu(sess)
+ return
+ }
+ if !g.isMemberOf(sess.Account.Name, actualName) {
+ sess.WriteLine("That character does not belong to this account.")
+ sess.State = net.StateMenu
+ g.showMenu(sess)
+ return
+ }
+ name = actualName
+
g.charsMu.Lock()
if existing := g.loggedInChars[name]; existing != nil {
g.charsMu.Unlock()
@@ -87,6 +102,7 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
g.showMenu(sess)
return
}
+ g.adoptCharacterIdentity(p, sess.Account.Name)
sess.Player = p
sess.State = net.StateGame
@@ -171,49 +187,45 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) {
g.showMenu(sess)
return
}
- if g.AccountStore.CharacterExists(newName) {
- sess.WriteLine("A character with that name already exists.")
- sess.Write("New name: ")
+
+ // 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.characterOnline(oldName) {
- sess.WriteLine("That character is logged in elsewhere. Log them out first.")
+ 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
}
-
- oldPath := g.AccountStore.CharPath(oldName)
- newPath := g.AccountStore.CharPath(newName)
- if err := os.Rename(oldPath, newPath); err != nil {
- sess.WriteLine(fmt.Sprintf("Error renaming: %v", err))
- g.showMenu(sess)
+ if newName != actualOld && g.AccountStore.CharacterExists(newName) {
+ sess.WriteLine("A character with that name already exists.")
+ sess.Write("New name: ")
return
}
-
- p, _ := g.AccountStore.LoadCharacter(newName)
- if p != nil {
- p.Name = newName
- g.AccountStore.SaveCharacter(p)
+ if g.characterOnline(actualOld) {
+ sess.WriteLine("That character is logged in elsewhere. Log them out first.")
+ sess.PendingChar = ""
+ g.showMenu(sess)
+ return
}
- acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
- if err != nil {
+ if err := g.renameCharacterTxn(sess.Account.Name, actualOld, newName); err != nil {
sess.WriteLine(fmt.Sprintf("Error renaming: %v", err))
g.showMenu(sess)
return
}
- for i, c := range acc.Characters {
- if c == oldName {
- acc.Characters[i] = newName
- break
- }
- }
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = acc.Characters
sess.PendingChar = ""
- sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName))
+ 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)
g.showMenu(sess)
}
@@ -256,39 +268,37 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) {
return
}
- if g.characterOnline(sess.PendingChar) {
- sess.WriteLine("That character is logged in elsewhere. Log them out first.")
+ // 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
}
-
- charPath := g.AccountStore.CharPath(sess.PendingChar)
- if err := os.Remove(charPath); err != nil {
- sess.WriteLine(fmt.Sprintf("Error deleting: %v", err))
+ 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
}
- acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
- if err != nil {
+ if err := g.deleteCharacterTxn(sess.Account.Name, actualName); err != nil {
sess.WriteLine(fmt.Sprintf("Error deleting: %v", err))
sess.PendingChar = ""
g.showMenu(sess)
return
}
- var newChars []string
- for _, c := range acc.Characters {
- if c != sess.PendingChar {
- newChars = append(newChars, c)
- }
- }
- acc.Characters = newChars
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = newChars
- sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar))
+ sess.WriteLine(fmt.Sprintf("%s has been deleted.", actualName))
sess.PendingChar = ""
+ g.publishAccountUpdate(sess.Account.Name)
g.showMenu(sess)
}