From 0b0b25222b3131ada79ff78c28eb8bf07d977deb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 15:35:52 -0400 Subject: feat: single-owner game state, transactional lifecycle, durable money on server stop --- internal/admin/api_rooms.go | 149 +++++++++++++++++++++++++++++++++----------- 1 file changed, 111 insertions(+), 38 deletions(-) (limited to 'internal/admin/api_rooms.go') 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__ - // 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) { -- cgit v1.2.3