diff options
| -rw-r--r-- | building_guide/admin.md | 37 | ||||
| -rw-r--r-- | data/.admin_history.json | 2 | ||||
| -rw-r--r-- | data/help/room.yaml | 1 | ||||
| -rw-r--r-- | data/rooms/intro/1005.yaml | 12 | ||||
| -rw-r--r-- | data/rooms/intro/1006.yaml | 12 | ||||
| -rw-r--r-- | data/rooms/intro/2001.yaml | 27 | ||||
| -rw-r--r-- | internal/admin/api_map.go | 2 | ||||
| -rw-r--r-- | internal/admin/api_room_insert_remove.go | 466 | ||||
| -rw-r--r-- | internal/admin/api_room_insert_remove_test.go | 535 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 2 | ||||
| -rw-r--r-- | internal/admin/id_alloc.go | 43 | ||||
| -rw-r--r-- | internal/admin/server.go | 9 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 12 | ||||
| -rw-r--r-- | internal/admin/static/editor.js | 18 | ||||
| -rw-r--r-- | internal/admin/static/map.js | 241 | ||||
| -rw-r--r-- | internal/game/cmd_room.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_room_insert.go | 91 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove.go | 292 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove_test.go | 331 | ||||
| -rw-r--r-- | internal/world/grid.go | 26 | ||||
| -rw-r--r-- | internal/world/grid_test.go | 181 | ||||
| -rw-r--r-- | internal/world/insert_remove.go | 152 | ||||
| -rw-r--r-- | internal/world/insert_remove_test.go | 186 |
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 ©, 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) + } +} |
