From 069d3c1c81d71042706372df153254487a765dfc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 29 Jun 2026 22:31:11 -0400 Subject: feat: wip web admin for mapping and crud --- internal/admin/api_rooms.go | 561 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 internal/admin/api_rooms.go (limited to 'internal/admin/api_rooms.go') diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go new file mode 100644 index 0000000..985eb49 --- /dev/null +++ b/internal/admin/api_rooms.go @@ -0,0 +1,561 @@ +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)] = float64(*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, "raw": string(data)}) + + 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) + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("delete room %d", id), + 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 + } + entries, err := os.ReadDir(filepath.Join(s.dataDir, "rooms")) + if err != nil { + writeJSON(w, []string{""}) + return + } + dirs := []string{""} + for _, e := range entries { + if e.IsDir() { + dirs = append(dirs, e.Name()) + } + } + 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"` + } + 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 + } + 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 || 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] + var dir, oppDir world.ExitDir + 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 + } + + roomA, _ := s.world.LoadRoom(body.From) + roomB, _ := s.world.LoadRoom(body.To) + if roomA.Exits == nil { + roomA.Exits = make(map[world.ExitDir]world.ExitDef) + } + if roomB.Exits == nil { + roomB.Exits = make(map[world.ExitDir]world.ExitDef) + } + roomA.Exits[dir] = world.ExitDef{Room: body.To} + 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 + } + 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 || 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] + var dir, oppDir world.ExitDir + 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 + } + + roomA, _ := s.world.LoadRoom(body.From) + roomB, _ := s.world.LoadRoom(body.To) + if roomA.Exits != nil { + delete(roomA.Exits, dir) + } + if roomB.Exits != nil { + delete(roomB.Exits, oppDir) + } + + 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("unlink %d %s %d", body.From, dir, body.To), + FilePath: pathA, + OldContent: oldA, + NewContent: newA, + }) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, oppDir, body.From), + FilePath: pathB, + OldContent: oldB, + NewContent: newB, + }) + 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 + } + 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) + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("rename room %d to %d", body.ID, body.NewID), + FilePath: oldPath, + NewFilePath: newPath, + OldContent: data, + NewContent: data, + }) + writeJSON(w, map[string]any{"ok": true}) +} -- cgit v1.2.3