aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_rooms.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/admin/api_rooms.go
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/admin/api_rooms.go')
-rw-r--r--internal/admin/api_rooms.go149
1 files changed, 111 insertions, 38 deletions
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 2c251bb..4cfb467 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -12,6 +12,7 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/world"
)
@@ -840,6 +841,18 @@ func (s *AdminServer) handleRoomMove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "path": newPath})
}
+// handleRoomRename executes a room-ID rename as one complete mutation plan:
+//
+// 1. Preflight reads every room file and computes after-images in memory
+// (the renamed room — id field, filename, and self-loop references — plus
+// every other room whose references moved). Load failures abort BEFORE
+// any mutation; nothing is half-applied.
+// 2. The file changes apply as a single journal transaction, rolling back
+// on any write failure.
+// 3. The wired runtime migration (players, Hub buckets, casino room keys,
+// world room-keyed state, mob instances) runs through one owner task.
+// 4. The undo record captures every before/after image and the idMap, so
+// undo/redo restore the whole plan, not just the renamed file.
func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -862,12 +875,10 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, "room not found", http.StatusBadRequest)
return
}
-
if _, ok := s.world.GetRoomPath(body.NewID); ok {
writeJSONError(w, fmt.Sprintf("room %d already exists", body.NewID), http.StatusConflict)
return
}
-
dir := filepath.Dir(oldPath)
newPath := filepath.Join(dir, strconv.Itoa(body.NewID)+".yaml")
if _, err := os.Stat(newPath); err == nil {
@@ -875,63 +886,125 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
return
}
- data, err := os.ReadFile(oldPath)
- if err != nil {
- writeJSONError(w, "read: "+err.Error(), http.StatusInternalServerError)
+ idMap := map[int]int{body.ID: body.NewID}
+
+ // ---- Preflight: read every room file and compute after-images in
+ // memory before mutating anything. Load or marshal failures abort the
+ // request with no half-applied state. ----
+ allIDs, _ := listRoomIDs(s.dataDir)
+ type roomImage struct {
+ path string
+ before []byte
+ after []byte
+ changed bool
+ }
+ images := make(map[int]*roomImage, len(allIDs))
+ for _, roomID := range allIDs {
+ path, pathOk := s.world.GetRoomPath(roomID)
+ if !pathOk {
+ writeJSONError(w, fmt.Sprintf("room %d has no file path", roomID), http.StatusInternalServerError)
+ return
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ writeJSONError(w, "read: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ img := &roomImage{path: path, before: data, after: data}
+
+ room, loadErr := s.world.LoadRoom(roomID)
+ if loadErr != nil {
+ writeJSONError(w, fmt.Sprintf("load room %d: %v", roomID, loadErr), http.StatusInternalServerError)
+ return
+ }
+ moved := room.RewriteRoomIDs(idMap)
+ if roomID == body.ID {
+ // The renamed room's own id field becomes the new ID (the
+ // file index keys by filename, but the room's self-view must
+ // agree with it).
+ room.ID = body.NewID
+ moved = true
+ }
+ if moved {
+ after, err := marshalYAML(room)
+ if err != nil {
+ writeJSONError(w, "marshal: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ img.after, img.changed = after, true
+ }
+ images[roomID] = img
+ }
+ renamed, ok := images[body.ID]
+ if !ok {
+ writeJSONError(w, "renamed room missing from plan", http.StatusInternalServerError)
return
}
- if err := os.WriteFile(newPath, data, 0644); err != nil {
- writeJSONError(w, "write: "+err.Error(), http.StatusInternalServerError)
+
+ // ---- Apply the file changes as one journal transaction. ----
+ txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal"))
+ if err := txn.Move(oldPath, newPath); err != nil {
+ writeJSONError(w, "plan move: "+err.Error(), http.StatusInternalServerError)
return
}
- if err := os.Remove(oldPath); err != nil {
- writeJSONError(w, "remove old: "+err.Error(), http.StatusInternalServerError)
+ // The renamed room's rewritten content (new id field + corrected
+ // self-loop) lands via a replace on the new path right after the move.
+ if err := txn.Replace(newPath, renamed.after, 0644); err != nil {
+ writeJSONError(w, "plan self write: "+err.Error(), http.StatusInternalServerError)
return
}
-
- s.world.RebuildRoomIndex(s.dataDir)
- s.world.ClearRoomState(body.ID)
-
- // Remap every genuine room-ID reference (exit targets, trigger/on-enter
- // room+teleport+despawn_rooms, mob wander_rooms) from the old ID to the
- // new one across all rooms — including the renamed room itself, so a
- // self-loop exit is corrected too. Only rooms whose references actually
- // moved are re-marshaled (preserving untouched files' formatting).
- idMap := map[int]int{body.ID: body.NewID}
- allIDs, _ := listRoomIDs(s.dataDir)
var refUpdates int
- for _, otherID := range allIDs {
- otherRoom, loadErr := s.world.LoadRoom(otherID)
- if loadErr != nil {
- continue
- }
- if !otherRoom.RewriteRoomIDs(idMap) {
+ for roomID, img := range images {
+ if !img.changed || roomID == body.ID {
continue
}
- otherPath, pathOk := s.world.GetRoomPath(otherID)
- if !pathOk {
- continue
+ if err := txn.Replace(img.path, img.after, 0644); err != nil {
+ writeJSONError(w, "plan write: "+err.Error(), http.StatusInternalServerError)
+ return
}
- writeYAMLFile(otherPath, otherRoom)
refUpdates++
}
+ if err := txn.Commit(); err != nil {
+ writeJSONError(w, "commit rename: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.world.ClearRoomState(body.ID)
// Migrate room-ID-embedded state across all characters (discovered-exit
- // player flags, RoomID, EnterSeqRoom, MapSymbols, RoomsVisited) so a
- // rename does not strand players or leave stale hidden_exit_<id>_<dir>
- // flags pointing at the old room ID.
+ // player flags, RoomID, EnterSeqRoom, MapSymbols, RoomsVisited) plus
+ // Hub membership buckets, casino room keys, world room-keyed state, and
+ // live mob instances, so a rename leaves no gameplay, broadcast, or
+ // player attached to the old location.
if s.rewriteRoomIDs != nil {
s.rewriteRoomIDs(idMap)
}
- s.undoStack.Push(ChangeDesc{
+ // ---- Undo record: the complete plan (renamed room before/after images
+ // including the rewritten id field and self-loop references, every
+ // rewritten reference room, and the idMap for inverse runtime
+ // migration). Undo/redo restore the whole plan, not just the renamed
+ // file. ----
+ change := ChangeDesc{
Description: fmt.Sprintf("rename room %d to %d (updated %d refs)", body.ID, body.NewID, refUpdates),
FilePath: oldPath,
NewFilePath: newPath,
- OldContent: data,
- NewContent: data,
- })
- writeJSON(w, map[string]any{"ok": true})
+ OldContent: renamed.before,
+ NewContent: renamed.after,
+ IDMap: idMap,
+ }
+ for roomID, img := range images {
+ if img.changed && roomID != body.ID {
+ change.ExtraFiles = append(change.ExtraFiles, ExtraFile{
+ FilePath: img.path,
+ OldContent: img.before,
+ NewContent: img.after,
+ })
+ }
+ }
+ s.undoStack.Push(change)
+ writeJSON(w, map[string]any{"ok": true, "refs": refUpdates})
}
func (s *AdminServer) handleRoomSwap(w http.ResponseWriter, r *http.Request) {