package admin import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "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 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") } fromID := 0 if linkFrom != nil { fromID = *linkFrom } id, subdir, allocErr := nextRoomIDInDir(s.dataDir, 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 } 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 } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("update room %d (add exit %s to %d)", *linkFrom, *linkDir, id), FilePath: srcPath, OldContent: oldContent, NewContent: newContent, }) } 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, }) 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/") if idx := strings.IndexByte(idStr, '/'); idx >= 0 { idStr = idStr[:idx] } id, err := strconv.Atoi(idStr) if err != nil { writeJSON(w, map[string]any{"error": "invalid room 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 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 { writeYAMLFile(otherPath, otherRoom) exitCleaned++ } } } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("delete room %d (cleaned %d exits)", id, exitCleaned), FilePath: path, OldContent: oldContent, IsDelete: true, }) 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 } entries, rdErr := os.ReadDir(path) if rdErr != nil { return nil } for _, e := range entries { if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" { rel, _ := filepath.Rel(base, path) dirs = append(dirs, rel) return nil } } return nil }) writeJSON(w, dirs) } 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"` } if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { writeJSON(w, map[string]any{"error": "invalid body, need from and to"}) return } roomA, errA := s.world.LoadRoom(body.From) roomB, errB := s.world.LoadRoom(body.To) if errA != nil || errB != nil { writeJSON(w, map[string]any{"error": "one or both rooms not found"}) 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 { writeJSON(w, map[string]any{"error": "invalid direction: " + body.Dir}) return } } else { grid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) if err != nil { return nil, false } return room, true }, nil, nil) cA, okA := grid.Coord[body.From] cB, okB := grid.Coord[body.To] if !okA || !okB { writeJSON(w, map[string]any{"error": "one or both rooms are not reachable from the seed room"}) return } if cA[2] != cB[2] { writeJSON(w, map[string]any{"error": "rooms must be on same z-level"}) 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 == "" { writeJSON(w, map[string]any{"error": "rooms are not adjacent"}) 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 } } } conflicts := 0 testGrid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) if err != nil { 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 { roomCopy.Exits[oppDir] = world.ExitDef{Room: body.From} } return &roomCopy, true }, 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 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) pathB, okB2 := s.world.GetRoomPath(body.To) if !okA2 || !okB2 { writeJSON(w, map[string]any{"error": "room path not found"}) return } oldA, _ := snapshotFile(pathA) oldB, _ := snapshotFile(pathB) newA, err := writeYAMLFile(pathA, roomA) if err != nil { writeJSON(w, map[string]any{"error": "write A: " + err.Error()}) return } newB, err := writeYAMLFile(pathB, roomB) if err != nil { writeJSON(w, map[string]any{"error": "write B: " + err.Error()}) return } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("link %d %s %d", body.From, dir, body.To), FilePath: pathA, OldContent: oldA, NewContent: newA, }) 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"` } if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 { if r.Body != nil { r.Body.Close() } writeJSON(w, map[string]any{"error": "invalid body"}) return } roomA, errA := s.world.LoadRoom(body.From) roomB, errB := s.world.LoadRoom(body.To) if errA != nil && errB != nil { writeJSON(w, map[string]any{"error": "both rooms not found"}) return } var dir, oppDir world.ExitDir if roomA != nil { for _, d := range world.ExitOrder { if exit, ok := roomA.Exits[d]; ok && exit.Room == body.To { dir = d oppDir = world.OppositeExit[d] break } } } if dir == "" && roomB != nil { for _, d := range world.ExitOrder { if exit, ok := roomB.Exits[d]; ok && exit.Room == body.From { oppDir = d dir = world.OppositeExit[d] break } } } if roomA != nil && roomA.Exits != nil { delete(roomA.Exits, dir) } if roomB != nil && roomB.Exits != nil { delete(roomB.Exits, oppDir) } var pushEntries []ChangeDesc if roomA != nil { pathA, okA2 := s.world.GetRoomPath(body.From) if !okA2 { writeJSON(w, map[string]any{"error": "room A path not found"}) return } oldA, _ := snapshotFile(pathA) newA, err := writeYAMLFile(pathA, roomA) if err != nil { writeJSON(w, map[string]any{"error": "write A: " + err.Error()}) return } pushEntries = append(pushEntries, ChangeDesc{ Description: fmt.Sprintf("unlink %d %s %d", body.From, dir, body.To), FilePath: pathA, OldContent: oldA, NewContent: newA, }) } if roomB != nil { pathB, okB2 := s.world.GetRoomPath(body.To) if !okB2 { writeJSON(w, map[string]any{"error": "room B path not found"}) return } oldB, _ := snapshotFile(pathB) newB, err := writeYAMLFile(pathB, roomB) if err != nil { writeJSON(w, map[string]any{"error": "write B: " + err.Error()}) return } pushEntries = append(pushEntries, ChangeDesc{ Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, oppDir, 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) allIDs, _ := listRoomIDs(s.dataDir) var exitUpdates int for _, otherID := range allIDs { if otherID == body.NewID { 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 != body.ID { continue } exit.Room = body.NewID otherRoom.Exits[dir] = exit changed = true } if changed { otherPath, pathOk := s.world.GetRoomPath(otherID) if pathOk { writeYAMLFile(otherPath, otherRoom) exitUpdates++ } } } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("rename room %d to %d (updated %d exits)", body.ID, body.NewID, exitUpdates), FilePath: oldPath, NewFilePath: newPath, OldContent: data, NewContent: data, }) writeJSON(w, map[string]any{"ok": true}) }