package admin import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/world" ) func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: ids, err := listRoomIDs(s.dataDir) if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } if ids == nil { ids = []int{} } writeJSON(w, ids) case http.MethodPost: body, err := io.ReadAll(r.Body) r.Body.Close() if err != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("read body: %v", err)}) return } var m map[string]any if err := json.Unmarshal(body, &m); err != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid json: %v", err)}) return } if m == nil { m = map[string]any{} } var linkFrom *int var linkDir *string var linkOneWay bool if v, ok := m["link_from"]; ok { if f, ok := v.(float64); ok { lf := int(f) linkFrom = &lf } delete(m, "link_from") } if v, ok := m["link_dir"]; ok { if d, ok := v.(string); ok && d != "" { ld := d linkDir = &ld } delete(m, "link_dir") } if v, ok := m["link_oneway"]; ok { if b, ok := v.(bool); ok { linkOneWay = b } delete(m, "link_oneway") } fromID := 0 if linkFrom != nil { fromID = *linkFrom } id, subdir, allocErr := s.nextRoomID(fromID) if allocErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("alloc id: %v", allocErr)}) return } m["id"] = id exits, _ := m["exits"].(map[string]any) if exits == nil { exits = make(map[string]any) m["exits"] = exits } var extraFiles []ExtraFile if linkFrom != nil && linkDir != nil && *linkDir != "" { dir := world.ExitDir(strings.ToLower(*linkDir)) if opp, ok := world.OppositeExit[dir]; ok { srcRoom, loadErr := s.world.LoadRoom(*linkFrom) if loadErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("load link_from room %d: %v", *linkFrom, loadErr)}) return } if srcRoom.Exits == nil { srcRoom.Exits = make(map[world.ExitDir]world.ExitDef) } srcRoom.Exits[dir] = world.ExitDef{Room: id} srcPath, srcOk := s.world.GetRoomPath(*linkFrom) if srcOk { oldContent, _ := snapshotFile(srcPath) newContent, writeErr := writeYAMLFile(srcPath, srcRoom) if writeErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("write source room: %v", writeErr)}) return } extraFiles = append(extraFiles, ExtraFile{ FilePath: srcPath, OldContent: oldContent, NewContent: newContent, }) } if !linkOneWay { exits[string(opp)] = *linkFrom } } } path := filepath.Join(subdir, strconv.Itoa(id)+".yaml") newContent, writeErr := writeMapAsYAML(path, m) if writeErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("write room: %v", writeErr)}) return } s.world.AddRoomPath(id, path) s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("create room %d", id), FilePath: path, NewContent: newContent, IsCreate: true, ExtraFiles: extraFiles, }) writeJSON(w, map[string]any{"room": m}) default: http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) } } func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/rooms/") sub := "" if idx := strings.IndexByte(idStr, '/'); idx >= 0 { sub = idStr[idx+1:] idStr = idStr[:idx] } id, err := strconv.Atoi(idStr) if err != nil { writeJSON(w, map[string]any{"error": "invalid room id"}) return } switch sub { case "course": s.handleRoomCourse(w, r, id) return case "courses/attach": s.handleRoomAttachCourse(w, r, id) return case "courses/detach": s.handleRoomDetachCourse(w, r, id) return } switch r.Method { case http.MethodGet: path, ok := s.world.GetRoomPath(id) if !ok { writeJSON(w, map[string]any{"error": "room not found"}) return } data, err := os.ReadFile(path) if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } m, err := yamlToMap(data) if err != nil { writeJSON(w, map[string]any{"error": "yaml parse error"}) return } m["id"] = id writeJSON(w, map[string]any{"room": m, "path": path, "file": path}) case http.MethodPut: path, ok := s.world.GetRoomPath(id) if !ok { writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) return } oldContent, snapErr := snapshotFile(path) if snapErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("snapshot room: %v", snapErr)}) return } var m map[string]any body, readErr := io.ReadAll(r.Body) r.Body.Close() if readErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("read body: %v", readErr)}) return } if err := json.Unmarshal(body, &m); err != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid json: %v", err)}) return } if m == nil { m = map[string]any{} } m["id"] = id newContent, writeErr := writeMapAsYAML(path, m) if writeErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("write room: %v", writeErr)}) return } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("update room %d", id), FilePath: path, OldContent: oldContent, NewContent: newContent, }) writeJSON(w, map[string]any{"room": m}) case http.MethodDelete: path, ok := s.world.GetRoomPath(id) if !ok { writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) return } oldContent, backupErr := snapshotFile(path) if backupErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("backup room: %v", backupErr)}) return } if err := os.Remove(path); err != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("delete room: %v", err)}) return } s.world.RebuildRoomIndex(s.dataDir) s.world.ClearRoomState(id) allIDs, _ := listRoomIDs(s.dataDir) var extraFiles []ExtraFile var exitCleaned int for _, otherID := range allIDs { if otherID == id { continue } otherRoom, loadErr := s.world.LoadRoom(otherID) if loadErr != nil { continue } changed := false for _, dir := range world.ExitOrder { exit, exists := otherRoom.Exits[dir] if !exists || exit.Room != id { continue } delete(otherRoom.Exits, dir) changed = true } if changed { otherPath, pathOk := s.world.GetRoomPath(otherID) if pathOk { oldExtra, _ := snapshotFile(otherPath) newContent, writeErr := writeYAMLFile(otherPath, otherRoom) if writeErr != nil { continue } extraFiles = append(extraFiles, ExtraFile{ FilePath: otherPath, OldContent: oldExtra, NewContent: newContent, }) exitCleaned++ } } } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("delete room %d (cleaned %d exits)", id, exitCleaned), FilePath: path, OldContent: oldContent, IsDelete: true, ExtraFiles: extraFiles, }) writeJSON(w, map[string]any{"ok": true}) default: http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) } } func listRoomIDs(dataDir string) ([]int, error) { var ids []int base := filepath.Join(dataDir, "rooms") err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() && filepath.Ext(d.Name()) == ".yaml" { idStr := d.Name()[:len(d.Name())-5] id, convErr := strconv.Atoi(idStr) if convErr == nil { ids = append(ids, id) } } return nil }) if os.IsNotExist(err) { return []int{}, nil } return ids, err } func (s *AdminServer) handleRoomDirs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } base := filepath.Join(s.dataDir, "rooms") dirs := []string{""} filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { if err != nil || !d.IsDir() || path == base { return nil } rel, _ := filepath.Rel(base, path) dirs = append(dirs, rel) return nil }) writeJSON(w, dirs) } func (s *AdminServer) handleRenameRoomDir(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Dir string `json:"dir"` NewName string `json:"new_name"` } if err := readJSON(r, &body); err != nil || body.NewName == "" { writeJSON(w, map[string]any{"error": "invalid"}) return } if body.Dir == "" { writeJSON(w, map[string]any{"error": "cannot rename root"}) return } newName := strings.TrimSpace(body.NewName) if newName == "" || strings.ContainsAny(newName, "/\\") || newName == "." || newName == ".." { writeJSON(w, map[string]any{"error": "invalid directory name"}) return } base := filepath.Join(s.dataDir, "rooms") parent := filepath.Dir(body.Dir) oldPath := filepath.Join(base, body.Dir) newRel := filepath.Join(parent, newName) newPath := filepath.Join(base, newRel) if _, err := os.Stat(oldPath); os.IsNotExist(err) { writeJSON(w, map[string]any{"error": "directory not found"}) return } if _, err := os.Stat(newPath); err == nil { writeJSON(w, map[string]any{"error": "directory already exists: " + newRel}) return } if err := os.Rename(oldPath, newPath); err != nil { writeJSON(w, map[string]any{"error": "rename failed: " + err.Error()}) return } s.world.RebuildRoomIndex(s.dataDir) writeJSON(w, map[string]any{"ok": true, "new_dir": newRel}) } func (s *AdminServer) handleCreateRoomDir(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Name string `json:"name"` } if err := readJSON(r, &body); err != nil { writeJSON(w, map[string]any{"error": "invalid"}) return } name := strings.TrimSpace(body.Name) if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { writeJSON(w, map[string]any{"error": "invalid directory name"}) return } newPath := filepath.Join(s.dataDir, "rooms", name) if _, err := os.Stat(newPath); err == nil { writeJSON(w, map[string]any{"error": "directory already exists"}) return } if err := os.Mkdir(newPath, 0755); err != nil { writeJSON(w, map[string]any{"error": "create failed: " + err.Error()}) return } writeJSON(w, map[string]any{"ok": true, "name": name}) } func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: var body struct { From int `json:"from"` To int `json:"to"` Dir string `json:"dir"` OneWay bool `json:"oneway"` } if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { writeJSONError(w, "invalid body, need from and to", http.StatusBadRequest) return } roomA, errA := s.world.LoadRoom(body.From) roomB, errB := s.world.LoadRoom(body.To) if errA != nil || errB != nil { writeJSONError(w, "one or both rooms not found", http.StatusBadRequest) return } var dir, oppDir world.ExitDir if body.Dir != "" { dir = world.ExitDir(strings.ToLower(body.Dir)) if opp, ok := world.OppositeExit[dir]; ok { oppDir = opp } else { writeJSONError(w, "invalid direction: "+body.Dir, http.StatusBadRequest) return } } else { grid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) if err != nil { log.Printf("link: BuildGrid failed to load room %d: %v", id, err) return nil, false } return room, true }, nil, nil, nil) cA, okA := grid.Coord[body.From] cB, okB := grid.Coord[body.To] if !okA || !okB { writeJSONError(w, "one or both rooms are not reachable from the seed room", http.StatusBadRequest) return } if cA[2] != cB[2] { writeJSONError(w, "rooms must be on same z-level", http.StatusBadRequest) return } dx, dy := cB[0]-cA[0], cB[1]-cA[1] for d, delta := range world.DirectionDeltas3D { if delta[0] == dx && delta[1] == dy && delta[2] == 0 { dir = d oppDir = world.OppositeExit[d] break } } if dir == "" { writeJSONError(w, "rooms are not adjacent", http.StatusBadRequest) return } } if roomA.Exits != nil { for d, exit := range roomA.Exits { if exit.Room == body.To { delete(roomA.Exits, d) break } } } if roomB.Exits != nil { for d, exit := range roomB.Exits { if exit.Room == body.From { delete(roomB.Exits, d) break } } } if roomA.Exits != nil { if existing, ok := roomA.Exits[dir]; ok && existing.Room != body.To { writeJSONError(w, fmt.Sprintf("%s in #%d already links to #%d", dir, body.From, existing.Room), http.StatusConflict) return } } if !body.OneWay { if roomB.Exits != nil { if existing, ok := roomB.Exits[oppDir]; ok && existing.Room != body.From { writeJSONError(w, fmt.Sprintf("%s in #%d already links to #%d", oppDir, body.To, existing.Room), http.StatusConflict) return } } } conflicts := 0 testGrid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) if err != nil { log.Printf("link: conflict test BuildGrid failed to load room %d: %v", id, err) return nil, false } roomCopy := *room if room.Exits != nil { roomCopy.Exits = make(map[world.ExitDir]world.ExitDef, len(room.Exits)) for k, v := range room.Exits { if (id == body.From && v.Room == body.To) || (id == body.To && v.Room == body.From) { continue } roomCopy.Exits[k] = v } } else { roomCopy.Exits = make(map[world.ExitDir]world.ExitDef) } if id == body.From { roomCopy.Exits[dir] = world.ExitDef{Room: body.To} } if id == body.To && !body.OneWay { roomCopy.Exits[oppDir] = world.ExitDef{Room: body.From} } return &roomCopy, true }, nil, nil, func(gc world.GridConflict) { conflicts++ }) _ = testGrid if conflicts > 0 { writeJSONError(w, "link would cause map conflicts (twists or overlaps)", http.StatusConflict) return } if roomA.Exits == nil { roomA.Exits = make(map[world.ExitDir]world.ExitDef) } roomA.Exits[dir] = world.ExitDef{Room: body.To} if !body.OneWay { if roomB.Exits == nil { roomB.Exits = make(map[world.ExitDir]world.ExitDef) } roomB.Exits[oppDir] = world.ExitDef{Room: body.From} } pathA, okA2 := s.world.GetRoomPath(body.From) if !okA2 { writeJSONError(w, "room path not found", http.StatusInternalServerError) return } oldA, _ := snapshotFile(pathA) newA, err := writeYAMLFile(pathA, roomA) if err != nil { writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) return } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("link %d %s %d", body.From, dir, body.To), FilePath: pathA, OldContent: oldA, NewContent: newA, }) if !body.OneWay { pathB, okB2 := s.world.GetRoomPath(body.To) if !okB2 { writeJSONError(w, "room B path not found", http.StatusInternalServerError) return } oldB, _ := snapshotFile(pathB) newB, err := writeYAMLFile(pathB, roomB) if err != nil { writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) return } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("link %d %s %d (reverse)", body.To, oppDir, body.From), FilePath: pathB, OldContent: oldB, NewContent: newB, }) } writeJSON(w, map[string]any{"ok": true}) case http.MethodDelete: var body struct { From int `json:"from"` To int `json:"to"` Side string `json:"side"` } if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { if r.Body != nil { r.Body.Close() } writeJSONError(w, "invalid body", http.StatusBadRequest) return } roomA, errA := s.world.LoadRoom(body.From) roomB, errB := s.world.LoadRoom(body.To) if errA != nil && errB != nil { writeJSONError(w, "both rooms not found", http.StatusBadRequest) return } var dirA, dirB world.ExitDir if roomA != nil { for _, d := range world.ExitOrder { if exit, ok := roomA.Exits[d]; ok && exit.Room == body.To { dirA = d dirB = world.OppositeExit[d] break } } } if dirA == "" && roomB != nil { for _, d := range world.ExitOrder { if exit, ok := roomB.Exits[d]; ok && exit.Room == body.From { dirB = d dirA = world.OppositeExit[d] break } } } if body.Side == "from" { if roomA != nil && roomA.Exits != nil { delete(roomA.Exits, dirA) } } else if body.Side == "to" { if roomB != nil && roomB.Exits != nil { delete(roomB.Exits, dirB) } } else { if roomA != nil && roomA.Exits != nil { delete(roomA.Exits, dirA) } if roomB != nil && roomB.Exits != nil { delete(roomB.Exits, dirB) } } var pushEntries []ChangeDesc if roomA != nil && (body.Side == "" || body.Side == "from") { pathA, okA2 := s.world.GetRoomPath(body.From) if !okA2 { writeJSONError(w, "room A path not found", http.StatusInternalServerError) return } oldA, _ := snapshotFile(pathA) newA, err := writeYAMLFile(pathA, roomA) if err != nil { writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) return } pushEntries = append(pushEntries, ChangeDesc{ Description: fmt.Sprintf("unlink %d %s %d", body.From, dirA, body.To), FilePath: pathA, OldContent: oldA, NewContent: newA, }) } if roomB != nil && (body.Side == "" || body.Side == "to") { pathB, okB2 := s.world.GetRoomPath(body.To) if !okB2 { writeJSONError(w, "room B path not found", http.StatusInternalServerError) return } oldB, _ := snapshotFile(pathB) newB, err := writeYAMLFile(pathB, roomB) if err != nil { writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) return } pushEntries = append(pushEntries, ChangeDesc{ Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, dirB, body.From), FilePath: pathB, OldContent: oldB, NewContent: newB, }) } for _, entry := range pushEntries { s.undoStack.Push(entry) } writeJSON(w, map[string]any{"ok": true}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *AdminServer) handleRoomMove(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { ID int `json:"id"` Dir string `json:"dir"` } if err := readJSON(r, &body); err != nil || body.ID <= 0 { writeJSON(w, map[string]any{"error": "invalid"}) return } oldPath, ok := s.world.GetRoomPath(body.ID) if !ok { writeJSON(w, map[string]any{"error": "room not found"}) return } dir := strings.TrimSpace(body.Dir) destDir := filepath.Join(s.dataDir, "rooms", dir) if _, err := os.Stat(destDir); os.IsNotExist(err) { writeJSON(w, map[string]any{"error": "directory does not exist: " + dir}) return } fileName := strconv.Itoa(body.ID) + ".yaml" newPath := filepath.Join(destDir, fileName) if _, err := os.Stat(newPath); err == nil { writeJSON(w, map[string]any{"error": "room already exists in target directory"}) return } data, err := os.ReadFile(oldPath) if err != nil { writeJSON(w, map[string]any{"error": "read: " + err.Error()}) return } if err := os.WriteFile(newPath, data, 0644); err != nil { writeJSON(w, map[string]any{"error": "write: " + err.Error()}) return } if err := os.Remove(oldPath); err != nil { writeJSON(w, map[string]any{"error": "remove old: " + err.Error()}) return } s.world.RebuildRoomIndex(s.dataDir) s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("move room %d to %s/", body.ID, dir), FilePath: oldPath, NewFilePath: newPath, OldContent: data, NewContent: data, }) writeJSON(w, map[string]any{"ok": true, "path": newPath}) } func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { ID int `json:"id"` NewID int `json:"new_id"` } if err := readJSON(r, &body); err != nil || body.ID <= 0 || body.NewID <= 0 { writeJSON(w, map[string]any{"error": "invalid"}) return } if body.ID == body.NewID { writeJSON(w, map[string]any{"ok": true}) return } oldPath, ok := s.world.GetRoomPath(body.ID) if !ok { writeJSON(w, map[string]any{"error": "room not found"}) return } if _, ok := s.world.GetRoomPath(body.NewID); ok { writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)}) return } dir := filepath.Dir(oldPath) newPath := filepath.Join(dir, strconv.Itoa(body.NewID)+".yaml") if _, err := os.Stat(newPath); err == nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)}) return } data, err := os.ReadFile(oldPath) if err != nil { writeJSON(w, map[string]any{"error": "read: " + err.Error()}) return } if err := os.WriteFile(newPath, data, 0644); err != nil { writeJSON(w, map[string]any{"error": "write: " + err.Error()}) return } if err := os.Remove(oldPath); err != nil { writeJSON(w, map[string]any{"error": "remove old: " + err.Error()}) 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) { continue } otherPath, pathOk := s.world.GetRoomPath(otherID) if !pathOk { continue } writeYAMLFile(otherPath, otherRoom) refUpdates++ } // 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. if s.rewriteRoomIDs != nil { s.rewriteRoomIDs(idMap) } s.undoStack.Push(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}) } func (s *AdminServer) handleDuplicateRooms(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, map[string]any{"error": "method not allowed"}) return } dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) type DupEntry struct { ID string `json:"id"` Paths []string `json:"paths"` } result := make([]DupEntry, len(dups)) for i, d := range dups { result[i] = DupEntry{ID: d.ID, Paths: d.Paths} } writeJSON(w, map[string]any{"duplicates": result}) } func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, map[string]any{"error": "method not allowed"}) return } var body struct { Path string `json:"path"` Action string `json:"action"` NewID int `json:"new_id"` } if err := readJSON(r, &body); err != nil || body.Path == "" || body.Action == "" { writeJSON(w, map[string]any{"error": "invalid body"}) return } roomsDir := filepath.Join(s.dataDir, "rooms") absRooms, _ := filepath.Abs(roomsDir) absPath, err := filepath.Abs(body.Path) if err != nil || !strings.HasPrefix(absPath, absRooms+string(filepath.Separator)) { writeJSON(w, map[string]any{"error": "invalid path"}) return } switch body.Action { case "delete": old, _ := snapshotFile(absPath) if err := os.Remove(absPath); err != nil { writeJSON(w, map[string]any{"error": "delete failed: " + err.Error()}) return } s.world.RebuildRoomIndex(s.dataDir) s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("delete duplicate room file %s", body.Path), FilePath: absPath, OldContent: old, IsDelete: true, }) writeJSON(w, map[string]any{"ok": true}) case "rename": if body.NewID <= 0 { writeJSON(w, map[string]any{"error": "new_id must be positive"}) return } if _, ok := s.world.GetRoomPath(body.NewID); ok { writeJSON(w, map[string]any{"error": "room ID already exists"}) return } newRel := filepath.Join(filepath.Dir(absPath), strconv.Itoa(body.NewID)+".yaml") if _, err := os.Stat(newRel); err == nil { writeJSON(w, map[string]any{"error": "target file already exists"}) return } oldContent, err := os.ReadFile(absPath) if err != nil { writeJSON(w, map[string]any{"error": "read failed: " + err.Error()}) return } m, err := yamlToMap(oldContent) if err != nil { writeJSON(w, map[string]any{"error": "yaml parse failed: " + err.Error()}) return } // A duplicate file was never in the room index (only one of the pair // is indexed), so no player has visited it and no other room points at // it. Renaming it to an unused ID therefore needs no exit/trigger/wander // reference rewrite and no player-state migration — only its own id // field and filename change. m["id"] = body.NewID newContent, err := writeMapAsYAML(newRel, m) if err != nil { writeJSON(w, map[string]any{"error": "write failed: " + err.Error()}) return } if err := os.Remove(absPath); err != nil { writeJSON(w, map[string]any{"error": "remove old failed: " + err.Error()}) return } s.world.RebuildRoomIndex(s.dataDir) s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("rename duplicate room %s to ID %d", body.Path, body.NewID), FilePath: absPath, NewFilePath: newRel, OldContent: oldContent, NewContent: newContent, }) writeJSON(w, map[string]any{"ok": true}) default: writeJSON(w, map[string]any{"error": "unknown action: " + body.Action}) } } func (s *AdminServer) handleCreateEmptyRoom(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) return } var body struct { Dir string `json:"dir"` } if err := readJSON(r, &body); err != nil { writeJSON(w, map[string]any{"error": "invalid"}) return } base := filepath.Join(s.dataDir, "rooms") targetDir := base if body.Dir != "" { targetDir = filepath.Join(base, body.Dir) if _, err := os.Stat(targetDir); os.IsNotExist(err) { writeJSON(w, map[string]any{"error": "directory not found"}) return } } used := map[int]bool{} filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() || filepath.Ext(d.Name()) != ".yaml" { return nil } name := d.Name() id, convErr := strconv.Atoi(name[:len(name)-5]) if convErr == nil && id > 0 { used[id] = true } return nil }) id := 1 for used[id] { id++ } path := filepath.Join(targetDir, strconv.Itoa(id)+".yaml") m := map[string]any{ "name": "New Room", "description": "An empty room.", } newContent, writeErr := writeMapAsYAML(path, m) if writeErr != nil { writeJSON(w, map[string]any{"error": "write room: " + writeErr.Error()}) return } s.world.RebuildRoomIndex(s.dataDir) s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("create room %d", id), FilePath: path, NewContent: newContent, IsCreate: true, }) writeJSON(w, map[string]any{"room": map[string]any{"id": id, "name": "New Room"}}) }