aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/.admin_history.json2
-rw-r--r--data/courses/reactor.yaml7
-rw-r--r--data/courses/rooftop.yaml6
-rw-r--r--data/courses/test2.yaml60
-rw-r--r--data/courses/vent_shaft.yaml5
-rw-r--r--data/items/000_test_item.yaml2
-rw-r--r--data/objects/000_test_object.yaml2
-rw-r--r--data/rooms/example_unused/agility2/99999200.yaml10
-rw-r--r--data/rooms/example_unused/agility2/99999201.yaml30
-rw-r--r--data/rooms/example_unused/agility2/99999203.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999204.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999205.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999206.yaml12
-rw-r--r--data/rooms/example_unused/agility2/99999210.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999211.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999212.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999213.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999214.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999215.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999220.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999221.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999222.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999223.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999224.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999225.yaml32
-rw-r--r--data/rooms/example_unused/agility2/99999226.yaml32
-rw-r--r--data/rooms/example_unused/town/9999931.yaml18
-rw-r--r--data/rooms/example_unused/underground2/9999950.yaml4
-rw-r--r--data/rooms/example_unused/underground2/9999951.yaml4
-rw-r--r--data/rooms/example_unused/underground2/9999965.yaml17
-rw-r--r--data/rooms/example_unused/underground2/9999966.yaml17
-rw-r--r--data/rooms/example_unused/underground2/9999967.yaml17
-rw-r--r--data/rooms/example_unused/underground2/9999968.yaml16
-rw-r--r--data/rooms/example_unused/wilderness/99999152.yaml16
-rw-r--r--data/rooms/example_unused/wilderness/99999160.yaml18
-rw-r--r--data/rooms/example_unused/wilderness/99999161.yaml25
-rw-r--r--data/rooms/example_unused/wilderness/99999162.yaml17
-rw-r--r--data/rooms/intro/1005.yaml16
-rw-r--r--data/rooms/intro/1006.yaml16
-rw-r--r--data/rooms/intro/1007.yaml20
-rw-r--r--data/rooms/intro/1008.yaml20
-rw-r--r--data/rooms/intro/1009.yaml12
-rw-r--r--data/rooms/intro/2001.yaml21
-rw-r--r--data/rooms/intro/2004.yaml2
-rw-r--r--data/rooms/test222/1.yaml13
-rw-r--r--internal/admin/api_room_courses.go247
-rw-r--r--internal/admin/api_rooms.go78
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/map.js292
-rw-r--r--internal/behavior/types.go1
-rw-r--r--internal/game/act_agility.go104
-rw-r--r--internal/game/core_course.go75
-rw-r--r--internal/game/core_course_test.go4
-rw-r--r--internal/game/game.go1
54 files changed, 1311 insertions, 430 deletions
diff --git a/data/.admin_history.json b/data/.admin_history.json
index 06f5786..cedb6a1 100644
--- a/data/.admin_history.json
+++ b/data/.admin_history.json
@@ -1 +1 @@
-{"history":[{"time":"2026-07-07T17:03:36-04:00","description":"create room 1005","file_path":"data/rooms/intro/1005.yaml","old_content":"","new_content":"exits:\n south: 1006\nid: 1005\nname: 'Room #1005'\n","is_delete":false,"is_create":true,"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 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: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T17:03:36-04:00","description":"create room 1007","file_path":"data/rooms/intro/1007.yaml","old_content":"","new_content":"exits:\n west: 1005\nid: 1007\nname: 'Room #1007'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1005.yaml","old_content":"exits:\n south: 1006\nid: 1005\nname: 'Room #1005'\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1007\n south:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T17:03:37-04:00","description":"create room 1008","file_path":"data/rooms/intro/1008.yaml","old_content":"","new_content":"exits:\n north: 1007\nid: 1008\nname: 'Room #1008'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"exits:\n west: 1005\nid: 1007\nname: 'Room #1007'\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T17:03:38-04:00","description":"delete room 1008 (cleaned 1 exits)","file_path":"data/rooms/intro/1008.yaml","old_content":"exits:\n north: 1007\nid: 1008\nname: 'Room #1008'\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1008\n west:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n west:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T17:03:41-04:00","description":"insert room 1005 east 1007 (new 1008)","file_path":"data/rooms/intro/1008.yaml","old_content":"","new_content":"id: 0\nname: 'Room #1008'\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1007\n west:\n room: 1005\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":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1007\n south:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1008\n south:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"},{"file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n west:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T17:03:43-04:00","description":"delete room 1007 (cleaned 1 exits)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: 'Room #1007'\ncolor: \"\"\ndescription: []\nexits:\n west:\n room: 1008\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/1008.yaml","old_content":"id: 0\nname: 'Room #1008'\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1007\n west:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1008\nname: 'Room #1008'\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n west:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T17:03:43-04:00","description":"delete room 1008 (cleaned 1 exits)","file_path":"data/rooms/intro/1008.yaml","old_content":"id: 1008\nname: 'Room #1008'\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n west:\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: 1005\nname: 'Room #1005'\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1008\n south:\n room: 1006\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: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T17:03:44-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: 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-07T17:03:45-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: 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: 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 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
+{"history":[{"time":"2026-07-07T21:21:13-04:00","description":"Updated course test2","file_path":"data/courses/test2.yaml","old_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - fail_damage:\n - 0\n - 0\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","new_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","is_delete":false,"is_create":false},{"time":"2026-07-07T21:21:15-04:00","description":"Updated course test2","file_path":"data/courses/test2.yaml","old_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","new_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","is_delete":false,"is_create":false},{"time":"2026-07-07T21:21:19-04:00","description":"Updated course test2","file_path":"data/courses/test2.yaml","old_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","new_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","is_delete":false,"is_create":false},{"time":"2026-07-07T21:21:40-04:00","description":"create room 1010","file_path":"data/rooms/intro/1010.yaml","old_content":"","new_content":"exits: {}\nid: 1010\nname: 'Room #1010'\n","is_delete":false,"is_create":true,"extra_files":[{"file_path":"data/rooms/intro/1009.yaml","old_content":"exits: {}\nid: 1009\nname: 'Room #1009'\n","new_content":"id: 1009\nname: 'Room #1009'\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n hidden: true\n always_blocked: true\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]},{"time":"2026-07-07T21:21:40-04:00","description":"attach room 1010 to course test2","file_path":"data/courses/test2.yaml","old_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","new_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\n - fail_damage:\n - 0\n - 0\n room_id: 1010\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","is_delete":false,"is_create":false},{"time":"2026-07-07T21:21:46-04:00","description":"detach room 1010 from course test2","file_path":"data/courses/test2.yaml","old_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\n - fail_damage:\n - 0\n - 0\n room_id: 1010\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","new_content":"completion_xp: 50\nname: Test 222\nobstacles:\n - exit_dir: south\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 3\n message: Test\n - delay: 2\n message: Test\n room_id: 2004\n verb: climb\n xp: 0\n - exit_dir: northeast\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: You do it\n room_id: 1005\n verb: jump\n xp: 0\n - exit_dir: east\n fail_damage:\n - 0\n - 0\n phases:\n - delay: 1\n message: DONEZO\n room_id: 1007\n verb: climb\n xp: 0\n - exit_dir: northwest\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1006\n verb: climb\n xp: 0\n - exit_dir: north\n fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1008\n verb: climb\n xp: 0\n - fail_damage:\n - 0\n - 0\n phases: []\n room_id: 1009\n verb: climb\n xp: 0\nrequired_level: 1\nstart_room: 2004\n","is_delete":false,"is_create":false},{"time":"2026-07-07T21:21:48-04:00","description":"delete room 1010 (cleaned 1 exits)","file_path":"data/rooms/intro/1010.yaml","old_content":"exits: {}\nid: 1010\nname: 'Room #1010'\n","new_content":"","is_delete":true,"is_create":false,"extra_files":[{"file_path":"data/rooms/intro/1009.yaml","old_content":"id: 1009\nname: 'Room #1009'\ncolor: \"\"\ndescription: []\nexits:\n east:\n room: 1010\n hidden: true\n always_blocked: true\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1009\nname: 'Room #1009'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n"}]}],"redo":null} \ No newline at end of file
diff --git a/data/courses/reactor.yaml b/data/courses/reactor.yaml
index c9c78d3..91b2873 100644
--- a/data/courses/reactor.yaml
+++ b/data/courses/reactor.yaml
@@ -8,6 +8,7 @@ obstacles:
ticks_per_phase: 3
xp: 40
fail_damage: [3, 6]
+ exit_dir: north
messages:
- "You grip the reactor scaffolding and begin your ascent..."
- "The metal groans under your weight as you climb higher, heat radiating from below..."
@@ -17,6 +18,7 @@ obstacles:
ticks_per_phase: 3
xp: 45
fail_damage: [3, 7]
+ exit_dir: north
messages:
- "You seize the heavy coolant chain dangling above the reactor pit..."
- "You swing out over the glowing core, heat blasting your face..."
@@ -26,6 +28,7 @@ obstacles:
ticks_per_phase: 3
xp: 50
fail_damage: [4, 7]
+ exit_dir: north
messages:
- "You step onto the massive steam pipe spanning the reactor chamber..."
- "Steam jets hiss from valves on either side as you shuffle along the pipe..."
@@ -35,6 +38,7 @@ obstacles:
ticks_per_phase: 2
xp: 45
fail_damage: [3, 6]
+ exit_dir: north
messages:
- "A section of the reactor platform is missing, leaving a gaping void..."
- "You back up, sprint, and leap with everything you've got..."
@@ -44,6 +48,7 @@ obstacles:
ticks_per_phase: 3
xp: 40
fail_damage: [3, 5]
+ exit_dir: north
messages:
- "You squeeze into the narrow service conduit, radiation warnings plastered on every surface..."
- "You drag yourself through on your elbows, sparks showering from damaged wiring above..."
@@ -53,6 +58,7 @@ obstacles:
ticks_per_phase: 2
xp: 50
fail_damage: [4, 7]
+ exit_dir: north
messages:
- "A radiation containment barrier blocks the path, humming with energy..."
- "You time the pulse cycle, sprint at the barrier, and throw yourself over it..."
@@ -62,6 +68,7 @@ obstacles:
ticks_per_phase: 3
xp: 55
fail_damage: [4, 8]
+ exit_dir: north
messages:
- "The final obstacle: a massive chasm over the reactor coolant pool..."
- "You sprint along the narrow runway, the abyss yawning below..."
diff --git a/data/courses/rooftop.yaml b/data/courses/rooftop.yaml
index fcf1006..68ad23b 100644
--- a/data/courses/rooftop.yaml
+++ b/data/courses/rooftop.yaml
@@ -8,6 +8,7 @@ obstacles:
ticks_per_phase: 2
xp: 15
fail_damage: [2, 4]
+ exit_dir: north
messages:
- "You grab the rough permacrete wall and begin to climb..."
- "Your fingers find cracks and ledges as you pull yourself higher..."
@@ -17,6 +18,7 @@ obstacles:
ticks_per_phase: 2
xp: 22
fail_damage: [2, 4]
+ exit_dir: north
messages:
- "You eye the gap between this building and the next..."
- "You sprint toward the edge, boots pounding on the rooftop..."
@@ -26,6 +28,7 @@ obstacles:
ticks_per_phase: 3
xp: 20
fail_damage: [2, 5]
+ exit_dir: north
messages:
- "You grab the dangling power cable with both hands..."
- "You kick off the ledge and swing out over the street far below..."
@@ -35,6 +38,7 @@ obstacles:
ticks_per_phase: 3
xp: 18
fail_damage: [2, 4]
+ exit_dir: north
messages:
- "You step onto the narrow structural beam spanning the alley..."
- "The beam sways slightly as you inch forward, arms out for balance..."
@@ -44,6 +48,7 @@ obstacles:
ticks_per_phase: 2
xp: 15
fail_damage: [2, 3]
+ exit_dir: north
messages:
- "You run toward the maintenance railing at full speed..."
- "You plant one hand on the rail and swing your legs over..."
@@ -53,6 +58,7 @@ obstacles:
ticks_per_phase: 2
xp: 20
fail_damage: [3, 5]
+ exit_dir: north
messages:
- "You stare at the final gap -- the widest yet..."
- "You take a deep breath, charge forward, and throw yourself into the air..."
diff --git a/data/courses/test2.yaml b/data/courses/test2.yaml
new file mode 100644
index 0000000..41e59f1
--- /dev/null
+++ b/data/courses/test2.yaml
@@ -0,0 +1,60 @@
+completion_xp: 50
+name: Test 222
+obstacles:
+ - exit_dir: south
+ fail_damage:
+ - 0
+ - 0
+ phases:
+ - delay: 3
+ message: Test
+ - delay: 2
+ message: Test
+ room_id: 2004
+ verb: climb
+ xp: 0
+ - exit_dir: northeast
+ fail_damage:
+ - 0
+ - 0
+ phases:
+ - delay: 1
+ message: You do it
+ room_id: 1005
+ verb: jump
+ xp: 0
+ - exit_dir: east
+ fail_damage:
+ - 0
+ - 0
+ phases:
+ - delay: 1
+ message: DONEZO
+ room_id: 1007
+ verb: climb
+ xp: 0
+ - exit_dir: northwest
+ fail_damage:
+ - 0
+ - 0
+ phases: []
+ room_id: 1006
+ verb: climb
+ xp: 0
+ - exit_dir: north
+ fail_damage:
+ - 0
+ - 0
+ phases: []
+ room_id: 1008
+ verb: climb
+ xp: 0
+ - fail_damage:
+ - 0
+ - 0
+ phases: []
+ room_id: 1009
+ verb: climb
+ xp: 0
+required_level: 1
+start_room: 2004
diff --git a/data/courses/vent_shaft.yaml b/data/courses/vent_shaft.yaml
index 766a961..69ca2d2 100644
--- a/data/courses/vent_shaft.yaml
+++ b/data/courses/vent_shaft.yaml
@@ -8,6 +8,7 @@ obstacles:
ticks_per_phase: 2
xp: 8
fail_damage: [1, 2]
+ exit_dir: north
messages:
- "You approach the corroded ventilation wall..."
- "You find footholds in the rusted panels and begin to climb..."
@@ -17,6 +18,7 @@ obstacles:
ticks_per_phase: 2
xp: 8
fail_damage: [1, 2]
+ exit_dir: north
messages:
- "You step onto the narrow coolant pipe..."
- "Arms outstretched, you carefully place one foot in front of the other..."
@@ -26,6 +28,7 @@ obstacles:
ticks_per_phase: 2
xp: 7
fail_damage: [1, 2]
+ exit_dir: north
messages:
- "You peer across the dark gap in the shaft floor..."
- "You take a few steps back, then sprint toward the edge..."
@@ -35,6 +38,7 @@ obstacles:
ticks_per_phase: 3
xp: 7
fail_damage: [1, 1]
+ exit_dir: north
messages:
- "You drop to your hands and knees at the narrow vent opening..."
- "You squeeze through the tight passage, metal scraping against your back..."
@@ -44,6 +48,7 @@ obstacles:
ticks_per_phase: 2
xp: 10
fail_damage: [1, 2]
+ exit_dir: north
messages:
- "You sit at the top of the emergency chute..."
- "You push off and accelerate down the smooth metal surface..."
diff --git a/data/items/000_test_item.yaml b/data/items/000_test_item.yaml
index e6609d8..2ac43a5 100644
--- a/data/items/000_test_item.yaml
+++ b/data/items/000_test_item.yaml
@@ -16,7 +16,7 @@ craft:
subtype: ""
success_message: ""
ticks_per_cycle: 2
- tool: ""
+ tool: pickaxe
type: fletching
xp: 10
description: An adamant-tipped arrow.
diff --git a/data/objects/000_test_object.yaml b/data/objects/000_test_object.yaml
index 4efd15c..ed2e897 100644
--- a/data/objects/000_test_object.yaml
+++ b/data/objects/000_test_object.yaml
@@ -12,6 +12,8 @@ gather:
base: 0
cap: 1
per_level: 0
+ tools:
+ - axe
hidden: false
id: 000_test_object
name: 000 test object
diff --git a/data/rooms/example_unused/agility2/99999200.yaml b/data/rooms/example_unused/agility2/99999200.yaml
index adf85a2..4afaf43 100644
--- a/data/rooms/example_unused/agility2/99999200.yaml
+++ b/data/rooms/example_unused/agility2/99999200.yaml
@@ -3,15 +3,7 @@ name: Agility Training Grounds
color: ""
description:
- text: 'A cavernous space beneath the asteroid''s surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced).'
-exits:
- east:
- room: 99999210
- north:
- room: 99999201
- south:
- room: 9999917
- west:
- room: 99999220
+exits: {}
objects: []
item_spawns: []
mobs: []
diff --git a/data/rooms/example_unused/agility2/99999201.yaml b/data/rooms/example_unused/agility2/99999201.yaml
index fcc25c1..bbb47c5 100644
--- a/data/rooms/example_unused/agility2/99999201.yaml
+++ b/data/rooms/example_unused/agility2/99999201.yaml
@@ -1,8 +1,28 @@
-name: "Ventilation Shaft - Corroded Wall"
-description: "A towering wall of corroded ventilation panels rises before you. Rust-eaten handholds and buckled seams offer a treacherous path upward. The air smells of old metal and recycled atmosphere."
-on_enter:
- - message: "Type 'scramble' to climb the wall."
+id: 99999201
+name: Ventilation Shaft - Corroded Wall
+color: ""
+description:
+ - text: A towering wall of corroded ventilation panels rises before you. Rust-eaten handholds and buckled seams offer a treacherous path upward. The air smells of old metal and recycled atmosphere.
exits:
down:
room: 99999200
- blocked_message: ""
+objects: []
+item_spawns: []
+mobs: []
+on_enter:
+ - message: Type 'scramble' to climb the wall.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999203.yaml b/data/rooms/example_unused/agility2/99999203.yaml
index f1d1a7f..37333a1 100644
--- a/data/rooms/example_unused/agility2/99999203.yaml
+++ b/data/rooms/example_unused/agility2/99999203.yaml
@@ -1,8 +1,26 @@
-name: "Ventilation Shaft - Shaft Gap"
-description: "The ventilation shaft floor is missing here -- a jagged gap drops into darkness. The far side is just barely within jumping distance. Exposed wiring sparks intermittently below."
+id: 99999203
+name: Ventilation Shaft - Shaft Gap
+color: ""
+description:
+ - text: The ventilation shaft floor is missing here -- a jagged gap drops into darkness. The far side is just barely within jumping distance. Exposed wiring sparks intermittently below.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'jump' to leap across the gap."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'jump' to leap across the gap.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999204.yaml b/data/rooms/example_unused/agility2/99999204.yaml
index 1555dbc..d8483d6 100644
--- a/data/rooms/example_unused/agility2/99999204.yaml
+++ b/data/rooms/example_unused/agility2/99999204.yaml
@@ -1,8 +1,26 @@
-name: "Ventilation Shaft - Narrow Vent"
-description: "The passage narrows dramatically here, becoming a tight rectangular vent barely wide enough to fit through. Scratches on the metal walls suggest others have squeezed through before you."
+id: 99999204
+name: Ventilation Shaft - Narrow Vent
+color: ""
+description:
+ - text: The passage narrows dramatically here, becoming a tight rectangular vent barely wide enough to fit through. Scratches on the metal walls suggest others have squeezed through before you.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'crawl' to squeeze through the vent."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'crawl' to squeeze through the vent.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999205.yaml b/data/rooms/example_unused/agility2/99999205.yaml
index 2fc75c2..1e36390 100644
--- a/data/rooms/example_unused/agility2/99999205.yaml
+++ b/data/rooms/example_unused/agility2/99999205.yaml
@@ -1,8 +1,26 @@
-name: "Ventilation Shaft - Emergency Chute"
-description: "A smooth metal chute angles steeply downward, polished by countless slides. An old emergency evacuation sign hangs crookedly on the wall. This is the final obstacle -- the chute leads back to the training grounds."
+id: 99999205
+name: Ventilation Shaft - Emergency Chute
+color: ""
+description:
+ - text: A smooth metal chute angles steeply downward, polished by countless slides. An old emergency evacuation sign hangs crookedly on the wall. This is the final obstacle -- the chute leads back to the training grounds.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'slide' to descend the chute."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'slide' to descend the chute.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999206.yaml b/data/rooms/example_unused/agility2/99999206.yaml
index 389ec3f..77a5971 100644
--- a/data/rooms/example_unused/agility2/99999206.yaml
+++ b/data/rooms/example_unused/agility2/99999206.yaml
@@ -1,4 +1,12 @@
-exits:
- southeast: 99999200
id: 99999206
name: 'Room #99999206'
+color: ""
+description: []
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999210.yaml b/data/rooms/example_unused/agility2/99999210.yaml
index de1d928..ea1f3f8 100644
--- a/data/rooms/example_unused/agility2/99999210.yaml
+++ b/data/rooms/example_unused/agility2/99999210.yaml
@@ -1,8 +1,26 @@
-name: "Rooftop Course - Hab Block Wall"
-description: "The exterior wall of Hab Block 7 rises three stories above the street. Rough permacrete and maintenance handholds provide a challenging climb. The city spreads out below, neon signs flickering in the perpetual twilight."
+id: 99999210
+name: Rooftop Course - Hab Block Wall
+color: ""
+description:
+ - text: The exterior wall of Hab Block 7 rises three stories above the street. Rough permacrete and maintenance handholds provide a challenging climb. The city spreads out below, neon signs flickering in the perpetual twilight.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'climb' to scale the wall."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'climb' to scale the wall.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999211.yaml b/data/rooms/example_unused/agility2/99999211.yaml
index 88fa6a9..156ae67 100644
--- a/data/rooms/example_unused/agility2/99999211.yaml
+++ b/data/rooms/example_unused/agility2/99999211.yaml
@@ -1,8 +1,26 @@
-name: "Rooftop Course - Building Gap"
-description: "You stand on the edge of Hab Block 7's roof. Across a three-meter gap, the roof of Hab Block 8 awaits. The street below is a dizzying drop. A few old bootprints mark the takeoff point."
+id: 99999211
+name: Rooftop Course - Building Gap
+color: ""
+description:
+ - text: You stand on the edge of Hab Block 7's roof. Across a three-meter gap, the roof of Hab Block 8 awaits. The street below is a dizzying drop. A few old bootprints mark the takeoff point.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'jump' to leap to the next building."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'jump' to leap to the next building.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999212.yaml b/data/rooms/example_unused/agility2/99999212.yaml
index 2477956..d669c2d 100644
--- a/data/rooms/example_unused/agility2/99999212.yaml
+++ b/data/rooms/example_unused/agility2/99999212.yaml
@@ -1,8 +1,26 @@
-name: "Rooftop Course - Cable Array"
-description: "A tangle of power cables and data lines stretches between two antenna towers. One thick cable hangs low enough to grab. The gap below drops to a dark alleyway between hab blocks."
+id: 99999212
+name: Rooftop Course - Cable Array
+color: ""
+description:
+ - text: A tangle of power cables and data lines stretches between two antenna towers. One thick cable hangs low enough to grab. The gap below drops to a dark alleyway between hab blocks.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'swing' to cross on the cable."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'swing' to cross on the cable.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999213.yaml b/data/rooms/example_unused/agility2/99999213.yaml
index 8918dbe..86fe54b 100644
--- a/data/rooms/example_unused/agility2/99999213.yaml
+++ b/data/rooms/example_unused/agility2/99999213.yaml
@@ -1,8 +1,26 @@
-name: "Rooftop Course - Narrow Beam"
-description: "A structural I-beam extends across the gap between two buildings, no wider than your foot. It sways slightly in the recycled air currents. Someone has scratched tally marks into the near end."
+id: 99999213
+name: Rooftop Course - Narrow Beam
+color: ""
+description:
+ - text: A structural I-beam extends across the gap between two buildings, no wider than your foot. It sways slightly in the recycled air currents. Someone has scratched tally marks into the near end.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'balance' to cross the beam."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'balance' to cross the beam.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999214.yaml b/data/rooms/example_unused/agility2/99999214.yaml
index 13f759a..523fd7b 100644
--- a/data/rooms/example_unused/agility2/99999214.yaml
+++ b/data/rooms/example_unused/agility2/99999214.yaml
@@ -1,8 +1,26 @@
-name: "Rooftop Course - Maintenance Railing"
-description: "A high maintenance railing blocks the path forward, topped with sensor equipment and warning labels. It's too high to step over but the right technique could clear it. Beyond the railing, the course continues."
+id: 99999214
+name: Rooftop Course - Maintenance Railing
+color: ""
+description:
+ - text: A high maintenance railing blocks the path forward, topped with sensor equipment and warning labels. It's too high to step over but the right technique could clear it. Beyond the railing, the course continues.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'vault' to clear the railing."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'vault' to clear the railing.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999215.yaml b/data/rooms/example_unused/agility2/99999215.yaml
index 062d4a1..cc7e9b1 100644
--- a/data/rooms/example_unused/agility2/99999215.yaml
+++ b/data/rooms/example_unused/agility2/99999215.yaml
@@ -1,8 +1,26 @@
-name: "Rooftop Course - Rooftop Edge"
-description: "The final jump. The gap here is wider than any before -- a full four meters of empty air between you and the landing platform. Far below, the streets of the asteroid colony pulse with dim light. This is the last obstacle."
+id: 99999215
+name: Rooftop Course - Rooftop Edge
+color: ""
+description:
+ - text: The final jump. The gap here is wider than any before -- a full four meters of empty air between you and the landing platform. Far below, the streets of the asteroid colony pulse with dim light. This is the last obstacle.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'leap' to make the final jump."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'leap' to make the final jump.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999220.yaml b/data/rooms/example_unused/agility2/99999220.yaml
index 7fa4b8d..8c97248 100644
--- a/data/rooms/example_unused/agility2/99999220.yaml
+++ b/data/rooms/example_unused/agility2/99999220.yaml
@@ -1,8 +1,26 @@
-name: "Reactor Course - Scaffolding"
-description: "Massive metal scaffolding surrounds the outer reactor housing. The structure vibrates with the reactor's pulse. Heat radiates from every surface, and warning klaxons sound periodically in the distance."
+id: 99999220
+name: Reactor Course - Scaffolding
+color: ""
+description:
+ - text: Massive metal scaffolding surrounds the outer reactor housing. The structure vibrates with the reactor's pulse. Heat radiates from every surface, and warning klaxons sound periodically in the distance.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'climb' to ascend the scaffolding."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'climb' to ascend the scaffolding.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999221.yaml b/data/rooms/example_unused/agility2/99999221.yaml
index 010bd44..1f71b7d 100644
--- a/data/rooms/example_unused/agility2/99999221.yaml
+++ b/data/rooms/example_unused/agility2/99999221.yaml
@@ -1,8 +1,26 @@
-name: "Reactor Course - Coolant Chain"
-description: "A heavy chain hangs from an overhead crane, suspended above the reactor cooling pit. The pit glows with an eerie blue-green light. The chain is your only way across -- the gantry ahead is the landing zone."
+id: 99999221
+name: Reactor Course - Coolant Chain
+color: ""
+description:
+ - text: A heavy chain hangs from an overhead crane, suspended above the reactor cooling pit. The pit glows with an eerie blue-green light. The chain is your only way across -- the gantry ahead is the landing zone.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'swing' to cross on the chain."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'swing' to cross on the chain.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999222.yaml b/data/rooms/example_unused/agility2/99999222.yaml
index 0f8f4b9..83161af 100644
--- a/data/rooms/example_unused/agility2/99999222.yaml
+++ b/data/rooms/example_unused/agility2/99999222.yaml
@@ -1,8 +1,26 @@
-name: "Reactor Course - Steam Pipe"
-description: "An enormous steam pipe, two meters in diameter, stretches across the reactor chamber. Steam vents periodically blast from pressure valves along its length. The pipe's surface is warm but not scalding -- yet."
+id: 99999222
+name: Reactor Course - Steam Pipe
+color: ""
+description:
+ - text: An enormous steam pipe, two meters in diameter, stretches across the reactor chamber. Steam vents periodically blast from pressure valves along its length. The pipe's surface is warm but not scalding -- yet.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'balance' to traverse the pipe."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'balance' to traverse the pipe.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999223.yaml b/data/rooms/example_unused/agility2/99999223.yaml
index 5eccf14..cb4506f 100644
--- a/data/rooms/example_unused/agility2/99999223.yaml
+++ b/data/rooms/example_unused/agility2/99999223.yaml
@@ -1,8 +1,26 @@
-name: "Reactor Course - Platform Gap"
-description: "A section of the reactor maintenance platform has collapsed into the void below. Emergency barriers block the edges, but someone has moved them aside here. The gap is intimidating but clearable."
+id: 99999223
+name: Reactor Course - Platform Gap
+color: ""
+description:
+ - text: A section of the reactor maintenance platform has collapsed into the void below. Emergency barriers block the edges, but someone has moved them aside here. The gap is intimidating but clearable.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'jump' to clear the gap."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'jump' to clear the gap.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999224.yaml b/data/rooms/example_unused/agility2/99999224.yaml
index 274c425..ee48e0f 100644
--- a/data/rooms/example_unused/agility2/99999224.yaml
+++ b/data/rooms/example_unused/agility2/99999224.yaml
@@ -1,8 +1,26 @@
-name: "Reactor Course - Service Conduit"
-description: "A narrow service conduit leads through the reactor shielding. Radiation warning symbols are painted on every surface. Damaged wiring hangs from the ceiling, sparking occasionally. It's the only way forward."
+id: 99999224
+name: Reactor Course - Service Conduit
+color: ""
+description:
+ - text: A narrow service conduit leads through the reactor shielding. Radiation warning symbols are painted on every surface. Damaged wiring hangs from the ceiling, sparking occasionally. It's the only way forward.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'crawl' to enter the conduit."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'crawl' to enter the conduit.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999225.yaml b/data/rooms/example_unused/agility2/99999225.yaml
index e4aa70e..158a213 100644
--- a/data/rooms/example_unused/agility2/99999225.yaml
+++ b/data/rooms/example_unused/agility2/99999225.yaml
@@ -1,8 +1,26 @@
-name: "Reactor Course - Radiation Barrier"
-description: "A containment barrier hums with energy, its surface shimmering with a faint purple glow. It pulses on and off in a regular cycle. Beyond it, the final stretch of the course is visible."
+id: 99999225
+name: Reactor Course - Radiation Barrier
+color: ""
+description:
+ - text: A containment barrier hums with energy, its surface shimmering with a faint purple glow. It pulses on and off in a regular cycle. Beyond it, the final stretch of the course is visible.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'vault' to clear the barrier."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'vault' to clear the barrier.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/agility2/99999226.yaml b/data/rooms/example_unused/agility2/99999226.yaml
index 6c35e88..4568b92 100644
--- a/data/rooms/example_unused/agility2/99999226.yaml
+++ b/data/rooms/example_unused/agility2/99999226.yaml
@@ -1,8 +1,26 @@
-name: "Reactor Course - Reactor Chasm"
-description: "The final obstacle. A massive chasm separates you from the exit platform, the reactor's coolant pool churning far below in shades of luminous green. A narrow runway of grating leads to the edge. This is the longest leap on the course."
+id: 99999226
+name: Reactor Course - Reactor Chasm
+color: ""
+description:
+ - text: The final obstacle. A massive chasm separates you from the exit platform, the reactor's coolant pool churning far below in shades of luminous green. A narrow runway of grating leads to the edge. This is the longest leap on the course.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
on_enter:
- - message: "Type 'leap' to make the final jump."
-exits:
- down:
- room: 99999200
- blocked_message: ""
+ - message: Type 'leap' to make the final jump.
+ condition: null
+ delay: 0
+ set_global_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/town/9999931.yaml b/data/rooms/example_unused/town/9999931.yaml
index f4d0215..4bec6b6 100644
--- a/data/rooms/example_unused/town/9999931.yaml
+++ b/data/rooms/example_unused/town/9999931.yaml
@@ -1,5 +1,15 @@
-name: "Below Town Square"
-description: "The central square of Lumbridge. A fountain gurgles peacefully in the center. Paths lead west toward the gathering grounds and east toward the production district."
+id: 9999931
+name: Below Town Square
+color: ""
+description:
+ - text: The central square of Lumbridge. A fountain gurgles peacefully in the center. Paths lead west toward the gathering grounds and east toward the production district.
exits:
- up: 999991
- down: 9999950
+ up:
+ room: 999991
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/underground2/9999950.yaml b/data/rooms/example_unused/underground2/9999950.yaml
index 3d01f72..2a2d8d0 100644
--- a/data/rooms/example_unused/underground2/9999950.yaml
+++ b/data/rooms/example_unused/underground2/9999950.yaml
@@ -3,9 +3,7 @@ name: The Assassin's Den
color: ""
description:
- text: A dimly lit basement accessible through a trapdoor. Tactical maps and bounty posters line the walls. The air smells of gun oil and burnt ozone. {141}The Client{/} sits behind a reinforced desk.
-exits:
- up:
- room: 9999931
+exits: {}
objects: []
item_spawns: []
mobs:
diff --git a/data/rooms/example_unused/underground2/9999951.yaml b/data/rooms/example_unused/underground2/9999951.yaml
index dd8440e..5f5dddb 100644
--- a/data/rooms/example_unused/underground2/9999951.yaml
+++ b/data/rooms/example_unused/underground2/9999951.yaml
@@ -3,9 +3,7 @@ name: Sewer Tunnels
color: ""
description:
- text: Dark, damp tunnels beneath the settlement. The floor is slick with moisture and something {82}slimy{/}. The smell is indescribable.
-exits:
- north:
- room: 9999950
+exits: {}
objects: []
item_spawns: []
mobs:
diff --git a/data/rooms/example_unused/underground2/9999965.yaml b/data/rooms/example_unused/underground2/9999965.yaml
index 424a4cd..64b83de 100644
--- a/data/rooms/example_unused/underground2/9999965.yaml
+++ b/data/rooms/example_unused/underground2/9999965.yaml
@@ -1,7 +1,14 @@
-name: "Titan's Rest"
-description: "A colossal vault built to house something immense. The ceiling vanishes into darkness above, and the walls are etched with maintenance schematics for a machine of staggering proportions. At the center, a titan sentinel awakens."
-exits:
- up: 64
- down: 66
+id: 9999965
+name: Titan's Rest
+color: ""
+description:
+ - text: A colossal vault built to house something immense. The ceiling vanishes into darkness above, and the walls are etched with maintenance schematics for a machine of staggering proportions. At the center, a titan sentinel awakens.
+exits: {}
+objects: []
+item_spawns: []
mobs:
- id: titan_sentinel
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/underground2/9999966.yaml b/data/rooms/example_unused/underground2/9999966.yaml
index a876453..f2da0b2 100644
--- a/data/rooms/example_unused/underground2/9999966.yaml
+++ b/data/rooms/example_unused/underground2/9999966.yaml
@@ -1,7 +1,14 @@
-name: "Psionic Nexus"
-description: "Reality grows thin here. The walls are covered in strange geometric patterns that hurt to look at directly. A low hum vibrates through your skull, and the air seems to bend around a central point of unseen pressure."
-exits:
- up: 65
- down: 67
+id: 9999966
+name: Psionic Nexus
+color: ""
+description:
+ - text: Reality grows thin here. The walls are covered in strange geometric patterns that hurt to look at directly. A low hum vibrates through your skull, and the air seems to bend around a central point of unseen pressure.
+exits: {}
+objects: []
+item_spawns: []
mobs:
- id: psionic_entity
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/underground2/9999967.yaml b/data/rooms/example_unused/underground2/9999967.yaml
index 494fe9e..28ab6ef 100644
--- a/data/rooms/example_unused/underground2/9999967.yaml
+++ b/data/rooms/example_unused/underground2/9999967.yaml
@@ -1,7 +1,14 @@
-name: "Ancient Chamber"
-description: "A perfectly spherical room carved from an unknown black metal. Glowing runes spiral across every surface in patterns that predate the asteroid itself. At the center, an ancient construct stirs from its eons-long vigil."
-exits:
- up: 66
- down: 68
+id: 9999967
+name: Ancient Chamber
+color: ""
+description:
+ - text: A perfectly spherical room carved from an unknown black metal. Glowing runes spiral across every surface in patterns that predate the asteroid itself. At the center, an ancient construct stirs from its eons-long vigil.
+exits: {}
+objects: []
+item_spawns: []
mobs:
- id: ancient_construct
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/underground2/9999968.yaml b/data/rooms/example_unused/underground2/9999968.yaml
index 6fe1aa0..1e9687c 100644
--- a/data/rooms/example_unused/underground2/9999968.yaml
+++ b/data/rooms/example_unused/underground2/9999968.yaml
@@ -1,6 +1,14 @@
-name: "The Void"
-description: "The bottom of the world. Light itself seems to hesitate before entering this place. The floor is unnaturally smooth black glass, and the only sound is the beating of your own heart. Something waits in the darkness — something that was here before the asteroid, before the colony, before anything."
-exits:
- up: 67
+id: 9999968
+name: The Void
+color: ""
+description:
+ - text: The bottom of the world. Light itself seems to hesitate before entering this place. The floor is unnaturally smooth black glass, and the only sound is the beating of your own heart. Something waits in the darkness — something that was here before the asteroid, before the colony, before anything.
+exits: {}
+objects: []
+item_spawns: []
mobs:
- id: void_reaper
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/wilderness/99999152.yaml b/data/rooms/example_unused/wilderness/99999152.yaml
index 6a23990..83b54af 100644
--- a/data/rooms/example_unused/wilderness/99999152.yaml
+++ b/data/rooms/example_unused/wilderness/99999152.yaml
@@ -1,9 +1,17 @@
-name: "Allotment Field"
-description: "A section with larger soil beds for growing vegetables. {94}Allotment patches{/} stretch across the floor under warm overhead lamps. A single {213}flower patch{/} sits in the corner."
-exits:
- north: 150
+id: 99999152
+name: Allotment Field
+color: ""
+description:
+ - text: A section with larger soil beds for growing vegetables. {94}Allotment patches{/} stretch across the floor under warm overhead lamps. A single {213}flower patch{/} sits in the corner.
+exits: {}
objects:
- id: allotment_patch
- id: allotment_patch
- id: allotment_patch
- id: flower_patch
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/wilderness/99999160.yaml b/data/rooms/example_unused/wilderness/99999160.yaml
index e90f19a..0bc6cc5 100644
--- a/data/rooms/example_unused/wilderness/99999160.yaml
+++ b/data/rooms/example_unused/wilderness/99999160.yaml
@@ -1,10 +1,12 @@
-name: "Market Square"
-description: "A noisy open-air market wedged between crumbling hab-blocks. Vendors hawk salvaged tech and reconstituted food from makeshift stalls. A {220 bold}Guard{/} watches over the area with a stern expression."
-exits:
- north: 1
- east: 161
+id: 99999160
+name: Market Square
+color: ""
+description:
+ - text: A noisy open-air market wedged between crumbling hab-blocks. Vendors hawk salvaged tech and reconstituted food from makeshift stalls. A {220 bold}Guard{/} watches over the area with a stern expression.
+exits: {}
objects:
- id: market_stall
+item_spawns: []
mobs:
- id: man
wander_interval: 15
@@ -12,4 +14,8 @@ mobs:
wander_interval: 18
- id: man
wander_interval: 20
- - id: guard \ No newline at end of file
+ - id: guard
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/wilderness/99999161.yaml b/data/rooms/example_unused/wilderness/99999161.yaml
index a82c5f1..033c816 100644
--- a/data/rooms/example_unused/wilderness/99999161.yaml
+++ b/data/rooms/example_unused/wilderness/99999161.yaml
@@ -1,11 +1,22 @@
-name: "Farm Outpost"
-description: "A cluster of hydroponic grow-pods on the asteroid's surface, shielded by a flickering atmospheric dome. Rows of bio-luminescent crops stretch into the distance."
-exits:
- west: 160
+id: 99999161
+name: Farm Outpost
+color: ""
+description:
+ - text: A cluster of hydroponic grow-pods on the asteroid's surface, shielded by a flickering atmospheric dome. Rows of bio-luminescent crops stretch into the distance.
+exits: {}
+objects: []
+item_spawns: []
mobs:
- id: farmer
- wander_rooms: [161]
+ wander_rooms:
+ - 161
- id: farmer
- wander_rooms: [161]
+ wander_rooms:
+ - 161
- id: bioengineer
- wander_rooms: [161] \ No newline at end of file
+ wander_rooms:
+ - 161
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/example_unused/wilderness/99999162.yaml b/data/rooms/example_unused/wilderness/99999162.yaml
index 292e6fa..297f0e7 100644
--- a/data/rooms/example_unused/wilderness/99999162.yaml
+++ b/data/rooms/example_unused/wilderness/99999162.yaml
@@ -1,4 +1,13 @@
-name: "Detention Cell"
-description: "A small, grimy holding cell. The walls are scratched with tally marks from previous occupants. A heavy door bars the only exit."
-exits:
- north: 1 \ No newline at end of file
+id: 99999162
+name: Detention Cell
+color: ""
+description:
+ - text: A small, grimy holding cell. The walls are scratched with tally marks from previous occupants. A heavy door bars the only exit.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml
new file mode 100644
index 0000000..3d81e8d
--- /dev/null
+++ b/data/rooms/intro/1005.yaml
@@ -0,0 +1,16 @@
+id: 1005
+name: 'Room #1005'
+color: ""
+description: []
+exits:
+ north:
+ room: 2004
+ northeast:
+ room: 1007
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1006.yaml b/data/rooms/intro/1006.yaml
new file mode 100644
index 0000000..88ee5dd
--- /dev/null
+++ b/data/rooms/intro/1006.yaml
@@ -0,0 +1,16 @@
+id: 1006
+name: 'Room #1006'
+color: ""
+description: []
+exits:
+ northwest:
+ room: 1008
+ hidden: true
+ always_blocked: true
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1007.yaml b/data/rooms/intro/1007.yaml
new file mode 100644
index 0000000..570183b
--- /dev/null
+++ b/data/rooms/intro/1007.yaml
@@ -0,0 +1,20 @@
+id: 1007
+name: 'Room #1007'
+color: ""
+description: []
+exits:
+ east:
+ room: 1006
+ hidden: true
+ always_blocked: true
+ west:
+ room: 2004
+ hidden: true
+ always_blocked: true
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1008.yaml b/data/rooms/intro/1008.yaml
new file mode 100644
index 0000000..600de4e
--- /dev/null
+++ b/data/rooms/intro/1008.yaml
@@ -0,0 +1,20 @@
+id: 1008
+name: 'Room #1008'
+color: ""
+description: []
+exits:
+ north:
+ room: 1009
+ hidden: true
+ always_blocked: true
+ southwest:
+ room: 2004
+ hidden: true
+ always_blocked: true
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1009.yaml b/data/rooms/intro/1009.yaml
new file mode 100644
index 0000000..80d1886
--- /dev/null
+++ b/data/rooms/intro/1009.yaml
@@ -0,0 +1,12 @@
+id: 1009
+name: 'Room #1009'
+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 2197f70..5b79018 100644
--- a/data/rooms/intro/2001.yaml
+++ b/data/rooms/intro/2001.yaml
@@ -1,5 +1,4 @@
-id: 2001
-name: Test Room
+block_transport: false
color: ""
description:
- text: It's a test room
@@ -10,12 +9,8 @@ exits:
room: 2006
southeast:
room: 2005
-objects:
- - name: hidden guy
- hidden: true
- description:
- - text: YOU FOUND HIM
- - id: tree
+hazard: ""
+id: 2001
item_spawns:
- id: bronze_axe
quantity: 1
@@ -28,8 +23,12 @@ item_spawns:
respawn_ticks: 1
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/data/rooms/intro/2004.yaml b/data/rooms/intro/2004.yaml
index 73d912e..c84ea1d 100644
--- a/data/rooms/intro/2004.yaml
+++ b/data/rooms/intro/2004.yaml
@@ -8,6 +8,8 @@ exits:
room: 2003
northwest:
room: 2002
+ south:
+ room: 1005
west:
room: 2005
objects: []
diff --git a/data/rooms/test222/1.yaml b/data/rooms/test222/1.yaml
index e110b2e..7834414 100644
--- a/data/rooms/test222/1.yaml
+++ b/data/rooms/test222/1.yaml
@@ -1,2 +1,13 @@
-description: An empty room.
+id: 1
name: New Room
+color: ""
+description:
+ - text: An empty room.
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index 740a102..e4cf849 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -5,6 +5,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
"gopkg.in/yaml.v3"
@@ -229,8 +230,13 @@ func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) {
NewContent: newContent,
IsCreate: isCreate,
})
+ warnings := s.reconcileCourseExits(body)
s.reloadCoursesIfWired()
- writeJSON(w, map[string]any{"ok": true, "id": id})
+ resp := map[string]any{"ok": true, "id": id}
+ if len(warnings) > 0 {
+ resp["warnings"] = warnings
+ }
+ writeJSON(w, resp)
case http.MethodDelete:
oldContent, err := snapshotFile(path)
@@ -366,6 +372,33 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ
}
m["obstacles"] = anyList
+ // --- course map exit management ---
+ // Set exit_dir on the previous obstacle.
+ // Append-only: the new obstacle is last so no new->next link is needed.
+ if insertAt > 0 {
+ prevIdx := insertAt - 1
+ prevRoom := obstacleRoomID(newList[prevIdx])
+ if prevRoom > 0 {
+ dir := strings.TrimSpace(strings.ToLower(body.Direction))
+ if dir == "auto" || dir == "" {
+ dir = "north"
+ }
+ if !isHorizontalDir(dir) {
+ writeJSON(w, map[string]any{"error": "invalid direction: " + dir})
+ return
+ }
+
+ prevObstacle := newList[prevIdx]
+ prevObstacle["exit_dir"] = dir
+ newList[prevIdx] = prevObstacle
+ }
+ }
+ anyList = make([]any, len(newList))
+ for i, o := range newList {
+ anyList[i] = o
+ }
+ m["obstacles"] = anyList
+
newContent, err := writeMapAsYAML(path, m)
if err != nil {
writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
@@ -378,55 +411,18 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ
NewContent: newContent,
})
- // --- course map exit management ---
- // Create always-blocked exits so the map BFS lays the course rooms in sequence.
- var prevRoom, nextRoom int
- if insertAt > 0 {
- prevRoom = obstacleRoomID(newList[insertAt-1])
- }
- if insertAt < len(newList)-1 {
- nextRoom = obstacleRoomID(newList[insertAt+1])
- }
-
- if prevRoom > 0 {
- pm, _, _, perr := s.loadRoomMapByID(prevRoom)
- if perr == nil {
- // Delete the old blocked link from prevRoom to nextRoom (now bypassed).
- if nextRoom > 0 {
- oldDir := findCourseExitDirToTarget(pm, nextRoom)
- if oldDir != "" {
- removeExit(pm, oldDir)
- }
- }
- dir := ensureValidDirection(body.Direction, pm)
- setCourseBlockedExit(pm, dir, roomID)
- s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("blocked exit %d->%d for course %s", prevRoom, roomID, body.CourseID))
- }
- }
- if nextRoom > 0 {
- cm, _, _, cerr := s.loadRoomMapByID(roomID)
- if cerr == nil {
- dir := autoPickDirection(cm)
- setCourseBlockedExit(cm, dir, nextRoom)
- s.saveRoomMapByID(roomID, cm, fmt.Sprintf("blocked exit %d->%d for course %s", roomID, nextRoom, body.CourseID))
- }
- }
-
s.reloadCoursesIfWired()
writeJSON(w, map[string]any{"ok": true, "course_id": body.CourseID, "obstacle_index": insertAt, "total_obstacles": len(newList)})
}
// Detach a room from its course; delete the course file if it becomes empty
-func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Request, roomID int) {
- if r.Method != http.MethodPost {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
+// detachRoomFromCourse removes roomID from the course it belongs to, if any.
+// Returns the course ID that was modified, or "" if none match.
+func (s *AdminServer) detachRoomFromCourse(roomID int) (courseID string, deleted bool) {
ids, err := s.listCourseIDs()
if err != nil {
- writeJSON(w, map[string]any{"error": err.Error()})
- return
+ return "", false
}
for _, id := range ids {
m, _, path, err := s.loadCourseMap(id)
@@ -440,44 +436,8 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
}
oldContent, _ := snapshotFile(path)
- var prevRoom, nextRoom int
if idx > 0 {
- prevRoom = obstacleRoomID(obstacles[idx-1])
- }
- if idx < len(obstacles)-1 {
- nextRoom = obstacleRoomID(obstacles[idx+1])
- }
-
- // --- course map exit cleanup ---
- var relinkDir string
- if prevRoom > 0 {
- pm, _, _, perr := s.loadRoomMapByID(prevRoom)
- if perr == nil {
- relinkDir = findCourseExitDirToTarget(pm, roomID)
- if relinkDir != "" {
- removeExit(pm, relinkDir)
- s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("remove blocked exit to %d (course %s)", roomID, id))
- }
- }
- }
- if nextRoom > 0 {
- cm, _, _, cerr := s.loadRoomMapByID(roomID)
- if cerr == nil {
- d := findCourseExitDirToTarget(cm, nextRoom)
- if d != "" {
- removeExit(cm, d)
- s.saveRoomMapByID(roomID, cm, fmt.Sprintf("remove blocked exit to %d (course %s)", nextRoom, id))
- }
- }
- }
- // Relink prevRoom -> nextRoom
- if prevRoom > 0 && nextRoom > 0 {
- pm, _, _, perr := s.loadRoomMapByID(prevRoom)
- if perr == nil {
- dir := ensureValidDirection(relinkDir, pm)
- setCourseBlockedExit(pm, dir, nextRoom)
- s.saveRoomMapByID(prevRoom, pm, fmt.Sprintf("relink blocked exit %d->%d (course %s)", prevRoom, nextRoom, id))
- }
+ delete(obstacles[idx-1], "exit_dir")
}
remaining := make([]map[string]any, 0, len(obstacles)-1)
@@ -485,10 +445,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
remaining = append(remaining, obstacles[idx+1:]...)
if len(remaining) == 0 {
- if err := os.Remove(path); err != nil {
- writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
- return
- }
+ os.Remove(path)
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("delete course %s (last obstacle removed)", id),
FilePath: path,
@@ -496,8 +453,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
IsDelete: true,
})
s.reloadCoursesIfWired()
- writeJSON(w, map[string]any{"ok": true, "course_id": id, "deleted": true})
- return
+ return id, true
}
anyList := make([]any, len(remaining))
@@ -507,8 +463,7 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
m["obstacles"] = anyList
newContent, err := writeMapAsYAML(path, m)
if err != nil {
- writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
- return
+ return "", false
}
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("detach room %d from course %s", roomID, id),
@@ -517,13 +472,23 @@ func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Requ
NewContent: newContent,
})
s.reloadCoursesIfWired()
- writeJSON(w, map[string]any{"ok": true, "course_id": id, "obstacle_index": idx, "total_obstacles": len(remaining)})
- return
+ return id, false
}
- writeJSON(w, map[string]any{"error": "room is not part of any course"})
+ return "", false
}
-// --- hidden exit management on room files -------------------------------
+func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Request, roomID int) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ courseID, deleted := s.detachRoomFromCourse(roomID)
+ if courseID == "" && !deleted {
+ writeJSON(w, map[string]any{"error": "room is not part of any course"})
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true, "course_id": courseID, "deleted": deleted})
+}
// loadRoomMapByID reads a room YAML file as a generic map.
func (s *AdminServer) loadRoomMapByID(roomID int) (map[string]any, []byte, string, error) {
@@ -562,25 +527,6 @@ func (s *AdminServer) saveRoomMapByID(roomID int, m map[string]any, desc string)
return nil
}
-// roomExitsMap returns the exits sub-map from a room map, initializing it
-// as an empty map if absent.
-func roomExitsMap(m map[string]any) map[string]any {
- if ex, ok := m["exits"].(map[string]any); ok {
- return ex
- }
- ex := make(map[string]any)
- m["exits"] = ex
- return ex
-}
-
-// setCourseBlockedExit adds or replaces an always-blocked exit in direction dir
-// targeting targetRoomID. Always-blocked exits are blocked to players but
-// render on the map; used to lay out course rooms in visual sequence.
-func setCourseBlockedExit(m map[string]any, dir string, targetRoomID int) {
- ex := roomExitsMap(m)
- ex[dir] = map[string]any{"room": targetRoomID, "always_blocked": true}
-}
-
// removeExit deletes an exit by direction. Returns true if it existed.
func removeExit(m map[string]any, dir string) bool {
ex, ok := m["exits"].(map[string]any)
@@ -619,45 +565,66 @@ func exitIsAlwaysBlocked(v any) bool {
return false
}
-// findCourseExitDirToTarget returns the direction of an always-blocked exit from m
-// targeting targetRoomID, or "" if none.
-func findCourseExitDirToTarget(m map[string]any, targetRoomID int) string {
- ex, ok := m["exits"].(map[string]any)
- if !ok {
- return ""
- }
- for dir, v := range ex {
- if exitIsAlwaysBlocked(v) && exitTargetRoom(v) == targetRoomID {
- return dir
+
+
+// isHorizontalDir returns whether d is one of the 8 horizontal directions.
+func isHorizontalDir(d string) bool {
+ for _, h := range horizontalExitDirs {
+ if d == h {
+ return true
}
}
- return ""
+ return false
}
-// autoPickDirection returns the first horizontal direction that has no exit
-// on room map m.
-func autoPickDirection(m map[string]any) string {
+
+
+// reconcileExitOnRoom validates that roomID has an exit in dir.
+// If expectedTarget > 0, also checks the exit points to that room.
+// Returns an error string or empty string on success.
+func (s *AdminServer) validateExitDir(roomID int, dir string, expectedTarget int) string {
+ m, _, _, err := s.loadRoomMapByID(roomID)
+ if err != nil {
+ return "cannot load room " + strconv.Itoa(roomID)
+ }
ex, _ := m["exits"].(map[string]any)
- for _, d := range horizontalExitDirs {
- if _, occupied := ex[d]; !occupied {
- return d
+
+ existing := ex[dir]
+ if existing == nil {
+ return "room " + strconv.Itoa(roomID) + " has no exit in direction " + dir
+ }
+ if expectedTarget > 0 {
+ target := exitTargetRoom(existing)
+ if target != expectedTarget {
+ return "direction " + dir + " on room " + strconv.Itoa(roomID) + " points to room " + strconv.Itoa(target) + ", expected " + strconv.Itoa(expectedTarget)
}
}
- return "north"
+ return ""
}
-// ensureValidDirection returns dir if it's a valid horizontal direction,
-// otherwise auto-picks one from m.
-func ensureValidDirection(dir string, m map[string]any) string {
- dir = strings.TrimSpace(strings.ToLower(dir))
- for _, d := range horizontalExitDirs {
- if d == dir {
- ex, _ := m["exits"].(map[string]any)
- if _, occupied := ex[dir]; !occupied {
- return dir
- }
- break
+// reconcileCourseExits validates every obstacle's exit_dir against the actual
+// exits on the corresponding room. Returns warnings for any mismatches found.
+func (s *AdminServer) reconcileCourseExits(m map[string]any) []string {
+ var warnings []string
+ obstacles := asMapList(m["obstacles"])
+
+ for i, obs := range obstacles {
+ roomID := obstacleRoomID(obs)
+ dir, _ := obs["exit_dir"].(string)
+ if dir == "" {
+ continue
+ }
+
+ var nextRoom int
+ if i < len(obstacles)-1 {
+ nextRoom = obstacleRoomID(obstacles[i+1])
+ }
+
+ errMsg := s.validateExitDir(roomID, dir, nextRoom)
+ if errMsg != "" {
+ warnings = append(warnings, errMsg)
}
}
- return autoPickDirection(m)
+
+ return warnings
} \ No newline at end of file
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 49a6601..0925f8b 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -48,6 +48,8 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
var linkFrom *int
var linkDir *string
var linkOneWay bool
+ var linkHidden bool
+ var linkBlocked bool
if v, ok := m["link_from"]; ok {
if f, ok := v.(float64); ok {
lf := int(f)
@@ -68,6 +70,18 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
}
delete(m, "link_oneway")
}
+ if v, ok := m["link_hidden"]; ok {
+ if b, ok := v.(bool); ok {
+ linkHidden = b
+ }
+ delete(m, "link_hidden")
+ }
+ if v, ok := m["link_blocked"]; ok {
+ if b, ok := v.(bool); ok {
+ linkBlocked = b
+ }
+ delete(m, "link_blocked")
+ }
fromID := 0
if linkFrom != nil {
@@ -80,6 +94,46 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
}
m["id"] = id
+ if linkFrom != nil && linkDir != nil && *linkDir != "" {
+ dir := world.ExitDir(strings.ToLower(*linkDir))
+ opp, validDir := world.OppositeExit[dir]
+ if validDir {
+ conflicts := 0
+ world.BuildGrid(s.cfg.StartingRoom, func(rid int) (*world.Room, bool) {
+ if rid == id {
+ ex := make(map[world.ExitDir]world.ExitDef)
+ if !linkOneWay {
+ ex[opp] = world.ExitDef{Room: *linkFrom}
+ }
+ return &world.Room{Exits: ex}, true
+ }
+ r, err := s.world.LoadRoom(rid)
+ if err != nil {
+ return nil, false
+ }
+ if rid == *linkFrom {
+ rc := *r
+ rc.Exits = make(map[world.ExitDir]world.ExitDef, len(r.Exits)+1)
+ for k, v := range r.Exits {
+ rc.Exits[k] = v
+ }
+ rc.Exits[dir] = world.ExitDef{Room: id}
+ return &rc, true
+ }
+ return r, true
+ }, nil, nil, func(gc world.GridConflict) {
+ conflicts++
+ })
+ if conflicts > 0 {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("Add Next Step: that grid spot is already occupied — room would cause map overlap")})
+ return
+ }
+ } else {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid direction: %s", *linkDir)})
+ return
+ }
+ }
+
exits, _ := m["exits"].(map[string]any)
if exits == nil {
exits = make(map[string]any)
@@ -100,7 +154,7 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
if srcRoom.Exits == nil {
srcRoom.Exits = make(map[world.ExitDir]world.ExitDef)
}
- srcRoom.Exits[dir] = world.ExitDef{Room: id}
+ srcRoom.Exits[dir] = world.ExitDef{Room: id, Hidden: linkHidden, AlwaysBlocked: linkBlocked}
srcPath, srcOk := s.world.GetRoomPath(*linkFrom)
if srcOk {
oldContent, _ := snapshotFile(srcPath)
@@ -247,6 +301,8 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
return
}
+ courseID, _ := s.detachRoomFromCourse(id)
+
if err := os.Remove(path); err != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("delete room: %v", err)})
return
@@ -301,7 +357,11 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
ExtraFiles: extraFiles,
})
- writeJSON(w, map[string]any{"ok": true})
+ resp := map[string]any{"ok": true}
+ if courseID != "" {
+ resp["course_cleaned"] = courseID
+ }
+ writeJSON(w, resp)
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
@@ -424,10 +484,12 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var body struct {
- From int `json:"from"`
- To int `json:"to"`
- Dir string `json:"dir"`
- OneWay bool `json:"oneway"`
+ From int `json:"from"`
+ To int `json:"to"`
+ Dir string `json:"dir"`
+ OneWay bool `json:"oneway"`
+ Hidden bool `json:"hidden"`
+ AlwaysBlocked bool `json:"always_blocked"`
}
if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 {
writeJSONError(w, "invalid body, need from and to", http.StatusBadRequest)
@@ -555,13 +617,13 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
if roomA.Exits == nil {
roomA.Exits = make(map[world.ExitDir]world.ExitDef)
}
- roomA.Exits[dir] = world.ExitDef{Room: body.To}
+ roomA.Exits[dir] = world.ExitDef{Room: body.To, Hidden: body.Hidden, AlwaysBlocked: body.AlwaysBlocked}
if !body.OneWay {
if roomB.Exits == nil {
roomB.Exits = make(map[world.ExitDir]world.ExitDef)
}
- roomB.Exits[oppDir] = world.ExitDef{Room: body.From}
+ roomB.Exits[oppDir] = world.ExitDef{Room: body.From, Hidden: body.Hidden, AlwaysBlocked: body.AlwaysBlocked}
}
pathA, okA2 := s.world.GetRoomPath(body.From)
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index d878db5..b710491 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -55,7 +55,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);display:flex;f
.link-mode-badge.oneway{background:rgba(80,50,20,0.92);color:#fca;border:1px solid #a64}
#mapSvg:focus{outline:none}
#mapSvg,#mapSvg *{user-select:none;-webkit-user-select:none;-moz-user-select:none}
-.notification{position:fixed;bottom:16px;right:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s}
+.notification{position:fixed;bottom:16px;left:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s}
.notification.error{border-color:var(--danger)}
.notification.success{border-color:var(--success)}
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 9974b2c..7cd4430 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -591,6 +591,7 @@ function loadRoomCourseData(id) {
API.get('/api/rooms/' + id + '/course').then(function(c) {
currentRoomData.course = c;
_coursePhases = null;
+ _courseFailShown = null;
if (currentPanelTab === 7 && selectedRoom === id) {
var content = document.querySelector('.panel-tab-content');
if (content) content.innerHTML = renderCourseTab();
@@ -618,6 +619,8 @@ function renderCourseTab() {
var prevRoom = idx > 0 ? (course.obstacles && course.obstacles[idx-1] && course.obstacles[idx-1].room_id) : 0;
var nextRoom = idx < total - 1 ? (course.obstacles && course.obstacles[idx+1] && course.obstacles[idx+1].room_id) : 0;
var roomID = currentRoomData.room.id;
+ var isLast = idx === total - 1;
+ var exitDir = obstacle.exit_dir || '';
// Header: Step N, Room #X (This Obstacle)
h += '<div class="course-header">';
@@ -626,10 +629,11 @@ function renderCourseTab() {
// Prev/Next buttons row
h += '<div style="display:flex;gap:6px;margin-bottom:6px">';
if (prevRoom) {
- h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + prevRoom + ')">&larr; Step ' + idx + ' (#' + prevRoom + ')</button>';
+ var prevDir = course.obstacles && course.obstacles[idx-1] && course.obstacles[idx-1].exit_dir || '';
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + prevRoom + ')">&larr; Step ' + idx + ' (#' + prevRoom + ')' + (prevDir ? ' from ' + prevDir : '') + '</button>';
}
if (nextRoom) {
- h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') &rarr;</button>';
+ h += '<button class="btn btn-sm btn-primary" style="flex:1" onclick="selectRoom(' + nextRoom + ')">Step ' + (idx + 2) + ' (#' + nextRoom + ') via ' + (exitDir || '?') + ' &rarr;</button>';
}
if (!prevRoom && !nextRoom) {
h += '<span style="font-size:11px;color:#888">Single-step course</span>';
@@ -637,26 +641,71 @@ function renderCourseTab() {
h += '</div>';
h += '</div>';
- // Obstacle details
+ // Direction to next step
h += '<div class="course-obstacle-section">';
- h += '<div class="form-group"><label>Verb</label><select id="courseObstacleVerb" onchange="courseAutoSave()">';
+ h += '<div class="form-group"><label>Direction to next step</label>';
+ h += '<div style="display:flex;gap:8px;align-items:center">';
+ h += '<select id="courseExitDir" onchange="courseAutoSave()" style="flex:1">';
+ h += '<option value="">(not set)</option>';
+ HORIZONTAL_DIRS.forEach(function(d) {
+ h += '<option value="' + d + '"' + (exitDir === d ? ' selected' : '') + '>' + d + '</option>';
+ });
+ h += '</select>';
+ if (isLast) {
+ h += '<button class="btn btn-sm btn-primary" onclick="courseAddNextStep(\'' + esc(cd.course_id) + '\')">Add New Step</button>';
+ }
+ h += '</div>';
+ if (isLast) {
+ h += '<div style="font-size:10px;color:#888;margin-top:2px">Pick a direction and click "Add New Step" to append the next obstacle (and jump to it). Otherwise the room this exit points to is the lap-completion destination.</div>';
+ } else {
+ h += '<div style="font-size:10px;color:#888;margin-top:2px">The exit in this direction is used for the post-phase teleport. Must reference an existing exit.</div>';
+ }
+ h += '</div>';
+
+ // Obstacle details
+ h += '<div style="display:flex;gap:10px">';
+ h += '<div class="form-group" style="flex:1"><label>Verb</label><select id="courseObstacleVerb" onchange="courseAutoSave()">';
OBSTACLE_VERBS.forEach(function(v) {
h += '<option value="' + v + '"' + (obstacle.verb === v ? ' selected' : '') + '>' + v + '</option>';
});
h += '</select></div>';
- h += '<div class="form-group"><label>XP</label><input id="courseObstacleXP" type="number" value="' + (obstacle.xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
- h += '<div class="form-group"><label>Fail Damage (min, max)</label><div style="display:flex;gap:6px">';
+ h += '<div class="form-group" style="flex:1"><label>XP</label><input id="courseObstacleXP" type="number" value="' + (obstacle.xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '</div>';
var fd = obstacle.fail_damage || [0,0];
var fdMin = Array.isArray(fd) ? fd[0] : 0;
var fdMax = Array.isArray(fd) ? fd[1] : 0;
- h += '<input id="courseObstacleFailMin" type="number" value="' + fdMin + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">';
- h += '<input id="courseObstacleFailMax" type="number" value="' + fdMax + '" style="flex:1" oninput="courseAutoSave()" onchange="courseAutoSave()">';
- h += '</div></div>';
- h += '<div class="form-group"><label>Fail Chance % (blank = derived)</label>';
+ var onFailRoom = obstacle.on_fail || '';
var fcVal = (obstacle.fail_chance !== undefined && obstacle.fail_chance !== null) ? obstacle.fail_chance : '';
- h += '<input id="courseObstacleFailChance" type="text" value="' + escAttr(String(fcVal)) + '" placeholder="derived" oninput="courseAutoSave()" onchange="courseAutoSave()">';
- h += '<div style="font-size:10px;color:#888;margin-top:2px">Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.</div>';
- h += '</div>';
+ if (_courseFailShown === null) {
+ var hasFailChance = fcVal !== '';
+ var hasFailDmg = fdMin > 0 || fdMax > 0;
+ var hasOnFail = !!obstacle.on_fail;
+ var hasFailCheckPhase = _coursePhases && _coursePhases.some(function(p){ return p.fail_check; });
+ _courseFailShown = hasFailChance || hasFailDmg || hasOnFail || hasFailCheckPhase;
+ }
+ if (_courseFailShown) {
+ h += '<div class="obj-card" style="margin-top:10px">';
+ h += '<div class="obj-card-header">';
+ h += '<span class="obj-card-label">Failure</span>';
+ h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();courseRemoveFailure()">X</button>';
+ h += '</div>';
+ h += '<div class="obj-card-body">';
+ h += '<div style="display:flex;gap:10px">';
+ h += '<div class="form-group" style="flex:1"><label>Min Dmg</label><input id="courseObstacleFailMin" type="number" value="' + fdMin + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group" style="flex:1"><label>Max Dmg</label><input id="courseObstacleFailMax" type="number" value="' + fdMax + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '</div>';
+ h += '<div style="display:flex;gap:10px">';
+ h += '<div class="form-group" style="flex:1"><label>Fail Room</label><input id="courseObstacleOnFailRoom" type="number" value="' + onFailRoom + '" placeholder="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group" style="flex:1"><label>Fail Chance %</label>';
+ h += '<input id="courseObstacleFailChance" type="text" value="' + escAttr(String(fcVal)) + '" placeholder="derived" oninput="courseAutoSave()" onchange="courseAutoSave()">';
+ h += '</div>';
+ h += '</div>';
+ h += '<div style="font-size:10px;color:#888;margin-top:2px">Derived: 30% at req level, -1% per level above, clamp 5-60%. A number here overrides it.</div>';
+ h += '</div>';
+ h += '</div>';
+ } else {
+ h += '<button class="btn btn-sm section-add-btn" style="margin-top:8px" onclick="courseAddFailure()">+ Add Failure Chance</button>';
+ }
h += '</div>';
// Phases section
@@ -670,7 +719,7 @@ function renderCourseTab() {
h += '<button class="btn btn-danger" onclick="courseDetach()">Remove from course</button>';
h += '</div>';
- // Collapsible course details (starts collapsed)
+ // Collapsible course details
h += '<div class="course-details-card" style="margin-top:12px">';
h += '<div class="obj-card-header" onclick="courseToggleDetails()">';
h += '<span class="obj-card-arrow">' + (_courseDetailsCollapsed ? '&#9654;' : '&#9660;') + '</span>';
@@ -679,9 +728,11 @@ function renderCourseTab() {
if (!_courseDetailsCollapsed) {
h += '<div class="obj-card-body">';
h += '<div class="form-group"><label>Course Name</label><input id="courseName" value="' + escAttr(course.name || '') + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
- h += '<div class="form-group"><label>Required Level</label><input id="courseRequiredLevel" type="number" value="' + (course.required_level || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
- h += '<div class="form-group"><label>Start Room (hub / fail return)</label><input id="courseStartRoom" type="number" value="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
- h += '<div class="form-group"><label>Completion XP (lap bonus)</label><input id="courseCompletionXP" type="number" value="' + (course.completion_xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group"><label>Start Room (fail return hub)</label><input id="courseStartRoom" type="number" value="' + (course.start_room || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div style="display:flex;gap:10px">';
+ h += '<div class="form-group" style="flex:1"><label>Required Level</label><input id="courseRequiredLevel" type="number" value="' + (course.required_level || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '<div class="form-group" style="flex:1"><label>XP per Lap</label><input id="courseCompletionXP" type="number" value="' + (course.completion_xp || 0) + '" oninput="courseAutoSave()" onchange="courseAutoSave()"></div>';
+ h += '</div>';
h += '</div>';
}
h += '</div>';
@@ -702,7 +753,7 @@ var HORIZONTAL_DIRS = ['north','south','east','west','northeast','northwest','so
function renderCourseTabEmpty() {
var h = '<p style="color:#888;font-size:11px;margin:6px 0 12px">This room is not part of any course.</p>';
- h += '<div class="form-group"><label>Direction for new step exit</label>';
+ h += '<div class="form-group"><label>Direction from the previous step to this room</label>';
h += '<select id="courseAttachDir"><option value="">auto</option>';
HORIZONTAL_DIRS.forEach(function(d) { h += '<option value="' + d + '">' + d + '</option>'; });
h += '</select></div>';
@@ -733,6 +784,7 @@ function getCoursePhases() {
}
var _coursePhases = null;
+var _courseFailShown = null;
function ensureCoursePhasesLoaded() {
if (_coursePhases === null) {
@@ -748,11 +800,13 @@ function renderCoursePhases() {
_coursePhases.forEach(function(p, i) {
h += '<div class="entry-row course-phase" data-idx="' + i + '">';
h += '<div class="entry-fields">';
- h += '<textarea class="phase-msg" rows="2" placeholder="message" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')" style="width:100%">' + esc(p.message || '') + '</textarea>';
- h += '<div style="display:flex;align-items:center;gap:6px;margin-top:4px">';
+ h += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">';
h += '<label class="mini-label">Delay</label><input class="mini-input phase-delay" type="text" inputmode="numeric" value="' + (p.delay || 0) + '" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')">';
- h += '<label class="mini-label" style="display:flex;align-items:center;gap:3px"><input type="radio" class="phase-fail" name="phaseFail" onchange="courseSetFailCheck(' + i + ')"' + (p.fail_check ? ' checked' : '') + '> Fail check here</label>';
+ if (_courseFailShown) {
+ h += '<label class="mini-label" style="display:flex;align-items:center;gap:3px"><input type="radio" class="phase-fail" name="phaseFail" onchange="courseSetFailCheck(' + i + ')"' + (p.fail_check ? ' checked' : '') + '> Fail check here</label>';
+ }
h += '</div>';
+ h += '<textarea class="phase-msg" rows="2" placeholder="message" oninput="courseUpdatePhase(' + i + ')" onchange="courseUpdatePhase(' + i + ')" style="width:100%">' + esc(p.message || '') + '</textarea>';
h += '</div>';
h += '<button class="btn btn-sm btn-danger" onclick="courseRemovePhase(' + i + ')">X</button>';
h += '</div>';
@@ -765,6 +819,7 @@ function courseAddPhase() {
_coursePhases.push({ message: '', delay: 0, fail_check: false });
renderCoursePhases();
courseAutoSave();
+ updateCourseIndicator();
}
function courseRemovePhase(i) {
@@ -772,6 +827,7 @@ function courseRemovePhase(i) {
_coursePhases.splice(i, 1);
renderCoursePhases();
courseAutoSave();
+ updateCourseIndicator();
}
function courseUpdatePhase(i) {
@@ -782,6 +838,7 @@ function courseUpdatePhase(i) {
_coursePhases[i].message = row.querySelector('.phase-msg').value;
_coursePhases[i].delay = parseFloat(row.querySelector('.phase-delay').value) || 0;
courseAutoSave();
+ updateCourseIndicator();
}
function courseSetFailCheck(i) {
@@ -789,6 +846,34 @@ function courseSetFailCheck(i) {
_coursePhases.forEach(function(p, j) { p.fail_check = (j === i); });
renderCoursePhases();
courseAutoSave();
+ updateCourseIndicator();
+}
+
+function courseAddFailure() {
+ _courseFailShown = true;
+ if (currentPanelTab === 7 && currentRoomData) {
+ var content = document.querySelector('.panel-tab-content');
+ if (content) content.innerHTML = renderCourseTab();
+ }
+}
+
+function courseRemoveFailure() {
+ _courseFailShown = false;
+ ensureCoursePhasesLoaded();
+ _coursePhases.forEach(function(p) { p.fail_check = false; });
+ var fminEl = document.querySelector('#courseObstacleFailMin');
+ var fmaxEl = document.querySelector('#courseObstacleFailMax');
+ var frmEl = document.querySelector('#courseObstacleOnFailRoom');
+ var fcEl = document.querySelector('#courseObstacleFailChance');
+ if (fminEl) fminEl.value = '0';
+ if (fmaxEl) fmaxEl.value = '0';
+ if (frmEl) frmEl.value = '';
+ if (fcEl) fcEl.value = '';
+ courseSave(true);
+ if (currentPanelTab === 7 && currentRoomData) {
+ var content = document.querySelector('.panel-tab-content');
+ if (content) content.innerHTML = renderCourseTab();
+ }
}
// ---- save / detach ------------------------------------------------------
@@ -808,17 +893,20 @@ function courseSyncPhasesFromDOM() {
var _courseSaveTimer = null;
+function updateCourseIndicator() {
+}
+
function courseAutoSave() {
if (_courseSaveTimer) clearTimeout(_courseSaveTimer);
_courseSaveTimer = setTimeout(function() { courseSave(true); }, 600);
}
function courseSave(silent) {
- if (!currentRoomData || !currentRoomData.course) { if (!silent) notify('No course loaded', 'error'); return; }
+ if (!currentRoomData || !currentRoomData.course) { if (!silent) notify('No course loaded', 'error'); return Promise.resolve(); }
var c = currentRoomData.course;
var course = c.course || {};
var courseID = c.course_id;
- if (!courseID) { if (!silent) notify('Missing course id', 'error'); return; }
+ if (!courseID) { if (!silent) notify('Missing course id', 'error'); return Promise.resolve(); }
courseSyncPhasesFromDOM();
@@ -838,6 +926,8 @@ function courseSave(silent) {
var fminEl = document.querySelector('#courseObstacleFailMin');
var fmaxEl = document.querySelector('#courseObstacleFailMax');
var fcEl = document.querySelector('#courseObstacleFailChance');
+ var exitDirEl = document.querySelector('#courseExitDir');
+ var onFailEl = document.querySelector('#courseObstacleOnFailRoom');
var verb = verbEl ? verbEl.value : (c.obstacle && c.obstacle.verb) || 'climb';
var xp = xpEl ? (parseInt(xpEl.value) || 0) : (c.obstacle && c.obstacle.xp) || 0;
var failMin = fminEl ? (parseInt(fminEl.value) || 0) : (Array.isArray(c.obstacle && c.obstacle.fail_damage) ? c.obstacle.fail_damage[0] : 0);
@@ -845,8 +935,10 @@ function courseSave(silent) {
var fcStr = fcEl ? fcEl.value : (c.obstacle && (c.obstacle.fail_chance !== undefined && c.obstacle.fail_chance !== null) ? String(c.obstacle.fail_chance) : '');
var fc = fcStr === '' ? null : parseFloat(fcStr);
if (fc !== null) { if (fc < 0) fc = 0; if (fc > 100) fc = 100; fc = fc / 100; }
+ var exitDir = exitDirEl ? exitDirEl.value : (c.obstacle && c.obstacle.exit_dir) || '';
var obstacles = (course.obstacles || []).slice();
+ var onFail = onFailEl ? (parseInt(onFailEl.value) || 0) : (c.obstacle && c.obstacle.on_fail) || 0;
var newObs = {
room_id: currentRoomData.room.id,
verb: verb,
@@ -854,6 +946,8 @@ function courseSave(silent) {
fail_damage: [failMin, failMax]
};
if (fc !== null) newObs.fail_chance = fc;
+ if (exitDir) newObs.exit_dir = exitDir;
+ if (onFail) newObs.on_fail = onFail;
newObs.phases = (_coursePhases || []).map(function(p) {
var ph = { message: p.message || '', delay: p.delay || 0 };
if (p.fail_check) ph.fail_check = true;
@@ -869,25 +963,23 @@ function courseSave(silent) {
obstacles: obstacles
};
- API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function() {
- if (!silent) notify('Course ' + courseID + ' saved', 'success');
+ return API.put('/api/courses/' + encodeURIComponent(courseID), body).then(function(resp) {
+ notify('Course ' + courseID + ' saved', 'success');
+ if (resp && resp.warnings && resp.warnings.length > 0) {
+ resp.warnings.forEach(function(w) { notify(w, 'error'); });
+ }
updateUndoBar();
- // Refresh the cached course data WITHOUT re-rendering the panel, so the
- // builder's cursor isn't disrupted (auto-save uses silent=true).
- API.get('/api/rooms/' + currentRoomData.room.id + '/course').then(function(c) {
+ return API.get('/api/rooms/' + currentRoomData.room.id + '/course').then(function(c) {
currentRoomData.course = c;
_coursePhases = null;
+ _courseFailShown = null;
}).catch(function() {});
- }).catch(function(e) { if (!silent) notify('Course save failed: ' + extractError(e), 'error'); });
+ }).catch(function(e) { notify('Course save failed: ' + extractError(e), 'error'); });
}
function courseDetach() {
if (!currentRoomData || !currentRoomData.course) return;
var c = currentRoomData.course;
- var total = c.total_obstacles || 1;
- var msg = 'Remove room #' + currentRoomData.room.id + ' from course ' + c.course_id + '?';
- if (total <= 1) msg = 'This is the last obstacle of course ' + c.course_id + '. Removing it will DELETE the course. Continue?';
- if (!confirm(msg)) return;
fetch('/api/rooms/' + currentRoomData.room.id + '/courses/detach', {
method: 'POST',
headers: {'Content-Type':'application/json'},
@@ -898,6 +990,7 @@ function courseDetach() {
notify(res.deleted ? ('Course ' + c.course_id + ' deleted') : ('Room removed from ' + c.course_id), 'success');
updateUndoBar();
_coursePhases = null;
+ _courseFailShown = null;
currentRoomData.course = null;
loadRoomCourseData(currentRoomData.room.id);
var content = document.querySelector('.panel-tab-content');
@@ -905,7 +998,107 @@ function courseDetach() {
}).catch(function(e) { notify('Detach failed: ' + e.message, 'error'); });
}
-// ---- attach / new course ------------------------------------------------
+// ---- attach / new course / add next step ---------------------------------
+
+// ensureCourseExit guarantees an exit exists from fromID toward dir.
+// If exitExists is already true the current exit is left untouched;
+// otherwise a hidden, always-blocked, one-way exit to toID is created.
+function ensureCourseExit(fromID, toID, dir, exitExists) {
+ if (exitExists) return Promise.resolve();
+ return API.post('/api/rooms/link', {
+ from: fromID, to: toID, dir: dir,
+ oneway: true, hidden: true, always_blocked: true
+ }).then(function(res) {
+ if (res && res.error) { throw new Error(res.error); }
+ });
+}
+
+function courseAddNextStep(courseID) {
+ if (!currentRoomData || !currentRoomData.room) return;
+ if (_courseSaveTimer) { clearTimeout(_courseSaveTimer); _courseSaveTimer = null; }
+ var roomID = currentRoomData.room.id;
+ var dirSel = document.querySelector('#courseExitDir');
+ var dir = dirSel ? dirSel.value : '';
+ if (!dir) { notify('Select a direction', 'error'); return; }
+
+ // Resolve the target room in this direction and whether an exit already
+ // exists. Priority: an explicit exit, then a room already on the grid in
+ // that direction, then finally create a brand-new room.
+ var existingExit = (currentRoomData.room.exits || {})[dir];
+ var resolveTarget;
+ if (existingExit) {
+ var target = typeof existingExit === 'object' ? existingExit.room : existingExit;
+ if (!target) { notify('Exit in direction ' + dir + ' has no target room', 'error'); return; }
+ resolveTarget = Promise.resolve({ targetID: target, exitExists: true });
+ } else {
+ var src = roomMap[roomID];
+ var delta = null;
+ for (var i = 0; i < gridDirs.length; i++) { if (gridDirs[i].dir === dir) { delta = gridDirs[i]; break; } }
+ var neighborID = (src && delta) ? occupied[(src.x + delta.dx) + ',' + (src.y + delta.dy)] : null;
+ if (neighborID) {
+ resolveTarget = Promise.resolve({ targetID: neighborID, exitExists: false });
+ } else {
+ // Empty cell: create a new room (backend adds a hidden, blocked one-way exit).
+ resolveTarget = createRoomPromise(roomID, dir, true, true, true).then(function(res) {
+ return { targetID: res.id, exitExists: true };
+ });
+ }
+ }
+
+ resolveTarget.then(function(t) {
+ var targetID = Number(t.targetID);
+ var exitExists = t.exitExists;
+ var cd = currentRoomData.course || {};
+ var course = cd.course || {};
+ var startRoom = Number(course.start_room || 0);
+ var obstacles = course.obstacles || [];
+
+ // Rule 1: target is the course's start room -> complete a loop. Set the
+ // last obstacle's exit_dir and don't add a new step.
+ if (startRoom && targetID === startRoom) {
+ return ensureCourseExit(roomID, targetID, dir, exitExists).then(function() {
+ var exitSel = document.querySelector('#courseExitDir');
+ if (exitSel) exitSel.value = dir;
+ return courseSave(true);
+ }).then(function() {
+ notify('Loop completed: last step exits ' + dir + ' to start room #' + targetID, 'success');
+ return loadMap().then(function() {
+ loadRoomCourseData(currentRoomData.room.id);
+ var content = document.querySelector('.panel-tab-content');
+ if (content && currentPanelTab === 7) content.innerHTML = renderCourseTab();
+ });
+ });
+ }
+
+ // Rule 2: target is already some other step of this course -> refuse.
+ for (var i = 0; i < obstacles.length; i++) {
+ if (Number(obstacles[i] && obstacles[i].room_id) === targetID) {
+ notify('Room #' + targetID + ' is already step ' + (i + 1) + ' of this course.', 'error');
+ return;
+ }
+ }
+
+ // Rule 3: attach the existing/new room as the next step.
+ return ensureCourseExit(roomID, targetID, dir, exitExists).then(function() {
+ return fetch('/api/rooms/' + targetID + '/courses/attach', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({ course_id: courseID, after_index: currentRoomData.course.obstacle_index, direction: dir }),
+ credentials: 'same-origin'
+ }).then(function(r) { return r.json(); });
+ }).then(function(res) {
+ if (!res) return;
+ if (res.error) { notify('Add failed: ' + res.error, 'error'); return; }
+ notify('Added step to course ' + courseID, 'success');
+ updateUndoBar();
+ _coursePhases = null;
+ _courseFailShown = null;
+ return loadMap().then(function() {
+ selectRoom(targetID);
+ });
+ });
+ }).catch(function(e) { notify('Add failed: ' + extractError(e), 'error'); });
+}
function addRoomToCourse(courseID) {
if (!currentRoomData || !currentRoomData.room) return;
@@ -922,6 +1115,7 @@ function addRoomToCourse(courseID) {
notify('Added room #' + roomID + ' to course ' + courseID, 'success');
updateUndoBar();
_coursePhases = null;
+ _courseFailShown = null;
loadRoomCourseData(roomID);
}).catch(function(e) { notify('Attach failed: ' + e.message, 'error'); });
}
@@ -1050,6 +1244,7 @@ function courseCreateConfirm() {
updateUndoBar();
courseCloseModal();
_coursePhases = null;
+ _courseFailShown = null;
loadRoomCourseData(roomID);
}).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
}
@@ -1111,6 +1306,7 @@ function switchPanelTab(idx) {
else if (idx === 6) html = renderHazardTab(r);
else if (idx === 7) {
_coursePhases = null;
+ _courseFailShown = null;
if (currentRoomData.course === undefined) {
html = renderCourseTabLoading();
loadRoomCourseData(r.id);
@@ -1925,8 +2121,10 @@ function deleteRoom(id) {
}
}
}
- API.del('/api/rooms/' + id).then(function() {
- notify('Room #' + id + ' deleted', 'success');
+ API.del('/api/rooms/' + id).then(function(res) {
+ var msg = 'Room #' + id + ' deleted';
+ if (res && res.course_cleaned) msg += ' (removed from course ' + res.course_cleaned + ')';
+ notify(msg, 'success');
updateUndoBar();
selectedRoom = null;
$('#panelContent').innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>';
@@ -1934,23 +2132,31 @@ function deleteRoom(id) {
}).catch(function(e) { notify('Delete failed: ' + extractError(e), 'error'); });
}
-function createRoom(fromID, dir, oneway) {
- API.get('/api/next-room-id?from=' + fromID).then(function(r) {
+function createRoomPromise(fromID, dir, oneway, hidden, blocked) {
+ return API.get('/api/next-room-id?from=' + fromID).then(function(r) {
var newID = r.id;
var name = 'Room #' + newID;
var body = { name: name, link_from: fromID, link_dir: dir };
if (oneway) body.link_oneway = true;
+ if (hidden) body.link_hidden = true;
+ if (blocked) body.link_blocked = true;
var srcRoom = roomMap[fromID];
if (srcRoom && srcRoom.color) body.color = srcRoom.color;
- API.post('/api/rooms', body).then(function(resp) {
+ return API.post('/api/rooms', body).then(function(resp) {
var createdId = (resp && resp.room && resp.room.id) || newID;
- notify('Room #' + createdId + ' created', 'success');
- updateUndoBar();
- loadMap().then(function() { selectRoom(createdId); });
- }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
+ return { id: createdId };
+ });
});
}
+function createRoom(fromID, dir, oneway) {
+ createRoomPromise(fromID, dir, oneway, false, false).then(function(res) {
+ notify('Room #' + res.id + ' created', 'success');
+ updateUndoBar();
+ loadMap().then(function() { selectRoom(res.id); });
+ }).catch(function(e) { notify('Create failed: ' + extractError(e), 'error'); });
+}
+
function showRoomContextMenu(x, y, id) {
var overlay = document.createElement('div');
overlay.className = 'cm-overlay';
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index 03876e5..c43a0d9 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -143,6 +143,7 @@ type ObstacleData struct {
TotalObstacles int
NextRoom int
StartRoom int
+ OnFailRoom int
ObstacleXP int
CompletionXP int
// FailChance is the resolved failure probability for this attempt.
diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go
index 18dbf4a..a243b44 100644
--- a/internal/game/act_agility.go
+++ b/internal/game/act_agility.go
@@ -44,6 +44,7 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle
TotalObstacles: info.TotalObstacles,
NextRoom: info.NextRoom,
StartRoom: info.StartRoom,
+ OnFailRoom: info.OnFailRoom,
ObstacleXP: info.ObstacleXP,
CompletionXP: info.CompletionXP,
FailChance: failPtr,
@@ -62,6 +63,11 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
phase := d.Phase
phases := d.Phases
+ if len(phases) == 0 {
+ g.completeObstacle(sess, p, d)
+ return
+ }
+
if phase >= len(phases) {
g.cancelAction(p)
return
@@ -84,58 +90,61 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
nextPhase := phase + 1
if nextPhase >= len(phases) {
- obstacleXP := d.ObstacleXP
- completionXP := d.CompletionXP
- nextRoom := d.NextRoom
- startRoom := d.StartRoom
- obstacleIndex := d.ObstacleIndex
- totalObstacles := d.TotalObstacles
- courseID := d.CourseID
-
- if obstacleXP > 0 {
- g.awardSkillXP(sess, p, player.Agility, obstacleXP)
- if p.OptionBool("xp_drops") {
- sess.WriteLine(g.colorize(sess, "xp",
- fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility])))
- }
+ g.completeObstacle(sess, p, d)
+ return
+ }
+
+ d.Phase = nextPhase
+ p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay)
+}
+
+func (g *Game) completeObstacle(sess *net.Session, p *player.Player, d *behavior.ObstacleData) {
+ obstacleXP := d.ObstacleXP
+ completionXP := d.CompletionXP
+ nextRoom := d.NextRoom
+ obstacleIndex := d.ObstacleIndex
+ totalObstacles := d.TotalObstacles
+ courseID := d.CourseID
+
+ isLastObstacle := obstacleIndex == totalObstacles-1
+
+ if obstacleXP > 0 {
+ g.awardSkillXP(sess, p, player.Agility, obstacleXP)
+ if !isLastObstacle && p.OptionBool("xp_drops") {
+ sess.WriteLine(g.colorize(sess, "xp",
+ fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility])))
}
+ }
- isLastObstacle := obstacleIndex == totalObstacles-1
-
- if isLastObstacle {
- if completionXP > 0 {
- g.awardSkillXP(sess, p, player.Agility, completionXP)
- if p.OptionBool("xp_drops") {
- sess.WriteLine(g.colorize(sess, "xp",
- fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility])))
- }
- }
-
- lapKey := "agility_laps_" + courseID
- laps := getPlayerFlagInt(p, lapKey) + 1
- g.setPlayerFlag(p, lapKey, laps)
-
- courseName := p.Action.TargetName
- sess.WriteLine(g.colorize(sess, "broadcast",
- fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps)))
-
- g.cancelAction(p)
- g.teleportPlayer(sess, p, startRoom)
- } else {
- g.cancelAction(p)
- g.teleportPlayer(sess, p, nextRoom)
+ if isLastObstacle {
+ if completionXP > 0 {
+ g.awardSkillXP(sess, p, player.Agility, completionXP)
}
- g.AccountStore.SaveCharacter(p)
- return
+ lapKey := "agility_laps_" + courseID
+ laps := getPlayerFlagInt(p, lapKey) + 1
+ g.setPlayerFlag(p, lapKey, laps)
+
+ courseName := p.Action.TargetName
+ msg := fmt.Sprintf("You've completed %d lap%s of %s", laps, pluralLap(laps), courseName)
+ combinedXP := obstacleXP + completionXP
+ if p.OptionBool("xp_drops") && combinedXP > 0 {
+ msg += fmt.Sprintf(" (+%d%s)", combinedXP, player.SkillAbbr[player.Agility])
+ }
+ sess.WriteLine(g.colorize(sess, "broadcast", msg))
+
+ g.cancelAction(p)
+ g.teleportPlayer(sess, p, nextRoom)
+ } else {
+ g.cancelAction(p)
+ g.teleportPlayer(sess, p, nextRoom)
}
- d.Phase = nextPhase
- p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay)
+ g.AccountStore.SaveCharacter(p)
}
func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.ObstacleData) {
- startRoom := d.StartRoom
+ onFailRoom := d.OnFailRoom
failMin := d.FailDamageMin
failMax := d.FailDamageMax
@@ -155,7 +164,14 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.Obs
g.AccountStore.SaveCharacter(p)
g.cancelAction(p)
- g.teleportPlayer(sess, p, startRoom)
+ g.teleportPlayer(sess, p, onFailRoom)
+}
+
+func pluralLap(n int) string {
+ if n == 1 {
+ return ""
+ }
+ return "s"
}
// calcFailChance returns the failure probability for an obstacle attempt.
diff --git a/internal/game/core_course.go b/internal/game/core_course.go
index 54894fd..031eff5 100644
--- a/internal/game/core_course.go
+++ b/internal/game/core_course.go
@@ -1,11 +1,14 @@
package game
import (
+ "log"
+ "os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/world"
)
// ObstaclePhase is a single phase of an obstacle's advancement sequence.
@@ -31,6 +34,8 @@ type ObstacleDef struct {
TicksPerPhase float64 `yaml:"ticks_per_phase"`
Messages []string `yaml:"messages"`
Phases []ObstaclePhase `yaml:"phases"`
+ ExitDir string `yaml:"exit_dir"`
+ OnFailRoom int `yaml:"on_fail,omitempty"`
}
type CourseConfig struct {
@@ -62,7 +67,9 @@ type ObstacleInfo struct {
FailDamage [2]int
NextRoom int
StartRoom int
+ OnFailRoom int
RequiredLevel int
+ ExitDir string
}
var obstacleVerbs = map[string]bool{}
@@ -85,6 +92,7 @@ type CourseStore struct {
courses map[string]*CourseConfig
roomToObstacle map[int]*ObstacleInfo
loaded bool
+ world *world.World
}
func NewCourseStore(dataDir string) *CourseStore {
@@ -94,6 +102,10 @@ func NewCourseStore(dataDir string) *CourseStore {
}
}
+func (cs *CourseStore) SetWorld(w *world.World) {
+ cs.world = w
+}
+
func (cs *CourseStore) LoadAll() {
cs.mu.Lock()
defer cs.mu.Unlock()
@@ -160,6 +172,52 @@ func resolvePhases(obs ObstacleDef) []PhaseInfo {
return phases
}
+func (cs *CourseStore) resolveExitTarget(roomID int, dir string) int {
+ if cs.world == nil || dir == "" {
+ return 0
+ }
+ path, ok := cs.world.GetRoomPath(roomID)
+ if !ok {
+ return 0
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return 0
+ }
+ var m map[string]any
+ if err := yaml.Unmarshal(data, &m); err != nil {
+ return 0
+ }
+ exits, _ := m["exits"].(map[string]any)
+ if exits == nil {
+ return 0
+ }
+ v, ok := exits[dir]
+ if !ok {
+ return 0
+ }
+ switch x := v.(type) {
+ case int:
+ return x
+ case int64:
+ return int(x)
+ case float64:
+ return int(x)
+ case map[string]any:
+ if r, ok := x["room"]; ok {
+ switch y := r.(type) {
+ case int:
+ return y
+ case int64:
+ return int(y)
+ case float64:
+ return int(y)
+ }
+ }
+ }
+ return 0
+}
+
func (cs *CourseStore) loadAllLocked() {
cs.loaded = true
cs.roomToObstacle = make(map[int]*ObstacleInfo)
@@ -178,6 +236,16 @@ func (cs *CourseStore) loadAllLocked() {
nextRoom := 0
if i < totalObstacles-1 {
nextRoom = cfg.Obstacles[i+1].RoomID
+ } else if obs.ExitDir != "" {
+ resolved := cs.resolveExitTarget(obs.RoomID, obs.ExitDir)
+ if resolved != 0 {
+ nextRoom = resolved
+ } else {
+ log.Printf("[WARNING] Course %q: last obstacle room %d has exit_dir=%q but no exit in that direction; falling back to start_room %d", cfg.ID, obs.RoomID, obs.ExitDir, cfg.StartRoom)
+ nextRoom = cfg.StartRoom
+ }
+ } else {
+ nextRoom = cfg.StartRoom
}
completionXP := 0
@@ -185,6 +253,11 @@ func (cs *CourseStore) loadAllLocked() {
completionXP = cfg.CompletionXP
}
+ onFailRoom := obs.OnFailRoom
+ if onFailRoom == 0 {
+ onFailRoom = cfg.StartRoom
+ }
+
info := &ObstacleInfo{
CourseID: cfg.ID,
CourseName: cfg.Name,
@@ -198,7 +271,9 @@ func (cs *CourseStore) loadAllLocked() {
FailDamage: obs.FailDamage,
NextRoom: nextRoom,
StartRoom: cfg.StartRoom,
+ OnFailRoom: onFailRoom,
RequiredLevel: cfg.RequiredLevel,
+ ExitDir: obs.ExitDir,
}
cs.roomToObstacle[obs.RoomID] = info
localVerbs[obs.Verb] = true
diff --git a/internal/game/core_course_test.go b/internal/game/core_course_test.go
index 3d9fdc2..64ec8ad 100644
--- a/internal/game/core_course_test.go
+++ b/internal/game/core_course_test.go
@@ -130,8 +130,8 @@ obstacles:
if o2.CompletionXP != 50 {
t.Errorf("last obstacle should carry completion_xp, got %d", o2.CompletionXP)
}
- if o2.NextRoom != 0 {
- t.Errorf("last obstacle should have next_room 0, got %d", o2.NextRoom)
+ if o2.NextRoom != 100 {
+ t.Errorf("last obstacle with no exit_dir should fall back to start_room 100, got %d", o2.NextRoom)
}
if cs.GetObstacle(999) != nil {
diff --git a/internal/game/game.go b/internal/game/game.go
index b9fb0af..f34e649 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -101,6 +101,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
hackingStates: make(map[string]*hacking.Session),
enterSeqs: make(map[string]*enterSeq),
}
+ g.CourseStore.SetWorld(g.World)
g.CourseStore.LoadAll()
g.LoadMods()
g.LoadTechs()