diff options
| -rw-r--r-- | building_guide/admin.md | 4 | ||||
| -rw-r--r-- | data/.admin_history.json | 2 | ||||
| -rw-r--r-- | data/rooms/intro/1005.yaml | 12 | ||||
| -rw-r--r-- | data/rooms/intro/1006.yaml | 12 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 140 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 2 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 99 | ||||
| -rw-r--r-- | internal/admin/templates/map.html | 1 |
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"> |
