diff options
| author | historia <[not public]> | 2026-07-14 01:36:31 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-14 01:36:31 -0400 |
| commit | d72957499f1e25741b8f7a91746fbe132fa68161 (patch) | |
| tree | 115302045c5bce0b62b812bbfcbe3737c9163bb5 | |
| parent | 5a6986a8c539364b7a166abdfb608b3cc00ecb3b (diff) | |
| download | thehouseoficarus-d72957499f1e25741b8f7a91746fbe132fa68161.tar.gz | |
feat(admin): copy paste rooms on map, swap rooms with adjacent rooms
| -rw-r--r-- | data/.admin_history.json | 2 | ||||
| -rw-r--r-- | data/courses/test.yaml | 13 | ||||
| -rw-r--r-- | data/rooms/intro/1007.yaml | 14 | ||||
| -rw-r--r-- | data/rooms/intro/1008.yaml | 17 | ||||
| -rw-r--r-- | data/rooms/intro/1009.yaml | 19 | ||||
| -rw-r--r-- | data/rooms/intro/1010.yaml | 10 | ||||
| -rw-r--r-- | data/rooms/intro/1011.yaml | 14 | ||||
| -rw-r--r-- | data/rooms/intro/1012.yaml | 6 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 136 | ||||
| -rw-r--r-- | internal/admin/server.go | 1 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 2 | ||||
| -rw-r--r-- | internal/admin/static/cardeditor.js | 4 | ||||
| -rw-r--r-- | internal/admin/static/intereditor.js | 4 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 149 | ||||
| -rw-r--r-- | internal/admin/static/mobeditor.js | 1 |
15 files changed, 320 insertions, 72 deletions
diff --git a/data/.admin_history.json b/data/.admin_history.json index 0d73349..1bafd70 100644 --- a/data/.admin_history.json +++ b/data/.admin_history.json @@ -1 +1 @@ -{"history":[{"time":"2026-07-13T22:57:46-04:00","description":"Delete trigger announce_99_copy","file_path":"data/triggers/player_flag/announce_99_copy.yaml","old_content":"steps:\n - broadcast_global: '%p has reached level 99 %v!'\non_player_flag: announce_99_skill\n","new_content":"","is_delete":false,"is_create":false}],"redo":null}
\ No newline at end of file +{"history":[{"time":"2026-07-14T01:27:45-04:00","description":"delete course test (last obstacle removed)","file_path":"data/courses/test.yaml","old_content":"completion_xp: 0\nname: test\nobstacles:\n - exit_dir: southwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 1007\n","new_content":"","is_delete":true,"is_create":false},{"time":"2026-07-14T01:27:45-04:00","description":"delete 3 rooms (cleaned 2 exits)","file_path":"data/rooms/intro/1008.yaml","old_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1009\n south:\n always_blocked: false\n blocked_message: \"\"\n condition: null\n hidden: false\n room: 1007\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: 'Room #1008'\nobjects: []\non_enter:\n - condition:\n all_of: []\n steps: []\n - steps: []\n - steps: []\non_exit:\n - steps: []\n - steps: []\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1010\n north:\n room: 1008\n south:\n room: 1005\nhazard: \"\"\nid: 1007\nitem_spawns: []\nmobs: []\nname: 'Room #1007'\nobjects: []\non_enter: []\non_exit: []\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1010\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"},{"file_path":"data/rooms/intro/1010.yaml","old_content":"block_transport: false\ncolor: D3\nexits:\n south:\n room: 1009\nhazard: \"\"\nid: 1010\nname: 'Room #1011'\n","new_content":"id: 1010\nname: 'Room #1011'\ncolor: D3\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"},{"file_path":"data/rooms/intro/1011.yaml","old_content":"color: D3\nexits:\n south:\n room: 1009\nid: 1011\nname: 'Room #1011'\n","new_content":"","is_delete":true},{"file_path":"data/rooms/intro/1009.yaml","old_content":"color: D3\nexits:\n south:\n room: 1009\nid: 1009\nname: 'Room #1011'\n","new_content":"","is_delete":true}]},{"time":"2026-07-14T01:27:52-04:00","description":"delete room 1010 (cleaned 1 exits)","file_path":"data/rooms/intro/1010.yaml","old_content":"id: 1010\nname: 'Room #1011'\ncolor: D3\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1010\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:27:58-04:00","description":"create room 1008","file_path":"data/rooms/intro/1008.yaml","old_content":"","new_content":"color: D3\nexits:\n west:\n room: 1007\nid: 1008\nname: 'Room #1008'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1008\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:27:59-04:00","description":"create room 1009","file_path":"data/rooms/intro/1009.yaml","old_content":"","new_content":"color: D3\nexits:\n south:\n room: 1008\nid: 1009\nname: 'Room #1009'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1008.yaml","old_content":"color: D3\nexits:\n west:\n room: 1007\nid: 1008\nname: 'Room #1008'\n","new_content":"id: 1008\nname: 'Room #1008'\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:27:59-04:00","description":"create room 1010","file_path":"data/rooms/intro/1010.yaml","old_content":"","new_content":"color: D3\nexits:\n east:\n room: 1009\nid: 1010\nname: 'Room #1010'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1009.yaml","old_content":"color: D3\nexits:\n south:\n room: 1008\nid: 1009\nname: 'Room #1009'\n","new_content":"id: 1009\nname: 'Room #1009'\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:28:00-04:00","description":"create room 1011","file_path":"data/rooms/intro/1011.yaml","old_content":"","new_content":"color: D3\nexits:\n south:\n room: 1010\nid: 1011\nname: 'Room #1011'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1010.yaml","old_content":"color: D3\nexits:\n east:\n room: 1009\nid: 1010\nname: 'Room #1010'\n","new_content":"id: 1010\nname: 'Room #1010'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1009\n north:\n room: 1011\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:28:00-04:00","description":"create room 1012","file_path":"data/rooms/intro/1012.yaml","old_content":"","new_content":"color: D3\nexits:\n west:\n room: 1011\nid: 1012\nname: 'Room #1012'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1011.yaml","old_content":"color: D3\nexits:\n south:\n room: 1010\nid: 1011\nname: 'Room #1011'\n","new_content":"id: 1011\nname: 'Room #1011'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1012\n south:\n room: 1010\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n"}]},{"time":"2026-07-14T01:28:08-04:00","description":"swap room 1008 with 1009 (north)","file_path":"data/rooms/intro/1008.yaml","old_content":"id: 1008\nname: 'Room #1008'\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: 'Room #1009'\nobjects: []\n","is_delete":false,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1009.yaml","old_content":"id: 1009\nname: 'Room #1009'\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nhazard: \"\"\nid: 1009\nitem_spawns: []\nmobs: []\nname: 'Room #1008'\nobjects: []\n"}]},{"time":"2026-07-14T01:28:15-04:00","description":"swap room 1009 with 1008 (south)","file_path":"data/rooms/intro/1009.yaml","old_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nhazard: \"\"\nid: 1009\nitem_spawns: []\nmobs: []\nname: 'Room #1008'\nobjects: []\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1010\nhazard: \"\"\nid: 1009\nitem_spawns: []\nmobs: []\nname: 'Room #1009'\nobjects: []\n","is_delete":false,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1008.yaml","old_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: 'Room #1009'\nobjects: []\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n north:\n room: 1009\n west:\n room: 1007\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: 'Room #1008'\nobjects: []\n"}]},{"time":"2026-07-14T01:28:39-04:00","description":"update room 1010","file_path":"data/rooms/intro/1010.yaml","old_content":"id: 1010\nname: 'Room #1010'\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1009\n north:\n room: 1011\nobjects: []\nitem_spawns: []\nmobs: []\nhazard: \"\"\nblock_transport: false\n","new_content":"block_transport: false\ncolor: D3\ndescription: []\nexits:\n east:\n room: 1009\n north:\n room: 1011\nhazard: \"\"\nid: 1010\nitem_spawns: []\nmobs: []\nname: 'Room #1009'\nobjects: []\n","is_delete":false,"is_create":false}],"redo":null}
\ No newline at end of file diff --git a/data/courses/test.yaml b/data/courses/test.yaml deleted file mode 100644 index f10c80d..0000000 --- a/data/courses/test.yaml +++ /dev/null @@ -1,13 +0,0 @@ -completion_xp: 0 -name: test -obstacles: - - exit_dir: southwest - fail_damage: - - 0 - - 0 - phases: [] - room_id: 1009 - verb: climb - xp: 0 -required_level: 1 -start_room: 1007 diff --git a/data/rooms/intro/1007.yaml b/data/rooms/intro/1007.yaml index ca6c580..415e24d 100644 --- a/data/rooms/intro/1007.yaml +++ b/data/rooms/intro/1007.yaml @@ -1,18 +1,14 @@ -block_transport: false +id: 1007 +name: 'Room #1007' color: D3 description: [] exits: east: - room: 1010 - north: room: 1008 south: room: 1005 -hazard: "" -id: 1007 +objects: [] item_spawns: [] mobs: [] -name: 'Room #1007' -objects: [] -on_enter: [] -on_exit: [] +hazard: "" +block_transport: false diff --git a/data/rooms/intro/1008.yaml b/data/rooms/intro/1008.yaml index 88e0d27..4104d24 100644 --- a/data/rooms/intro/1008.yaml +++ b/data/rooms/intro/1008.yaml @@ -2,13 +2,9 @@ block_transport: false color: D3 description: [] exits: - east: + north: room: 1009 - south: - always_blocked: false - blocked_message: "" - condition: null - hidden: false + west: room: 1007 hazard: "" id: 1008 @@ -16,12 +12,3 @@ item_spawns: [] mobs: [] name: 'Room #1008' objects: [] -on_enter: - - condition: - all_of: [] - steps: [] - - steps: [] - - steps: [] -on_exit: - - steps: [] - - steps: [] diff --git a/data/rooms/intro/1009.yaml b/data/rooms/intro/1009.yaml index 2fc64f3..256eecb 100644 --- a/data/rooms/intro/1009.yaml +++ b/data/rooms/intro/1009.yaml @@ -3,27 +3,12 @@ color: D3 description: [] exits: south: - always_blocked: false - blocked_message: "" - condition: null - hidden: false - room: 1010 - southwest: - always_blocked: true - hidden: true - room: 1007 - west: room: 1008 + west: + room: 1010 hazard: "" id: 1009 item_spawns: [] mobs: [] name: 'Room #1009' objects: [] -on_enter: - - lock: true - steps: - - messages: - - fdsa -on_exit: [] -triggers: [] diff --git a/data/rooms/intro/1010.yaml b/data/rooms/intro/1010.yaml index 00c01ad..94b2143 100644 --- a/data/rooms/intro/1010.yaml +++ b/data/rooms/intro/1010.yaml @@ -2,15 +2,13 @@ block_transport: false color: D3 description: [] exits: - north: + east: room: 1009 - west: - room: 1007 + north: + room: 1011 hazard: "" id: 1010 item_spawns: [] mobs: [] -name: 'Room #1010' +name: 'Room #1009' objects: [] -on_enter: [] -triggers: [] diff --git a/data/rooms/intro/1011.yaml b/data/rooms/intro/1011.yaml new file mode 100644 index 0000000..5ba5b39 --- /dev/null +++ b/data/rooms/intro/1011.yaml @@ -0,0 +1,14 @@ +id: 1011 +name: 'Room #1011' +color: D3 +description: [] +exits: + east: + room: 1012 + south: + room: 1010 +objects: [] +item_spawns: [] +mobs: [] +hazard: "" +block_transport: false diff --git a/data/rooms/intro/1012.yaml b/data/rooms/intro/1012.yaml new file mode 100644 index 0000000..f2433eb --- /dev/null +++ b/data/rooms/intro/1012.yaml @@ -0,0 +1,6 @@ +color: D3 +exits: + west: + room: 1011 +id: 1012 +name: 'Room #1012' diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 1339bcd..84c3348 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -841,7 +841,7 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { 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"}) + writeJSONError(w, "invalid", http.StatusBadRequest) return } if body.ID == body.NewID { @@ -850,33 +850,33 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { } oldPath, ok := s.world.GetRoomPath(body.ID) if !ok { - writeJSON(w, map[string]any{"error": "room not found"}) + writeJSONError(w, "room not found", http.StatusBadRequest) return } if _, ok := s.world.GetRoomPath(body.NewID); ok { - writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)}) + 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 { - writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)}) + writeJSONError(w, fmt.Sprintf("room %d already exists", body.NewID), http.StatusConflict) return } data, err := os.ReadFile(oldPath) if err != nil { - writeJSON(w, map[string]any{"error": "read: " + err.Error()}) + writeJSONError(w, "read: "+err.Error(), http.StatusInternalServerError) return } if err := os.WriteFile(newPath, data, 0644); err != nil { - writeJSON(w, map[string]any{"error": "write: " + err.Error()}) + writeJSONError(w, "write: "+err.Error(), http.StatusInternalServerError) return } if err := os.Remove(oldPath); err != nil { - writeJSON(w, map[string]any{"error": "remove old: " + err.Error()}) + writeJSONError(w, "remove old: "+err.Error(), http.StatusInternalServerError) return } @@ -925,6 +925,128 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true}) } +func (s *AdminServer) handleRoomSwap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + From int `json:"from"` + Dir string `json:"dir"` + } + if err := readJSON(r, &body); err != nil || body.From <= 0 || body.Dir == "" { + writeJSONError(w, "invalid body, need from and dir", http.StatusBadRequest) + return + } + + dir := world.ExitDir(strings.ToLower(body.Dir)) + if _, ok := world.OppositeExit[dir]; !ok { + writeJSONError(w, "invalid direction: "+body.Dir, http.StatusBadRequest) + return + } + + aID := body.From + aRoom, err := s.world.LoadRoom(aID) + if err != nil { + writeJSONError(w, fmt.Sprintf("could not load room #%d: %v", aID, err), http.StatusBadRequest) + return + } + aExit, hasExit := aRoom.Exits[dir] + if !hasExit { + writeJSONError(w, fmt.Sprintf("room #%d has no %s exit", aID, dir), http.StatusBadRequest) + return + } + bID := aExit.Room + if bID <= 0 { + writeJSONError(w, fmt.Sprintf("room #%d %s exit is invalid", aID, dir), http.StatusBadRequest) + return + } + if bID == aID { + writeJSONError(w, "cannot swap a room with itself", http.StatusBadRequest) + return + } + + aPath, aOK := s.world.GetRoomPath(aID) + if !aOK { + writeJSONError(w, fmt.Sprintf("could not find file for room #%d", aID), http.StatusInternalServerError) + return + } + bPath, bOK := s.world.GetRoomPath(bID) + if !bOK { + writeJSONError(w, fmt.Sprintf("could not find file for room #%d", bID), http.StatusInternalServerError) + return + } + + oldA, _ := snapshotFile(aPath) + oldB, _ := snapshotFile(bPath) + + dataA, err := os.ReadFile(aPath) + if err != nil { + writeJSONError(w, "read A: "+err.Error(), http.StatusInternalServerError) + return + } + dataB, err := os.ReadFile(bPath) + if err != nil { + writeJSONError(w, "read B: "+err.Error(), http.StatusInternalServerError) + return + } + + mapA, err := yamlToMap(dataA) + if err != nil { + writeJSONError(w, "parse A: "+err.Error(), http.StatusInternalServerError) + return + } + mapB, err := yamlToMap(dataB) + if err != nil { + writeJSONError(w, "parse B: "+err.Error(), http.StatusInternalServerError) + return + } + + idA := mapA["id"] + exitsA := mapA["exits"] + idB := mapB["id"] + exitsB := mapB["exits"] + + newA := make(map[string]any, len(mapB)) + for k, v := range mapB { + newA[k] = v + } + newA["id"] = idA + newA["exits"] = exitsA + + newB := make(map[string]any, len(mapA)) + for k, v := range mapA { + newB[k] = v + } + newB["id"] = idB + newB["exits"] = exitsB + + newAContent, err := writeMapAsYAML(aPath, newA) + if err != nil { + writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) + return + } + newBContent, err := writeMapAsYAML(bPath, newB) + if err != nil { + os.WriteFile(aPath, oldA, 0644) + writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) + return + } + + s.undoStack.Push(ChangeDesc{ + Description: fmt.Sprintf("swap room %d with %d (%s)", aID, bID, dir), + FilePath: aPath, + OldContent: oldA, + NewContent: newAContent, + ExtraFiles: []ExtraFile{{ + FilePath: bPath, + OldContent: oldB, + NewContent: newBContent, + }}, + }) + writeJSON(w, map[string]any{"ok": true, "a": aID, "b": bID}) +} + func (s *AdminServer) handleDuplicateRooms(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, map[string]any{"error": "method not allowed"}) diff --git a/internal/admin/server.go b/internal/admin/server.go index 61fcb09..9983a0b 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -125,6 +125,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor apiMux.HandleFunc("/api/rooms/remove", s.handleRoomRemove) apiMux.HandleFunc("/api/rooms/move", s.handleRoomMove) apiMux.HandleFunc("/api/rooms/rename", s.handleRoomRename) + apiMux.HandleFunc("/api/rooms/swap", s.handleRoomSwap) apiMux.HandleFunc("/api/rooms/bulk-edit", s.handleBulkEdit) apiMux.HandleFunc("/api/rooms/bulk-delete", s.handleBulkDelete) apiMux.HandleFunc("/api/rooms", s.handleRooms) diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 63a12e5..3d54e54 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -199,6 +199,8 @@ g.ud-hover:hover text{font-weight:bold} .cm-menu{position:fixed;z-index:200;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 0;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.4)} .cm-menu button:not(.btn){display:block;width:100%;padding:6px 12px;text-align:left;font-size:12px;font-family:monospace;background:none;border:none;color:var(--text);cursor:pointer} .cm-menu button:not(.btn):hover{background:var(--accent)} +.cm-menu button:not(.btn):disabled{opacity:.4;cursor:default;background:transparent} +.cm-menu button:not(.btn):disabled:hover{background:transparent} .cm-menu button.danger{color:#f55} .cm-menu button.danger:hover{background:var(--danger)} .cm-menu .cm-title{font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px;padding:4px 12px 6px;border-bottom:1px solid var(--border);margin-bottom:4px} diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js index cf3a8b7..83eed2e 100644 --- a/internal/admin/static/cardeditor.js +++ b/internal/admin/static/cardeditor.js @@ -682,6 +682,10 @@ function ced_renderTagHelp(type) { h += 'Message fields support template variables:<br>%i1 = first ingredient name<br>%i2 = second ingredient name<br>%n = output item name.<br>Color tags are also supported: {NN}text{/} where NN is a hex color index (00–FF).'; } else if (type === 'gather') { h += 'Message fields support template variables: {name} = object name.<br/>Color tags are also supported: {NN}text{/} where NN is a hex color index (00–FF).'; + } else if (type === 'trigger') { + h += 'Supports %p (player) %v (flag value) and color tags: {NN}text{/} where NN = hex index 00–FF (bold, dim, underline).'; + } else if (type === 'talk') { + h += 'Supports color tags: {NN}text{/} where NN = hex index 00–FF (bold, dim, underline). Surround NPC speech in "quotes" for automatic dialog color.'; } h += '</div>'; return h; diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js index da1812d..2023874 100644 --- a/internal/admin/static/intereditor.js +++ b/internal/admin/static/intereditor.js @@ -707,6 +707,9 @@ function ie_renderAct(stepObj, secId, idx, si, secPath) { rows += '<input class="ie-act-input" data-ie-act-key="' + escAttr(at.key) + '"' + (at.kind === 'number' ? ' type="number"' : '') + ' data-ie-act-kind="' + escAttr(at.kind) + '" value="' + escAttr(String(val)) + '" placeholder="' + escAttr(at.hint || '') + '" style="flex:1">'; rows += '<button class="btn btn-sm btn-danger ie-act-rm" onclick="ie_removeActEffect(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ',\'' + escAttr(at.key) + '\')">X</button>'; rows += '</div>'; + if (at.kind === 'text') { + rows += ced_renderTagHelp('trigger'); + } } }); @@ -739,6 +742,7 @@ function ie_renderMessages(step, secId, idx, si, secPath) { }); h += '</div>'; h += '<button class="btn btn-sm ie-add-btn" style="margin-top:2px" onclick="ie_addMessage(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',' + si + ')">+ Add Message</button>'; + h += ced_renderTagHelp('trigger'); h += '</div>'; return h; } diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index 259fac6..f8cee51 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -8,6 +8,7 @@ var linkDragOneWay = false; var ctrlHeld = false; var delOneWay = false; var delDrag = false, delFromId = null, delTargetId = null; +var roomClip = null; // { features:{...} } var roomMap = {}, occupied = {}; var multiSelectedIds = new Set(); var multiDrag = false, multiSubtract = false, multiStartSVGX = 0, multiStartSVGY = 0; @@ -2077,16 +2078,16 @@ async function doSavePanel() { var idChanged = false; var dirChanged = false; - if (dirEl && newDir !== currentDir) { - await API.post('/api/rooms/move', {id: id, dir: newDir}); - dirChanged = true; - } if (newID !== id) { await API.post('/api/rooms/rename', {id: id, new_id: newID}); selectedRoom = newID; id = newID; idChanged = true; } + if (dirEl && newDir !== currentDir) { + await API.post('/api/rooms/move', {id: id, dir: newDir}); + dirChanged = true; + } await API.put('/api/rooms/' + id, r); notify('Room #' + id + ' saved', 'success'); @@ -2098,10 +2099,39 @@ async function doSavePanel() { selectRoom(id); } } catch (e) { + if (selectedRoom) { + var idEl = $('#roomID'); + if (idEl) idEl.value = String(selectedRoom); + } notify('Save failed: ' + extractError(e), 'error'); } } +function copyRoomFeatures(id) { + API.get('/api/rooms/' + id).then(function(data) { + if (!data || !data.room) { notify('Copy failed: no room', 'error'); return; } + var feats = JSON.parse(JSON.stringify(data.room)); + delete feats.id; delete feats.exits; + roomClip = { features: feats, sourceId: id }; + notify('Copied Room #' + id, 'success'); + }).catch(function(e) { notify('Copy failed: ' + extractError(e), 'error'); }); +} + +function pasteRoomFeatures(targetId) { + if (!roomClip) { notify('Nothing to paste', 'error'); return; } + API.get('/api/rooms/' + targetId).then(function(data) { + if (!data || !data.room) { notify('Paste failed: target room not found', 'error'); return; } + var merged = JSON.parse(JSON.stringify(roomClip.features)); + merged.id = data.room.id; + merged.exits = data.room.exits || {}; + return API.put('/api/rooms/' + targetId, merged); + }).then(function() { + notify('Pasted onto Room #' + targetId, 'success'); + updateUndoBar(); + loadMap().then(function() { if (selectedRoom === targetId) selectRoom(targetId); }); + }).catch(function(e) { notify('Paste failed: ' + extractError(e), 'error'); }); +} + function deleteRoom(id) { var cb = document.getElementById('confirmDelete'); if (!cb || cb.checked) { @@ -2231,6 +2261,18 @@ function showRoomContextMenu(x, y, id) { menu.style.top = y + 'px'; menu.oncontextmenu = function(e) { e.preventDefault(); }; + var copyBtn = document.createElement('button'); + copyBtn.textContent = 'Copy'; + copyBtn.onclick = function() { closeAll(); copyRoomFeatures(id); }; + menu.appendChild(copyBtn); + + if (roomClip) { + var pasteBtn = document.createElement('button'); + pasteBtn.textContent = 'Paste (Room #' + roomClip.sourceId + ')'; + pasteBtn.onclick = function() { closeAll(); pasteRoomFeatures(id); }; + menu.appendChild(pasteBtn); + } + var delBtn = document.createElement('button'); delBtn.textContent = 'Delete Room'; delBtn.className = 'danger'; @@ -2242,6 +2284,7 @@ function showRoomContextMenu(x, y, id) { menu.appendChild(buildSubmenuItem('Insert Room', id, 'insert', closeAll, overlay, menu)); menu.appendChild(buildSubmenuItem('Remove Room', id, 'remove', closeAll, overlay, menu)); + menu.appendChild(buildSwapSubmenuItem('Swap', id, closeAll, overlay, menu)); var selBtn = document.createElement('button'); selBtn.textContent = 'Room Details'; @@ -2408,6 +2451,94 @@ function buildSubmenuItem(label, id, mode, closeAll, overlay, menu) { return item; } +// buildSwapSubmenuItem builds a "Swap" parent row whose nested submenu lists +// every direction the room has an exit in (matching roomDirs ordering). +function buildSwapSubmenuItem(label, id, closeAll, overlay, menu) { + var dirs = roomDirs(id); + var empty = dirs.length === 0; + + var item = document.createElement('div'); + item.className = 'cm-item-has-sub' + (empty ? ' disabled' : ''); + + var btn = document.createElement('button'); + btn.textContent = label; + var arrow = document.createElement('span'); + arrow.className = 'cm-arrow'; + arrow.textContent = '\u25B6'; + item.appendChild(btn); + item.appendChild(arrow); + + if (empty) { + btn.onclick = function() { + notify('No exits to swap from #' + id, 'error'); + }; + return item; + } + + var sub = document.createElement('div'); + sub.className = 'cm-submenu'; + var title = document.createElement('div'); + title.className = 'cm-title'; + title.textContent = 'Swap Room'; + sub.appendChild(title); + dirs.forEach(function(d) { + var opt = document.createElement('button'); + opt.textContent = dirLabel(d); + (function(dir) { + opt.onclick = function(e) { + e.stopPropagation(); + closeAll(); + swapRooms(id, dir); + }; + })(d); + sub.appendChild(opt); + }); + item.appendChild(sub); + + var open = false; + function showSub() { + if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } + var sibs = menu.querySelectorAll('.cm-submenu.open'); + sibs.forEach(function(s) { + if (s !== sub) { s.classList.remove('open'); s.classList.remove('flip-left'); s.classList.remove('flip-up'); } + }); + sub.classList.add('open'); + flipSubmenu(sub); + open = true; + } + function hideSubSoon() { + if (overlay._subHideTimer) clearTimeout(overlay._subHideTimer); + overlay._subHideTimer = setTimeout(function() { + sub.classList.remove('open'); + sub.classList.remove('flip-left'); + sub.classList.remove('flip-up'); + open = false; + overlay._subHideTimer = null; + }, 250); + } + + item.addEventListener('mouseenter', showSub); + item.addEventListener('mouseleave', hideSubSoon); + sub.addEventListener('mouseenter', function() { + if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; } + }); + sub.addEventListener('mouseleave', hideSubSoon); + + btn.onclick = function(e) { + e.stopPropagation(); + if (open) { + sub.classList.remove('open'); + sub.classList.remove('flip-left'); + sub.classList.remove('flip-up'); + open = false; + } else { + showSub(); + } + }; + + return item; +} + // roomDirs returns the room's outward exits (horizontal + up/down) derived from // mapData.links and mapData.{upLinks,downLinks}. Horizontal exits are ordered by // gridDirs; up/down appear last. Empty when the room has no exits. @@ -2502,6 +2633,16 @@ function removeRoom(fromID, dir) { }).catch(function(e) { notify('Remove failed: ' + extractError(e), 'error'); }); } +// swapRooms swaps the room contents (everything except id and exits) of +// fromID with the room in the given direction. +function swapRooms(fromID, dir) { + API.post('/api/rooms/swap', { from: fromID, dir: dir }).then(function(r) { + notify('Swapped #' + fromID + ' with #' + r.b + ' (' + dirLabel(dir) + ')', 'success'); + updateUndoBar(); + loadMap().then(function() { selectRoom(fromID); }); + }).catch(function(e) { notify('Swap failed: ' + extractError(e), 'error'); }); +} + function mouseToGrid(e) { var svgEl = $('#mapSvg'); if (!svgEl) return null; diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js index 2bef881..849dcf9 100644 --- a/internal/admin/static/mobeditor.js +++ b/internal/admin/static/mobeditor.js @@ -284,6 +284,7 @@ function renderMobCard(sec, data) { if (sec.id === 'talk') { h += '<div id="talktree"></div>'; + h += ced_renderTagHelp('talk'); } } h += '</div>'; |
