aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/admin.md37
-rw-r--r--data/.admin_history.json2
-rw-r--r--data/help/room.yaml1
-rw-r--r--data/rooms/intro/1005.yaml12
-rw-r--r--data/rooms/intro/1006.yaml12
-rw-r--r--data/rooms/intro/2001.yaml27
-rw-r--r--internal/admin/api_map.go2
-rw-r--r--internal/admin/api_room_insert_remove.go466
-rw-r--r--internal/admin/api_room_insert_remove_test.go535
-rw-r--r--internal/admin/api_rooms.go2
-rw-r--r--internal/admin/id_alloc.go43
-rw-r--r--internal/admin/server.go9
-rw-r--r--internal/admin/static/admin.css12
-rw-r--r--internal/admin/static/editor.js18
-rw-r--r--internal/admin/static/map.js241
-rw-r--r--internal/game/cmd_room.go3
-rw-r--r--internal/game/cmd_room_insert.go91
-rw-r--r--internal/game/cmd_room_remove.go292
-rw-r--r--internal/game/cmd_room_remove_test.go331
-rw-r--r--internal/world/grid.go26
-rw-r--r--internal/world/grid_test.go181
-rw-r--r--internal/world/insert_remove.go152
-rw-r--r--internal/world/insert_remove_test.go186
23 files changed, 2541 insertions, 140 deletions
diff --git a/building_guide/admin.md b/building_guide/admin.md
index 023fae1..fe940e3 100644
--- a/building_guide/admin.md
+++ b/building_guide/admin.md
@@ -105,6 +105,7 @@ to see all available actions:
| `addspawn` | `room addspawn <item_id> [qty] [respawn]` | Add an item spawn (must exist in data/items/) |
| `remspawn` | `room remspawn <item_id>` | Remove an item spawn |
| `insert` | `room insert <direction> [name]` | Insert a new room into an exit, pushing rooms further out |
+| `remove` | `room remove <direction>` | Delete the inserted room and pull the far end back |
All room commands are admin-only. Referenced objects, mobs, and items are validated
before being added.
@@ -117,7 +118,37 @@ twist. For up/down, no grid check is needed — the new room simply becomes an
intermediate floor. The new room inherits the current room's outward-facing
exit properties (conditions, blocked messages, flags), while the far room's
reciprocal exit properties are preserved on its side. You are teleported into
-the new room, just like `dig`.
+the new room, just like `dig`. Diagonal directions (NE/NW/SE/SW) are supported.
+
+`room remove` is the inverse of `room insert`: it deletes the room reached via
+the current room's `<direction>` exit (the "inserted" room) and "pulls" the
+far end of the map back one step — the room that was beyond the deleted room
+becomes adjacent to the current room, and everything reachable beyond it shifts
+one step toward you. The current room's `<direction>` exit is repointed at the
+far room (preserving the current room's exit properties), and the far room's
+reciprocal exit is repointed back at the current room (preserving the far
+room's own exit properties). You stay where you are; any other player standing
+in the deleted room is relocated to the current room.
+
+`room remove` fails (and prints a message, changing nothing) when:
+
+- the `<direction>` exit's room does not lead back to the current room via the
+ opposite direction (it's not an insert chain),
+- the deleted room has no room beyond it to pull (a dead end — use `undig`
+ instead),
+- the deleted room has any exit other than the back-exit and the forward-exit,
+- the far room's opposite exit does not lead back to the deleted room,
+- some other room points into the deleted room (it was modified since
+ insertion), or
+- the pull would cause a map overlap or twist.
+
+Both `room insert` and `room remove` are also available from the admin web
+GUI's Map page: right-click a room and choose **Insert Room…** or
+**Remove Room…**, then pick a direction from the submenu. The GUI endpoints
+perform the same grid validation and exit-property preservation as the
+in-game commands. The GUI cannot relocate live players standing in a removed
+room (the same limitation as the existing Delete Room action); ensure no one
+is in the target room before removing it from the GUI.
#### god
```
@@ -250,9 +281,9 @@ The map page (`/`) is the main navigation hub:
The typical admin worldbuilding workflow:
-1. **Lay out rooms:** Use `dig` to create connected rooms quickly, or use the web map to create and link rooms visually. If `dig` would create a grid overlap, a two-way link is created instead — no duplicate rooms. Use `room insert` to place a new room between two already-connected rooms without breaking the map grid.
+1. **Lay out rooms:** Use `dig` to create connected rooms quickly, or use the web map to create and link rooms visually. If `dig` would create a grid overlap, a two-way link is created instead — no duplicate rooms. Use `room insert` to place a new room between two already-connected rooms without breaking the map grid, and `room remove` to delete an inserted room and pull the far end back.
2. **Flesh out rooms:** Use `room` commands to set names, descriptions, add objects/mobs/spawns directly from inside the game. Use the web admin editors for richer CRUD with undo/redo. For deeper edits, edit the YAML files directly in `data/rooms/` to add on-enter scripts, triggers, hazards, etc.
-3. **Rearrange:** Use `swapid` to renumber rooms and `undig` to delete rooms you no longer want (with full exit cleanup). Use `close` to remove individual exits.
+3. **Rearrange:** Use `swapid` to renumber rooms and `undig` to delete rooms you no longer want (with full exit cleanup). Use `room remove` to cleanly undo a `room insert` (it pulls the far end back and rejects edits that would break the grid). Use `close` to remove individual exits.
4. **Reload:** Run `reload` to pick up YAML changes without restarting (rooms auto-reload).
5. **Test:** Use `goto` to jump around while debugging room connections and content.
6. **God mode:** Use `god` to test dangerous areas safely — full stats, no aggro, pass through blocked exits. `ungod` restores normal state.
diff --git a/data/.admin_history.json b/data/.admin_history.json
index f990f30..1068582 100644
--- a/data/.admin_history.json
+++ b/data/.admin_history.json
@@ -1 +1 @@
-{"history":[{"time":"2026-07-06T22:04:45-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\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\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-06T22:04:45-04:00","description":"unlink 1005 south 2001 (reverse)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\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-06T22:04:48-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\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\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-06T22:04:55-04:00","description":"unlink 1006 south 2001","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-06T22:05:09-04:00","description":"update room 2001","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\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":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n north:\n blocked_message: \"\"\n condition: null\n hidden: true\n room: 1006\n set_flags: null\n set_player_flags: null\n south:\n room: 2006\n southeast:\n room: 2005\nhazard: \"\"\nid: 2001\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\nname: Test Room\nobjects:\n - description:\n - text: YOU FOUND HIM\n hidden: true\n name: hidden guy\n - id: tree\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false}],"redo":null} \ No newline at end of file
+{"history":[{"time":"2026-07-07T05:28:00-04:00","description":"insert room 2001 north 1006 (new 1007)","file_path":"data/rooms/intro/1007.yaml","old_content":"","new_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\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 north:\n room: 1006\n south:\n room: 2006\n southeast:\n room: 2005\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\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"},{"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:\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:28:03-04:00","description":"delete room 1007 (cleaned 2 exits)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1006\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/1006.yaml","old_content":"id: 1006\nname: 'Room #1006'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1006\nname: 'Room #1006'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"},{"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\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\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-07T05:28:05-04:00","description":"delete room 1006 (cleaned 0 exits)","file_path":"data/rooms/intro/1006.yaml","old_content":"id: 1006\nname: 'Room #1006'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"","is_delete":true,"is_create":false},{"time":"2026-07-07T05:28:07-04:00","description":"delete room 1005 (cleaned 0 exits)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"","is_delete":true,"is_create":false},{"time":"2026-07-07T05:28:09-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\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\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-07T05:28:43-04:00","description":"delete room 1005 (cleaned 1 exits)","file_path":"data/rooms/intro/1005.yaml","old_content":"exits:\n south: 2001\nid: 1005\nname: 'Room #1005'\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: 1005\n south:\n room: 2006\n southeast:\n room: 2005\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\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-07T05:28:43-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\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\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-07T05:28:54-04:00","description":"insert room 1005 south 2001 (new 1006)","file_path":"data/rooms/intro/1006.yaml","old_content":"","new_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1005\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":true,"extra_files":[{"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:\n south:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"},{"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\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\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-07T05:28:57-04:00","description":"delete room 1005 (cleaned 1 exits)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1006\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/1006.yaml","old_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1005\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1006\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:01-04:00","description":"insert room 2001 north 1006 (new 1005)","file_path":"data/rooms/intro/1005.yaml","old_content":"","new_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\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 north:\n room: 1006\n south:\n room: 2006\n southeast:\n room: 2005\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\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"},{"file_path":"data/rooms/intro/1006.yaml","old_content":"id: 1006\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1006\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:09-04:00","description":"delete room 1006 (cleaned 1 exits)","file_path":"data/rooms/intro/1006.yaml","old_content":"id: 1006\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1005\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/1005.yaml","old_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:10-04:00","description":"delete room 1005 (cleaned 1 exits)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\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: 1005\n south:\n room: 2006\n southeast:\n room: 2005\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\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-07T05:29:13-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\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\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-07T05:29:22-04:00","description":"create room 1006","file_path":"data/rooms/intro/1006.yaml","old_content":"","new_content":"exits:\n south: 1005\nid: 1006\nname: 'Room #1006'\n","is_delete":false,"is_create":true,"extra_files":[{"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:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:26-04:00","description":"insert room 2001 north 1005 (new 1007)","file_path":"data/rooms/intro/1007.yaml","old_content":"","new_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1005\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\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 north:\n room: 1005\n south:\n room: 2006\n southeast:\n room: 2005\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\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"},{"file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:28-04:00","description":"delete room 1006 (cleaned 1 exits)","file_path":"data/rooms/intro/1006.yaml","old_content":"exits:\n south: 1005\nid: 1006\nname: 'Room #1006'\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n north:\n room: 1006\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:29-04:00","description":"delete room 1005 (cleaned 1 exits)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\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: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1005\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:30-04:00","description":"delete room 1007 (cleaned 1 exits)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\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\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\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-07T05:29:31-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\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\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-07T05:29:34-04:00","description":"insert room 2001 north 1005 (new 1006)","file_path":"data/rooms/intro/1006.yaml","old_content":"","new_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1005\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\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 north:\n room: 1005\n south:\n room: 2006\n southeast:\n room: 2005\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\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"},{"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:\n south:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:41-04:00","description":"insert room 2001 north 1006 (new 1007)","file_path":"data/rooms/intro/1007.yaml","old_content":"","new_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\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 north:\n room: 1006\n south:\n room: 2006\n southeast:\n room: 2005\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\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"},{"file_path":"data/rooms/intro/1006.yaml","old_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1005\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1006\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1005\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:42-04:00","description":"delete room 1005 (cleaned 1 exits)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1006\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/1006.yaml","old_content":"id: 1006\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1005\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1006\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1007\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:43-04:00","description":"delete room 1006 (cleaned 1 exits)","file_path":"data/rooms/intro/1006.yaml","old_content":"id: 1006\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\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: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1006\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T05:29:43-04:00","description":"delete room 1007 (cleaned 1 exits)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\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\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\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-07T05:30:56-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\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\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-07T05:31:27-04:00","description":"delete room 1005 (cleaned 1 exits)","file_path":"data/rooms/intro/1005.yaml","old_content":"exits:\n south: 2001\nid: 1005\nname: 'Room #1005'\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: 1005\n south:\n room: 2006\n southeast:\n room: 2005\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\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/help/room.yaml b/data/help/room.yaml
index d2cbcb4..7996b0f 100644
--- a/data/help/room.yaml
+++ b/data/help/room.yaml
@@ -16,4 +16,5 @@ description: |-
addspawn <item_id> [qty] [respawn_ticks] — add an item spawn
remspawn <item_id> — remove an item spawn
insert <direction> [name] — insert a new room into an exit, pushing rooms beyond
+ remove <direction> — delete the inserted room and pull the far end back
Type 'room' alone to see this list in-game.
diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml
deleted file mode 100644
index faf82e3..0000000
--- a/data/rooms/intro/1005.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index 5a87adb..0000000
--- a/data/rooms/intro/1006.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-id: 1006
-name: 'Room #1006'
-color: ""
-description: []
-exits: {}
-objects: []
-item_spawns: []
-mobs: []
-on_enter: []
-hazard: ""
-block_transport: false
-triggers: []
diff --git a/data/rooms/intro/2001.yaml b/data/rooms/intro/2001.yaml
index a9570f6..2197f70 100644
--- a/data/rooms/intro/2001.yaml
+++ b/data/rooms/intro/2001.yaml
@@ -1,23 +1,21 @@
-block_transport: false
+id: 2001
+name: Test Room
color: ""
description:
- text: It's a test room
exits:
east:
room: 2002
- north:
- blocked_message: ""
- condition: null
- always_blocked: true
- room: 1006
- set_flags: null
- set_player_flags: null
south:
room: 2006
southeast:
room: 2005
-hazard: ""
-id: 2001
+objects:
+ - name: hidden guy
+ hidden: true
+ description:
+ - text: YOU FOUND HIM
+ - id: tree
item_spawns:
- id: bronze_axe
quantity: 1
@@ -31,12 +29,7 @@ item_spawns:
mobs:
- id: man
- id: flight_attendant_copy
-name: Test Room
-objects:
- - description:
- - text: YOU FOUND HIM
- hidden: true
- name: hidden guy
- - id: tree
on_enter: []
+hazard: ""
+block_transport: false
triggers: []
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
index a7802f9..3705cde 100644
--- a/internal/admin/api_map.go
+++ b/internal/admin/api_map.go
@@ -442,7 +442,7 @@ func (s *AdminServer) handleNextRoomID(w http.ResponseWriter, r *http.Request) {
if fromStr != "" {
fromID, _ = strconv.Atoi(fromStr)
}
- id, _, err := nextRoomIDInDir(s.dataDir, fromID)
+ id, _, err := s.nextRoomID(fromID)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
diff --git a/internal/admin/api_room_insert_remove.go b/internal/admin/api_room_insert_remove.go
new file mode 100644
index 0000000..c5ed54a
--- /dev/null
+++ b/internal/admin/api_room_insert_remove.go
@@ -0,0 +1,466 @@
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/world"
+)
+
+// handleRoomInsert models the in-game `room insert <direction>` for the admin
+// web GUI. Body: {from, dir, name?}. It creates a new room between `from` (A)
+// and the room A's dir exit leads to (B), rewiring A→dir→new, new→dir→B and
+// (when B's opposite exit points back at A) B→oppositeDir→new, preserving exit
+// properties on both sides. A grid-conflict check (world.InsertGridConflicts)
+// rejects inserts that would cause an overlap or twist. Returns the new room.
+//
+// The write sequence is transactional: the new room is written first, then A,
+// then B. If any write after the first fails, the already-applied writes are
+// rolled back (new room file deleted, A/B restored) so the on-disk world stays
+// consistent and no partial undo entry is pushed. The room index is only
+// touched after all writes succeed.
+func (s *AdminServer) handleRoomInsert(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ From int `json:"from"`
+ Dir string `json:"dir"`
+ Name string `json:"name"`
+ }
+ 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))
+ oppositeDir, ok := world.OppositeExit[dir]
+ if !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
+ bRoom, err := s.world.LoadRoom(bID)
+ if err != nil {
+ writeJSONError(w, fmt.Sprintf("could not load the room to the %s (#%d): %v", dir, bID, err), http.StatusBadRequest)
+ return
+ }
+
+ name := strings.TrimSpace(body.Name)
+ if name == "" {
+ name = "New Room"
+ }
+
+ aPath, aPathOK := s.world.GetRoomPath(aID)
+ if !aPathOK {
+ writeJSONError(w, fmt.Sprintf("could not find the file for room #%d", aID), http.StatusInternalServerError)
+ return
+ }
+
+ newID, subdir, allocErr := s.nextRoomID(aID)
+ if allocErr != nil {
+ writeJSONError(w, fmt.Sprintf("could not allocate a room id: %v", allocErr), http.StatusInternalServerError)
+ return
+ }
+
+ // Grid-conflict check on a hypothetical post-insert world.
+ conflicts := world.InsertGridConflicts(aID, dir, bID, newID, func(id int) (*world.Room, bool) {
+ r, err := s.world.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ })
+ if len(conflicts) > 0 {
+ writeJSONError(w, s.formatGridConflicts(conflicts, "insert"), http.StatusConflict)
+ return
+ }
+
+ // --- Transactional write sequence -------------------------------------
+ newPath := filepath.Join(subdir, strconv.Itoa(newID)+".yaml")
+ newRoom := &world.Room{
+ Name: name,
+ Description: behavior.DescList{
+ {Text: "A featureless room."},
+ },
+ Exits: map[world.ExitDir]world.ExitDef{
+ dir: {Room: bID},
+ oppositeDir: {Room: aID},
+ },
+ }
+ newContent, werr := writeYAMLFile(newPath, newRoom)
+ if werr != nil {
+ writeJSONError(w, "could not write the new room: "+werr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Rewire A's dir exit to the new room, preserving A's dir-exit props.
+ oldA, _ := snapshotFile(aPath)
+ aRoom.Exits[dir] = world.RewirePreserving(aExit, newID)
+ newA, werr := writeYAMLFile(aPath, aRoom)
+ if werr != nil {
+ rollbackRemove(newPath)
+ writeJSONError(w, "could not update room #"+strconv.Itoa(aID)+": "+werr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Rewire B's reciprocal exit to the new room, preserving B's own exit
+ // props, only when B's opposite exit already points back at A (lenient
+ // one-way insertion when it does not).
+ var extraFiles []ExtraFile
+ bPath, bPathOK := s.world.GetRoomPath(bID)
+ var oldB, newB []byte
+ bRewired := false
+ if bPathOK {
+ if bExit, ok := bRoom.Exits[oppositeDir]; ok && bExit.Room == aID {
+ oldB, _ = snapshotFile(bPath)
+ bRoom.Exits[oppositeDir] = world.RewirePreserving(bExit, newID)
+ var berr error
+ newB, berr = writeYAMLFile(bPath, bRoom)
+ if berr != nil {
+ rollbackWrite(aPath, oldA)
+ rollbackRemove(newPath)
+ writeJSONError(w, "could not update the far room #"+strconv.Itoa(bID)+": "+berr.Error(), http.StatusInternalServerError)
+ return
+ }
+ bRewired = true
+ }
+ }
+
+ // All writes succeeded: register the new room and record the change.
+ s.world.AddRoomPath(newID, newPath)
+ extraFiles = append(extraFiles, ExtraFile{
+ FilePath: aPath,
+ OldContent: oldA,
+ NewContent: newA,
+ })
+ if bRewired {
+ extraFiles = append(extraFiles, ExtraFile{
+ FilePath: bPath,
+ OldContent: oldB,
+ NewContent: newB,
+ })
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("insert room %d %s %d (new %d)", aID, dir, bID, newID),
+ FilePath: newPath,
+ NewContent: newContent,
+ IsCreate: true,
+ ExtraFiles: extraFiles,
+ })
+
+ writeJSON(w, map[string]any{"room": map[string]any{"id": newID, "name": name}})
+}
+
+// handleRoomRemove models the in-game `room remove <direction>` for the admin
+// web GUI. Body: {from, dir}. It deletes the room reached via `from`'s dir exit
+// (B) and pulls the far room C (B's dir exit's target) back to `from`, rewiring
+// from→dir→C and C→oppositeDir→from, preserving exit properties on both sides.
+// It fails (400/409) under the same conditions as the in-game command. The GUI
+// cannot relocate live players standing in B (a documented limitation of the
+// admin/game decoupling — same as the existing Delete Room endpoint); it does
+// clear room state and mob instances for B.
+//
+// The write sequence is transactional: A is rewritten first, then C, then B is
+// deleted. If any step after the first fails, the already-applied writes are
+// rolled back (A/C restored, B's file left intact) so the on-disk world stays
+// consistent and no partial undo entry is pushed. Index/state cleanup runs only
+// after the delete succeeds.
+func (s *AdminServer) handleRoomRemove(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, `{"error":"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))
+ oppositeDir, ok := world.OppositeExit[dir]
+ if !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 == aID {
+ writeJSONError(w, "that exit loops back to the same room", http.StatusBadRequest)
+ return
+ }
+ bRoom, err := s.world.LoadRoom(bID)
+ if err != nil {
+ writeJSONError(w, fmt.Sprintf("could not load the room to the %s (#%d): %v", dir, bID, err), http.StatusBadRequest)
+ return
+ }
+ bLabel := s.roomLabel(bRoom, bID)
+
+ // Guard 1: B leads back to A via oppositeDir.
+ backExit, hasBack := bRoom.Exits[oppositeDir]
+ if !hasBack || backExit.Room != aID {
+ writeJSONError(w, fmt.Sprintf("cannot remove: %s does not lead back here via %s (not an insert chain)", bLabel, oppositeDir), http.StatusBadRequest)
+ return
+ }
+ // Guard 2: B has a forward dir exit.
+ fwdExit, hasFwd := bRoom.Exits[dir]
+ if !hasFwd {
+ writeJSONError(w, fmt.Sprintf("cannot remove: %s has no room beyond to pull (use Delete Room for a dead-end)", bLabel), http.StatusBadRequest)
+ return
+ }
+ cID := fwdExit.Room
+ if cID == bID || cID == aID {
+ writeJSONError(w, "cannot remove: the far room is not a distinct room", http.StatusBadRequest)
+ return
+ }
+ // Guard 3: B has exactly two exits.
+ if len(bRoom.Exits) != 2 {
+ writeJSONError(w, fmt.Sprintf("cannot remove: %s has other exits besides %s and %s", bLabel, oppositeDir, dir), http.StatusBadRequest)
+ return
+ }
+ // Guard 4: C's opposite exit points back at B.
+ cRoom, err := s.world.LoadRoom(cID)
+ if err != nil {
+ writeJSONError(w, fmt.Sprintf("could not load the far room #%d: %v", cID, err), http.StatusBadRequest)
+ return
+ }
+ cOppExit, hasCOpp := cRoom.Exits[oppositeDir]
+ if !hasCOpp || cOppExit.Room != bID {
+ writeJSONError(w, fmt.Sprintf("cannot remove: the far room #%d does not lead back to %s via %s", cID, bLabel, oppositeDir), http.StatusBadRequest)
+ return
+ }
+ // Guard 5: no extra inbound edges to B.
+ if extra := s.countExtraInbound(bID, aID, cID, dir, oppositeDir); extra > 0 {
+ writeJSONError(w, fmt.Sprintf("cannot remove: %d other exit(s) point into %s (it was modified since insertion)", extra, bLabel), http.StatusBadRequest)
+ return
+ }
+ // Guard 6: grid-conflict check on a hypothetical post-remove world.
+ conflicts := world.RemoveGridConflicts(aID, dir, func(id int) (*world.Room, bool) {
+ r, err := s.world.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ })
+ if len(conflicts) > 0 {
+ writeJSONError(w, s.formatGridConflicts(conflicts, "remove"), http.StatusConflict)
+ return
+ }
+
+ // --- Transactional write sequence -------------------------------------
+ aPath, aPathOK := s.world.GetRoomPath(aID)
+ if !aPathOK {
+ writeJSONError(w, fmt.Sprintf("could not find the file for room #%d", aID), http.StatusInternalServerError)
+ return
+ }
+ oldA, _ := snapshotFile(aPath)
+ aRoom.Exits[dir] = world.RewirePreserving(aExit, cID)
+ newA, werr := writeYAMLFile(aPath, aRoom)
+ if werr != nil {
+ writeJSONError(w, "could not update room #"+strconv.Itoa(aID)+": "+werr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ cPath, cPathOK := s.world.GetRoomPath(cID)
+ oldC, _ := snapshotFile(cPath)
+ var newC []byte
+ cRewired := false
+ if cPathOK {
+ cRoom.Exits[oppositeDir] = world.RewirePreserving(cOppExit, aID)
+ var cerr error
+ newC, cerr = writeYAMLFile(cPath, cRoom)
+ if cerr != nil {
+ rollbackWrite(aPath, oldA)
+ writeJSONError(w, "could not update the far room #"+strconv.Itoa(cID)+": "+cerr.Error(), http.StatusInternalServerError)
+ return
+ }
+ cRewired = true
+ }
+
+ bPath, bPathOK := s.world.GetRoomPath(bID)
+ if !bPathOK {
+ rollbackWrite(aPath, oldA)
+ if cRewired {
+ rollbackWrite(cPath, oldC)
+ }
+ writeJSONError(w, fmt.Sprintf("could not find the file for room #%d", bID), http.StatusInternalServerError)
+ return
+ }
+ oldB, _ := snapshotFile(bPath)
+ if rerr := os.Remove(bPath); rerr != nil {
+ rollbackWrite(aPath, oldA)
+ if cRewired {
+ rollbackWrite(cPath, oldC)
+ }
+ writeJSONError(w, "could not delete room #"+strconv.Itoa(bID)+": "+rerr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // All writes succeeded: rebuild the index and clear B's live state.
+ var extraFiles []ExtraFile
+ extraFiles = append(extraFiles, ExtraFile{
+ FilePath: aPath,
+ OldContent: oldA,
+ NewContent: newA,
+ })
+ if cRewired {
+ extraFiles = append(extraFiles, ExtraFile{
+ FilePath: cPath,
+ OldContent: oldC,
+ NewContent: newC,
+ })
+ }
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.world.ClearRoomState(bID)
+ if s.mobStore != nil {
+ s.mobStore.RemoveMobsInRoom(bID)
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("remove room %d %s (pull %d to %d)", bID, dir, cID, aID),
+ FilePath: bPath,
+ OldContent: oldB,
+ IsDelete: true,
+ ExtraFiles: extraFiles,
+ })
+
+ writeJSON(w, map[string]any{"ok": true, "removed": bID, "pulled_to": cID})
+}
+
+// rollbackWrite restores a file's previous content. Used to undo an already-
+// applied write when a later step in a transactional handler fails. Errors are
+// logged (not returned) because there is no further recovery available.
+func rollbackWrite(path string, oldContent []byte) {
+ if oldContent == nil {
+ return
+ }
+ if err := os.WriteFile(path, oldContent, 0644); err != nil {
+ fmt.Printf("admin: rollback write %s failed: %v\n", path, err)
+ }
+}
+
+// rollbackRemove deletes a file that was just created. Used to undo a new-room
+// write when a later step in insert fails. Errors are logged (not returned).
+func rollbackRemove(path string) {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ fmt.Printf("admin: rollback remove %s failed: %v\n", path, err)
+ }
+}
+
+// roomLabel returns "#id" or "#id (Name)" for human-readable error messages.
+func (s *AdminServer) roomLabel(r *world.Room, id int) string {
+ if r != nil && r.Name != "" {
+ return fmt.Sprintf("#%d (%s)", id, r.Name)
+ }
+ return fmt.Sprintf("#%d", id)
+}
+
+// formatGridConflicts renders the conflicts returned by Insert/RemoveGridConflicts
+// as a single human-readable string, mirroring the in-game command's messages:
+// twists become "room #N (Name) would have an ambiguous grid position" and
+// overlaps become "would collide with room #N (Name)". mode is "insert" or
+// "remove" and selects the leading verb.
+func (s *AdminServer) formatGridConflicts(cs []world.GridConflict, mode string) string {
+ verb := "insert"
+ if mode == "remove" {
+ verb = "remove"
+ }
+ var parts []string
+ for _, c := range cs {
+ switch c.Kind {
+ case "twist":
+ r, _ := s.world.LoadRoom(c.Target)
+ parts = append(parts, fmt.Sprintf("room %s would have an ambiguous grid position after %s", s.roomLabel(r, c.Target), verb))
+ case "overlap":
+ tgt, _ := s.world.LoadRoom(c.Target)
+ occ, _ := s.world.LoadRoom(c.Occupier)
+ parts = append(parts, fmt.Sprintf("%s would cause a grid collision: room %s cannot be placed because room %s is already there", verb, s.roomLabel(tgt, c.Target), s.roomLabel(occ, c.Occupier)))
+ }
+ }
+ if len(parts) == 0 {
+ return fmt.Sprintf("cannot %s: it would cause map conflicts", verb)
+ }
+ return "cannot " + verb + ": " + strings.Join(parts, "; ")
+}
+
+// countExtraInbound mirrors Game.countExtraInbound for the admin API: it
+// counts exits in rooms other than A and C that point into B, plus any A/C
+// exits to B other than the expected (A→dir→B, C→oppositeDir→B). It walks
+// data/rooms/ and matches \b<id>\b against raw file contents to skip rooms
+// that don't mention bID at all, then unmarshals only the matching files.
+func (s *AdminServer) countExtraInbound(bID, aID, cID int, dir, oppositeDir world.ExitDir) int {
+ roomsDir := filepath.Join(s.dataDir, "rooms")
+ re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, bID))
+ var extra int
+ _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
+ return nil
+ }
+ data, rerr := os.ReadFile(path)
+ if rerr != nil {
+ return nil
+ }
+ if !re.MatchString(string(data)) {
+ return nil
+ }
+ idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
+ rid, serr := strconv.Atoi(idStr)
+ if serr != nil || rid == bID {
+ return nil
+ }
+ var room world.Room
+ if yerr := yaml.Unmarshal(data, &room); yerr != nil {
+ return nil
+ }
+ if room.Exits == nil {
+ return nil
+ }
+ for ed, exitDef := range room.Exits {
+ if exitDef.Room != bID {
+ continue
+ }
+ if rid == aID && ed == dir {
+ continue
+ }
+ if rid == cID && ed == oppositeDir {
+ continue
+ }
+ extra++
+ }
+ return nil
+ })
+ return extra
+}
diff --git a/internal/admin/api_room_insert_remove_test.go b/internal/admin/api_room_insert_remove_test.go
new file mode 100644
index 0000000..4f04a99
--- /dev/null
+++ b/internal/admin/api_room_insert_remove_test.go
@@ -0,0 +1,535 @@
+package admin
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+// newTestAdminServer builds an AdminServer wired to a temp data dir containing
+// the given room bodies (map of id -> YAML body), with a live World, MobStore,
+// and UndoStack. Only the fields the insert/remove handlers touch are set.
+func newTestAdminServer(t *testing.T, rooms map[int]string) *AdminServer {
+ t.Helper()
+ dir := t.TempDir()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for id, body := range rooms {
+ if err := os.WriteFile(filepath.Join(roomsDir, strconv.Itoa(id)+".yaml"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return &AdminServer{
+ world: world.New(dir),
+ mobStore: world.NewMobStore(dir),
+ dataDir: dir,
+ undoStack: NewUndoStack(dir),
+ }
+}
+
+func writeRoomFile(t *testing.T, dir string, id int, body string) {
+ t.Helper()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(roomsDir, strconv.Itoa(id)+".yaml"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func roomExists(s *AdminServer, id int) bool {
+ _, err := s.world.LoadRoom(id)
+ return err == nil
+}
+
+func roomExitTarget(s *AdminServer, id int, dir world.ExitDir) (int, bool) {
+ r, err := s.world.LoadRoom(id)
+ if err != nil {
+ return 0, false
+ }
+ e, ok := r.Exits[dir]
+ if !ok {
+ return 0, false
+ }
+ return e.Room, true
+}
+
+// postInsert drives the insert endpoint with the given body and returns the
+// decoded response alongside the response status code.
+func postInsert(t *testing.T, s *AdminServer, body string) (map[string]any, int) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/api/rooms/insert", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ s.handleRoomInsert(rr, req)
+ var resp map[string]any
+ _ = json.Unmarshal(rr.Body.Bytes(), &resp)
+ return resp, rr.Code
+}
+
+func postRemove(t *testing.T, s *AdminServer, body string) (map[string]any, int) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/api/rooms/remove", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ s.handleRoomRemove(rr, req)
+ var resp map[string]any
+ _ = json.Unmarshal(rr.Body.Bytes(), &resp)
+ return resp, rr.Code
+}
+
+// TestInsertSuccess verifies a clean two-way insert creates the new room,
+// rewires A and B onto it, and pushes an undo entry that restores everything.
+func TestInsertSuccess(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ })
+
+ resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"Mid"}`)
+ if code != http.StatusOK {
+ t.Fatalf("insert: expected 200, got %d: %v", code, resp)
+ }
+ room := resp["room"].(map[string]any)
+ newID := int(room["id"].(float64))
+ if room["name"] != "Mid" {
+ t.Errorf("insert: expected name Mid, got %v", room["name"])
+ }
+
+ // A→east→new, new→east→2, new→west→1, B→west→new.
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != newID {
+ t.Errorf("A east: expected %d, got %d", newID, tgt)
+ }
+ if tgt, _ := roomExitTarget(s, newID, world.East); tgt != 2 {
+ t.Errorf("new east: expected 2, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, newID, world.West); tgt != 1 {
+ t.Errorf("new west: expected 1, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.West); tgt != newID {
+ t.Errorf("B west: expected %d, got %d", newID, tgt)
+ }
+
+ // Undo restores the original two-room world.
+ change := s.undoStack.Undo()
+ if change == nil {
+ t.Fatal("undo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(change)
+ if roomExists(s, newID) {
+ t.Error("undo: new room file should be gone")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 2 {
+ t.Errorf("undo: A east should be 2, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.West); tgt != 1 {
+ t.Errorf("undo: B west should be 1, got %d", tgt)
+ }
+
+ // Redo re-applies the insert.
+ redoChange := s.undoStack.Redo()
+ if redoChange == nil {
+ t.Fatal("redo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(redoChange)
+ if !roomExists(s, newID) {
+ t.Error("redo: new room file should be back")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != newID {
+ t.Errorf("redo: A east should be %d, got %d", newID, tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.West); tgt != newID {
+ t.Errorf("redo: B west should be %d, got %d", newID, tgt)
+ }
+}
+
+// TestInsertDefaultName confirms an empty name falls back to "New Room".
+func TestInsertDefaultName(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ })
+ resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":""}`)
+ if code != http.StatusOK {
+ t.Fatalf("insert: expected 200, got %d: %v", code, resp)
+ }
+ if resp["room"].(map[string]any)["name"] != "New Room" {
+ t.Errorf("insert: expected default name 'New Room', got %v", resp["room"].(map[string]any)["name"])
+ }
+}
+
+// TestInsertGridConflict verifies a conflicting insert returns 409 with a
+// human-readable message (not raw JSON, not the old generic string).
+func TestInsertGridConflict(t *testing.T) {
+ // 1→east→2→east→3 and 1→south→4→east→5→east→6→east→7→north→8 at (3,0,0).
+ // Inserting between 1 and 2 pushes 3 to (3,0,0), colliding with 8.
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n south: 4\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ 4: "name: D\nexits:\n east: 5\n north: 1\n",
+ 5: "name: E\nexits:\n east: 6\n west: 4\n",
+ 6: "name: F\nexits:\n east: 7\n west: 5\n",
+ 7: "name: G\nexits:\n north: 8\n west: 6\n",
+ 8: "name: H\nexits:\n south: 7\n",
+ })
+ resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"X"}`)
+ if code != http.StatusConflict {
+ t.Fatalf("insert conflict: expected 409, got %d: %v", code, resp)
+ }
+ msg, _ := resp["error"].(string)
+ if !strings.Contains(msg, "cannot insert:") {
+ t.Errorf("insert conflict: expected 'cannot insert:' prefix, got %q", msg)
+ }
+ if !strings.Contains(msg, "collision") {
+ t.Errorf("insert conflict: expected overlap wording, got %q", msg)
+ }
+ if !strings.Contains(msg, "#3") || !strings.Contains(msg, "#8") {
+ t.Errorf("insert conflict: expected both colliding room ids (#3 and #8), got %q", msg)
+ }
+ // No partial writes: the world is unchanged.
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 2 {
+ t.Errorf("insert conflict: A east should still be 2, got %d", tgt)
+ }
+}
+
+// TestRemoveSuccess verifies a clean remove deletes B, pulls C back to A, and
+// undo/redo round-trip.
+func TestRemoveSuccess(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ })
+ resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
+ if code != http.StatusOK {
+ t.Fatalf("remove: expected 200, got %d: %v", code, resp)
+ }
+ if int(resp["removed"].(float64)) != 2 {
+ t.Errorf("remove: expected removed=2, got %v", resp["removed"])
+ }
+ if int(resp["pulled_to"].(float64)) != 3 {
+ t.Errorf("remove: expected pulled_to=3, got %v", resp["pulled_to"])
+ }
+ if roomExists(s, 2) {
+ t.Error("remove: B file should be gone")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 3 {
+ t.Errorf("remove: A east should be 3, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 3, world.West); tgt != 1 {
+ t.Errorf("remove: C west should be 1, got %d", tgt)
+ }
+
+ // Undo restores B and reverts A and C.
+ change := s.undoStack.Undo()
+ if change == nil {
+ t.Fatal("undo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(change)
+ if !roomExists(s, 2) {
+ t.Error("undo: B file should be back")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 2 {
+ t.Errorf("undo: A east should be 2, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 3, world.West); tgt != 2 {
+ t.Errorf("undo: C west should be 2, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.West); tgt != 1 {
+ t.Errorf("undo: B west should be 1, got %d", tgt)
+ }
+ if tgt, _ := roomExitTarget(s, 2, world.East); tgt != 3 {
+ t.Errorf("undo: B east should be 3, got %d", tgt)
+ }
+
+ // Redo re-deletes B and repulls C.
+ redoChange := s.undoStack.Redo()
+ if redoChange == nil {
+ t.Fatal("redo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(redoChange)
+ if roomExists(s, 2) {
+ t.Error("redo: B file should be gone again")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 3 {
+ t.Errorf("redo: A east should be 3, got %d", tgt)
+ }
+}
+
+// TestRemoveGuards exercises each structural guard, checking the 400 status and
+// a human-readable, specific error message.
+func TestRemoveGuards(t *testing.T) {
+ tests := []struct {
+ name string
+ rooms map[int]string
+ body string
+ want string
+ }{
+ {
+ name: "no such exit",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ },
+ body: `{"from":1,"dir":"north"}`,
+ want: "has no north exit",
+ },
+ {
+ name: "self loop",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 1\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "loops back",
+ },
+ {
+ name: "not an insert chain (B does not lead back)",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "not an insert chain",
+ },
+ {
+ name: "dead end (B has no forward exit)",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "no room beyond",
+ },
+ {
+ name: "B has extra exits",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n north: 4\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ 4: "name: D\nexits:\n south: 2\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "other exits besides",
+ },
+ {
+ name: "C does not lead back to B",
+ rooms: map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n east: 4\n",
+ 4: "name: D\nexits:\n west: 3\n",
+ },
+ body: `{"from":1,"dir":"east"}`,
+ want: "does not lead back to",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ s := newTestAdminServer(t, tc.rooms)
+ resp, code := postRemove(t, s, tc.body)
+ if code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %v", code, resp)
+ }
+ msg, _ := resp["error"].(string)
+ if !strings.Contains(msg, tc.want) {
+ t.Errorf("expected message containing %q, got %q", tc.want, msg)
+ }
+ if strings.HasPrefix(msg, "{") || strings.Contains(msg, `"error"`) {
+ t.Errorf("error message looks like raw JSON: %q", msg)
+ }
+ })
+ }
+}
+
+// TestRemoveGridConflict verifies a conflicting remove returns 409 with rich
+// wording (overlap) instead of the old generic message.
+func TestRemoveGridConflict(t *testing.T) {
+ // 1→east→2→east→3 (3 has south→6), 1→south→4→east→5. Removing 2 pulls 3
+ // to (1,0,0); 6 (at 3's old south neighbor (2,1,0)) follows to (1,1,0)? No
+ // — the pull shifts the whole beyond-component; 6 lands on 5's cell (1,1,0).
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n south: 4\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n south: 6\n west: 2\n",
+ 4: "name: D\nexits:\n east: 5\n north: 1\n",
+ 5: "name: E\nexits:\n west: 4\n",
+ 6: "name: F\nexits:\n north: 3\n",
+ })
+ resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
+ if code != http.StatusConflict {
+ t.Fatalf("remove conflict: expected 409, got %d: %v", code, resp)
+ }
+ msg, _ := resp["error"].(string)
+ if !strings.Contains(msg, "cannot remove:") {
+ t.Errorf("remove conflict: expected 'cannot remove:' prefix, got %q", msg)
+ }
+ if !strings.Contains(msg, "collision") {
+ t.Errorf("remove conflict: expected overlap wording, got %q", msg)
+ }
+ // No partial writes: B is still there, A and C unchanged.
+ if !roomExists(s, 2) {
+ t.Error("remove conflict: B file should still exist")
+ }
+ if tgt, _ := roomExitTarget(s, 1, world.East); tgt != 2 {
+ t.Errorf("remove conflict: A east should still be 2, got %d", tgt)
+ }
+}
+
+// TestRemoveExtraInbound verifies the extra-inbound guard fires when a third
+// room points into B, and that the message names the count.
+func TestRemoveExtraInbound(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ 9: "name: X\nexits:\n north: 2\n", // extra inbound edge into B
+ })
+ resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
+ if code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %v", code, resp)
+ }
+ msg, _ := resp["error"].(string)
+ if !strings.Contains(msg, "other exit(s) point into") {
+ t.Errorf("expected extra-inbound message, got %q", msg)
+ }
+}
+
+// TestNextRoomIDNoCrossSubdirCollision confirms the allocator does not return an
+// ID that already exists in a different subdirectory.
+func TestNextRoomIDNoCrossSubdirCollision(t *testing.T) {
+ dir := t.TempDir()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // Room 1 in root, room 2 in a subdirectory "zone".
+ writeRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ zoneDir := filepath.Join(roomsDir, "zone")
+ if err := os.MkdirAll(zoneDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(zoneDir, "2.yaml"), []byte("name: B\nexits:\n west: 1\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := &AdminServer{
+ world: world.New(dir),
+ mobStore: world.NewMobStore(dir),
+ dataDir: dir,
+ undoStack: NewUndoStack(dir),
+ }
+ // Allocating near room 1 (root) must skip 2 (which lives in zone/).
+ id, _, err := s.nextRoomID(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id == 2 {
+ t.Fatalf("nextRoomID returned %d, which collides with the zone/ room", id)
+ }
+ if roomExists(s, id) {
+ t.Errorf("nextRoomID returned an already-used id %d", id)
+ }
+}
+
+// testMobDef returns a minimal mob def with enough HP that MobsInRoom (which
+// filters HP > 0) reports it as present.
+func testMobDef() *world.MobDef {
+ return &world.MobDef{
+ ID: "testmob",
+ Name: "Test",
+ Combat: &world.MobCombat{
+ Stats: world.MobCombatStats{HP: 10, MaxMeleeHit: 1},
+ },
+ }
+}
+
+// TestUndoInsertClearsMobs verifies that undoing an insert drops in-memory mob
+// instances for the (now-deleted) new room, via rebuildAfterUndo's stat check.
+func TestUndoInsertClearsMobs(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n west: 1\n",
+ })
+ resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"Mid"}`)
+ if code != http.StatusOK {
+ t.Fatalf("insert: expected 200, got %d: %v", code, resp)
+ }
+ newID := int(resp["room"].(map[string]any)["id"].(float64))
+
+ // Spawn a transient mob in the new room so the store has something to clear.
+ inst := s.mobStore.SpawnTransient(testMobDef(), &world.SpawnMobConfig{ID: "testmob"}, newID, "")
+ if inst == nil {
+ t.Fatal("SpawnTransient returned nil")
+ }
+ if got := len(s.mobStore.MobsInRoom(newID)); got != 1 {
+ t.Fatalf("precondition: expected 1 mob in new room, got %d", got)
+ }
+
+ change := s.undoStack.Undo()
+ if change == nil {
+ t.Fatal("undo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(change)
+
+ if got := len(s.mobStore.MobsInRoom(newID)); got != 0 {
+ t.Errorf("undo: expected mobs for deleted room to be cleared, got %d", got)
+ }
+}
+
+// TestRedoRemoveClearsMobs verifies that redoing a remove drops mob instances
+// for the re-deleted room.
+func TestRedoRemoveClearsMobs(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 1: "name: A\nexits:\n east: 2\n",
+ 2: "name: B\nexits:\n east: 3\n west: 1\n",
+ 3: "name: C\nexits:\n west: 2\n",
+ })
+ if _, code := postRemove(t, s, `{"from":1,"dir":"east"}`); code != http.StatusOK {
+ t.Fatal("remove failed")
+ }
+ // Undo to bring B back, then seed a mob in B before redoing.
+ change := s.undoStack.Undo()
+ if change == nil {
+ t.Fatal("undo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(change)
+ if !roomExists(s, 2) {
+ t.Fatal("precondition: B should exist after undo")
+ }
+ s.mobStore.SpawnTransient(testMobDef(), &world.SpawnMobConfig{ID: "testmob"}, 2, "")
+ if got := len(s.mobStore.MobsInRoom(2)); got != 1 {
+ t.Fatalf("precondition: expected 1 mob in B, got %d", got)
+ }
+
+ redoChange := s.undoStack.Redo()
+ if redoChange == nil {
+ t.Fatal("redo: expected a change, got nil")
+ }
+ s.rebuildAfterUndo(redoChange)
+
+ if got := len(s.mobStore.MobsInRoom(2)); got != 0 {
+ t.Errorf("redo: expected mobs for re-deleted B to be cleared, got %d", got)
+ }
+}
+
+// TestInsertInvalidBody confirms malformed input yields 400, not a panic.
+func TestInsertInvalidBody(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{1: "name: A\n"})
+ for _, body := range []string{`{}`, `{"from":0,"dir":"east"}`, `{"from":1,"dir":""}`, `{"from":1,"dir":"sideways"}`} {
+ resp, code := postInsert(t, s, body)
+ if code != http.StatusBadRequest {
+ t.Errorf("insert %q: expected 400, got %d: %v", body, code, resp)
+ }
+ }
+}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 124b093..8b4a28f 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -73,7 +73,7 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
if linkFrom != nil {
fromID = *linkFrom
}
- id, subdir, allocErr := nextRoomIDInDir(s.dataDir, fromID)
+ id, subdir, allocErr := s.nextRoomID(fromID)
if allocErr != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("alloc id: %v", allocErr)})
return
diff --git a/internal/admin/id_alloc.go b/internal/admin/id_alloc.go
index d79718b..3a3c973 100644
--- a/internal/admin/id_alloc.go
+++ b/internal/admin/id_alloc.go
@@ -6,24 +6,43 @@ import (
"strconv"
)
-func nextRoomIDInDir(dataDir string, fromRoomID int) (int, string, error) {
- base := filepath.Join(dataDir, "rooms")
+// nextRoomID allocates a free room ID for a new room that will live in the same
+// directory as fromRoomID (so new IDs cluster near their neighbors). Unlike the
+// old per-subdir scan, the candidate is checked against EVERY room ID on disk
+// (via listRoomIDs, which walks the whole data/rooms tree), so it can never
+// collide with an ID that lives in a different subdirectory. The starting point
+// is the local subdir's minimum existing ID (clustering), then the first
+// globally-free ID scanning upward.
+func (s *AdminServer) nextRoomID(fromRoomID int) (int, string, error) {
+ base := filepath.Join(s.dataDir, "rooms")
subdir, err := findRoomSubdir(base, fromRoomID)
if err != nil {
subdir = base
}
- used := map[int]bool{}
- scanDir(subdir, used)
- if len(used) == 0 {
- return 1, subdir, nil
- }
- minID := 1<<31 - 1
- for id := range used {
- if id < minID {
- minID = id
+
+ localUsed := map[int]bool{}
+ scanDir(subdir, localUsed)
+ start := 1
+ if len(localUsed) > 0 {
+ minID := 1<<31 - 1
+ for id := range localUsed {
+ if id < minID {
+ minID = id
+ }
}
+ start = minID
+ }
+
+ globalUsed, _ := listRoomIDs(s.dataDir)
+ used := make(map[int]bool, len(globalUsed))
+ for _, id := range globalUsed {
+ used[id] = true
+ }
+
+ id := start
+ if id < 1 {
+ id = 1
}
- id := minID
for used[id] {
id++
}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 6ad20f5..17835db 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -20,6 +20,7 @@ import (
"net"
"net/http"
"path/filepath"
+ "os"
"strconv"
"strings"
"time"
@@ -120,6 +121,8 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/room-dirs/rename", s.handleRenameRoomDir)
apiMux.HandleFunc("/api/room-dirs/create", s.handleCreateRoomDir)
apiMux.HandleFunc("/api/rooms/link", s.handleRoomLink)
+ apiMux.HandleFunc("/api/rooms/insert", s.handleRoomInsert)
+ 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", s.handleRooms)
@@ -619,6 +622,12 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) {
if idStr := strings.TrimSuffix(name, ".yaml"); idStr != name {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
s.world.ClearRoomState(id)
+ // If the room file is now gone (true after undo-of-create and
+ // redo-of-delete), drop any in-memory mob instances for it so
+ // they don't keep ticking against a room that no longer exists.
+ if _, statErr := os.Stat(p); os.IsNotExist(statErr) && s.mobStore != nil {
+ s.mobStore.RemoveMobsInRoom(id)
+ }
}
}
}
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 6822646..d878db5 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -235,6 +235,18 @@ g.ud-hover:hover text{font-weight:bold}
.cm-menu button:hover{background:var(--accent)}
.cm-menu button.danger{color:#f55}
.cm-menu button.danger:hover{background:var(--danger)}
+.cm-menu button.muted{color:#888}
+.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}
+.cm-item-has-sub{position:relative}
+.cm-item-has-sub>button{display:block;width:100%;padding:6px 24px 6px 12px;text-align:left;font-size:12px;font-family:monospace;background:none;border:none;color:var(--text);cursor:pointer}
+.cm-item-has-sub>button:hover{background:var(--accent)}
+.cm-item-has-sub .cm-arrow{position:absolute;right:8px;top:6px;font-size:9px;color:#888;pointer-events:none}
+.cm-item-has-sub.disabled{opacity:.5}
+.cm-item-has-sub.disabled>button{cursor:not-allowed}
+.cm-submenu{position:absolute;left:100%;top:-5px;display:none;min-width:120px;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 0;box-shadow:0 4px 16px rgba(0,0,0,.4);z-index:201}
+.cm-submenu.open{display:block}
+.cm-submenu.flip-left{left:auto;right:100%}
+.cm-submenu.flip-up{top:auto;bottom:-5px}
.rename-header{display:flex;align-items:center;gap:6px}
.rename-header-text{margin-bottom:0!important;padding-bottom:0!important;border-bottom:none!important}
.rename-pencil{font-size:14px;cursor:pointer;color:#888;padding:2px 4px;border-radius:3px;transition:color .15s,transform .15s}
diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js
index 50d86f1..1bd6872 100644
--- a/internal/admin/static/editor.js
+++ b/internal/admin/static/editor.js
@@ -278,12 +278,21 @@ function showDirContextMenu(e, dir) {
overlay.className = 'cm-overlay';
overlay.id = '_ctxMenuOverlay';
overlay.onclick = function() { closeContextMenu(); };
+ overlay.addEventListener('contextmenu', function(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var cx = ev.clientX, cy = ev.clientY;
+ closeContextMenu();
+ var el = document.elementFromPoint(cx, cy);
+ if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2}));
+ });
var menu = document.createElement('div');
menu.className = 'cm-menu';
menu.id = '_ctxMenu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
+ menu.oncontextmenu = function(ev) { ev.preventDefault(); };
function addBtn(label, cls, handler) {
var btn = document.createElement('button');
@@ -324,12 +333,21 @@ function showItemContextMenu(e, id) {
overlay.className = 'cm-overlay';
overlay.id = '_ctxMenuOverlay';
overlay.onclick = function() { closeContextMenu(); };
+ overlay.addEventListener('contextmenu', function(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var cx = ev.clientX, cy = ev.clientY;
+ closeContextMenu();
+ var el = document.elementFromPoint(cx, cy);
+ if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2}));
+ });
var menu = document.createElement('div');
menu.className = 'cm-menu';
menu.id = '_ctxMenu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
+ menu.oncontextmenu = function(ev) { ev.preventDefault(); };
function addBtn(label, cls, handler) {
var btn = document.createElement('button');
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 3fdd543..9044286 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -17,6 +17,11 @@ var gridDirs = [
{dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'},
{dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'}
];
+var oppositeDir = {
+ north:'south', south:'north', east:'west', west:'east',
+ northeast:'southwest', northwest:'southeast',
+ southeast:'northwest', southwest:'northeast'
+};
var saveTimer = null;
var upTarget = {}, downTarget = {};
@@ -1926,7 +1931,7 @@ function deleteRoom(id) {
selectedRoom = null;
$('#panelContent').innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>';
loadMap();
- }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+ }).catch(function(e) { notify('Delete failed: ' + extractError(e), 'error'); });
}
function createRoom(fromID, dir, oneway) {
@@ -1942,39 +1947,249 @@ function createRoom(fromID, dir, oneway) {
notify('Room #' + createdId + ' created', 'success');
updateUndoBar();
loadMap().then(function() { selectRoom(createdId); });
- }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); });
+ }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
});
}
function showRoomContextMenu(x, y, id) {
var overlay = document.createElement('div');
overlay.className = 'cm-overlay';
- overlay.onclick = function() { overlay.remove(); menu.remove(); };
+ // Closing the menu also kills any open submenu and pending hide timer.
+ var closeAll = function() {
+ if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; }
+ overlay.remove(); menu.remove();
+ };
+ overlay.onclick = closeAll;
+ overlay.addEventListener('contextmenu', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ var cx = e.clientX, cy = e.clientY;
+ closeAll();
+ var el = document.elementFromPoint(cx, cy);
+ while (el && el !== document.body && !(el.classList && el.classList.contains('rm'))) {
+ el = el.parentElement;
+ }
+ if (el && el.classList && el.classList.contains('rm')) {
+ var rid = parseInt(el.getAttribute('data-id'));
+ if (rid) showRoomContextMenu(cx, cy, rid);
+ }
+ });
var menu = document.createElement('div');
menu.className = 'cm-menu';
menu.style.left = x + 'px';
menu.style.top = y + 'px';
+ menu.oncontextmenu = function(e) { e.preventDefault(); };
var delBtn = document.createElement('button');
delBtn.textContent = 'Delete Room';
delBtn.className = 'danger';
delBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ closeAll();
deleteRoom(id);
};
menu.appendChild(delBtn);
+ menu.appendChild(buildSubmenuItem('Insert Room', id, 'insert', closeAll, overlay, menu));
+ menu.appendChild(buildSubmenuItem('Remove Room', id, 'remove', closeAll, overlay, menu));
+
var selBtn = document.createElement('button');
selBtn.textContent = 'Room Details';
selBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ closeAll();
selectRoom(id);
};
menu.appendChild(selBtn);
document.body.appendChild(overlay);
document.body.appendChild(menu);
+
+ // Clamp inside the viewport so the menu never spills off-screen.
+ clampMenuToViewport(menu);
+}
+
+// buildSubmenuItem constructs a parent menu row ("Insert Room"/"Remove Room")
+// that carries a nested submenu of the room's horizontal exits. The submenu is
+// revealed by both hover (Windows-style: mouseenter with a small dismiss delay
+// on mouseleave) and click (keyboard/mobile accessibility), mirroring how
+// Windows cascading menus behave. closeAll closes the whole context menu;
+// overlay carries the shared hide-timer slot.
+function buildSubmenuItem(label, id, mode, closeAll, overlay, menu) {
+ var dirs = roomHorizontalDirs(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) {
+ // Still surface the "no exits" reason on click so it's discoverable.
+ btn.onclick = function() {
+ notify(mode === 'insert'
+ ? 'No horizontal exits to insert into from #' + id
+ : 'No horizontal exits to remove 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 = mode === 'insert' ? 'Insert Room' : 'Remove 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();
+ if (mode === 'insert') insertRoom(id, dir);
+ else removeRoom(id, dir);
+ };
+ })(d);
+ sub.appendChild(opt);
+ });
+ item.appendChild(sub);
+
+ var open = false;
+ function showSub() {
+ if (overlay._subHideTimer) { clearTimeout(overlay._subHideTimer); overlay._subHideTimer = null; }
+ // Only one submenu open at a time.
+ 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);
+ // Keep the submenu alive while the cursor is inside it (crosses the gap).
+ 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;
+}
+
+// roomHorizontalDirs returns the room's outward horizontal exits on the current
+// z-level, in gridDirs order, derived from mapData.links. Empty when the room
+// has no horizontal exits to insert into / remove from.
+function roomHorizontalDirs(id) {
+ if (!mapData || !mapData.links) return [];
+ var horizontal = {};
+ gridDirs.forEach(function(d) { horizontal[d.dir] = true; });
+ var seen = {};
+ var dirs = [];
+ mapData.links.forEach(function(l) {
+ if (l.from === id && horizontal[l.dir] && !seen[l.dir]) {
+ seen[l.dir] = true;
+ dirs.push(l.dir);
+ } else if (l.to === id && l.bidirectional && horizontal[l.dir]) {
+ var opp = oppositeDir[l.dir];
+ if (opp && !seen[opp]) {
+ seen[opp] = true;
+ dirs.push(opp);
+ }
+ }
+ });
+ var ordered = [];
+ gridDirs.forEach(function(d) {
+ if (seen[d.dir]) ordered.push(d.dir);
+ });
+ return ordered;
+}
+
+// flipSubmenu toggles flip classes so the submenu stays on-screen when the
+// parent is near the right or bottom edge of the viewport.
+function flipSubmenu(sub) {
+ sub.classList.remove('flip-left');
+ sub.classList.remove('flip-up');
+ var rect = sub.getBoundingClientRect();
+ if (rect.right > window.innerWidth - 4) sub.classList.add('flip-left');
+ if (rect.bottom > window.innerHeight - 4) sub.classList.add('flip-up');
+}
+
+// clampMenuToViewport nudges a top-level context menu inside the viewport.
+function clampMenuToViewport(menu) {
+ var rect = menu.getBoundingClientRect();
+ if (rect.right > window.innerWidth - 4) {
+ menu.style.left = Math.max(4, window.innerWidth - rect.width - 4) + 'px';
+ }
+ if (rect.bottom > window.innerHeight - 4) {
+ menu.style.top = Math.max(4, window.innerHeight - rect.height - 4) + 'px';
+ }
+}
+
+// dirLabel turns a direction key ("north", "northeast", ...) into a display
+// label ("North", "Northeast", ...). Falls back to title-casing the input.
+function dirLabel(d) {
+ var labels = {
+ north: 'North', south: 'South', east: 'East', west: 'West',
+ northeast: 'Northeast', northwest: 'Northwest',
+ southeast: 'Southeast', southwest: 'Southwest',
+ up: 'Up', down: 'Down'
+ };
+ return labels[d] || d.charAt(0).toUpperCase() + d.slice(1);
+}
+
+// insertRoom calls the admin insert endpoint to push a new room into id's dir
+// exit, then refreshes the map and selects the new room. The backend fills in
+// the default name ("New Room") when none is supplied.
+function insertRoom(fromID, dir) {
+ API.post('/api/rooms/insert', { from: fromID, dir: dir, name: '' }).then(function(r) {
+ var nid = r && r.room && r.room.id;
+ notify('Inserted room #' + nid + ' to the ' + dirLabel(dir), 'success');
+ updateUndoBar();
+ loadMap().then(function() { if (nid) selectRoom(nid); });
+ }).catch(function(e) { notify('Insert failed: ' + extractError(e), 'error'); });
+}
+
+// removeRoom calls the admin remove endpoint to delete id's dir exit's room
+// and pull the far room back, then refreshes the map.
+function removeRoom(fromID, dir) {
+ if (!confirm('Remove the room to the ' + dirLabel(dir) + ' of #' + fromID +
+ ' and pull the far end back?')) return;
+ API.post('/api/rooms/remove', { from: fromID, dir: dir }).then(function(r) {
+ var pulled = r && r.pulled_to;
+ notify('Removed room; far end pulled to #' + pulled, 'success');
+ updateUndoBar();
+ loadMap().then(function() { selectRoom(fromID); });
+ }).catch(function(e) { notify('Remove failed: ' + extractError(e), 'error'); });
}
function mouseToGrid(e) {
@@ -2256,18 +2471,28 @@ function loadDisconnectedRooms(dir) {
function showDcContextMenu(e, id) {
var overlay = document.createElement('div');
overlay.className = 'cm-overlay';
- overlay.onclick = function() { overlay.remove(); menu.remove(); };
+ var closeAll = function() { overlay.remove(); menu.remove(); };
+ overlay.onclick = closeAll;
+ overlay.addEventListener('contextmenu', function(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var cx = ev.clientX, cy = ev.clientY;
+ closeAll();
+ var el = document.elementFromPoint(cx, cy);
+ if (el) el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true, clientX:cx, clientY:cy, button:2}));
+ });
var menu = document.createElement('div');
menu.className = 'cm-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
+ menu.oncontextmenu = function(ev) { ev.preventDefault(); };
var delBtn = document.createElement('button');
delBtn.textContent = 'Delete Room';
delBtn.className = 'danger';
delBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ closeAll();
deleteRoom(id);
};
menu.appendChild(delBtn);
@@ -2275,7 +2500,7 @@ function showDcContextMenu(e, id) {
var selBtn = document.createElement('button');
selBtn.textContent = 'Room Details';
selBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ closeAll();
selectRoom(id);
};
menu.appendChild(selBtn);
diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go
index 971e298..3210ef5 100644
--- a/internal/game/cmd_room.go
+++ b/internal/game/cmd_room.go
@@ -51,6 +51,8 @@ func (g *Game) executeRoom(sess *net.Session, args []string, rawInput string) {
g.roomSetColor(sess, rest)
case "insert":
g.roomInsert(sess, rest)
+ case "remove":
+ g.roomRemove(sess, rest)
default:
sess.WriteLine(fmt.Sprintf("Unknown room action: %s", action))
g.showRoomHelp(sess)
@@ -73,6 +75,7 @@ func (g *Game) showRoomHelp(sess *net.Session) {
sess.WriteLine(" room remspawn <item_id> — remove an item spawn")
sess.WriteLine(" room color <spec|off> — set or clear room map color")
sess.WriteLine(" room insert <direction> [name] — insert a new room into the exit, pushing rooms beyond")
+ sess.WriteLine(" room remove <direction> — delete the inserted room and pull the far end back")
}
func (g *Game) roomSetName(sess *net.Session, args []string) {
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 988cd08..b72591f 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -65,53 +65,42 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
oppositeDir := world.OppositeExit[dir]
- if delta3D, ok := world.DirectionDeltas3D[dir]; ok {
- coord, roomAt := g.buildGridFrom(p.RoomID)
-
- sSet := g.bfsReachable(targetID, func(_ int, _ world.ExitDir, target int) bool {
- return target == p.RoomID
- })
+ newID, err := findNextRoomID(curPath)
+ if err != nil {
+ sess.WriteLine("Error scanning room directory.")
+ return
+ }
- withoutEdge := g.bfsReachable(p.RoomID, func(rid int, d world.ExitDir, target int) bool {
- return rid == p.RoomID && d == dir && target == targetID
- })
- for rid := range sSet {
- if withoutEdge[rid] {
- room, _ := g.World.LoadRoom(rid)
- conflictName := fmt.Sprintf("#%d", rid)
+ conflicts := world.InsertGridConflicts(p.RoomID, dir, targetID, newID, func(id int) (*world.Room, bool) {
+ r, err := g.World.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ })
+ if len(conflicts) > 0 {
+ for _, c := range conflicts {
+ switch c.Kind {
+ case "twist":
+ room, _ := g.World.LoadRoom(c.Target)
+ conflictName := fmt.Sprintf("#%d", c.Target)
if room != nil {
- conflictName = fmt.Sprintf("#%d (%s)", rid, room.Name)
+ conflictName = fmt.Sprintf("#%d (%s)", c.Target, room.Name)
}
sess.WriteLine(fmt.Sprintf(
"Cannot insert: room %s would have an ambiguous grid position after insertion (reachable via an alternate path from here).",
conflictName))
- return
- }
- }
-
- for rid := range sSet {
- if _, inGrid := coord[rid]; !inGrid {
- continue
- }
- oldPos := coord[rid]
- newPos := [3]int{oldPos[0] + delta3D[0], oldPos[1] + delta3D[1], oldPos[2] + delta3D[2]}
- if occupier, ok := roomAt[newPos]; ok && !sSet[occupier] {
- occRoom, _ := g.World.LoadRoom(occupier)
- occName := fmt.Sprintf("#%d", occupier)
+ case "overlap":
+ occRoom, _ := g.World.LoadRoom(c.Occupier)
+ occName := fmt.Sprintf("#%d", c.Occupier)
if occRoom != nil {
- occName = fmt.Sprintf("#%d (%s)", occupier, occRoom.Name)
+ occName = fmt.Sprintf("#%d (%s)", c.Occupier, occRoom.Name)
}
sess.WriteLine(fmt.Sprintf(
"Cannot insert: pushing %s would cause grid collision with %s.",
targetName, occName))
- return
}
}
- }
-
- newID, err := findNextRoomID(curPath)
- if err != nil {
- sess.WriteLine("Error scanning room directory.")
return
}
@@ -228,37 +217,3 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
g.runEnterSteps(sess, newID)
g.checkAggro(sess)
}
-
-// bfsReachable returns the set of rooms reachable from startID over the room
-// exit graph, restricted to known rooms. skipEdge, when non-nil, prunes an
-// individual directed exit (the edge from rid via dir to target) from the walk.
-func (g *Game) bfsReachable(startID int, skipEdge func(rid int, dir world.ExitDir, target int) bool) map[int]bool {
- roomIndex := g.World.RoomIndex()
- visited := map[int]bool{startID: true}
- queue := []int{startID}
-
- for len(queue) > 0 {
- rid := queue[0]
- queue = queue[1:]
- room, err := g.World.LoadRoom(rid)
- if err != nil {
- continue
- }
- for _, ed := range world.ExitOrder {
- exit, ok := room.Exits[ed]
- if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
- continue
- }
- target := exit.Room
- if skipEdge != nil && skipEdge(rid, ed, target) {
- continue
- }
- if visited[target] {
- continue
- }
- visited[target] = true
- queue = append(queue, target)
- }
- }
- return visited
-}
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
new file mode 100644
index 0000000..78c1234
--- /dev/null
+++ b/internal/game/cmd_room_remove.go
@@ -0,0 +1,292 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+// roomRemove is the inverse of roomInsert: it deletes the room reached via the
+// current room's dir exit (the "inserted" room B) and pulls the far room C (B's
+// dir exit's target) back to the current room, rewiring A→dir→C and
+// C→oppositeDir→A. The beyond-rooms shift one step toward A. It fails when:
+// - B does not lead back to the current room via oppositeDir (not an insert
+// chain),
+// - B has no forward dir exit (a dead end — use `undig` for that),
+// - B has any exit other than oppositeDir and dir (other exits in the deleted
+// room),
+// - C's opposite exit does not point back at B (broken reciprocity),
+// - some other room points into B (extra inbound edge — B was modified since
+// insertion),
+// - the pull would cause a grid overlap or twist (RemoveGridConflicts).
+//
+// Admin gating is inherited from executeRoom.
+func (g *Game) roomRemove(sess *net.Session, args []string) {
+ p := sess.Player
+ if p == nil {
+ return
+ }
+
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room remove <direction>")
+ return
+ }
+
+ dir := g.World.ResolveExit(args[0])
+ if dir == "" {
+ sess.WriteLine("Invalid direction. Use n/s/e/w/ne/nw/se/sw/u/d.")
+ return
+ }
+
+ oppositeDir := world.OppositeExit[dir]
+ aID := p.RoomID
+
+ aPath, ok := g.World.GetRoomPath(aID)
+ if !ok {
+ sess.WriteLine("Error: can't find current room file.")
+ return
+ }
+ aRoom, err := g.World.LoadRoom(aID)
+ if err != nil {
+ sess.WriteLine("Error loading current room.")
+ return
+ }
+
+ aExit, hasExit := aRoom.Exits[dir]
+ if !hasExit {
+ sess.WriteLine(fmt.Sprintf("There is no exit to the %s from here.", dir))
+ return
+ }
+ bID := aExit.Room
+ if bID == aID {
+ sess.WriteLine("That exit loops back to this room; nothing to remove.")
+ return
+ }
+
+ bRoom, err := g.World.LoadRoom(bID)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Target room %d not found.", bID))
+ return
+ }
+ bName := fmt.Sprintf("#%d (%s)", bID, bRoom.Name)
+
+ // Guard 1: B must lead back to A via oppositeDir.
+ backExit, hasBack := bRoom.Exits[oppositeDir]
+ if !hasBack || backExit.Room != aID {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: %s does not lead back here via %s (not an insert chain).",
+ bName, oppositeDir))
+ return
+ }
+
+ // Guard 2: B must have a forward dir exit (a far room C to pull).
+ fwdExit, hasFwd := bRoom.Exits[dir]
+ if !hasFwd {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: %s has no room beyond to pull (use `undig %s` to delete a dead-end room).",
+ bName, dir))
+ return
+ }
+ cID := fwdExit.Room
+ if cID == bID || cID == aID {
+ sess.WriteLine(fmt.Sprintf("Cannot remove: the far room in %s is not a distinct room.", dir))
+ return
+ }
+
+ // Guard 3: B must have exactly two exits (oppositeDir back to A and dir
+ // forward to C). Any other exit means B is not a clean insert chain.
+ if len(bRoom.Exits) != 2 {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: %s has other exits besides %s and %s.",
+ bName, oppositeDir, dir))
+ return
+ }
+
+ // Guard 4: C's opposite exit must point back at B.
+ cRoom, err := g.World.LoadRoom(cID)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Far room %d not found.", cID))
+ return
+ }
+ cOppExit, hasCOpp := cRoom.Exits[oppositeDir]
+ if !hasCOpp || cOppExit.Room != bID {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: the far room #%d does not lead back to %s via %s.",
+ cID, bName, oppositeDir))
+ return
+ }
+
+ // Guard 5: no other room points into B. The only allowed inbound edges are
+ // A→dir→B and C→oppositeDir→B.
+ if extra := g.countExtraInbound(bID, aID, cID, dir, oppositeDir); extra > 0 {
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: %d other exit(s) point into %s (it was modified since insertion).",
+ extra, bName))
+ return
+ }
+
+ // Guard 6: the pull must not cause a grid overlap or twist.
+ conflicts := world.RemoveGridConflicts(aID, dir, func(id int) (*world.Room, bool) {
+ r, err := g.World.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ })
+ if len(conflicts) > 0 {
+ for _, c := range conflicts {
+ switch c.Kind {
+ case "twist":
+ room, _ := g.World.LoadRoom(c.Target)
+ name := fmt.Sprintf("#%d", c.Target)
+ if room != nil {
+ name = fmt.Sprintf("#%d (%s)", c.Target, room.Name)
+ }
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: pulling the far end would give room %s an ambiguous grid position.",
+ name))
+ case "overlap":
+ occRoom, _ := g.World.LoadRoom(c.Occupier)
+ occName := fmt.Sprintf("#%d", c.Occupier)
+ if occRoom != nil {
+ occName = fmt.Sprintf("#%d (%s)", c.Occupier, occRoom.Name)
+ }
+ sess.WriteLine(fmt.Sprintf(
+ "Cannot remove: pulling the far end would cause grid collision with %s.",
+ occName))
+ }
+ }
+ return
+ }
+
+ // Apply: rewire A's dir exit to C, preserving A's current dir-exit props.
+ aRoom.Exits[dir] = world.ExitDef{
+ Room: cID,
+ Condition: aExit.Condition,
+ BlockedMessage: aExit.BlockedMessage,
+ SetFlags: aExit.SetFlags,
+ SetPlayerFlags: aExit.SetPlayerFlags,
+ Hidden: aExit.Hidden,
+ AlwaysBlocked: aExit.AlwaysBlocked,
+ }
+ aData, err := yaml.Marshal(aRoom)
+ if err != nil {
+ sess.WriteLine("Error updating current room YAML.")
+ return
+ }
+ if err := os.WriteFile(aPath, aData, 0644); err != nil {
+ sess.WriteLine("Error writing current room file.")
+ return
+ }
+
+ // Rewire C's opposite exit to A, preserving C's current opposite-exit props.
+ cRoom.Exits[oppositeDir] = world.ExitDef{
+ Room: aID,
+ Condition: cOppExit.Condition,
+ BlockedMessage: cOppExit.BlockedMessage,
+ SetFlags: cOppExit.SetFlags,
+ SetPlayerFlags: cOppExit.SetPlayerFlags,
+ Hidden: cOppExit.Hidden,
+ AlwaysBlocked: cOppExit.AlwaysBlocked,
+ }
+ cPath, cPathOK := g.World.GetRoomPath(cID)
+ if cPathOK {
+ cData, cerr := yaml.Marshal(cRoom)
+ if cerr == nil {
+ os.WriteFile(cPath, cData, 0644)
+ }
+ }
+
+ // Delete B's file.
+ bPath, bPathOK := g.World.GetRoomPath(bID)
+ if !bPathOK {
+ sess.WriteLine(fmt.Sprintf("Room file for %d not found.", bID))
+ return
+ }
+ if rerr := os.Remove(bPath); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error deleting room file: %v", rerr))
+ return
+ }
+
+ g.World.RebuildRoomIndex(g.DataDir)
+ g.MobStore.RemoveMobsInRoom(bID)
+ g.World.ClearRoomState(bID)
+
+ // Relocate any other players standing in B to A (the current player is in
+ // A and unaffected). Mirrors performUndig's relocate tail.
+ for _, other := range g.Hub.AllSessions() {
+ if other == sess || other.Player == nil {
+ continue
+ }
+ if other.Player.RoomID == bID {
+ other.Player.RoomID = aID
+ if g.Hub != nil {
+ g.Hub.EnterRoom(other, aID)
+ }
+ other.WriteLine("The room around you collapses. You find yourself elsewhere.")
+ g.doLook(other)
+ }
+ }
+
+ cName := fmt.Sprintf("#%d (%s)", cID, cRoom.Name)
+ sess.WriteLine(fmt.Sprintf("You remove %s and pull %s back to the %s.", bName, cName, dir))
+ g.doLook(sess)
+}
+
+// countExtraInbound returns the number of exits in rooms OTHER than A and C
+// that point into B, plus any A/C exits to B other than the expected
+// (A→dir→B, C→oppositeDir→B). A nonzero result means B has been modified since
+// it was inserted and the remove would silently orphan edges. It walks
+// data/rooms/ and matches \b<id>\b against raw file contents to skip rooms
+// that don't mention bID at all, then unmarshals the matching files.
+func (g *Game) countExtraInbound(bID, aID, cID int, dir, oppositeDir world.ExitDir) int {
+ roomsDir := filepath.Join(g.DataDir, "rooms")
+ re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, bID))
+ var extra int
+ _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
+ return nil
+ }
+ data, rerr := os.ReadFile(path)
+ if rerr != nil {
+ return nil
+ }
+ if !re.MatchString(string(data)) {
+ return nil
+ }
+ idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
+ rid, serr := strconv.Atoi(idStr)
+ if serr != nil || rid == bID {
+ return nil
+ }
+ var room world.Room
+ if yerr := yaml.Unmarshal(data, &room); yerr != nil {
+ return nil
+ }
+ if room.Exits == nil {
+ return nil
+ }
+ for ed, exitDef := range room.Exits {
+ if exitDef.Room != bID {
+ continue
+ }
+ // Allowed: A→dir→B and C→oppositeDir→B.
+ if rid == aID && ed == dir {
+ continue
+ }
+ if rid == cID && ed == oppositeDir {
+ continue
+ }
+ extra++
+ }
+ return nil
+ })
+ return extra
+}
diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go
new file mode 100644
index 0000000..1868c30
--- /dev/null
+++ b/internal/game/cmd_room_remove_test.go
@@ -0,0 +1,331 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+// removeTestConn is a minimal Conn that records writes for assertions.
+type removeTestConn struct{ buf []byte }
+
+func (c *removeTestConn) ReadMessage() (string, error) { return "", nil }
+func (c *removeTestConn) Write(b []byte) (int, error) { c.buf = append(c.buf, b...); return len(b), nil }
+func (c *removeTestConn) Close() error { return nil }
+func (c *removeTestConn) SetEcho(bool) error { return nil }
+
+func (c *removeTestConn) output() string { return string(c.buf) }
+
+// newRemoveGame builds a Game with the data stores roomRemove touches, a Hub
+// (so relocate logic is exercised), and no telephony. Rooms are written into
+// dir by the caller.
+func newRemoveGame(t *testing.T, dir string) *Game {
+ t.Helper()
+ g := &Game{
+ Deps: Deps{
+ World: world.New(dir),
+ MobStore: world.NewMobStore(dir),
+ AccountStore: player.NewAccountStore(dir),
+ DataDir: dir,
+ },
+ Hub: net.NewHub(),
+ Combat: combat.NewTracker(),
+ safespot: NewSafespotManager(),
+ }
+ return g
+}
+
+func newRemoveSession(p *player.Player) *net.Session {
+ return &net.Session{Conn: &removeTestConn{}, Player: p, State: net.StateGame}
+}
+
+func writeRemoveRoomFile(t *testing.T, dir string, id int, body string) {
+ t.Helper()
+ roomsDir := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(roomsDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(roomsDir, strconv.Itoa(id)+".yaml"), []byte(body), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func assertOutContains(t *testing.T, sess *net.Session, sub string) {
+ t.Helper()
+ out := sess.Conn.(*removeTestConn).output()
+ if !strings.Contains(out, sub) {
+ t.Errorf("expected session output to contain %q, got:\n%s", sub, out)
+ }
+}
+
+func assertOutNotContains(t *testing.T, sess *net.Session, sub string) {
+ t.Helper()
+ out := sess.Conn.(*removeTestConn).output()
+ if strings.Contains(out, sub) {
+ t.Errorf("expected session output to NOT contain %q, got:\n%s", sub, out)
+ }
+}
+
+// reloadAfterCmd re-reads a room from disk after a command has mutated it.
+func reloadRoom(t *testing.T, g *Game, id int) *world.Room {
+ t.Helper()
+ r, err := g.World.LoadRoom(id)
+ if err != nil {
+ t.Fatalf("reload room %d: %v", id, err)
+ }
+ return r
+}
+
+func TestRoomRemoveSuccessPull(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+
+ assertOutContains(t, sess, "You remove #2 (B) and pull #3 (C) back to the east.")
+
+ // A's east exit now leads to C (3), not B (2).
+ a := reloadRoom(t, g, 1)
+ if a.Exits[world.East].Room != 3 {
+ t.Errorf("A.Exits[east] = %d, want 3", a.Exits[world.East].Room)
+ }
+ // C's west exit now leads to A (1), not B (2).
+ c := reloadRoom(t, g, 3)
+ if c.Exits[world.West].Room != 1 {
+ t.Errorf("C.Exits[west] = %d, want 1", c.Exits[world.West].Room)
+ }
+ // B's file is gone.
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); !os.IsNotExist(err) {
+ t.Errorf("expected 2.yaml to be deleted, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveDiagonalPull(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n northeast: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n northeast: 3\n southwest: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n southwest: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"northeast"})
+
+ assertOutContains(t, sess, "You remove #2 (B) and pull #3 (C) back to the northeast.")
+ a := reloadRoom(t, g, 1)
+ if a.Exits[world.Northeast].Room != 3 {
+ t.Errorf("A.Exits[ne] = %d, want 3", a.Exits[world.Northeast].Room)
+ }
+ c := reloadRoom(t, g, 3)
+ if c.Exits[world.Southwest].Room != 1 {
+ t.Errorf("C.Exits[sw] = %d, want 1", c.Exits[world.Southwest].Room)
+ }
+}
+
+func TestRoomRemoveNoExit(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "There is no exit to the east from here.")
+}
+
+func TestRoomRemoveNotInsertChain(t *testing.T) {
+ dir := t.TempDir()
+ // B does not lead back to A via west.
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "does not lead back here via west")
+ // Nothing was deleted.
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveDeadEndFarRoom(t *testing.T) {
+ dir := t.TempDir()
+ // B leads back to A but has no forward east exit.
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n west: 1\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "no room beyond to pull")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveOtherExitsInB(t *testing.T) {
+ dir := t.TempDir()
+ // B has east (fwd), west (back), AND up (extra).
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n up: 4\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n down: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "has other exits besides west and east")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveFarReciprocityBroken(t *testing.T) {
+ dir := t.TempDir()
+ // C's west does not point back to B.
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "does not lead back to #2")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveExtraInboundEdge(t *testing.T) {
+ dir := t.TempDir()
+ // A clean chain A-east->B-east->C, but an unrelated room 5 also points to B.
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n north: 2\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "other exit(s) point into #2")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+// TestRoomRemoveOverlap mirrors TestRemoveGridConflictsOverlap: removing B
+// pulls room 6 onto room 5's cell, which must be rejected.
+func TestRoomRemoveOverlap(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n south: 4\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n south: 6\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n east: 5\n north: 1\n")
+ writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n west: 4\n")
+ writeRemoveRoomFile(t, dir, 6, "name: F\nexits:\n north: 3\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+ assertOutContains(t, sess, "pulling the far end would cause grid collision")
+ if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil {
+ t.Errorf("2.yaml should still exist, got err=%v", err)
+ }
+}
+
+func TestRoomRemoveInvalidDirection(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"sideways"})
+ assertOutContains(t, sess, "Invalid direction")
+}
+
+func TestRoomRemoveUsage(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n")
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, nil)
+ assertOutContains(t, sess, "Usage: room remove <direction>")
+}
+
+// TestRoomRemovePreservesExitProps confirms the near-side exit's flags travel
+// onto the repointed A→dir→C edge (and the far side's onto C→oppositeDir→A).
+func TestRoomRemovePreservesExitProps(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, `name: A
+exits:
+ east:
+ room: 2
+ blocked_message: "A rock blocks the way."
+ hidden: true
+`)
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, `name: C
+exits:
+ west:
+ room: 2
+ blocked_message: "The far side is sealed."
+`)
+ g := newRemoveGame(t, dir)
+ sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
+
+ g.roomRemove(sess, []string{"east"})
+
+ a := reloadRoom(t, g, 1)
+ if a.Exits[world.East].Room != 3 {
+ t.Errorf("A.Exits[east].Room = %d, want 3", a.Exits[world.East].Room)
+ }
+ if a.Exits[world.East].BlockedMessage != "A rock blocks the way." {
+ t.Errorf("A.Exits[east].BlockedMessage = %q, want preserved", a.Exits[world.East].BlockedMessage)
+ }
+ if !a.Exits[world.East].Hidden {
+ t.Error("A.Exits[east].Hidden should be preserved true")
+ }
+ c := reloadRoom(t, g, 3)
+ if c.Exits[world.West].Room != 1 {
+ t.Errorf("C.Exits[west].Room = %d, want 1", c.Exits[world.West].Room)
+ }
+ if c.Exits[world.West].BlockedMessage != "The far side is sealed." {
+ t.Errorf("C.Exits[west].BlockedMessage = %q, want preserved", c.Exits[world.West].BlockedMessage)
+ }
+}
+
+// TestRoomRemoveRelocatesPlayersInB verifies another session standing in B is
+// moved to A and notified. The caller stays in A.
+func TestRoomRemoveRelocatesPlayersInB(t *testing.T) {
+ dir := t.TempDir()
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ g := newRemoveGame(t, dir)
+
+ caller := newRemoveSession(&player.Player{Name: "builder", RoomID: 1})
+ bystander := newRemoveSession(&player.Player{Name: "wanderer", RoomID: 2})
+ g.Hub.Add(caller)
+ g.Hub.Add(bystander)
+ g.Hub.EnterRoom(caller, 1)
+ g.Hub.EnterRoom(bystander, 2)
+
+ g.roomRemove(caller, []string{"east"})
+
+ if bystander.Player.RoomID != 1 {
+ t.Errorf("bystander RoomID = %d, want 1 (relocated to A)", bystander.Player.RoomID)
+ }
+ assertOutContains(t, bystander, "The room around you collapses")
+}
diff --git a/internal/world/grid.go b/internal/world/grid.go
index 2ea897a..7be8eed 100644
--- a/internal/world/grid.go
+++ b/internal/world/grid.go
@@ -87,12 +87,28 @@ func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, e
continue
}
- g.Coord[target] = want
- g.RoomAt[want] = target
- g.Dist[target] = g.Dist[rid] + 1
- queue = append(queue, target)
- }
+ g.Coord[target] = want
+ g.RoomAt[want] = target
+ g.Dist[target] = g.Dist[rid] + 1
+ queue = append(queue, target)
+ }
}
return g
}
+
+// BuildGridConflicts lays out the rooms reachable from seed exactly like
+// BuildGrid but returns every twist/overlap conflict encountered instead of
+// silently skipping them. Callers pass a load closure that may return *edited
+// copies* of rooms to model a hypothetical edit (e.g. an insert or a remove)
+// without writing anything to disk; a non-empty result means the modeled world
+// cannot be embedded on the grid. include and edgeInclude default to "place
+// and follow everything" (geometry is independent of gating), matching the
+// startup validator's validateRoomGrid.
+func BuildGridConflicts(seed int, load func(int) (*Room, bool)) []GridConflict {
+ var conflicts []GridConflict
+ BuildGrid(seed, load, nil, nil, func(gc GridConflict) {
+ conflicts = append(conflicts, gc)
+ })
+ return conflicts
+}
diff --git a/internal/world/grid_test.go b/internal/world/grid_test.go
new file mode 100644
index 0000000..04d749c
--- /dev/null
+++ b/internal/world/grid_test.go
@@ -0,0 +1,181 @@
+package world
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+)
+
+func writeGridTestRoom(t *testing.T, dir string, id int, body string) {
+ t.Helper()
+ rooms := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(rooms, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(rooms, strconv.Itoa(id)+".yaml"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func loadGridRoom(w *World) func(int) (*Room, bool) {
+ return func(id int) (*Room, bool) {
+ r, err := w.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return r, true
+ }
+}
+
+func hasConflictKind(conflicts []GridConflict, kind string) bool {
+ for _, c := range conflicts {
+ if c.Kind == kind {
+ return true
+ }
+ }
+ return false
+}
+
+func TestBuildGridConflictsClean(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n south: 3\n east: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n south: 4\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n east: 4\n")
+ writeGridTestRoom(t, dir, 4, "name: corner\n")
+
+ w := New(dir)
+ if conflicts := BuildGridConflicts(1, loadGridRoom(w)); len(conflicts) != 0 {
+ t.Errorf("expected no conflicts, got: %+v", conflicts)
+ }
+}
+
+func TestBuildGridConflictsOverlap(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n south: 3\n east: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n south: 5\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n east: 4\n")
+ writeGridTestRoom(t, dir, 4, "name: four\n")
+ writeGridTestRoom(t, dir, 5, "name: five\n")
+
+ w := New(dir)
+ conflicts := BuildGridConflicts(1, loadGridRoom(w))
+ if !hasConflictKind(conflicts, "overlap") {
+ t.Errorf("expected an overlap conflict, got: %+v", conflicts)
+ }
+}
+
+func TestBuildGridConflictsTwist(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n south: 4\n east: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: 3\n")
+ writeGridTestRoom(t, dir, 3, "name: three\n")
+
+ w := New(dir)
+ conflicts := BuildGridConflicts(1, loadGridRoom(w))
+ if !hasConflictKind(conflicts, "twist") {
+ t.Errorf("expected a twist conflict, got: %+v", conflicts)
+ }
+}
+
+func TestBuildGridConflictsDiagonalClean(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n northeast: 2\n")
+ writeGridTestRoom(t, dir, 2, "name: two\n exits:\n southwest: 1\n")
+
+ w := New(dir)
+ if conflicts := BuildGridConflicts(1, loadGridRoom(w)); len(conflicts) != 0 {
+ t.Errorf("expected no conflicts for clean diagonal, got: %+v", conflicts)
+ }
+}
+
+// TestBuildGridConflictsHypotheticalEdit models the insert-remove use case:
+// the load closure returns edited copies of rooms reflecting a proposed edit,
+// and BuildGridConflicts reports whether the resulting world is still clean.
+// Here we model "insert a new room 5 between 1 and 2 on the east axis" and
+// confirm the (clean) post-edit world has no conflicts.
+func TestBuildGridConflictsHypotheticalInsert(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "name: one\nexits:\n east: 2\n")
+ writeGridTestRoom(t, dir, 2, "name: two\nexits:\n west: 1\n")
+ w := New(dir)
+
+ newRoom := &Room{Name: "new", Exits: map[ExitDir]ExitDef{
+ East: {Room: 2}, West: {Room: 1},
+ }}
+ load := func(id int) (*Room, bool) {
+ if id == 5 {
+ return newRoom, true
+ }
+ r, err := w.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ copy := *r
+ if r.Exits != nil {
+ copy.Exits = make(map[ExitDir]ExitDef, len(r.Exits))
+ for k, v := range r.Exits {
+ if id == 1 && k == East {
+ copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, SetFlags: v.SetFlags, SetPlayerFlags: v.SetPlayerFlags, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
+ continue
+ }
+ if id == 2 && k == West {
+ copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, SetFlags: v.SetFlags, SetPlayerFlags: v.SetPlayerFlags, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
+ continue
+ }
+ copy.Exits[k] = v
+ }
+ }
+ return &copy, true
+ }
+ if conflicts := BuildGridConflicts(1, load); len(conflicts) != 0 {
+ t.Errorf("clean insert should produce no conflicts, got: %+v", conflicts)
+ }
+}
+
+// TestBuildGridConflictsHypotheticalRemoveOverlap models "remove room 2 and
+// pull the beyond-rooms back toward room 1". Layout:
+//
+// 1 east→2 east→3 south→6 (6 at (2,1,0))
+// 1 south→4 east→5 (5 at (1,1,0))
+//
+// After removing 2 and rewiring 1.Exits[East]=3, 3.Exits[West]=1, the BFS
+// re-places 3 at (1,0,0) and 6 at (1,1,0) — colliding with 5 which stays at
+// (1,1,0). The helper must report an overlap without anything being written.
+func TestBuildGridConflictsHypotheticalRemoveOverlap(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n south: 4\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n south: 6\n west: 2\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: 5\n north: 1\n")
+ writeGridTestRoom(t, dir, 5, "exits:\n west: 4\n")
+ writeGridTestRoom(t, dir, 6, "exits:\n north: 3\n")
+ w := New(dir)
+
+ load := func(id int) (*Room, bool) {
+ if id == 2 {
+ return nil, false // room removed
+ }
+ r, err := w.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ rc := *r
+ rc.Exits = make(map[ExitDir]ExitDef, len(r.Exits))
+ for k, v := range r.Exits {
+ rc.Exits[k] = v
+ }
+ if id == 1 {
+ rc.Exits[East] = ExitDef{Room: 3}
+ }
+ if id == 3 {
+ rc.Exits[West] = ExitDef{Room: 1}
+ }
+ return &rc, true
+ }
+ conflicts := BuildGridConflicts(1, load)
+ if !hasConflictKind(conflicts, "overlap") {
+ t.Errorf("expected an overlap from pulling 6 onto 5's cell, got: %+v", conflicts)
+ }
+}
diff --git a/internal/world/insert_remove.go b/internal/world/insert_remove.go
new file mode 100644
index 0000000..0811587
--- /dev/null
+++ b/internal/world/insert_remove.go
@@ -0,0 +1,152 @@
+package world
+
+// copyExits returns a shallow copy of an exit map so a hypothetical room copy
+// can be mutated without touching the original. A fresh map is returned even
+// when src is nil so callers can safely assign into it.
+func copyExits(src map[ExitDir]ExitDef) map[ExitDir]ExitDef {
+ dst := make(map[ExitDir]ExitDef, len(src))
+ for k, v := range src {
+ dst[k] = v
+ }
+ return dst
+}
+
+// RewirePreserving returns an ExitDef pointing at newTarget that keeps every
+// non-Room field of src (conditions, blocked messages, flags, hidden/always-
+// blocked). It is the shared "repoint an exit without losing its properties"
+// operation used by insert and remove (both in-game and admin).
+func RewirePreserving(src ExitDef, newTarget int) ExitDef {
+ return ExitDef{
+ Room: newTarget,
+ Condition: src.Condition,
+ BlockedMessage: src.BlockedMessage,
+ SetFlags: src.SetFlags,
+ SetPlayerFlags: src.SetPlayerFlags,
+ Hidden: src.Hidden,
+ AlwaysBlocked: src.AlwaysBlocked,
+ }
+}
+
+// InsertGridConflicts returns the grid conflicts that would result from
+// inserting a new room (identified by newID, which may be a sentinel such as
+// -1 since no file is written) between roomID and the room its dir exit
+// currently leads to (targetID). The new room inherits roomID's outward dir
+// exit properties on the near side and, when the far room's opposite exit
+// points back at roomID, the far room's reciprocal exit is repointed at the
+// new room preserving its own properties — exactly mirroring the writes
+// roomInsert performs. load is the real room loader (g.World.LoadRoom or
+// s.world.LoadRoom). A non-empty result means the insert would make the map
+// unembeddable (overlap or twist) and must be rejected.
+func InsertGridConflicts(roomID int, dir ExitDir, targetID, newID int, load func(int) (*Room, bool)) []GridConflict {
+ oppositeDir := OppositeExit[dir]
+
+ srcRoom, _ := load(roomID)
+ var srcDirExit ExitDef
+ if srcRoom != nil {
+ srcDirExit = srcRoom.Exits[dir]
+ }
+
+ targetRoom, targetOK := load(targetID)
+ hasReciprocal := targetOK && targetRoom.Exits[oppositeDir].Room == roomID
+ var targetOppExit ExitDef
+ if hasReciprocal {
+ targetOppExit = targetRoom.Exits[oppositeDir]
+ }
+
+ newRoom := &Room{Exits: map[ExitDir]ExitDef{
+ dir: {Room: targetID},
+ oppositeDir: {Room: roomID},
+ }}
+
+ return BuildGridConflicts(roomID, func(id int) (*Room, bool) {
+ if id == newID {
+ return newRoom, true
+ }
+ r, ok := load(id)
+ if !ok {
+ return nil, false
+ }
+ if id == roomID {
+ rc := *r
+ rc.Exits = copyExits(r.Exits)
+ rc.Exits[dir] = RewirePreserving(srcDirExit, newID)
+ return &rc, true
+ }
+ if id == targetID && hasReciprocal {
+ rc := *r
+ rc.Exits = copyExits(r.Exits)
+ rc.Exits[oppositeDir] = RewirePreserving(targetOppExit, newID)
+ return &rc, true
+ }
+ return r, true
+ })
+}
+
+// RemoveGridConflicts returns the grid conflicts that would result from
+// removing roomID's dir exit's target (the "inserted" room B) and pulling the
+// far room C (B's dir exit's target, when present) back to roomID — the exact
+// inverse of InsertGridConflicts. roomID's dir exit is repointed at C
+// preserving its current properties; C's opposite exit is repointed at roomID
+// preserving its current properties. When B has no forward exit (a dead end),
+// roomID's dir exit is simply removed (no pull). load is the real room loader.
+// A non-empty result means the pull would make the map unembeddable.
+//
+// The structural guards (B actually leads back to roomID, B has a forward
+// exit, B has no other exits, no extra inbound edges, C's opposite points back
+// at B) are the caller's responsibility; this helper only models the
+// geometric consequence of the rewiring.
+func RemoveGridConflicts(roomID int, dir ExitDir, load func(int) (*Room, bool)) []GridConflict {
+ oppositeDir := OppositeExit[dir]
+
+ srcRoom, ok := load(roomID)
+ if !ok {
+ return nil
+ }
+ bExit, has := srcRoom.Exits[dir]
+ if !has {
+ return nil
+ }
+ bID := bExit.Room
+ bRoom, ok := load(bID)
+ if !ok {
+ return nil
+ }
+ cExit, hasFar := bRoom.Exits[dir]
+ var cID int
+ if hasFar {
+ cID = cExit.Room
+ }
+ var cOppExit ExitDef
+ if hasFar {
+ if cRoom, ok := load(cID); ok {
+ cOppExit = cRoom.Exits[oppositeDir]
+ }
+ }
+
+ return BuildGridConflicts(roomID, func(id int) (*Room, bool) {
+ if id == bID {
+ return nil, false
+ }
+ r, ok := load(id)
+ if !ok {
+ return nil, false
+ }
+ if id == roomID {
+ rc := *r
+ rc.Exits = copyExits(r.Exits)
+ if hasFar {
+ rc.Exits[dir] = RewirePreserving(bExit, cID)
+ } else {
+ delete(rc.Exits, dir)
+ }
+ return &rc, true
+ }
+ if hasFar && id == cID {
+ rc := *r
+ rc.Exits = copyExits(r.Exits)
+ rc.Exits[oppositeDir] = RewirePreserving(cOppExit, roomID)
+ return &rc, true
+ }
+ return r, true
+ })
+}
diff --git a/internal/world/insert_remove_test.go b/internal/world/insert_remove_test.go
new file mode 100644
index 0000000..3be3222
--- /dev/null
+++ b/internal/world/insert_remove_test.go
@@ -0,0 +1,186 @@
+package world
+
+import "testing"
+
+// insertSentinel is the hypothetical new-room ID passed to InsertGridConflicts
+// during tests. It must be a positive value not used by any real room in the
+// fixture (BuildGrid skips exits whose target <= 0).
+const insertSentinel = 9999
+
+func TestInsertGridConflictsCleanNSEW(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n west: 1\n")
+ w := New(dir)
+ if c := InsertGridConflicts(1, East, 2, insertSentinel, loadGridRoom(w)); len(c) != 0 {
+ t.Errorf("clean east insert: expected no conflicts, got %+v", c)
+ }
+}
+
+func TestInsertGridConflictsCleanDiagonal(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n northeast: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n southwest: 1\n")
+ w := New(dir)
+ if c := InsertGridConflicts(1, Northeast, 2, insertSentinel, loadGridRoom(w)); len(c) != 0 {
+ t.Errorf("clean NE insert: expected no conflicts, got %+v", c)
+ }
+}
+
+func TestInsertGridConflictsCleanUpDown(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n up: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n down: 1\n")
+ w := New(dir)
+ if c := InsertGridConflicts(1, Up, 2, insertSentinel, loadGridRoom(w)); len(c) != 0 {
+ t.Errorf("clean up insert: expected no conflicts, got %+v", c)
+ }
+}
+
+// TestInsertGridConflictsOverlap: inserting on 1→east→2 would push room 3
+// (beyond 2 to the east) from (2,0,0) to (3,0,0); a separate room 4 already at
+// (3,0,0) via 1→north→5→east→4 collides with the pushed 3.
+//
+// 1 east→2 east→3 (3 at (2,0,0), pushed to (3,0,0))
+// 1 north→5 east→4 (4 at (1,-1,0)? no: 5 at (0,-1,0), 4 at (1,-1,0))
+//
+// To land 4 at (3,0,0) we use 1→east is the insert axis, so place the blocker
+// via a longer chain that resolves to (3,0,0): 1→north→5→east→6→east→7 lands
+// 7 at (2,-1,0), not (3,0,0). Instead use a clean ring: 1→east→2 is the axis,
+// and put 4 at (2,0,0) (3's current spot) — no, 3 is there.
+//
+// Simplest reliable overlap: 1→east→2→east→3 and 1→south→6→east→7→north→3 is
+// a twist (3 reachable two ways). For an *overlap* on push, the pushed 3 must
+// collide with a room NOT in the beyond-set. Place room 8 at (3,0,0) via a
+// path that does not pass through 2: 1→north→5→north→8? that is (0,-2,0).
+//
+// Correct geometry for an overlap: 1 east→2 east→3 (3 at (2,0,0)). Insert on
+// 1→east pushes 2→(2,0,0)? No — insert pushes the *beyond* set (rooms beyond
+// the inserted room), i.e. 2 and everything reachable from 2 without going
+// back through 1. Inserting between 1 and 2 pushes 2 (and 3) east by one:
+// 2→(2,0,0), 3→(3,0,0). So we need a room already at (3,0,0) reachable from 1
+// without using 1→east. 1→south→4→east→5→east→6 lands 6 at (2,1,0). Not it.
+// 1→south→4→east→5→north→6 lands 6 at (1,0,0)? 4 at (0,1,0), 5 at (1,1,0),
+// 6 at (1,0,0) — that's where 2 currently is → overlap with 2 after push? 2
+// pushes to (2,0,0), 6 stays at (1,0,0): no collision. We need (3,0,0).
+// 1→south→4→east→5→north→6→east→7: 4(0,1,0) 5(1,1,0) 6(1,0,0) 7(2,0,0) =
+// collides with 3's *current* (2,0,0) → that's a pre-existing overlap (invalid
+// world), not what we want.
+//
+// Use a longer chain to (3,0,0): 1→south→4(0,1,0)→east→5(1,1,0)→east→6(2,1,0)
+// →north→7(2,0,0) collides with 3. So reach (3,0,0): 1→s→4→e→5→e→6→e→7(3,1,0)
+// →n→8(3,0,0). Then pushing 3 to (3,0,0) collides with 8. 8 is not in the
+// beyond-set (reachable from 1 via south, not via east/2), so it's an overlap.
+func TestInsertGridConflictsOverlap(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n south: 4\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n west: 2\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: 5\n north: 1\n")
+ writeGridTestRoom(t, dir, 5, "exits:\n east: 6\n west: 4\n")
+ writeGridTestRoom(t, dir, 6, "exits:\n east: 7\n west: 5\n")
+ writeGridTestRoom(t, dir, 7, "exits:\n north: 8\n west: 6\n")
+ writeGridTestRoom(t, dir, 8, "exits:\n south: 7\n")
+ w := New(dir)
+
+ // Sanity: the pre-edit world must be clean.
+ if c := BuildGridConflicts(1, loadGridRoom(w)); len(c) != 0 {
+ t.Fatalf("precondition: world should be clean, got %+v", c)
+ }
+ c := InsertGridConflicts(1, East, 2, insertSentinel, loadGridRoom(w))
+ if !hasConflictKind(c, "overlap") {
+ t.Errorf("expected overlap from pushing 3 onto 8's cell, got %+v", c)
+ }
+}
+
+// TestInsertGridConflictsTwist: room 3 beyond 2 is also reachable from 1 via a
+// separate path, so after the push 3 would have two candidate positions.
+// 1 east→2 east→3 (3 at (2,0,0), pushed to (3,0,0))
+// 1 south→4 east→5 north→3 (3 at (1,0,0))
+// 3 is reachable two ways already → the pre-edit world is itself a twist. The
+// insert helper must surface a twist conflict.
+func TestInsertGridConflictsTwist(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n south: 4\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n west: 2\n south: 5\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: 5\n north: 1\n")
+ writeGridTestRoom(t, dir, 5, "exits:\n north: 3\n west: 4\n")
+ w := New(dir)
+
+ c := InsertGridConflicts(1, East, 2, insertSentinel, loadGridRoom(w))
+ if !hasConflictKind(c, "twist") {
+ t.Errorf("expected a twist conflict, got %+v", c)
+ }
+}
+
+// TestInsertGridConflictsOneWayFarReciprocal: when the far room's opposite
+// exit does NOT point back at the source, insert performs a one-way insertion
+// (only the near side is rewired). The helper must model that and still allow a
+// geometrically clean insert.
+func TestInsertGridConflictsOneWayFarReciprocal(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n") // no west exit back to 1
+ w := New(dir)
+ if c := InsertGridConflicts(1, East, 2, insertSentinel, loadGridRoom(w)); len(c) != 0 {
+ t.Errorf("one-way insert should be clean, got %+v", c)
+ }
+}
+
+func TestRemoveGridConflictsCleanPull(t *testing.T) {
+ dir := t.TempDir()
+ // 1 east→2 east→3, with reciprocals. Removing 2 pulls 3 to (1,0,0). Clean.
+ writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n west: 2\n")
+ w := New(dir)
+ if c := RemoveGridConflicts(1, East, loadGridRoom(w)); len(c) != 0 {
+ t.Errorf("clean pull should produce no conflicts, got %+v", c)
+ }
+}
+
+func TestRemoveGridConflictsDiagonalPull(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n northeast: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n northeast: 3\n southwest: 1\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n southwest: 2\n")
+ w := New(dir)
+ if c := RemoveGridConflicts(1, Northeast, loadGridRoom(w)); len(c) != 0 {
+ t.Errorf("clean diagonal pull should produce no conflicts, got %+v", c)
+ }
+}
+
+// TestRemoveGridConflictsOverlap mirrors TestBuildGridConflictsHypotheticalRemoveOverlap:
+// removing 2 pulls 6 onto 5's cell.
+func TestRemoveGridConflictsOverlap(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n south: 4\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n south: 6\n west: 2\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: 5\n north: 1\n")
+ writeGridTestRoom(t, dir, 5, "exits:\n west: 4\n")
+ writeGridTestRoom(t, dir, 6, "exits:\n north: 3\n")
+ w := New(dir)
+
+ if c := BuildGridConflicts(1, loadGridRoom(w)); len(c) != 0 {
+ t.Fatalf("precondition: world should be clean, got %+v", c)
+ }
+ c := RemoveGridConflicts(1, East, loadGridRoom(w))
+ if !hasConflictKind(c, "overlap") {
+ t.Errorf("expected overlap from pulling 6 onto 5's cell, got %+v", c)
+ }
+}
+
+// TestRemoveGridConflictsDeadEnd: when the room being removed has no forward
+// exit, the helper models a plain deletion (A's dir exit removed). In a clean
+// world this cannot introduce a conflict.
+func TestRemoveGridConflictsDeadEnd(t *testing.T) {
+ dir := t.TempDir()
+ writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n west: 1\n")
+ w := New(dir)
+ if c := RemoveGridConflicts(1, East, loadGridRoom(w)); len(c) != 0 {
+ t.Errorf("dead-end removal should produce no conflicts, got %+v", c)
+ }
+}