aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-06 17:41:12 -0400
committerhistoria <[not public]>2026-07-06 17:41:12 -0400
commit00554b66df9310073c3b5e1aebd125ea54d9d554 (patch)
tree27ce9751867a00a31d22b08452ddf08a3b4e1513
parent94823b52168b44894fb5e9c960358ed02ebb122b (diff)
downloadthehouseoficarus-00554b66df9310073c3b5e1aebd125ea54d9d554.tar.gz
feat: shift+drag to create one-way links on admin map
-rw-r--r--building_guide/admin.md4
-rw-r--r--data/.admin_history.json2
-rw-r--r--data/rooms/intro/1005.yaml12
-rw-r--r--data/rooms/intro/1006.yaml12
-rw-r--r--internal/admin/api_rooms.go140
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/map.js99
-rw-r--r--internal/admin/templates/map.html1
8 files changed, 183 insertions, 89 deletions
diff --git a/building_guide/admin.md b/building_guide/admin.md
index fbe15d5..023fae1 100644
--- a/building_guide/admin.md
+++ b/building_guide/admin.md
@@ -239,7 +239,9 @@ even across sessions.
The map page (`/`) is the main navigation hub:
- Rooms are displayed as colored nodes on a 3D grid, connected by links (bars for two-way, arrows for one-way, X for blocked).
- Click a room node to edit its properties (name, description, color, exits, objects, mobs, spawns).
-- Drag connections between rooms to create or relink exits.
+- **Drag** connections between rooms to create reciprocal exits. **Shift+drag** to create a one-way exit (only the exit from the source room is written; the target room is not modified).
+- **Right-drag** between rooms to delete the exit (full reciprocal; the sidebar Exits tab has a "Remove reverse" button to convert a two-way exit to one-way).
+- If deleting an exit severs a room from the seed grid, the room moves to the "Disconnected Rooms" sidebar. Relink it via the Exits tab "Add" form with its ID and direction.
- Right-click (or long-press) a room to delete it or access advanced options.
- The map uses the same BFS-based grid layout as the in-game map, showing all rooms on the current z-plane.
- Up/down links are shown with special glyphs when connecting rooms on different z-levels.
diff --git a/data/.admin_history.json b/data/.admin_history.json
index 6926f3a..ada2f83 100644
--- a/data/.admin_history.json
+++ b/data/.admin_history.json
@@ -1 +1 @@
-{"history":[{"time":"2026-07-05T22:17:30-04:00","description":"unlink 99999202 down 99999200","file_path":"data/rooms/example_unused/agility2/99999202.yaml","old_content":"name: \"Ventilation Shaft - Coolant Pipe Walkway\"\ndescription: \"A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery.\"\non_enter:\n - message: \"Type 'balance' to cross the pipe.\"\nexits:\n down:\n room: 99999200\n blocked_message: \"\"\n","new_content":"id: 99999202\nname: Ventilation Shaft - Coolant Pipe Walkway\ncolor: \"\"\ndescription:\n - text: A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery.\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter:\n - message: Type 'balance' to cross the pipe.\n condition: null\n delay: 0\n set_flags: {}\n set_player_flags: {}\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-05T22:17:30-04:00","description":"unlink 99999200 up 99999202 (reverse)","file_path":"data/rooms/example_unused/agility2/99999200.yaml","old_content":"name: \"Agility Training Grounds\"\ndescription: \"A cavernous space beneath the asteroid's surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced).\"\nexits:\n south: 9999917\n north: 99999201\n east: 99999210\n west: 99999220\n","new_content":"id: 99999200\nname: Agility Training Grounds\ncolor: \"\"\ndescription:\n - text: 'A cavernous space beneath the asteroid''s surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced).'\nexits:\n east:\n room: 99999210\n north:\n room: 99999201\n south:\n room: 9999917\n west:\n room: 99999220\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false}],"redo":null} \ No newline at end of file
+{"history":[{"time":"2026-07-06T17:37:10-04:00","description":"create room 1005","file_path":"data/rooms/intro/1005.yaml","old_content":"","new_content":"exits:\n south: 2001\nid: 1005\nname: 'Room #1005'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1005\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T17:37:13-04:00","description":"unlink 2001 north 1005","file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1005\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T17:37:13-04:00","description":"unlink 1005 south 2001 (reverse)","file_path":"data/rooms/intro/1005.yaml","old_content":"exits:\n south: 2001\nid: 1005\nname: 'Room #1005'\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T17:37:16-04:00","description":"create room 1006","file_path":"data/rooms/intro/1006.yaml","old_content":"","new_content":"exits:\n south: 2001\nid: 1006\nname: 'Room #1006'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1006\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T17:37:23-04:00","description":"unlink 2001 north 1006","file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1006\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T17:37:23-04:00","description":"unlink 1006 south 2001 (reverse)","file_path":"data/rooms/intro/1006.yaml","old_content":"exits:\n south: 2001\nid: 1006\nname: 'Room #1006'\n","new_content":"id: 1006\nname: 'Room #1006'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-07-06T17:39:43-04:00","description":"create room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"","new_content":"exits:\n south: 2001\nid: 1007\nname: 'Room #1007'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1007\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T17:39:46-04:00","description":"create room 1008","file_path":"data/rooms/intro/1008.yaml","old_content":"","new_content":"exits:\n south: 1007\nid: 1008\nname: 'Room #1008'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"exits:\n south: 2001\nid: 1007\nname: 'Room #1007'\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1008\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T17:39:49-04:00","description":"create room 1009","file_path":"data/rooms/intro/1009.yaml","old_content":"","new_content":"exits:\n south: 1008\nid: 1009\nname: 'Room #1009'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1008.yaml","old_content":"exits:\n south: 1007\nid: 1008\nname: 'Room #1008'\n","new_content":"id: 1008\nname: 'Room #1008'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1009\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T17:39:57-04:00","description":"delete room 1009 (cleaned 1 exits)","file_path":"data/rooms/intro/1009.yaml","old_content":"exits:\n south: 1008\nid: 1009\nname: 'Room #1009'\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1008.yaml","old_content":"id: 1008\nname: 'Room #1008'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1009\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1008\nname: 'Room #1008'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T17:39:58-04:00","description":"delete room 1008 (cleaned 1 exits)","file_path":"data/rooms/intro/1008.yaml","old_content":"id: 1008\nname: 'Room #1008'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\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: \"\"\ndescription: []\nexits:\n north:\n room: 1008\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-06T17:39:59-04:00","description":"delete room 1007 (cleaned 1 exits)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n room: 1007\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects:\n - name: hidden guy\n hidden: true\n description:\n - text: YOU FOUND HIM\n - id: tree\nitem_spawns:\n - id: bronze_axe\n quantity: 1\n respawn_ticks: 1\n - id: pot_of_flour\n quantity: 1\n respawn_ticks: 1\n - id: bucket_of_water\n quantity: 1\n respawn_ticks: 1\nmobs:\n - id: man\n - id: flight_attendant_copy\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]}],"redo":null} \ No newline at end of file
diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml
new file mode 100644
index 0000000..faf82e3
--- /dev/null
+++ b/data/rooms/intro/1005.yaml
@@ -0,0 +1,12 @@
+id: 1005
+name: 'Room #1005'
+color: ""
+description: []
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1006.yaml b/data/rooms/intro/1006.yaml
new file mode 100644
index 0000000..5a87adb
--- /dev/null
+++ b/data/rooms/intro/1006.yaml
@@ -0,0 +1,12 @@
+id: 1006
+name: 'Room #1006'
+color: ""
+description: []
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 448503d..b5353c2 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -415,19 +415,20 @@ 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"`
+ From int `json:"from"`
+ To int `json:"to"`
+ Dir string `json:"dir"`
+ OneWay bool `json:"oneway"`
}
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"})
+ writeJSONError(w, "invalid body, need from and to", http.StatusBadRequest)
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"})
+ writeJSONError(w, "one or both rooms not found", http.StatusBadRequest)
return
}
@@ -437,7 +438,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
if opp, ok := world.OppositeExit[dir]; ok {
oppDir = opp
} else {
- writeJSON(w, map[string]any{"error": "invalid direction: " + body.Dir})
+ writeJSONError(w, "invalid direction: "+body.Dir, http.StatusBadRequest)
return
}
} else {
@@ -453,11 +454,11 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
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"})
+ writeJSONError(w, "one or both rooms are not reachable from the seed room", http.StatusBadRequest)
return
}
if cA[2] != cB[2] {
- writeJSON(w, map[string]any{"error": "rooms must be on same z-level"})
+ writeJSONError(w, "rooms must be on same z-level", http.StatusBadRequest)
return
}
dx, dy := cB[0]-cA[0], cB[1]-cA[1]
@@ -469,7 +470,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
}
}
if dir == "" {
- writeJSON(w, map[string]any{"error": "rooms are not adjacent"})
+ writeJSONError(w, "rooms are not adjacent", http.StatusBadRequest)
return
}
}
@@ -491,6 +492,21 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
}
}
+ if roomA.Exits != nil {
+ if existing, ok := roomA.Exits[dir]; ok && existing.Room != body.To {
+ writeJSONError(w, fmt.Sprintf("%s in #%d already links to #%d", dir, body.From, existing.Room), http.StatusConflict)
+ return
+ }
+ }
+ if !body.OneWay {
+ if roomB.Exits != nil {
+ if existing, ok := roomB.Exits[oppDir]; ok && existing.Room != body.From {
+ writeJSONError(w, fmt.Sprintf("%s in #%d already links to #%d", oppDir, body.To, existing.Room), http.StatusConflict)
+ return
+ }
+ }
+ }
+
conflicts := 0
testGrid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) {
room, err := s.world.LoadRoom(id)
@@ -513,7 +529,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
if id == body.From {
roomCopy.Exits[dir] = world.ExitDef{Room: body.To}
}
- if id == body.To {
+ if id == body.To && !body.OneWay {
roomCopy.Exits[oppDir] = world.ExitDef{Room: body.From}
}
return &roomCopy, true
@@ -532,28 +548,23 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
}
roomA.Exits[dir] = world.ExitDef{Room: body.To}
- if roomB.Exits == nil {
- roomB.Exits = make(map[world.ExitDir]world.ExitDef)
+ if !body.OneWay {
+ if roomB.Exits == nil {
+ roomB.Exits = make(map[world.ExitDir]world.ExitDef)
+ }
+ roomB.Exits[oppDir] = world.ExitDef{Room: body.From}
}
- 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"})
+ if !okA2 {
+ writeJSONError(w, "room path not found", http.StatusInternalServerError)
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()})
+ writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError)
return
}
s.undoStack.Push(ChangeDesc{
@@ -562,24 +573,39 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
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,
- })
+
+ if !body.OneWay {
+ pathB, okB2 := s.world.GetRoomPath(body.To)
+ if !okB2 {
+ writeJSONError(w, "room B path not found", http.StatusInternalServerError)
+ return
+ }
+ oldB, _ := snapshotFile(pathB)
+ newB, err := writeYAMLFile(pathB, roomB)
+ if err != nil {
+ writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ 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"`
+ From int `json:"from"`
+ To int `json:"to"`
+ Side string `json:"side"`
}
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"})
+ writeJSONError(w, "invalid body", http.StatusBadRequest)
return
}
@@ -587,73 +613,83 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
roomB, errB := s.world.LoadRoom(body.To)
if errA != nil && errB != nil {
- writeJSON(w, map[string]any{"error": "both rooms not found"})
+ writeJSONError(w, "both rooms not found", http.StatusBadRequest)
return
}
- var dir, oppDir world.ExitDir
+ var dirA, dirB 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]
+ dirA = d
+ dirB = world.OppositeExit[d]
break
}
}
}
- if dir == "" && roomB != nil {
+ if dirA == "" && roomB != nil {
for _, d := range world.ExitOrder {
if exit, ok := roomB.Exits[d]; ok && exit.Room == body.From {
- oppDir = d
- dir = world.OppositeExit[d]
+ dirB = d
+ dirA = world.OppositeExit[d]
break
}
}
}
- if roomA != nil && roomA.Exits != nil {
- delete(roomA.Exits, dir)
- }
- if roomB != nil && roomB.Exits != nil {
- delete(roomB.Exits, oppDir)
+ if body.Side == "from" {
+ if roomA != nil && roomA.Exits != nil {
+ delete(roomA.Exits, dirA)
+ }
+ } else if body.Side == "to" {
+ if roomB != nil && roomB.Exits != nil {
+ delete(roomB.Exits, dirB)
+ }
+ } else {
+ if roomA != nil && roomA.Exits != nil {
+ delete(roomA.Exits, dirA)
+ }
+ if roomB != nil && roomB.Exits != nil {
+ delete(roomB.Exits, dirB)
+ }
}
var pushEntries []ChangeDesc
- if roomA != nil {
+ if roomA != nil && (body.Side == "" || body.Side == "from") {
pathA, okA2 := s.world.GetRoomPath(body.From)
if !okA2 {
- writeJSON(w, map[string]any{"error": "room A path not found"})
+ writeJSONError(w, "room A path not found", http.StatusInternalServerError)
return
}
oldA, _ := snapshotFile(pathA)
newA, err := writeYAMLFile(pathA, roomA)
if err != nil {
- writeJSON(w, map[string]any{"error": "write A: " + err.Error()})
+ writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError)
return
}
pushEntries = append(pushEntries, ChangeDesc{
- Description: fmt.Sprintf("unlink %d %s %d", body.From, dir, body.To),
+ Description: fmt.Sprintf("unlink %d %s %d", body.From, dirA, body.To),
FilePath: pathA,
OldContent: oldA,
NewContent: newA,
})
}
- if roomB != nil {
+ if roomB != nil && (body.Side == "" || body.Side == "to") {
pathB, okB2 := s.world.GetRoomPath(body.To)
if !okB2 {
- writeJSON(w, map[string]any{"error": "room B path not found"})
+ writeJSONError(w, "room B path not found", http.StatusInternalServerError)
return
}
oldB, _ := snapshotFile(pathB)
newB, err := writeYAMLFile(pathB, roomB)
if err != nil {
- writeJSON(w, map[string]any{"error": "write B: " + err.Error()})
+ writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError)
return
}
pushEntries = append(pushEntries, ChangeDesc{
- Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, oppDir, body.From),
+ Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, dirB, body.From),
FilePath: pathB,
OldContent: oldB,
NewContent: newB,
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 376bf23..c620b46 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -51,6 +51,8 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f
.z-controls .z-sep{width:1px;height:20px;background:var(--border);flex-shrink:0}
.z-dir-form{display:none;position:absolute;left:0;top:calc(100% + 4px);background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px 10px;z-index:11;gap:6px;align-items:center;white-space:nowrap}
.room-tooltip{position:absolute;background:var(--panel);border:1px solid var(--border);padding:6px 10px;border-radius:3px;font-size:11px;pointer-events:none;z-index:20;white-space:nowrap}
+.link-mode-badge{position:absolute;bottom:12px;right:12px;padding:6px 12px;border-radius:4px;font-size:12px;font-family:monospace;pointer-events:none;z-index:20;background:rgba(30,30,60,0.9);color:#ccc;border:1px solid var(--border)}
+.link-mode-badge.oneway{background:rgba(80,50,20,0.92);color:#fca;border:1px solid #a64}
.notification{position:fixed;bottom:16px;right:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s}
.notification.error{border-color:var(--danger)}
.notification.success{border-color:var(--success)}
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index f0b4d87..f89a434 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -4,6 +4,8 @@ var panX = 0, panY = 0, scale = 1;
var dragging = false, startX = 0, startY = 0, prevX = 0, prevY = 0;
var dragRoom = false;
var linkDrag = false, linkFromId = null, linkTargetId = null, linkGhostDir = null;
+var linkDragOneWay = false;
+var shiftHeld = false;
var delDrag = false, delFromId = null, delTargetId = null;
var roomMap = {}, occupied = {};
var dirChangedByUser = false;
@@ -35,6 +37,7 @@ function onMapMouseMove(e) {
if (linkFromId && moved) {
linkDrag = true;
+ showDragBadge(linkDragOneWay ? 'One-way link' : 'Two-way link');
updateLinkDragTarget(e);
}
}
@@ -62,7 +65,7 @@ function onMapMouseUp(e) {
}
if (linkDrag && linkFromId) {
- if (linkTargetId) { createLink(linkFromId, linkTargetId); }
+ if (linkTargetId) { createLink(linkFromId, linkTargetId, linkDragOneWay); }
else if (linkGhostDir) { createRoom(linkFromId, linkGhostDir); }
} else if (!moved && !delDrag) {
if (e.target.classList.contains('rm')) {
@@ -74,8 +77,9 @@ function onMapMouseUp(e) {
clearLinkHighlight();
clearDelHighlight();
+ hideDragBadge();
dragging = false; dragRoom = false;
- linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null;
+ linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; linkDragOneWay = false;
delDrag = false; delFromId = null; delTargetId = null;
}
@@ -86,11 +90,25 @@ function cancelMapDrag() {
_mapMouseCleanup = false;
clearLinkHighlight();
clearDelHighlight();
+ hideDragBadge();
dragging = false; dragRoom = false;
- linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null;
+ linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null; linkDragOneWay = false;
delDrag = false; delFromId = null; delTargetId = null;
}
+function showDragBadge(text) {
+ var badge = document.getElementById('linkModeBadge');
+ if (!badge) return;
+ badge.textContent = text;
+ badge.className = 'link-mode-badge' + (linkDragOneWay ? ' oneway' : '');
+ badge.style.display = '';
+}
+
+function hideDragBadge() {
+ var badge = document.getElementById('linkModeBadge');
+ if (badge) badge.style.display = 'none';
+}
+
function changeZ(dz) {
if (dz === 0) currentZ = 0; else currentZ += dz;
panX = 0; panY = 0; scale = 1;
@@ -299,6 +317,7 @@ function renderMap(data) {
return;
}
+ e.preventDefault();
dragRoom = onRoom || onGhost;
dragging = true;
startX = e.clientX; startY = e.clientY;
@@ -307,6 +326,7 @@ function renderMap(data) {
linkDrag = false;
linkTargetId = null;
linkGhostDir = null;
+ linkDragOneWay = onRoom ? (e.shiftKey || shiftHeld) : false;
delDrag = false; delFromId = null; delTargetId = null;
clearLinkHighlight();
document.addEventListener('mousemove', onMapMouseMove);
@@ -1160,13 +1180,16 @@ function renderExitsSection(r) {
var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null;
var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false;
var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden);
+ var isBidi = mapData && mapData.links && mapData.links.some(function(l) {
+ return l.bidirectional && ((l.from === r.id && l.to === target) || (l.from === target && l.to === r.id));
+ });
h += '<div class="exit-row" data-dir="' + d + '">';
h += '<span class="exit-dir"' + (isConditional ? ' style="color:#f55"' : '') + '>' + d + '</span>';
h += '<span class="exit-target" onclick="selectRoom(' + target + ')"># ' + target + '</span>';
- h += '<label style="font-size:11px;margin-left:4px;cursor:pointer;color:#aaa"><input type="checkbox" class="exit-hidden" style="vertical-align:middle"' + (hidden ? ' checked' : '') + ' onchange="toggleExitHidden(\'' + d + '\')"> Hidden</label>';
if (bmsg) h += '<span class="exit-meta">Blocked: ' + esc(bmsg) + '</span>';
if (cond) h += '<span class="exit-meta">Conditional</span>';
h += '<button class="btn btn-sm" onclick="editExitCondition(\'' + d + '\')">Edit</button>';
+ if (isBidi) h += '<button class="btn btn-sm" onclick="deleteLink(' + target + ',' + r.id + ',\'from\')" title="Remove reverse exit to make one-way">\u21C6</button>';
h += '<button class="btn btn-sm btn-danger" onclick="deleteExit(\'' + d + '\', ' + target + ')">X</button>';
h += '</div>';
});
@@ -1175,6 +1198,7 @@ function renderExitsSection(r) {
exitDirs.forEach(function(d) { h += '<option value="' + d + '">' + d + '</option>'; });
h += '</select>';
h += '<input id="exitTargetID" type="number" placeholder="Target ID">';
+ h += '<label style="font-size:11px;cursor:pointer;color:#aaa;margin:0 4px;display:flex;align-items:center;gap:3px"><input type="checkbox" id="exitReciprocal" checked style="vertical-align:middle"> Reciprocal</label>';
h += '<button class="exit-add-btn" onclick="addExit()">Add</button>';
h += '</div>';
h += '</div>';
@@ -1421,8 +1445,13 @@ function addExit() {
var dir = $('#exitDirSelect').value;
var target = parseInt($('#exitTargetID').value);
if (!dir || !target) { notify('Enter target room ID', 'error'); return; }
- API.post('/api/rooms/link', {from: selectedRoom, to: target, dir: dir}).then(function() {
- notify('Exit added: ' + dir + ' #' + target, 'success');
+ var reciprocalEl = $('#exitReciprocal');
+ var reciprocal = reciprocalEl ? reciprocalEl.checked : true;
+ var body = {from: selectedRoom, to: target, dir: dir};
+ if (!reciprocal) body.oneway = true;
+ API.post('/api/rooms/link', body).then(function(res) {
+ if (res && res.error) { notify('Add exit failed: ' + res.error, 'error'); return; }
+ notify('Exit added: ' + dir + ' #' + target + (reciprocal ? '' : ' (one-way)'), 'success');
updateUndoBar();
loadMap().then(function() { selectRoom(selectedRoom); });
}).catch(function(e) { notify('Add exit failed: ' + extractError(e), 'error'); });
@@ -1430,15 +1459,17 @@ function addExit() {
function deleteExit(dir, target) {
if (!selectedRoom || !currentRoomData) return;
- if (!confirm('Delete exit ' + dir + ' to #' + target + '?')) return;
fetch('/api/rooms/link', {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({from: selectedRoom, to: target}),
credentials: 'same-origin'
}).then(function(r) {
- if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || 'request failed'); });
- return r.json();
+ return r.json().then(function(j) {
+ if (!r.ok) throw new Error(j.error || 'request failed');
+ if (j.error) throw new Error(j.error);
+ return j;
+ });
}).then(function() {
notify('Removed exit ' + dir + ' to #' + target, 'success');
updateUndoBar();
@@ -2014,21 +2045,16 @@ function clearLinkHighlight() {
}
}
-function createLink(fromId, toId) {
- API.post('/api/rooms/link', {from: fromId, to: toId}).then(function() {
- notify('Linked rooms #'+fromId+' '+toId, 'success');
+function createLink(fromId, toId, oneway) {
+ var body = {from: fromId, to: toId};
+ if (oneway) body.oneway = true;
+ API.post('/api/rooms/link', body).then(function(res) {
+ if (res && res.error) { notify('Link failed: ' + res.error, 'error'); return; }
+ notify('Linked rooms #'+fromId+' '+toId + (oneway ? ' (one-way)' : ''), 'success');
updateUndoBar();
- if (mapData && mapData.rooms) {
- var fr = mapData.rooms.find(function(r) { return r.id === fromId; });
- var tr = mapData.rooms.find(function(r) { return r.id === toId; });
- if (fr && tr) {
- mapData.links = mapData.links || [];
- mapData.links.push({from: fromId, to: toId, dir: gridDeltaToDir(tr.x - fr.x, tr.y - fr.y) || '', bidirectional: true});
- renderMap(mapData);
- }
- }
+ loadMap().then(function() { selectRoom(selectedRoom); });
}).catch(function(e) {
- notify('Link failed: ' + e.message, 'error');
+ notify('Link failed: ' + extractError(e), 'error');
});
}
@@ -2105,26 +2131,26 @@ function clearDelHighlight() {
}
}
-function deleteLink(fromId, toId) {
+function deleteLink(fromId, toId, side) {
+ var body = {from: fromId, to: toId};
+ if (side) body.side = side;
fetch('/api/rooms/link', {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({from: fromId, to: toId}),
+ body: JSON.stringify(body),
credentials: 'same-origin'
}).then(function(r) {
- if (!r.ok) throw new Error('unlink failed');
- return r.json();
+ return r.json().then(function(j) {
+ if (!r.ok) throw new Error(j.error || 'unlink failed');
+ if (j.error) throw new Error(j.error);
+ return j;
+ });
}).then(function() {
- notify('Removed link #'+fromId+' '+toId, 'success');
+ notify('Removed link #'+fromId+' '+toId + (side ? ' (' + side + ' side only)' : ''), 'success');
updateUndoBar();
- if (mapData && mapData.links) {
- mapData.links = mapData.links.filter(function(l) {
- return !((l.from === fromId && l.to === toId) || (l.from === toId && l.to === fromId));
- });
- renderMap(mapData);
- }
+ loadMap().then(function() { selectRoom(selectedRoom); });
}).catch(function(e) {
- notify('Delete link failed: ' + e.message, 'error');
+ notify('Delete link failed: ' + extractError(e), 'error');
});
}
@@ -2368,4 +2394,7 @@ function reloadRoomDirs(cb) {
});
}
-document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize(); });
+document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); initPanelResize();
+ document.addEventListener('keydown', function(e) { if (e.key === 'Shift') shiftHeld = true; });
+ document.addEventListener('keyup', function(e) { if (e.key === 'Shift') shiftHeld = false; });
+});
diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html
index 1e45f8e..eb95db7 100644
--- a/internal/admin/templates/map.html
+++ b/internal/admin/templates/map.html
@@ -42,6 +42,7 @@
</div>
<svg id="mapSvg" style="width:100%;height:100%"></svg>
<div class="room-tooltip" id="tooltip" style="display:none"></div>
+ <div id="linkModeBadge" class="link-mode-badge" style="display:none"></div>
</div>
<div class="panel" id="sidePanel">
<div id="panelContent">