aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_rooms.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/api_rooms.go')
-rw-r--r--internal/admin/api_rooms.go561
1 files changed, 561 insertions, 0 deletions
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})
+}