aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/.admin_history.json2
-rw-r--r--data/courses/test.yaml13
-rw-r--r--data/rooms/intro/1007.yaml14
-rw-r--r--data/rooms/intro/1008.yaml17
-rw-r--r--data/rooms/intro/1009.yaml19
-rw-r--r--data/rooms/intro/1010.yaml10
-rw-r--r--data/rooms/intro/1011.yaml14
-rw-r--r--data/rooms/intro/1012.yaml6
-rw-r--r--internal/admin/api_rooms.go136
-rw-r--r--internal/admin/server.go1
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/cardeditor.js4
-rw-r--r--internal/admin/static/intereditor.js4
-rw-r--r--internal/admin/static/map.js149
-rw-r--r--internal/admin/static/mobeditor.js1
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>';