aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--cmd/mud/main.go14
-rw-r--r--config.yaml19
-rw-r--r--data/.admin_history.yaml1
-rw-r--r--data/rooms/intro/1.yaml (renamed from data/rooms/intro/1015.yaml)13
-rw-r--r--data/rooms/intro/10.yaml12
-rw-r--r--data/rooms/intro/1001.yaml206
-rw-r--r--data/rooms/intro/1005.yaml24
-rw-r--r--data/rooms/intro/1007.yaml18
-rw-r--r--data/rooms/intro/1008.yaml26
-rw-r--r--data/rooms/intro/1009.yaml14
-rw-r--r--data/rooms/intro/1012.yaml8
-rw-r--r--data/rooms/intro/1016.yaml28
-rw-r--r--data/rooms/intro/12.yaml4
-rw-r--r--data/rooms/intro/2.yaml16
-rw-r--r--data/rooms/intro/2001.yaml28
-rw-r--r--data/rooms/intro/2002.yaml26
-rw-r--r--data/rooms/intro/2003.yaml34
-rw-r--r--data/rooms/intro/3.yaml18
-rw-r--r--data/rooms/intro/4.yaml16
-rw-r--r--data/rooms/intro/5.yaml6
-rw-r--r--data/rooms/intro/6.yaml4
-rw-r--r--data/rooms/intro/7.yaml4
-rw-r--r--data/rooms/intro/8.yaml14
-rw-r--r--data/rooms/intro/9.yaml12
-rw-r--r--internal/admin/api_courses.go138
-rw-r--r--internal/admin/api_dashboard.go39
-rw-r--r--internal/admin/api_drops.go168
-rw-r--r--internal/admin/api_files.go135
-rw-r--r--internal/admin/api_flags.go14
-rw-r--r--internal/admin/api_hazards.go166
-rw-r--r--internal/admin/api_helpers.go31
-rw-r--r--internal/admin/api_items.go166
-rw-r--r--internal/admin/api_map.go241
-rw-r--r--internal/admin/api_mobs.go163
-rw-r--r--internal/admin/api_modules.go138
-rw-r--r--internal/admin/api_objects.go166
-rw-r--r--internal/admin/api_players.go77
-rw-r--r--internal/admin/api_rooms.go561
-rw-r--r--internal/admin/api_search.go82
-rw-r--r--internal/admin/api_techs.go138
-rw-r--r--internal/admin/id_alloc.go64
-rw-r--r--internal/admin/server.go395
-rw-r--r--internal/admin/static/admin.css102
-rw-r--r--internal/admin/static/admin.js55
-rw-r--r--internal/admin/static/colorpicker.js197
-rw-r--r--internal/admin/static/editor.js174
-rw-r--r--internal/admin/static/map.js775
-rw-r--r--internal/admin/static/talktree.js92
-rw-r--r--internal/admin/templates/courses.html14
-rw-r--r--internal/admin/templates/dashboard.html22
-rw-r--r--internal/admin/templates/drops.html18
-rw-r--r--internal/admin/templates/files.html81
-rw-r--r--internal/admin/templates/hazards.html14
-rw-r--r--internal/admin/templates/items.html45
-rw-r--r--internal/admin/templates/layout.html48
-rw-r--r--internal/admin/templates/login.html28
-rw-r--r--internal/admin/templates/map.html21
-rw-r--r--internal/admin/templates/mobs.html52
-rw-r--r--internal/admin/templates/modules.html14
-rw-r--r--internal/admin/templates/objects.html29
-rw-r--r--internal/admin/templates/players.html33
-rw-r--r--internal/admin/templates/techs.html14
-rw-r--r--internal/admin/undo.go247
-rw-r--r--internal/admin/xterm_to_css.go40
-rw-r--r--internal/admin/yaml_util.go117
-rw-r--r--internal/config/config.go48
-rw-r--r--internal/net/server.go42
68 files changed, 5563 insertions, 207 deletions
diff --git a/.gitignore b/.gitignore
index 1cb07df..3466d59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
/data/players/characters/*
!/data/players/characters/.gitkeep
thoi
+mud
.cache/
.npm/
.local/
diff --git a/cmd/mud/main.go b/cmd/mud/main.go
index db801e8..12b842c 100644
--- a/cmd/mud/main.go
+++ b/cmd/mud/main.go
@@ -8,6 +8,7 @@ import (
"os/signal"
"syscall"
+ "thehouseoficarus/internal/admin"
"thehouseoficarus/internal/config"
"thehouseoficarus/internal/game"
"thehouseoficarus/internal/net"
@@ -77,6 +78,19 @@ func main() {
g.World.AddGroundItem(1, j, 500)
}
+ if cfg.AdminHTTPS.Enabled {
+ adminSrv, err := admin.NewServer(cfg, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir)
+ if err != nil {
+ log.Fatalf("failed to start admin server: %v", err)
+ }
+ go func() {
+ if err := adminSrv.ListenAndServe(); err != nil {
+ log.Printf("admin server error: %v", err)
+ }
+ }()
+ log.Printf("Admin HTTPS listening on :%d", cfg.AdminHTTPS.Port)
+ }
+
if cfg.Telnet.Enabled {
log.Printf("Telnet listening on :%d", cfg.Telnet.Port)
}
diff --git a/config.yaml b/config.yaml
index a55e032..55dfa3e 100644
--- a/config.yaml
+++ b/config.yaml
@@ -5,6 +5,10 @@ startup_validation:
root_rooms: [1001]
ignore_unreachable: []
+tls:
+ cert_file: ""
+ key_file: ""
+
telnet:
enabled: true
port: 4000
@@ -13,21 +17,19 @@ http:
enabled: true
port: 8888
-# To use Telnet TLS or HTTPS, configure the path of a certificate and private
-# key. If cert_file and key_file are left empty, a self-signed certificate is
-# generated automatically on startup.
-
telnet_tls:
enabled: false
port: 4001
- cert_file: ""
- key_file: ""
https:
enabled: false
port: 8443
- cert_file: ""
- key_file: ""
+
+admin_https:
+ enabled: true
+ port: 9090
+ admin_accounts:
+ - admin
default_colors:
room_name: "51 bold"
@@ -56,4 +58,3 @@ default_colors:
credits_pickup: "DC"
map_at: "0F"
map_blocked: "C4"
-
diff --git a/data/.admin_history.yaml b/data/.admin_history.yaml
new file mode 100644
index 0000000..01b724b
--- /dev/null
+++ b/data/.admin_history.yaml
@@ -0,0 +1 @@
+{"history":[{"time":"2026-06-29T20:43:59-04:00","description":"create room 1","file_path":"data/rooms/1.yaml","old_content":"","new_content":"exits:\n north: 1018\nid: 1\nname: 'New Room #1'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T20:44:31-04:00","description":"update room 1018 (add exit east to 1)","file_path":"data/rooms/intro/1018.yaml","old_content":"id: 1018\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1\n northwest:\n room: 1016\n south:\n room: 1\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1018\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1\n northwest:\n room: 1016\n south:\n room: 1\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T20:44:31-04:00","description":"create room 1","file_path":"data/rooms/1.yaml","old_content":"","new_content":"exits:\n west: 1018\nid: 1\nname: 'New Room #1'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T20:44:39-04:00","description":"update room 1018 (add exit east to 1)","file_path":"data/rooms/intro/1018.yaml","old_content":"id: 1018\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1\n northwest:\n room: 1016\n south:\n room: 1\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1018\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1\n northwest:\n room: 1016\n south:\n room: 1\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T20:44:39-04:00","description":"create room 1","file_path":"data/rooms/1.yaml","old_content":"","new_content":"exits:\n west: 1018\nid: 1\nname: 'New Room #1'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T20:44:43-04:00","description":"delete room 1","file_path":"data/rooms/1.yaml","old_content":"exits:\n west: 1018\nid: 1\nname: 'New Room #1'\n","new_content":"","is_delete":true,"is_create":false},{"time":"2026-06-29T20:44:46-04:00","description":"update room 1018 (add exit east to 1)","file_path":"data/rooms/intro/1018.yaml","old_content":"id: 1018\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1\n northwest:\n room: 1016\n south:\n room: 1\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1018\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1\n northwest:\n room: 1016\n south:\n room: 1\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T20:44:46-04:00","description":"create room 1","file_path":"data/rooms/1.yaml","old_content":"","new_content":"exits:\n west: 1018\nid: 1\nname: 'New Room #1'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T20:47:34-04:00","description":"update room 1014 (add exit east to 1)","file_path":"data/rooms/intro/1014.yaml","old_content":"id: 1014\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1007\n up:\n room: 1015\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1014\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1\n north:\n room: 1007\n up:\n room: 1015\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T20:47:34-04:00","description":"create room 1","file_path":"data/rooms/1.yaml","old_content":"","new_content":"exits:\n west: 1014\nid: 1\nname: 'New Room #1'\n","is_delete":false,"is_create":true},{"time":"2026-06-30T00:52:27Z","description":"update room 1001 (add exit west to 1)","file_path":"data/rooms/intro/1001.yaml","old_content":"name: \"Inside Transport Shuttle\"\ndescription:\n - text: \"A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft.\"\n condition:\n player_flag: 1001_touchdown\n not: true\n - text: \"A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark.\"\nobjects:\n - name: framed sign\n hidden: true\n description: |-\n We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\n When you arrive at your final destination, please be aware of the following basic commands:\n\n {0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\n {0B bold}Look:{/} {0A}look{/}, {0A}look \u003citem/object/mob\u003e{/}, and {0A}map{/}\n {0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\n {0A}get \u003citem\u003e{/}, {0A}drop \u003citem\u003e{/}, {0A}wear \u003citem\u003e{/}, {0A}remove \u003citem\u003e{/}\n {0A}use \u003citem\u003e on \u003citem/object\u003e{/}\n {0A}eq{/} for equiped gear\n {0B bold}Comms:{/} {0A}talk \u003cmob\u003e{/} for NPC chat\n {0A}say \u003cmsg\u003e{/} and {0A}global \u003cmsg\u003e{/} for human chat\n {0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \n {0B bold}Combat{/} {0A}attack \u003cmob\u003e{/}\n {0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\n {0B bold}Options{/} {0A}options{/} for a list of account options\n {0A}options \u003copt\u003e \u003cvalue\u003e{/} to change an option\n\n Type {0C bold}what{/} for a list of possible common actions\n\n Make sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!\n on_look:\n set_player_flags:\n 1001_look_sign: true\n - name: door\n hidden: true\n description: \"A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level.\"\n - name: window\n hidden: true\n description: \"A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments.\"\n - name: instrument panel\n aliases: [cockpit]\n hidden: true\n description: \"The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd.\"\n - name: pilot\n hidden: true\n description: \"That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously.\"\nmobs:\n - flight_attendant\nexits:\n east: 2001\n down:\n room: 1002\n condition:\n player_flag: 1001_touchdown\n blocked_message: \"The piston-powered staircase is firmly closed.\"\non_enter:\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 5\n message: \"{0B bold}Welcome to The House of Icarus{/}\"\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 7\n message: \"{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}\"\n set_player_flags:\n 1001_welcome: true\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 7\n message: \"{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide. That's it. Explore and have fun.{/}\"\ntriggers:\n - on_player_flag: 1001_look_sign\n steps:\n - delay: 5\n message: \"The cabin shakes as the small craft touches down\"\n - delay: 5\n message: \"The pistons hiss as the rear staircase opens\"\n - set_player_flags:\n 1001_touchdown: true\nblock_transport: true\n","new_content":"id: 1001\nname: Inside Transport Shuttle\ncolor: \"\"\ndescription:\n - text: A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft.\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_touchdown\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n - text: A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark.\nexits:\n down:\n room: 1002\n condition:\n flag: \"\"\n value: null\n not: false\n player_flag: 1001_touchdown\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n blocked_message: The piston-powered staircase is firmly closed.\n east:\n room: 2001\n west:\n room: 1\nobjects:\n - name: framed sign\n hidden: true\n description:\n - text: \"We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\\n\\nWhen you arrive at your final destination, please be aware of the following basic commands:\\n\\n{0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\\n{0B bold}Look:{/} {0A}look{/}, {0A}look \u003citem/object/mob\u003e{/}, and {0A}map{/}\\n{0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\\n {0A}get \u003citem\u003e{/}, {0A}drop \u003citem\u003e{/}, {0A}wear \u003citem\u003e{/}, {0A}remove \u003citem\u003e{/}\\n {0A}use \u003citem\u003e on \u003citem/object\u003e{/}\\n {0A}eq{/} for equiped gear\\n{0B bold}Comms:{/} {0A}talk \u003cmob\u003e{/} for NPC chat\\n {0A}say \u003cmsg\u003e{/} and {0A}global \u003cmsg\u003e{/} for human chat\\n{0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \\n{0B bold}Combat{/} {0A}attack \u003cmob\u003e{/}\\n{0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\\n{0B bold}Options{/} {0A}options{/} for a list of account options\\n {0A}options \u003copt\u003e \u003cvalue\u003e{/} to change an option\\n\\nType {0C bold}what{/} for a list of possible common actions\\n\\nMake sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!\"\n on_look:\n set_flags: {}\n set_player_flags:\n 1001_look_sign: true\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n cost: 0\n shop: null\n assign_task: false\n skip_task: false\n extend_task: false\n reputation_cost: 0\n sawmill: false\n aps_node: false\n - name: door\n hidden: true\n description:\n - text: A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level.\n - name: window\n hidden: true\n description:\n - text: A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments.\n - name: instrument panel\n aliases:\n - cockpit\n hidden: true\n description:\n - text: The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd.\n - name: pilot\n hidden: true\n description:\n - text: That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously.\nitem_spawns: []\nmobs:\n - id: flight_attendant\non_enter:\n - message: '{0B bold}Welcome to The House of Icarus{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 5\n set_flags: {}\n set_player_flags: {}\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n - message: '{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 7\n set_flags: {}\n set_player_flags:\n 1001_welcome: true\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n - message: '{0B}Type{/} {0A}help newplayer{/} {0B}for the new player''s guide. That''s it. Explore and have fun.{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 7\n set_flags: {}\n set_player_flags: {}\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\nhazard: \"\"\nblock_transport: true\ntriggers:\n - id: \"\"\n on_player_flag: 1001_look_sign\n on_flag: \"\"\n value: null\n room: 0\n steps:\n - delay: 5\n message: The cabin shakes as the small craft touches down\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags: {}\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n - delay: 5\n message: The pistons hiss as the rear staircase opens\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags: {}\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n - delay: 0\n message: \"\"\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags:\n 1001_touchdown: true\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n","is_delete":false,"is_create":false},{"time":"2026-06-30T00:52:27Z","description":"create room 1","file_path":"data/rooms/intro/1.yaml","old_content":"","new_content":"exits:\n east: 1001\nid: 1\nname: Test Room\n","is_delete":false,"is_create":true},{"time":"2026-06-30T00:53:43Z","description":"update room 1001 (add exit west to 1)","file_path":"data/rooms/intro/1001.yaml","old_content":"name: \"Inside Transport Shuttle\"\ndescription:\n - text: \"A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft.\"\n condition:\n player_flag: 1001_touchdown\n not: true\n - text: \"A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark.\"\nobjects:\n - name: framed sign\n hidden: true\n description: |-\n We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\n When you arrive at your final destination, please be aware of the following basic commands:\n\n {0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\n {0B bold}Look:{/} {0A}look{/}, {0A}look \u003citem/object/mob\u003e{/}, and {0A}map{/}\n {0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\n {0A}get \u003citem\u003e{/}, {0A}drop \u003citem\u003e{/}, {0A}wear \u003citem\u003e{/}, {0A}remove \u003citem\u003e{/}\n {0A}use \u003citem\u003e on \u003citem/object\u003e{/}\n {0A}eq{/} for equiped gear\n {0B bold}Comms:{/} {0A}talk \u003cmob\u003e{/} for NPC chat\n {0A}say \u003cmsg\u003e{/} and {0A}global \u003cmsg\u003e{/} for human chat\n {0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \n {0B bold}Combat{/} {0A}attack \u003cmob\u003e{/}\n {0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\n {0B bold}Options{/} {0A}options{/} for a list of account options\n {0A}options \u003copt\u003e \u003cvalue\u003e{/} to change an option\n\n Type {0C bold}what{/} for a list of possible common actions\n\n Make sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!\n on_look:\n set_player_flags:\n 1001_look_sign: true\n - name: door\n hidden: true\n description: \"A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level.\"\n - name: window\n hidden: true\n description: \"A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments.\"\n - name: instrument panel\n aliases: [cockpit]\n hidden: true\n description: \"The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd.\"\n - name: pilot\n hidden: true\n description: \"That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously.\"\nmobs:\n - flight_attendant\nexits:\n east: 2001\n down:\n room: 1002\n condition:\n player_flag: 1001_touchdown\n blocked_message: \"The piston-powered staircase is firmly closed.\"\non_enter:\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 5\n message: \"{0B bold}Welcome to The House of Icarus{/}\"\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 7\n message: \"{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}\"\n set_player_flags:\n 1001_welcome: true\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 7\n message: \"{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide. That's it. Explore and have fun.{/}\"\ntriggers:\n - on_player_flag: 1001_look_sign\n steps:\n - delay: 5\n message: \"The cabin shakes as the small craft touches down\"\n - delay: 5\n message: \"The pistons hiss as the rear staircase opens\"\n - set_player_flags:\n 1001_touchdown: true\nblock_transport: true\n","new_content":"id: 1001\nname: Inside Transport Shuttle\ncolor: \"\"\ndescription:\n - text: A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft.\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_touchdown\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n - text: A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark.\nexits:\n down:\n room: 1002\n condition:\n flag: \"\"\n value: null\n not: false\n player_flag: 1001_touchdown\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n blocked_message: The piston-powered staircase is firmly closed.\n east:\n room: 2001\n west:\n room: 1\nobjects:\n - name: framed sign\n hidden: true\n description:\n - text: \"We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\\n\\nWhen you arrive at your final destination, please be aware of the following basic commands:\\n\\n{0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\\n{0B bold}Look:{/} {0A}look{/}, {0A}look \u003citem/object/mob\u003e{/}, and {0A}map{/}\\n{0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\\n {0A}get \u003citem\u003e{/}, {0A}drop \u003citem\u003e{/}, {0A}wear \u003citem\u003e{/}, {0A}remove \u003citem\u003e{/}\\n {0A}use \u003citem\u003e on \u003citem/object\u003e{/}\\n {0A}eq{/} for equiped gear\\n{0B bold}Comms:{/} {0A}talk \u003cmob\u003e{/} for NPC chat\\n {0A}say \u003cmsg\u003e{/} and {0A}global \u003cmsg\u003e{/} for human chat\\n{0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \\n{0B bold}Combat{/} {0A}attack \u003cmob\u003e{/}\\n{0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\\n{0B bold}Options{/} {0A}options{/} for a list of account options\\n {0A}options \u003copt\u003e \u003cvalue\u003e{/} to change an option\\n\\nType {0C bold}what{/} for a list of possible common actions\\n\\nMake sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!\"\n on_look:\n set_flags: {}\n set_player_flags:\n 1001_look_sign: true\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n cost: 0\n shop: null\n assign_task: false\n skip_task: false\n extend_task: false\n reputation_cost: 0\n sawmill: false\n aps_node: false\n - name: door\n hidden: true\n description:\n - text: A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level.\n - name: window\n hidden: true\n description:\n - text: A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments.\n - name: instrument panel\n aliases:\n - cockpit\n hidden: true\n description:\n - text: The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd.\n - name: pilot\n hidden: true\n description:\n - text: That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously.\nitem_spawns: []\nmobs:\n - id: flight_attendant\non_enter:\n - message: '{0B bold}Welcome to The House of Icarus{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 5\n set_flags: {}\n set_player_flags: {}\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n - message: '{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 7\n set_flags: {}\n set_player_flags:\n 1001_welcome: true\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n - message: '{0B}Type{/} {0A}help newplayer{/} {0B}for the new player''s guide. That''s it. Explore and have fun.{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 7\n set_flags: {}\n set_player_flags: {}\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\nhazard: \"\"\nblock_transport: true\ntriggers:\n - id: \"\"\n on_player_flag: 1001_look_sign\n on_flag: \"\"\n value: null\n room: 0\n steps:\n - delay: 5\n message: The cabin shakes as the small craft touches down\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags: {}\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n - delay: 5\n message: The pistons hiss as the rear staircase opens\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags: {}\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n - delay: 0\n message: \"\"\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags:\n 1001_touchdown: true\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n","is_delete":false,"is_create":false},{"time":"2026-06-30T00:53:43Z","description":"create room 1","file_path":"data/rooms/intro/1.yaml","old_content":"","new_content":"exits:\n east: 1001\nid: 1\nname: Test West Room\n","is_delete":false,"is_create":true},{"time":"2026-06-30T00:53:43Z","description":"delete room 1014","file_path":"data/rooms/intro/1014.yaml","old_content":"id: 1014\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1007\n up:\n room: 1015\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"","is_delete":true,"is_create":false},{"time":"2026-06-29T21:12:59-04:00","description":"update room 2002 (add exit northwest to 1)","file_path":"data/rooms/intro/2002.yaml","old_content":"id: 2002\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n southeast:\n room: 2004\n west:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 2002\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:12:59-04:00","description":"create room 1","file_path":"data/rooms/intro/1.yaml","old_content":"","new_content":"exits:\n southeast: 2002\nid: 1\nname: 'Room #1'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T21:13:00-04:00","description":"update room 1 (add exit northwest to 2)","file_path":"data/rooms/intro/1.yaml","old_content":"exits:\n southeast: 2002\nid: 1\nname: 'Room #1'\n","new_content":"id: 1\nname: 'Room #1'\ncolor: \"\"\ndescription: []\nexits:\n northwest:\n room: 2\n southeast:\n room: 2002\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:13:00-04:00","description":"create room 2","file_path":"data/rooms/intro/2.yaml","old_content":"","new_content":"exits:\n southeast: 1\nid: 2\nname: 'Room #2'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T21:13:01-04:00","description":"update room 2 (add exit northeast to 3)","file_path":"data/rooms/intro/2.yaml","old_content":"exits:\n southeast: 1\nid: 2\nname: 'Room #2'\n","new_content":"id: 2\nname: 'Room #2'\ncolor: \"\"\ndescription: []\nexits:\n northeast:\n room: 3\n southeast:\n room: 1\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:13:01-04:00","description":"create room 3","file_path":"data/rooms/intro/3.yaml","old_content":"","new_content":"exits:\n southwest: 2\nid: 3\nname: 'Room #3'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T21:13:02-04:00","description":"update room 3 (add exit southeast to 4)","file_path":"data/rooms/intro/3.yaml","old_content":"exits:\n southwest: 2\nid: 3\nname: 'Room #3'\n","new_content":"id: 3\nname: 'Room #3'\ncolor: \"\"\ndescription: []\nexits:\n southeast:\n room: 4\n southwest:\n room: 2\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:13:02-04:00","description":"create room 4","file_path":"data/rooms/intro/4.yaml","old_content":"","new_content":"exits:\n northwest: 3\nid: 4\nname: 'Room #4'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T21:13:03-04:00","description":"update room 4 (add exit west to 5)","file_path":"data/rooms/intro/4.yaml","old_content":"exits:\n northwest: 3\nid: 4\nname: 'Room #4'\n","new_content":"id: 4\nname: 'Room #4'\ncolor: \"\"\ndescription: []\nexits:\n northwest:\n room: 3\n west:\n room: 5\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:13:03-04:00","description":"create room 5","file_path":"data/rooms/intro/5.yaml","old_content":"","new_content":"exits:\n east: 4\nid: 5\nname: 'Room #5'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T21:24:30-04:00","description":"update room 1001 (add exit up to 6)","file_path":"data/rooms/intro/1001.yaml","old_content":"name: \"Inside Transport Shuttle\"\ndescription:\n - text: \"A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft.\"\n condition:\n player_flag: 1001_touchdown\n not: true\n - text: \"A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark.\"\nobjects:\n - name: framed sign\n hidden: true\n description: |-\n We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\n When you arrive at your final destination, please be aware of the following basic commands:\n\n {0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\n {0B bold}Look:{/} {0A}look{/}, {0A}look \u003citem/object/mob\u003e{/}, and {0A}map{/}\n {0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\n {0A}get \u003citem\u003e{/}, {0A}drop \u003citem\u003e{/}, {0A}wear \u003citem\u003e{/}, {0A}remove \u003citem\u003e{/}\n {0A}use \u003citem\u003e on \u003citem/object\u003e{/}\n {0A}eq{/} for equiped gear\n {0B bold}Comms:{/} {0A}talk \u003cmob\u003e{/} for NPC chat\n {0A}say \u003cmsg\u003e{/} and {0A}global \u003cmsg\u003e{/} for human chat\n {0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \n {0B bold}Combat{/} {0A}attack \u003cmob\u003e{/}\n {0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\n {0B bold}Options{/} {0A}options{/} for a list of account options\n {0A}options \u003copt\u003e \u003cvalue\u003e{/} to change an option\n\n Type {0C bold}what{/} for a list of possible common actions\n\n Make sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!\n on_look:\n set_player_flags:\n 1001_look_sign: true\n - name: door\n hidden: true\n description: \"A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level.\"\n - name: window\n hidden: true\n description: \"A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments.\"\n - name: instrument panel\n aliases: [cockpit]\n hidden: true\n description: \"The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd.\"\n - name: pilot\n hidden: true\n description: \"That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously.\"\nmobs:\n - flight_attendant\nexits:\n east: 2001\n down:\n room: 1002\n condition:\n player_flag: 1001_touchdown\n blocked_message: \"The piston-powered staircase is firmly closed.\"\non_enter:\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 5\n message: \"{0B bold}Welcome to The House of Icarus{/}\"\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 7\n message: \"{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}\"\n set_player_flags:\n 1001_welcome: true\n - condition:\n player_flag: 1001_welcome\n not: true\n delay: 7\n message: \"{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide. That's it. Explore and have fun.{/}\"\ntriggers:\n - on_player_flag: 1001_look_sign\n steps:\n - delay: 5\n message: \"The cabin shakes as the small craft touches down\"\n - delay: 5\n message: \"The pistons hiss as the rear staircase opens\"\n - set_player_flags:\n 1001_touchdown: true\nblock_transport: true\n","new_content":"id: 1001\nname: Inside Transport Shuttle\ncolor: \"\"\ndescription:\n - text: A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft.\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_touchdown\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n - text: A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark.\nexits:\n down:\n room: 1002\n condition:\n flag: \"\"\n value: null\n not: false\n player_flag: 1001_touchdown\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n blocked_message: The piston-powered staircase is firmly closed.\n east:\n room: 2001\n up:\n room: 6\nobjects:\n - name: framed sign\n hidden: true\n description:\n - text: \"We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\\n\\nWhen you arrive at your final destination, please be aware of the following basic commands:\\n\\n{0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\\n{0B bold}Look:{/} {0A}look{/}, {0A}look \u003citem/object/mob\u003e{/}, and {0A}map{/}\\n{0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\\n {0A}get \u003citem\u003e{/}, {0A}drop \u003citem\u003e{/}, {0A}wear \u003citem\u003e{/}, {0A}remove \u003citem\u003e{/}\\n {0A}use \u003citem\u003e on \u003citem/object\u003e{/}\\n {0A}eq{/} for equiped gear\\n{0B bold}Comms:{/} {0A}talk \u003cmob\u003e{/} for NPC chat\\n {0A}say \u003cmsg\u003e{/} and {0A}global \u003cmsg\u003e{/} for human chat\\n{0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \\n{0B bold}Combat{/} {0A}attack \u003cmob\u003e{/}\\n{0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\\n{0B bold}Options{/} {0A}options{/} for a list of account options\\n {0A}options \u003copt\u003e \u003cvalue\u003e{/} to change an option\\n\\nType {0C bold}what{/} for a list of possible common actions\\n\\nMake sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!\"\n on_look:\n set_flags: {}\n set_player_flags:\n 1001_look_sign: true\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n cost: 0\n shop: null\n assign_task: false\n skip_task: false\n extend_task: false\n reputation_cost: 0\n sawmill: false\n aps_node: false\n - name: door\n hidden: true\n description:\n - text: A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level.\n - name: window\n hidden: true\n description:\n - text: A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments.\n - name: instrument panel\n aliases:\n - cockpit\n hidden: true\n description:\n - text: The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd.\n - name: pilot\n hidden: true\n description:\n - text: That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously.\nitem_spawns: []\nmobs:\n - id: flight_attendant\non_enter:\n - message: '{0B bold}Welcome to The House of Icarus{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 5\n set_flags: {}\n set_player_flags: {}\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n - message: '{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 7\n set_flags: {}\n set_player_flags:\n 1001_welcome: true\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n - message: '{0B}Type{/} {0A}help newplayer{/} {0B}for the new player''s guide. That''s it. Explore and have fun.{/}'\n condition:\n flag: \"\"\n value: null\n not: true\n player_flag: 1001_welcome\n has_item: \"\"\n min_credits: 0\n all_of: []\n any_of: []\n delay: 7\n set_flags: {}\n set_player_flags: {}\n broadcast: \"\"\n broadcast_global: \"\"\n spawn_mob: null\n despawn_mob: \"\"\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\nhazard: \"\"\nblock_transport: true\ntriggers:\n - id: \"\"\n on_player_flag: 1001_look_sign\n on_flag: \"\"\n value: null\n room: 0\n steps:\n - delay: 5\n message: The cabin shakes as the small craft touches down\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags: {}\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n - delay: 5\n message: The pistons hiss as the rear staircase opens\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags: {}\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n - delay: 0\n message: \"\"\n broadcast: \"\"\n broadcast_global: \"\"\n set_flags: {}\n set_player_flags:\n 1001_touchdown: true\n spawn_mob: null\n give_item: \"\"\n take_item: \"\"\n teleport: 0\n heal: 0\n despawn_mob: \"\"\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:24:30-04:00","description":"create room 6","file_path":"data/rooms/intro/6.yaml","old_content":"","new_content":"exits:\n down: 1001\nid: 6\nname: 'Room #6'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T21:24:50-04:00","description":"update room 5","file_path":"data/rooms/intro/5.yaml","old_content":"exits:\n east: 4\nid: 5\nname: 'Room #5'\n","new_content":"exits:\n east: 4\n north:\n room: 3\nid: 5\nname: 'Room #5'\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:24:50-04:00","description":"update room 3","file_path":"data/rooms/intro/3.yaml","old_content":"id: 3\nname: 'Room #3'\ncolor: \"\"\ndescription: []\nexits:\n southeast:\n room: 4\n southwest:\n room: 2\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 5\n southeast:\n room: 4\n southwest:\n room: 2\nhazard: \"\"\nid: 3\nitem_spawns: []\nmobs: []\nname: 'Room #3'\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:25:42-04:00","description":"update room 1008","file_path":"data/rooms/intro/1008.yaml","old_content":"id: 1008\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1005\n southeast:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1005\n south:\n room: 2003\n southeast:\n room: 1006\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: New Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:25:42-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"id: 2003\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:17-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:17-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"id: 2002\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:18-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:18-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:19-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:19-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:21-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:21-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:26-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:26-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:26-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:26-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:27-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:27-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:27-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:28:27-04:00","description":"update room 2003","file_path":"data/rooms/intro/2003.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 1006\n north:\n room: 1008\n northeast:\n room: 1005\n south:\n room: 2004\n southwest:\n room: 2005\n west:\n room: 2002\nhazard: \"\"\nid: 2003\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:39:47-04:00","description":"update room 2001","file_path":"data/rooms/intro/2001.yaml","old_content":"id: 2001\nname: Test Room\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:39:47-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:39:49-04:00","description":"update room 2001","file_path":"data/rooms/intro/2001.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:39:49-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:39:55-04:00","description":"update room 2001","file_path":"data/rooms/intro/2001.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:39:55-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:39:57-04:00","description":"update room 2001","file_path":"data/rooms/intro/2001.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2002\n south:\n room: 2006\n southeast:\n room: 2005\n west:\n room: 1001\nhazard: \"\"\nid: 2001\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:39:57-04:00","description":"update room 2002","file_path":"data/rooms/intro/2002.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: It's a test room\nexits:\n east:\n room: 2003\n northwest:\n room: 1\n southeast:\n room: 2004\n west:\n room: 2001\nhazard: \"\"\nid: 2002\nitem_spawns: []\nmobs: []\nname: Test Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:43:52-04:00","description":"update room 1008 (add exit north to 7)","file_path":"data/rooms/intro/1008.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1005\n south:\n room: 2003\n southeast:\n room: 1006\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: New Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"id: 1008\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1005\n north:\n room: 7\n south:\n room: 2003\n southeast:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:43:52-04:00","description":"create room 7","file_path":"data/rooms/intro/7.yaml","old_content":"","new_content":"exits:\n south: 1008\nid: 7\nname: 'Room #7'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T21:44:39-04:00","description":"update room 1008","file_path":"data/rooms/intro/1008.yaml","old_content":"id: 1008\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1005\n north:\n room: 7\n south:\n room: 2003\n southeast:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: 00FFFF\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1005\n north:\n room: 7\n south:\n room: 2003\n southeast:\n room: 1006\nhazard: \"\"\nid: 1008\nitem_spawns: []\nmobs: []\nname: New Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:45:30-04:00","description":"update room 1016","file_path":"data/rooms/intro/1016.yaml","old_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southeast:\n room: 1018\n southwest:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nhazard: \"\"\nid: 1016\nitem_spawns: []\nmobs: []\nname: New Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:45:56-04:00","description":"update room 1005","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n northeast:\n room: 1016\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nhazard: \"\"\nid: 1005\nitem_spawns: []\nmobs: []\nname: New Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:46:03-04:00","description":"update room 1009 (add exit northwest to 8)","file_path":"data/rooms/intro/1009.yaml","old_content":"id: 1009\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1016\n southwest:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1009\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1016\n northwest:\n room: 8\n southwest:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T21:46:03-04:00","description":"create room 8","file_path":"data/rooms/intro/8.yaml","old_content":"","new_content":"exits:\n southeast: 1009\nid: 8\nname: 'Room #8'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T22:04:39-04:00","description":"link 1005 north 8","file_path":"data/rooms/intro/1005.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nhazard: \"\"\nid: 1005\nitem_spawns: []\nmobs: []\nname: New Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 8\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:39-04:00","description":"link 8 south 1005 (reverse)","file_path":"data/rooms/intro/8.yaml","old_content":"exits:\n southeast: 1009\nid: 8\nname: 'Room #8'\n","new_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1005\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:39-04:00","description":"unlink 8 south 1005","file_path":"data/rooms/intro/8.yaml","old_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1005\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:39-04:00","description":"unlink 1005 north 8 (reverse)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 8\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:40-04:00","description":"link 1005 north 8","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 8\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:40-04:00","description":"link 8 south 1005 (reverse)","file_path":"data/rooms/intro/8.yaml","old_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1005\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:41-04:00","description":"unlink 8 south 1005","file_path":"data/rooms/intro/8.yaml","old_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1005\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:41-04:00","description":"unlink 1005 north 8 (reverse)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 8\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:42-04:00","description":"link 1005 north 8","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 8\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:42-04:00","description":"link 8 south 1005 (reverse)","file_path":"data/rooms/intro/8.yaml","old_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1005\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:42-04:00","description":"unlink 8 south 1005","file_path":"data/rooms/intro/8.yaml","old_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n south:\n room: 1005\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 8\nname: 'Room #8'\ncolor: \"\"\ndescription: []\nexits:\n southeast:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:42-04:00","description":"unlink 1005 north 8 (reverse)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 8\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:45-04:00","description":"link 1005 east 1009","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1009\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:45-04:00","description":"link 1009 west 1005 (reverse)","file_path":"data/rooms/intro/1009.yaml","old_content":"id: 1009\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1016\n northwest:\n room: 8\n southwest:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1009\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n north:\n room: 1016\n northwest:\n room: 8\n southwest:\n room: 1006\n west:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:48-04:00","description":"link 1016 southwest 1005","file_path":"data/rooms/intro/1016.yaml","old_content":"block_transport: false\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nhazard: \"\"\nid: 1016\nitem_spawns: []\nmobs: []\nname: New Room\nobjects: []\non_enter: []\ntriggers: []\n","new_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:48-04:00","description":"link 1005 northeast 1016 (reverse)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1009\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1009\n northeast:\n room: 1016\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:49-04:00","description":"unlink 1005 northeast 1016","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1009\n northeast:\n room: 1016\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1009\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:49-04:00","description":"unlink 1016 southwest 1005 (reverse)","file_path":"data/rooms/intro/1016.yaml","old_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:51-04:00","description":"link 1016 southwest 1005","file_path":"data/rooms/intro/1016.yaml","old_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:53-04:00","description":"link 1016 southwest 1005","file_path":"data/rooms/intro/1016.yaml","old_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:04:53-04:00","description":"link 1005 northeast 1016 (reverse)","file_path":"data/rooms/intro/1005.yaml","old_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1009\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1005\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1009\n northeast:\n room: 1016\n south:\n room: 1006\n southeast:\n room: 1007\n southwest:\n room: 2003\n west:\n room: 1008\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:05:39-04:00","description":"update room 1016","file_path":"data/rooms/intro/1016.yaml","old_content":"id: 1016\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"block_transport: false\ncolor: \"97\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1017\n south:\n room: 1009\n southwest:\n room: 1005\nhazard: \"\"\nid: 1016\nitem_spawns: []\nmobs: []\nname: New Room\nobjects: []\non_enter: []\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:20:52-04:00","description":"update room 1007 (add exit east to 9)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 9\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:20:52-04:00","description":"create room 9","file_path":"data/rooms/intro/9.yaml","old_content":"","new_content":"exits:\n west: 1007\nid: 9\nname: 'Room #9'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T22:20:54-04:00","description":"unlink 9 west 1007","file_path":"data/rooms/intro/9.yaml","old_content":"exits:\n west: 1007\nid: 9\nname: 'Room #9'\n","new_content":"id: 9\nname: 'Room #9'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:20:54-04:00","description":"unlink 1007 east 9 (reverse)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 9\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:21:00-04:00","description":"update room 1007 (add exit east to 10)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 10\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:21:00-04:00","description":"create room 10","file_path":"data/rooms/intro/10.yaml","old_content":"","new_content":"exits:\n west: 1007\nid: 10\nname: 'Room #10'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T22:21:04-04:00","description":"unlink 1007 east 10","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 10\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:21:04-04:00","description":"unlink 10 west 1007 (reverse)","file_path":"data/rooms/intro/10.yaml","old_content":"exits:\n west: 1007\nid: 10\nname: 'Room #10'\n","new_content":"id: 10\nname: 'Room #10'\ncolor: \"\"\ndescription: []\nexits: {}\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:21:41-04:00","description":"update room 1007 (add exit up to 11)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n northwest:\n room: 1005\n south:\n room: 1014\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n northwest:\n room: 1005\n south:\n room: 1014\n up:\n room: 11\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:21:41-04:00","description":"create room 11","file_path":"data/rooms/intro/11.yaml","old_content":"","new_content":"exits:\n down: 1007\nid: 11\nname: 'Room #11'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T22:21:45-04:00","description":"update room 1007 (add exit down to 12)","file_path":"data/rooms/intro/1007.yaml","old_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n northwest:\n room: 1005\n south:\n room: 1014\n up:\n room: 11\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1007\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 12\n northwest:\n room: 1005\n south:\n room: 1014\n up:\n room: 11\n west:\n room: 1006\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false},{"time":"2026-06-29T22:21:45-04:00","description":"create room 12","file_path":"data/rooms/intro/12.yaml","old_content":"","new_content":"exits:\n up: 1007\nid: 12\nname: 'Room #12'\n","is_delete":false,"is_create":true},{"time":"2026-06-29T22:29:07-04:00","description":"delete room 11","file_path":"data/rooms/intro/11.yaml","old_content":"exits:\n down: 1007\nid: 11\nname: 'Room #11'\n","new_content":"","is_delete":true,"is_create":false},{"time":"2026-06-29T22:29:12-04:00","description":"delete room 1015","file_path":"data/rooms/intro/1015.yaml","old_content":"id: 0\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n down:\n room: 1014\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"","is_delete":true,"is_create":false},{"time":"2026-06-29T22:29:22-04:00","description":"unlink 1012 west 1011","file_path":"data/rooms/intro/1012.yaml","old_content":"id: 1012\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1013\n west:\n room: 1011\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1012\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1013\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false}],"redo":[{"time":"2026-06-29T22:29:22-04:00","description":"unlink 1011 east 1012 (reverse)","file_path":"data/rooms/intro/1011.yaml","old_content":"id: 1011\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n east:\n room: 1012\n south:\n room: 1010\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","new_content":"id: 1011\nname: New Room\ncolor: \"\"\ndescription:\n - text: A featureless room.\nexits:\n south:\n room: 1010\nobjects: []\nitem_spawns: []\nmobs: []\non_enter: []\nhazard: \"\"\nblock_transport: false\ntriggers: []\n","is_delete":false,"is_create":false}]} \ No newline at end of file
diff --git a/data/rooms/intro/1015.yaml b/data/rooms/intro/1.yaml
index 74686bd..fb1ba49 100644
--- a/data/rooms/intro/1015.yaml
+++ b/data/rooms/intro/1.yaml
@@ -1,11 +1,12 @@
-id: 0
-name: New Room
+id: 1
+name: 'Room #1'
color: ""
-description:
- - text: A featureless room.
+description: []
exits:
- down:
- room: 1014
+ northwest:
+ room: 2
+ southeast:
+ room: 2002
objects: []
item_spawns: []
mobs: []
diff --git a/data/rooms/intro/10.yaml b/data/rooms/intro/10.yaml
new file mode 100644
index 0000000..a98b261
--- /dev/null
+++ b/data/rooms/intro/10.yaml
@@ -0,0 +1,12 @@
+id: 10
+name: 'Room #10'
+color: ""
+description: []
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml
index 6f84799..66e1908 100644
--- a/data/rooms/intro/1001.yaml
+++ b/data/rooms/intro/1001.yaml
@@ -1,85 +1,185 @@
-name: "Inside Transport Shuttle"
+id: 1001
+name: Inside Transport Shuttle
+color: ""
description:
- - text: "A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft."
+ - text: A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. The piston-powered staircase is firmly closed at the rear of the craft.
condition:
- player_flag: 1001_touchdown
+ flag: ""
+ value: null
not: true
- - text: "A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark."
+ player_flag: 1001_touchdown
+ has_item: ""
+ min_credits: 0
+ all_of: []
+ any_of: []
+ - text: A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large {11}framed sign{/} hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark.
+exits:
+ down:
+ room: 1002
+ condition:
+ flag: ""
+ value: null
+ not: false
+ player_flag: 1001_touchdown
+ has_item: ""
+ min_credits: 0
+ all_of: []
+ any_of: []
+ blocked_message: The piston-powered staircase is firmly closed.
+ east:
+ room: 2001
+ up:
+ room: 6
objects:
- name: framed sign
hidden: true
- description: |-
- We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].
-
- When you arrive at your final destination, please be aware of the following basic commands:
-
- {0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}
- {0B bold}Look:{/} {0A}look{/}, {0A}look <item/object/mob>{/}, and {0A}map{/}
- {0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory
- {0A}get <item>{/}, {0A}drop <item>{/}, {0A}wear <item>{/}, {0A}remove <item>{/}
- {0A}use <item> on <item/object>{/}
- {0A}eq{/} for equiped gear
- {0B bold}Comms:{/} {0A}talk <mob>{/} for NPC chat
- {0A}say <msg>{/} and {0A}global <msg>{/} for human chat
- {0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats.
- {0B bold}Combat{/} {0A}attack <mob>{/}
- {0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}
- {0B bold}Options{/} {0A}options{/} for a list of account options
- {0A}options <opt> <value>{/} to change an option
-
- Type {0C bold}what{/} for a list of possible common actions
-
- Make sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!
+ description:
+ - text: "We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\nWhen you arrive at your final destination, please be aware of the following basic commands:\n\n{0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\n{0B bold}Look:{/} {0A}look{/}, {0A}look <item/object/mob>{/}, and {0A}map{/}\n{0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\n {0A}get <item>{/}, {0A}drop <item>{/}, {0A}wear <item>{/}, {0A}remove <item>{/}\n {0A}use <item> on <item/object>{/}\n {0A}eq{/} for equiped gear\n{0B bold}Comms:{/} {0A}talk <mob>{/} for NPC chat\n {0A}say <msg>{/} and {0A}global <msg>{/} for human chat\n{0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \n{0B bold}Combat{/} {0A}attack <mob>{/}\n{0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\n{0B bold}Options{/} {0A}options{/} for a list of account options\n {0A}options <opt> <value>{/} to change an option\n\nType {0C bold}what{/} for a list of possible common actions\n\nMake sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!"
on_look:
+ set_flags: {}
set_player_flags:
1001_look_sign: true
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+ cost: 0
+ shop: null
+ assign_task: false
+ skip_task: false
+ extend_task: false
+ reputation_cost: 0
+ sawmill: false
+ aps_node: false
- name: door
hidden: true
- description: "A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level."
+ description:
+ - text: A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level.
- name: window
hidden: true
- description: "A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments."
+ description:
+ - text: A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments.
- name: instrument panel
- aliases: [cockpit]
+ aliases:
+ - cockpit
hidden: true
- description: "The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd."
+ description:
+ - text: The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd.
- name: pilot
hidden: true
- description: "That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously."
+ description:
+ - text: That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously.
+item_spawns: []
mobs:
- - flight_attendant
-exits:
- east: 2001
- down:
- room: 1002
- condition:
- player_flag: 1001_touchdown
- blocked_message: "The piston-powered staircase is firmly closed."
+ - id: flight_attendant
on_enter:
- - condition:
- player_flag: 1001_welcome
+ - message: '{0B bold}Welcome to The House of Icarus{/}'
+ condition:
+ flag: ""
+ value: null
not: true
- delay: 5
- message: "{0B bold}Welcome to The House of Icarus{/}"
- - condition:
player_flag: 1001_welcome
+ has_item: ""
+ min_credits: 0
+ all_of: []
+ any_of: []
+ delay: 5
+ set_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+ - message: '{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}'
+ condition:
+ flag: ""
+ value: null
not: true
+ player_flag: 1001_welcome
+ has_item: ""
+ min_credits: 0
+ all_of: []
+ any_of: []
delay: 7
- message: "{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}"
+ set_flags: {}
set_player_flags:
1001_welcome: true
- - condition:
- player_flag: 1001_welcome
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+ - message: '{0B}Type{/} {0A}help newplayer{/} {0B}for the new player''s guide. That''s it. Explore and have fun.{/}'
+ condition:
+ flag: ""
+ value: null
not: true
+ player_flag: 1001_welcome
+ has_item: ""
+ min_credits: 0
+ all_of: []
+ any_of: []
delay: 7
- message: "{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide. That's it. Explore and have fun.{/}"
+ set_flags: {}
+ set_player_flags: {}
+ broadcast: ""
+ broadcast_global: ""
+ spawn_mob: null
+ despawn_mob: ""
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+hazard: ""
+block_transport: true
triggers:
- - on_player_flag: 1001_look_sign
+ - id: ""
+ on_player_flag: 1001_look_sign
+ on_flag: ""
+ value: null
+ room: 0
steps:
- delay: 5
- message: "The cabin shakes as the small craft touches down"
+ message: The cabin shakes as the small craft touches down
+ broadcast: ""
+ broadcast_global: ""
+ set_flags: {}
+ set_player_flags: {}
+ spawn_mob: null
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+ despawn_mob: ""
- delay: 5
- message: "The pistons hiss as the rear staircase opens"
- - set_player_flags:
+ message: The pistons hiss as the rear staircase opens
+ broadcast: ""
+ broadcast_global: ""
+ set_flags: {}
+ set_player_flags: {}
+ spawn_mob: null
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+ despawn_mob: ""
+ - delay: 0
+ message: ""
+ broadcast: ""
+ broadcast_global: ""
+ set_flags: {}
+ set_player_flags:
1001_touchdown: true
-block_transport: true
+ spawn_mob: null
+ give_item: ""
+ take_item: ""
+ teleport: 0
+ heal: 0
+ despawn_mob: ""
diff --git a/data/rooms/intro/1005.yaml b/data/rooms/intro/1005.yaml
index 1d6f521..21c6633 100644
--- a/data/rooms/intro/1005.yaml
+++ b/data/rooms/intro/1005.yaml
@@ -2,18 +2,20 @@ id: 1005
name: New Room
color: ""
description:
- - text: A featureless room.
+ - text: A featureless room.
exits:
- northeast:
- room: 1016
- south:
- room: 1006
- southeast:
- room: 1007
- southwest:
- room: 2003
- west:
- room: 1008
+ east:
+ room: 1009
+ northeast:
+ room: 1016
+ south:
+ room: 1006
+ southeast:
+ room: 1007
+ southwest:
+ room: 2003
+ west:
+ room: 1008
objects: []
item_spawns: []
mobs: []
diff --git a/data/rooms/intro/1007.yaml b/data/rooms/intro/1007.yaml
index 5d47fa4..4af0a79 100644
--- a/data/rooms/intro/1007.yaml
+++ b/data/rooms/intro/1007.yaml
@@ -2,14 +2,18 @@ id: 1007
name: New Room
color: ""
description:
- - text: A featureless room.
+ - text: A featureless room.
exits:
- northwest:
- room: 1005
- south:
- room: 1014
- west:
- room: 1006
+ down:
+ room: 12
+ northwest:
+ room: 1005
+ south:
+ room: 1014
+ up:
+ room: 11
+ west:
+ room: 1006
objects: []
item_spawns: []
mobs: []
diff --git a/data/rooms/intro/1008.yaml b/data/rooms/intro/1008.yaml
index a889954..a7a8a2f 100644
--- a/data/rooms/intro/1008.yaml
+++ b/data/rooms/intro/1008.yaml
@@ -1,17 +1,21 @@
-id: 1008
-name: New Room
-color: ""
+block_transport: false
+color: 00FFFF
description:
- - text: A featureless room.
+ - text: A featureless room.
exits:
- east:
- room: 1005
- southeast:
- room: 1006
-objects: []
+ east:
+ room: 1005
+ north:
+ room: 7
+ south:
+ room: 2003
+ southeast:
+ room: 1006
+hazard: ""
+id: 1008
item_spawns: []
mobs: []
+name: New Room
+objects: []
on_enter: []
-hazard: ""
-block_transport: false
triggers: []
diff --git a/data/rooms/intro/1009.yaml b/data/rooms/intro/1009.yaml
index d95cdfd..7a0886d 100644
--- a/data/rooms/intro/1009.yaml
+++ b/data/rooms/intro/1009.yaml
@@ -2,12 +2,16 @@ id: 1009
name: New Room
color: ""
description:
- - text: A featureless room.
+ - text: A featureless room.
exits:
- north:
- room: 1016
- southwest:
- room: 1006
+ north:
+ room: 1016
+ northwest:
+ room: 8
+ southwest:
+ room: 1006
+ west:
+ room: 1005
objects: []
item_spawns: []
mobs: []
diff --git a/data/rooms/intro/1012.yaml b/data/rooms/intro/1012.yaml
index 6d83cff..20f6aa8 100644
--- a/data/rooms/intro/1012.yaml
+++ b/data/rooms/intro/1012.yaml
@@ -2,12 +2,10 @@ id: 1012
name: New Room
color: ""
description:
- - text: A featureless room.
+ - text: A featureless room.
exits:
- south:
- room: 1013
- west:
- room: 1011
+ south:
+ room: 1013
objects: []
item_spawns: []
mobs: []
diff --git a/data/rooms/intro/1016.yaml b/data/rooms/intro/1016.yaml
index 287af37..8023b39 100644
--- a/data/rooms/intro/1016.yaml
+++ b/data/rooms/intro/1016.yaml
@@ -1,21 +1,19 @@
-id: 1016
-name: New Room
-color: ""
+block_transport: false
+color: "97"
description:
- - text: A featureless room.
+ - text: A featureless room.
exits:
- down:
- room: 1017
- south:
- room: 1009
- southeast:
- room: 1018
- southwest:
- room: 1005
-objects: []
+ down:
+ room: 1017
+ south:
+ room: 1009
+ southwest:
+ room: 1005
+hazard: ""
+id: 1016
item_spawns: []
mobs: []
+name: New Room
+objects: []
on_enter: []
-hazard: ""
-block_transport: false
triggers: []
diff --git a/data/rooms/intro/12.yaml b/data/rooms/intro/12.yaml
new file mode 100644
index 0000000..89bfe35
--- /dev/null
+++ b/data/rooms/intro/12.yaml
@@ -0,0 +1,4 @@
+exits:
+ up: 1007
+id: 12
+name: 'Room #12'
diff --git a/data/rooms/intro/2.yaml b/data/rooms/intro/2.yaml
new file mode 100644
index 0000000..37f7c70
--- /dev/null
+++ b/data/rooms/intro/2.yaml
@@ -0,0 +1,16 @@
+id: 2
+name: 'Room #2'
+color: ""
+description: []
+exits:
+ northeast:
+ room: 3
+ southeast:
+ room: 1
+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 dc82c06..9f44298 100644
--- a/data/rooms/intro/2001.yaml
+++ b/data/rooms/intro/2001.yaml
@@ -1,21 +1,21 @@
-id: 2001
-name: Test Room
+block_transport: false
color: ""
description:
- - text: It's a test room
+ - text: It's a test room
exits:
- east:
- room: 2002
- south:
- room: 2006
- southeast:
- room: 2005
- west:
- room: 1001
-objects: []
+ east:
+ room: 2002
+ south:
+ room: 2006
+ southeast:
+ room: 2005
+ west:
+ room: 1001
+hazard: ""
+id: 2001
item_spawns: []
mobs: []
+name: Test Room
+objects: []
on_enter: []
-hazard: ""
-block_transport: false
triggers: []
diff --git a/data/rooms/intro/2002.yaml b/data/rooms/intro/2002.yaml
index ce2dede..744fbb2 100644
--- a/data/rooms/intro/2002.yaml
+++ b/data/rooms/intro/2002.yaml
@@ -1,19 +1,21 @@
-id: 2002
-name: Test Room
+block_transport: false
color: ""
description:
- - text: It's a test room
+ - text: It's a test room
exits:
- east:
- room: 2003
- southeast:
- room: 2004
- west:
- room: 2001
-objects: []
+ east:
+ room: 2003
+ northwest:
+ room: 1
+ southeast:
+ room: 2004
+ west:
+ room: 2001
+hazard: ""
+id: 2002
item_spawns: []
mobs: []
+name: Test Room
+objects: []
on_enter: []
-hazard: ""
-block_transport: false
triggers: []
diff --git a/data/rooms/intro/2003.yaml b/data/rooms/intro/2003.yaml
index 8e80cff..3fcc0c2 100644
--- a/data/rooms/intro/2003.yaml
+++ b/data/rooms/intro/2003.yaml
@@ -1,23 +1,25 @@
-id: 2003
-name: Test Room
+block_transport: false
color: ""
description:
- - text: It's a test room
+ - text: It's a test room
exits:
- east:
- room: 1006
- northeast:
- room: 1005
- south:
- room: 2004
- southwest:
- room: 2005
- west:
- room: 2002
-objects: []
+ east:
+ room: 1006
+ north:
+ room: 1008
+ northeast:
+ room: 1005
+ south:
+ room: 2004
+ southwest:
+ room: 2005
+ west:
+ room: 2002
+hazard: ""
+id: 2003
item_spawns: []
mobs: []
+name: Test Room
+objects: []
on_enter: []
-hazard: ""
-block_transport: false
triggers: []
diff --git a/data/rooms/intro/3.yaml b/data/rooms/intro/3.yaml
new file mode 100644
index 0000000..75a05c5
--- /dev/null
+++ b/data/rooms/intro/3.yaml
@@ -0,0 +1,18 @@
+block_transport: false
+color: ""
+description: []
+exits:
+ south:
+ room: 5
+ southeast:
+ room: 4
+ southwest:
+ room: 2
+hazard: ""
+id: 3
+item_spawns: []
+mobs: []
+name: 'Room #3'
+objects: []
+on_enter: []
+triggers: []
diff --git a/data/rooms/intro/4.yaml b/data/rooms/intro/4.yaml
new file mode 100644
index 0000000..e7b81a8
--- /dev/null
+++ b/data/rooms/intro/4.yaml
@@ -0,0 +1,16 @@
+id: 4
+name: 'Room #4'
+color: ""
+description: []
+exits:
+ northwest:
+ room: 3
+ west:
+ room: 5
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/5.yaml b/data/rooms/intro/5.yaml
new file mode 100644
index 0000000..0e11af4
--- /dev/null
+++ b/data/rooms/intro/5.yaml
@@ -0,0 +1,6 @@
+exits:
+ east: 4
+ north:
+ room: 3
+id: 5
+name: 'Room #5'
diff --git a/data/rooms/intro/6.yaml b/data/rooms/intro/6.yaml
new file mode 100644
index 0000000..5f16cfc
--- /dev/null
+++ b/data/rooms/intro/6.yaml
@@ -0,0 +1,4 @@
+exits:
+ down: 1001
+id: 6
+name: 'Room #6'
diff --git a/data/rooms/intro/7.yaml b/data/rooms/intro/7.yaml
new file mode 100644
index 0000000..9c73429
--- /dev/null
+++ b/data/rooms/intro/7.yaml
@@ -0,0 +1,4 @@
+exits:
+ south: 1008
+id: 7
+name: 'Room #7'
diff --git a/data/rooms/intro/8.yaml b/data/rooms/intro/8.yaml
new file mode 100644
index 0000000..2edf88c
--- /dev/null
+++ b/data/rooms/intro/8.yaml
@@ -0,0 +1,14 @@
+id: 8
+name: 'Room #8'
+color: ""
+description: []
+exits:
+ southeast:
+ room: 1009
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/data/rooms/intro/9.yaml b/data/rooms/intro/9.yaml
new file mode 100644
index 0000000..2476d76
--- /dev/null
+++ b/data/rooms/intro/9.yaml
@@ -0,0 +1,12 @@
+id: 9
+name: 'Room #9'
+color: ""
+description: []
+exits: {}
+objects: []
+item_spawns: []
+mobs: []
+on_enter: []
+hazard: ""
+block_transport: false
+triggers: []
diff --git a/internal/admin/api_courses.go b/internal/admin/api_courses.go
new file mode 100644
index 0000000..63b356c
--- /dev/null
+++ b/internal/admin/api_courses.go
@@ -0,0 +1,138 @@
+package admin
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listYAMLFiles(s.dataDir, "courses")
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if ids == nil {
+ ids = []string{}
+ }
+ writeJSON(w, ids)
+ case http.MethodPost:
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid json"})
+ return
+ }
+ id, _ := m["id"].(string)
+ if strings.TrimSpace(id) == "" {
+ writeJSON(w, map[string]any{"error": "missing id"})
+ return
+ }
+ delete(m, "id")
+ path := filepath.Join(s.dataDir, "courses", id+".yaml")
+ if _, err := os.Stat(path); err == nil {
+ writeJSON(w, map[string]any{"error": "course already exists"})
+ return
+ }
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Created course " + id,
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/courses/")
+ if id == "" {
+ http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ data, path, err := findYAMLFileInSubdirs(s.dataDir, "courses", id)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + id})
+ return
+ }
+ raw, err := yamlToMap(data)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "parse error: " + err.Error()})
+ return
+ }
+ raw["id"] = id
+ raw["_path"] = path
+ raw["_raw"] = string(data)
+ writeJSON(w, raw)
+
+ case http.MethodPost, http.MethodPut:
+ var body map[string]any
+ if err := readJSON(r, &body); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ delete(body, "id")
+ delete(body, "_path")
+ delete(body, "_raw")
+
+ path := filepath.Join(s.dataDir, "courses", id+".yaml")
+ var oldContent []byte
+ if existing, err := snapshotFile(path); err == nil {
+ oldContent = existing
+ }
+
+ newContent, err := writeMapAsYAML(path, body)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+
+ isCreate := oldContent == nil
+ desc := "Updated course " + id
+ if isCreate {
+ desc = "Created course " + id
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: desc,
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ IsCreate: isCreate,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ case http.MethodDelete:
+ _, path, err := findYAMLFileInSubdirs(s.dataDir, "courses", id)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + id})
+ return
+ }
+ oldContent, _ := snapshotFile(path)
+ if err := os.Remove(path); err != nil {
+ writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Deleted course " + id,
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/admin/api_dashboard.go b/internal/admin/api_dashboard.go
new file mode 100644
index 0000000..f7e7a6f
--- /dev/null
+++ b/internal/admin/api_dashboard.go
@@ -0,0 +1,39 @@
+package admin
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+)
+
+func (s *AdminServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ countYAML := func(subdir string) int {
+ dir := filepath.Join(s.dataDir, subdir)
+ n := 0
+ filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return nil
+ }
+ if filepath.Ext(d.Name()) == ".yaml" {
+ n++
+ }
+ return nil
+ })
+ return n
+ }
+
+ dashboard := map[string]any{
+ "roomCount": countYAML("rooms"),
+ "itemCount": countYAML("items"),
+ "objectCount": countYAML("objects"),
+ "mobCount": countYAML("mobs"),
+ "playerCount": countYAML("players/characters"),
+ "accountCount": countYAML("players/accounts"),
+ }
+ writeJSON(w, dashboard)
+}
diff --git a/internal/admin/api_drops.go b/internal/admin/api_drops.go
new file mode 100644
index 0000000..7645d39
--- /dev/null
+++ b/internal/admin/api_drops.go
@@ -0,0 +1,168 @@
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "thehouseoficarus/internal/behavior"
+)
+
+func (s *AdminServer) handleDrops(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listYAMLFiles(s.dataDir, "drops")
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if ids == nil {
+ ids = []string{}
+ }
+ writeJSON(w, ids)
+
+ case http.MethodPost:
+ s.createDrop(w, r)
+
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleDropByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/drops/")
+ if id == "" {
+ http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.getDrop(w, r, id)
+ case http.MethodPut:
+ s.updateDrop(w, r, id)
+ case http.MethodDelete:
+ s.deleteDrop(w, r, id)
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) getDrop(w http.ResponseWriter, r *http.Request, id string) {
+ data, path, err := readYAMLFile(s.dataDir, "drops", id)
+ if err != nil {
+ http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound)
+ return
+ }
+ m, err := yamlToMap(data)
+ if err != nil {
+ http.Error(w, `{"error":"failed to parse drop yaml"}`, http.StatusInternalServerError)
+ return
+ }
+ m["_path"] = path
+ m["_raw"] = string(data)
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) updateDrop(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := readYAMLFile(s.dataDir, "drops", id)
+ if err != nil {
+ http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound)
+ return
+ }
+
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+ m["id"] = id
+
+ oldContent, err := snapshotFile(path)
+ if err != nil {
+ http.Error(w, `{"error":"failed to read existing drop"}`, http.StatusInternalServerError)
+ return
+ }
+
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write drop"}`, http.StatusInternalServerError)
+ return
+ }
+
+ behavior.ClearDropIndex()
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Update drop %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) deleteDrop(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := readYAMLFile(s.dataDir, "drops", id)
+ if err != nil {
+ http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound)
+ return
+ }
+
+ oldContent := backupFile(path)
+
+ if err := os.Remove(path); err != nil {
+ http.Error(w, `{"error":"failed to delete drop"}`, http.StatusInternalServerError)
+ return
+ }
+
+ behavior.ClearDropIndex()
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Delete drop %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+
+ writeJSON(w, map[string]any{"deleted": id})
+}
+
+func (s *AdminServer) createDrop(w http.ResponseWriter, r *http.Request) {
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
+ http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest)
+ return
+ }
+
+ path := filepath.Join(s.dataDir, "drops", id+".yaml")
+ if _, err := os.Stat(path); err == nil {
+ http.Error(w, `{"error":"drop already exists"}`, http.StatusConflict)
+ return
+ }
+
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write drop"}`, http.StatusInternalServerError)
+ return
+ }
+
+ behavior.ClearDropIndex()
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Create drop %s", id),
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+
+ writeJSON(w, m)
+}
diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go
new file mode 100644
index 0000000..2a87dd3
--- /dev/null
+++ b/internal/admin/api_files.go
@@ -0,0 +1,135 @@
+package admin
+
+import (
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
+ relPath := r.URL.Query().Get("path")
+ relPath = strings.TrimPrefix(relPath, "/")
+ relPath = strings.TrimPrefix(relPath, "data/")
+
+ switch r.Method {
+ case http.MethodGet:
+ if relPath == "" {
+ entries, err := os.ReadDir(s.dataDir)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ var files []map[string]any
+ for _, e := range entries {
+ files = append(files, map[string]any{
+ "name": e.Name(),
+ "dir": e.IsDir(),
+ })
+ }
+ writeJSON(w, files)
+ return
+ }
+
+ fullPath := filepath.Join(s.dataDir, relPath)
+ info, err := os.Stat(fullPath)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + relPath})
+ return
+ }
+
+ if info.IsDir() {
+ entries, err := os.ReadDir(fullPath)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ var files []map[string]any
+ for _, e := range entries {
+ files = append(files, map[string]any{
+ "name": e.Name(),
+ "dir": e.IsDir(),
+ })
+ }
+ writeJSON(w, files)
+ return
+ }
+
+ data, err := os.ReadFile(fullPath)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "read error: " + err.Error()})
+ return
+ }
+ writeJSON(w, map[string]any{
+ "name": info.Name(),
+ "content": string(data),
+ "path": relPath,
+ })
+
+ case http.MethodPut, http.MethodPost:
+ if relPath == "" {
+ writeJSON(w, map[string]any{"error": "missing path"})
+ return
+ }
+ fullPath := filepath.Join(s.dataDir, relPath)
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "read body: " + err.Error()})
+ return
+ }
+
+ var oldContent []byte
+ if existing, err := snapshotFile(fullPath); err == nil {
+ oldContent = existing
+ }
+
+ dir := filepath.Dir(fullPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ writeJSON(w, map[string]any{"error": "mkdir: " + err.Error()})
+ return
+ }
+
+ if err := os.WriteFile(fullPath, body, 0644); err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+
+ isCreate := oldContent == nil
+ desc := "Updated file " + relPath
+ if isCreate {
+ desc = "Created file " + relPath
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: desc,
+ FilePath: fullPath,
+ OldContent: oldContent,
+ NewContent: body,
+ IsCreate: isCreate,
+ })
+ writeJSON(w, map[string]any{"ok": true, "path": relPath})
+
+ case http.MethodDelete:
+ if relPath == "" {
+ writeJSON(w, map[string]any{"error": "missing path"})
+ return
+ }
+ fullPath := filepath.Join(s.dataDir, relPath)
+ oldContent, _ := snapshotFile(fullPath)
+ if err := os.Remove(fullPath); err != nil {
+ writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Deleted file " + relPath,
+ FilePath: fullPath,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+ writeJSON(w, map[string]any{"ok": true, "path": relPath})
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/admin/api_flags.go b/internal/admin/api_flags.go
new file mode 100644
index 0000000..e2594f3
--- /dev/null
+++ b/internal/admin/api_flags.go
@@ -0,0 +1,14 @@
+package admin
+
+import "net/http"
+
+func (s *AdminServer) handleFlags(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ writeJSON(w, map[string]any{
+ "flags": []any{},
+ "note": "world flags are in-memory only",
+ })
+}
diff --git a/internal/admin/api_hazards.go b/internal/admin/api_hazards.go
new file mode 100644
index 0000000..c8c0282
--- /dev/null
+++ b/internal/admin/api_hazards.go
@@ -0,0 +1,166 @@
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (s *AdminServer) handleHazards(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listYAMLFiles(s.dataDir, "hazards")
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if ids == nil {
+ ids = []string{}
+ }
+ writeJSON(w, ids)
+
+ case http.MethodPost:
+ s.createHazard(w, r)
+
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleHazardByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/hazards/")
+ if id == "" {
+ http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.getHazard(w, r, id)
+ case http.MethodPut:
+ s.updateHazard(w, r, id)
+ case http.MethodDelete:
+ s.deleteHazard(w, r, id)
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) getHazard(w http.ResponseWriter, r *http.Request, id string) {
+ data, path, err := readYAMLFile(s.dataDir, "hazards", id)
+ if err != nil {
+ http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound)
+ return
+ }
+ m, err := yamlToMap(data)
+ if err != nil {
+ http.Error(w, `{"error":"failed to parse hazard yaml"}`, http.StatusInternalServerError)
+ return
+ }
+ m["_path"] = path
+ m["_raw"] = string(data)
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) updateHazard(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := readYAMLFile(s.dataDir, "hazards", id)
+ if err != nil {
+ http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound)
+ return
+ }
+
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+ m["id"] = id
+
+ oldContent, err := snapshotFile(path)
+ if err != nil {
+ http.Error(w, `{"error":"failed to read existing hazard"}`, http.StatusInternalServerError)
+ return
+ }
+
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write hazard"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.world.ClearHazardCache()
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Update hazard %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) deleteHazard(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := readYAMLFile(s.dataDir, "hazards", id)
+ if err != nil {
+ http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound)
+ return
+ }
+
+ oldContent := backupFile(path)
+
+ if err := os.Remove(path); err != nil {
+ http.Error(w, `{"error":"failed to delete hazard"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.world.ClearHazardCache()
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Delete hazard %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+
+ writeJSON(w, map[string]any{"deleted": id})
+}
+
+func (s *AdminServer) createHazard(w http.ResponseWriter, r *http.Request) {
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
+ http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest)
+ return
+ }
+
+ path := filepath.Join(s.dataDir, "hazards", id+".yaml")
+ if _, err := os.Stat(path); err == nil {
+ http.Error(w, `{"error":"hazard already exists"}`, http.StatusConflict)
+ return
+ }
+
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write hazard"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.world.ClearHazardCache()
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Create hazard %s", id),
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+
+ writeJSON(w, m)
+}
diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go
new file mode 100644
index 0000000..78e59d4
--- /dev/null
+++ b/internal/admin/api_helpers.go
@@ -0,0 +1,31 @@
+package admin
+
+import (
+ "bytes"
+ "os"
+
+ "gopkg.in/yaml.v3"
+)
+
+func yamlToMap(data []byte) (map[string]any, error) {
+ var m map[string]any
+ if err := yaml.Unmarshal(data, &m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func writeMapAsYAML(path string, m map[string]any) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(m); err != nil {
+ return nil, err
+ }
+ enc.Close()
+ content := buf.Bytes()
+ if err := os.WriteFile(path, content, 0644); err != nil {
+ return nil, err
+ }
+ return content, nil
+}
diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go
new file mode 100644
index 0000000..3ab20ae
--- /dev/null
+++ b/internal/admin/api_items.go
@@ -0,0 +1,166 @@
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (s *AdminServer) handleItems(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listYAMLFiles(s.dataDir, "items")
+ if err != nil {
+ http.Error(w, `{"error":"failed to list items"}`, http.StatusInternalServerError)
+ return
+ }
+ if ids == nil {
+ ids = []string{}
+ }
+ writeJSON(w, ids)
+ case http.MethodPost:
+ s.createItem(w, r)
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleItemByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/items/")
+ if id == "" {
+ http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.getItem(w, r, id)
+ case http.MethodPut:
+ s.updateItem(w, r, id)
+ case http.MethodDelete:
+ s.deleteItem(w, r, id)
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) findItemFile(id string) ([]byte, string, error) {
+ data, path, err := readYAMLFile(s.dataDir, "items", id)
+ if err == nil {
+ return data, path, nil
+ }
+ return findYAMLFileInSubdirs(s.dataDir, "items", id)
+}
+
+func (s *AdminServer) getItem(w http.ResponseWriter, r *http.Request, id string) {
+ data, path, err := s.findItemFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"item not found"}`, http.StatusNotFound)
+ return
+ }
+ m, err := yamlToMap(data)
+ if err != nil {
+ http.Error(w, `{"error":"failed to parse item yaml"}`, http.StatusInternalServerError)
+ return
+ }
+ m["_path"] = path
+ m["_raw"] = string(data)
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) updateItem(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := s.findItemFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"item not found"}`, http.StatusNotFound)
+ return
+ }
+
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+ m["id"] = id
+
+ oldContent, err := snapshotFile(path)
+ if err != nil {
+ http.Error(w, `{"error":"failed to read existing item"}`, http.StatusInternalServerError)
+ return
+ }
+
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write item"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Update item %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) deleteItem(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := s.findItemFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"item not found"}`, http.StatusNotFound)
+ return
+ }
+
+ oldContent := backupFile(path)
+
+ if err := os.Remove(path); err != nil {
+ http.Error(w, `{"error":"failed to delete item"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Delete item %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+
+ writeJSON(w, map[string]any{"deleted": id})
+}
+
+func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) {
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+ id, _ := m["id"].(string)
+ if strings.TrimSpace(id) == "" {
+ http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest)
+ return
+ }
+
+ if _, _, err := s.findItemFile(id); err == nil {
+ http.Error(w, `{"error":"item already exists"}`, http.StatusConflict)
+ return
+ }
+
+ path := filepath.Join(s.dataDir, "items", id+".yaml")
+
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write item"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Create item %s", id),
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+
+ writeJSON(w, m)
+}
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
new file mode 100644
index 0000000..1520279
--- /dev/null
+++ b/internal/admin/api_map.go
@@ -0,0 +1,241 @@
+package admin
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+
+ "thehouseoficarus/internal/world"
+)
+
+func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ return
+ }
+
+ zStr := r.URL.Query().Get("z")
+ z, err := strconv.Atoi(zStr)
+ if err != nil {
+ z = 0
+ }
+
+ dir := r.URL.Query().Get("dir")
+ seed := s.cfg.StartingRoom
+ if dir != "" {
+ base := filepath.Join(s.dataDir, "rooms", dir)
+ if low, ok := findLowestRoom(base); ok {
+ seed = low
+ }
+ }
+
+ g := world.BuildGrid(seed, func(id int) (*world.Room, bool) {
+ room, err := s.world.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return room, true
+ }, nil, nil)
+
+ type RoomEntry struct {
+ ID int `json:"id"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ Symbol string `json:"symbol"`
+ }
+
+ type LinkEntry struct {
+ From int `json:"from"`
+ To int `json:"to"`
+ Dir string `json:"dir"`
+ Bidirectional bool `json:"bidirectional"`
+ }
+
+ type UDLink struct {
+ From int `json:"from"`
+ To int `json:"to"`
+ }
+
+ roomData := make(map[int]*world.Room)
+ var rooms []RoomEntry
+
+ minX, maxX := 0, 0
+ minY, maxY := 0, 0
+ first := true
+
+ for rid, coord := range g.Coord {
+ if coord[2] != z {
+ continue
+ }
+
+ r, err := s.world.LoadRoom(rid)
+ if err != nil {
+ continue
+ }
+ roomData[rid] = r
+
+ rooms = append(rooms, RoomEntry{
+ ID: rid,
+ X: coord[0],
+ Y: coord[1],
+ Name: r.Name,
+ Color: r.Color,
+ })
+
+ if first {
+ minX, maxX = coord[0], coord[0]
+ minY, maxY = coord[1], coord[1]
+ first = false
+ } else {
+ if coord[0] < minX {
+ minX = coord[0]
+ }
+ if coord[0] > maxX {
+ maxX = coord[0]
+ }
+ if coord[1] < minY {
+ minY = coord[1]
+ }
+ if coord[1] > maxY {
+ maxY = coord[1]
+ }
+ }
+ }
+
+ var links []LinkEntry
+ var upLinks []UDLink
+ var downLinks []UDLink
+
+ seenLinks := make(map[string]bool)
+ for rid, room := range roomData {
+ c := g.Coord[rid]
+ for dir, exit := range room.Exits {
+ target := exit.Room
+ if target <= 0 {
+ continue
+ }
+ targetCoord, ok := g.Coord[target]
+ if !ok {
+ continue
+ }
+
+ if targetCoord[2] > c[2] {
+ upLinks = append(upLinks, UDLink{From: rid, To: target})
+ continue
+ }
+ if targetCoord[2] < c[2] {
+ downLinks = append(downLinks, UDLink{From: rid, To: target})
+ continue
+ }
+
+ if dir == world.Up || dir == world.Down {
+ continue
+ }
+
+ if _, onZ := roomData[target]; !onZ {
+ continue
+ }
+
+ bidirectional := false
+ if targetRoom, ok := roomData[target]; ok {
+ if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid {
+ bidirectional = true
+ }
+ }
+
+ key := linkKey(rid, target)
+ if bidirectional && seenLinks[key] {
+ continue
+ }
+ seenLinks[key] = true
+
+ links = append(links, LinkEntry{
+ From: rid,
+ To: target,
+ Dir: string(dir),
+ Bidirectional: bidirectional,
+ })
+ }
+ }
+
+ writeJSON(w, map[string]any{
+ "rooms": rooms,
+ "links": links,
+ "upLinks": upLinks,
+ "downLinks": downLinks,
+ "dir": getRoomDir(s, seed),
+ "bounds": map[string]int{
+ "minX": minX,
+ "maxX": maxX,
+ "minY": minY,
+ "maxY": maxY,
+ },
+ })
+}
+
+func getRoomDir(s *AdminServer, roomID int) string {
+ path, ok := s.world.GetRoomPath(roomID)
+ if !ok {
+ return ""
+ }
+ rel, err := filepath.Rel(filepath.Join(s.dataDir, "rooms"), filepath.Dir(path))
+ if err != nil || rel == "." {
+ return ""
+ }
+ return rel
+}
+
+func linkKey(a, b int) string {
+ if a < b {
+ return strconv.Itoa(a) + "-" + strconv.Itoa(b)
+ }
+ return strconv.Itoa(b) + "-" + strconv.Itoa(a)
+}
+
+func findLowestRoom(dir string) (int, bool) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return 0, false
+ }
+ lowest := 0
+ found := false
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ if filepath.Ext(name) != ".yaml" {
+ continue
+ }
+ id, err := strconv.Atoi(name[:len(name)-5])
+ if err != nil || id <= 0 {
+ continue
+ }
+ if !found || id < lowest {
+ lowest = id
+ found = true
+ }
+ }
+ return lowest, found
+}
+
+func (s *AdminServer) handleNextRoomID(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ return
+ }
+ fromStr := r.URL.Query().Get("from")
+ fromID := 0
+ if fromStr != "" {
+ fromID, _ = strconv.Atoi(fromStr)
+ }
+ id, _, err := nextRoomIDInDir(s.dataDir, fromID)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ writeJSON(w, map[string]any{"id": id})
+}
diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go
new file mode 100644
index 0000000..5fdfbc8
--- /dev/null
+++ b/internal/admin/api_mobs.go
@@ -0,0 +1,163 @@
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (s *AdminServer) handleMobs(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listYAMLFiles(s.dataDir, "mobs")
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if ids == nil {
+ ids = []string{}
+ }
+ writeJSON(w, ids)
+
+ case http.MethodPost:
+ s.createMob(w, r)
+
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleMobByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/mobs/")
+ if id == "" {
+ http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.getMob(w, r, id)
+ case http.MethodPut:
+ s.updateMob(w, r, id)
+ case http.MethodDelete:
+ s.deleteMob(w, r, id)
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) findMobFile(id string) ([]byte, string, error) {
+ data, path, err := readYAMLFile(s.dataDir, "mobs", id)
+ if err == nil {
+ return data, path, nil
+ }
+ return findYAMLFileInSubdirs(s.dataDir, "mobs", id)
+}
+
+func (s *AdminServer) getMob(w http.ResponseWriter, r *http.Request, id string) {
+ data, path, err := s.findMobFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound)
+ return
+ }
+ m, err := yamlToMap(data)
+ if err != nil {
+ http.Error(w, `{"error":"failed to parse mob yaml"}`, http.StatusInternalServerError)
+ return
+ }
+ m["_path"] = path
+ m["_raw"] = string(data)
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) updateMob(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := s.findMobFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound)
+ return
+ }
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+ m["id"] = id
+
+ oldContent, err := snapshotFile(path)
+ if err != nil {
+ http.Error(w, `{"error":"failed to read existing mob"}`, http.StatusInternalServerError)
+ return
+ }
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write mob"}`, http.StatusInternalServerError)
+ return
+ }
+ s.mobStore.ReloadDefs(s.dataDir)
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Update mob %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) deleteMob(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := s.findMobFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound)
+ return
+ }
+
+ oldContent := backupFile(path)
+
+ if err := os.Remove(path); err != nil {
+ http.Error(w, `{"error":"failed to delete mob"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.mobStore.ReloadDefs(s.dataDir)
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Delete mob %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+
+ writeJSON(w, map[string]any{"deleted": id})
+}
+
+func (s *AdminServer) createMob(w http.ResponseWriter, r *http.Request) {
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+ id, _ := m["id"].(string)
+ if strings.TrimSpace(id) == "" {
+ http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest)
+ return
+ }
+ if _, _, err := s.findMobFile(id); err == nil {
+ http.Error(w, `{"error":"mob already exists"}`, http.StatusConflict)
+ return
+ }
+ path := filepath.Join(s.dataDir, "mobs", id+".yaml")
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write mob"}`, http.StatusInternalServerError)
+ return
+ }
+ s.mobStore.ReloadDefs(s.dataDir)
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Create mob %s", id),
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+ writeJSON(w, m)
+}
diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go
new file mode 100644
index 0000000..e676cd7
--- /dev/null
+++ b/internal/admin/api_modules.go
@@ -0,0 +1,138 @@
+package admin
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (s *AdminServer) handleModules(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listYAMLFiles(s.dataDir, "modules")
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if ids == nil {
+ ids = []string{}
+ }
+ writeJSON(w, ids)
+ case http.MethodPost:
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid json"})
+ return
+ }
+ id, _ := m["id"].(string)
+ if strings.TrimSpace(id) == "" {
+ writeJSON(w, map[string]any{"error": "missing id"})
+ return
+ }
+ delete(m, "id")
+ path := filepath.Join(s.dataDir, "modules", id+".yaml")
+ if _, err := os.Stat(path); err == nil {
+ writeJSON(w, map[string]any{"error": "module already exists"})
+ return
+ }
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Created module " + id,
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/modules/")
+ if id == "" {
+ http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ data, path, err := findYAMLFileInSubdirs(s.dataDir, "modules", id)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + id})
+ return
+ }
+ raw, err := yamlToMap(data)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "parse error: " + err.Error()})
+ return
+ }
+ raw["id"] = id
+ raw["_path"] = path
+ raw["_raw"] = string(data)
+ writeJSON(w, raw)
+
+ case http.MethodPost, http.MethodPut:
+ var body map[string]any
+ if err := readJSON(r, &body); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ delete(body, "id")
+ delete(body, "_path")
+ delete(body, "_raw")
+
+ path := filepath.Join(s.dataDir, "modules", id+".yaml")
+ var oldContent []byte
+ if existing, err := snapshotFile(path); err == nil {
+ oldContent = existing
+ }
+
+ newContent, err := writeMapAsYAML(path, body)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+
+ isCreate := oldContent == nil
+ desc := "Updated module " + id
+ if isCreate {
+ desc = "Created module " + id
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: desc,
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ IsCreate: isCreate,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ case http.MethodDelete:
+ _, path, err := findYAMLFileInSubdirs(s.dataDir, "modules", id)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + id})
+ return
+ }
+ oldContent, _ := snapshotFile(path)
+ if err := os.Remove(path); err != nil {
+ writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Deleted module " + id,
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go
new file mode 100644
index 0000000..e4a2c60
--- /dev/null
+++ b/internal/admin/api_objects.go
@@ -0,0 +1,166 @@
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (s *AdminServer) handleObjects(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listYAMLFiles(s.dataDir, "objects")
+ if err != nil {
+ http.Error(w, `{"error":"failed to list objects"}`, http.StatusInternalServerError)
+ return
+ }
+ if ids == nil {
+ ids = []string{}
+ }
+ writeJSON(w, ids)
+ case http.MethodPost:
+ s.createObject(w, r)
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleObjectByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/objects/")
+ if id == "" {
+ http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.getObject(w, r, id)
+ case http.MethodPut:
+ s.updateObject(w, r, id)
+ case http.MethodDelete:
+ s.deleteObject(w, r, id)
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) findObjectFile(id string) ([]byte, string, error) {
+ data, path, err := readYAMLFile(s.dataDir, "objects", id)
+ if err == nil {
+ return data, path, nil
+ }
+ return findYAMLFileInSubdirs(s.dataDir, "objects", id)
+}
+
+func (s *AdminServer) getObject(w http.ResponseWriter, r *http.Request, id string) {
+ data, path, err := s.findObjectFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"object not found"}`, http.StatusNotFound)
+ return
+ }
+ m, err := yamlToMap(data)
+ if err != nil {
+ http.Error(w, `{"error":"failed to parse object yaml"}`, http.StatusInternalServerError)
+ return
+ }
+ m["_path"] = path
+ m["_raw"] = string(data)
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) updateObject(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := s.findObjectFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"object not found"}`, http.StatusNotFound)
+ return
+ }
+
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+ m["id"] = id
+
+ oldContent, err := snapshotFile(path)
+ if err != nil {
+ http.Error(w, `{"error":"failed to read existing object"}`, http.StatusInternalServerError)
+ return
+ }
+
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write object"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Update object %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) deleteObject(w http.ResponseWriter, r *http.Request, id string) {
+ _, path, err := s.findObjectFile(id)
+ if err != nil {
+ http.Error(w, `{"error":"object not found"}`, http.StatusNotFound)
+ return
+ }
+
+ oldContent := backupFile(path)
+
+ if err := os.Remove(path); err != nil {
+ http.Error(w, `{"error":"failed to delete object"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Delete object %s", id),
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+
+ writeJSON(w, map[string]any{"deleted": id})
+}
+
+func (s *AdminServer) createObject(w http.ResponseWriter, r *http.Request) {
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
+ return
+ }
+ id, _ := m["id"].(string)
+ if strings.TrimSpace(id) == "" {
+ http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest)
+ return
+ }
+
+ if _, _, err := s.findObjectFile(id); err == nil {
+ http.Error(w, `{"error":"object already exists"}`, http.StatusConflict)
+ return
+ }
+
+ path := filepath.Join(s.dataDir, "objects", id+".yaml")
+
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ http.Error(w, `{"error":"failed to write object"}`, http.StatusInternalServerError)
+ return
+ }
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("Create object %s", id),
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+
+ writeJSON(w, m)
+}
diff --git a/internal/admin/api_players.go b/internal/admin/api_players.go
new file mode 100644
index 0000000..2d0c09b
--- /dev/null
+++ b/internal/admin/api_players.go
@@ -0,0 +1,77 @@
+package admin
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+
+ "thehouseoficarus/internal/player"
+)
+
+func (s *AdminServer) handlePlayers(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ nameFilter := r.URL.Query().Get("name")
+
+ if nameFilter != "" {
+ name, err := s.accountStore.FindCharacter(nameFilter)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "character not found: " + nameFilter})
+ return
+ }
+ charPath := s.accountStore.CharPath(name)
+ data, err := os.ReadFile(charPath)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "read error: " + err.Error()})
+ return
+ }
+ var p player.Player
+ if err := yaml.Unmarshal(data, &p); err != nil {
+ writeJSON(w, map[string]any{"error": "parse error: " + err.Error()})
+ return
+ }
+ writeJSON(w, p)
+ return
+ }
+
+ charsDir := filepath.Join(s.dataDir, "players", "characters")
+ entries, err := os.ReadDir(charsDir)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+
+ type playerSummary struct {
+ Name string `json:"name"`
+ Level int `json:"level"`
+ Room int `json:"room"`
+ }
+
+ var players []playerSummary
+ for _, e := range entries {
+ if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
+ continue
+ }
+ rawName := strings.TrimSuffix(e.Name(), ".yaml")
+ data, err := os.ReadFile(filepath.Join(charsDir, e.Name()))
+ if err != nil {
+ continue
+ }
+ var p player.Player
+ if err := yaml.Unmarshal(data, &p); err != nil {
+ continue
+ }
+ players = append(players, playerSummary{
+ Name: rawName,
+ Level: p.CombatLevel(),
+ Room: p.RoomID,
+ })
+ }
+ writeJSON(w, players)
+}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
new file mode 100644
index 0000000..985eb49
--- /dev/null
+++ b/internal/admin/api_rooms.go
@@ -0,0 +1,561 @@
+package admin
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/world"
+)
+
+func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listRoomIDs(s.dataDir)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if ids == nil {
+ ids = []int{}
+ }
+ writeJSON(w, ids)
+
+ case http.MethodPost:
+ body, err := io.ReadAll(r.Body)
+ r.Body.Close()
+ if err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("read body: %v", err)})
+ return
+ }
+
+ var m map[string]any
+ if err := json.Unmarshal(body, &m); err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid json: %v", err)})
+ return
+ }
+ if m == nil {
+ m = map[string]any{}
+ }
+
+ var linkFrom *int
+ var linkDir *string
+ if v, ok := m["link_from"]; ok {
+ if f, ok := v.(float64); ok {
+ lf := int(f)
+ linkFrom = &lf
+ }
+ delete(m, "link_from")
+ }
+ if v, ok := m["link_dir"]; ok {
+ if d, ok := v.(string); ok && d != "" {
+ ld := d
+ linkDir = &ld
+ }
+ delete(m, "link_dir")
+ }
+
+ fromID := 0
+ if linkFrom != nil {
+ fromID = *linkFrom
+ }
+ id, subdir, allocErr := nextRoomIDInDir(s.dataDir, fromID)
+ if allocErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("alloc id: %v", allocErr)})
+ return
+ }
+ m["id"] = id
+
+ exits, _ := m["exits"].(map[string]any)
+ if exits == nil {
+ exits = make(map[string]any)
+ m["exits"] = exits
+ }
+
+ if linkFrom != nil && linkDir != nil && *linkDir != "" {
+ dir := world.ExitDir(strings.ToLower(*linkDir))
+ if opp, ok := world.OppositeExit[dir]; ok {
+ srcRoom, loadErr := s.world.LoadRoom(*linkFrom)
+ if loadErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("load link_from room %d: %v", *linkFrom, loadErr)})
+ return
+ }
+
+ if srcRoom.Exits == nil {
+ srcRoom.Exits = make(map[world.ExitDir]world.ExitDef)
+ }
+ srcRoom.Exits[dir] = world.ExitDef{Room: id}
+ srcPath, srcOk := s.world.GetRoomPath(*linkFrom)
+ if srcOk {
+ oldContent, _ := snapshotFile(srcPath)
+ newContent, writeErr := writeYAMLFile(srcPath, srcRoom)
+ if writeErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("write source room: %v", writeErr)})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("update room %d (add exit %s to %d)", *linkFrom, *linkDir, id),
+ FilePath: srcPath,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+ }
+
+ exits[string(opp)] = float64(*linkFrom)
+ }
+ }
+
+ path := filepath.Join(subdir, strconv.Itoa(id)+".yaml")
+ newContent, writeErr := writeMapAsYAML(path, m)
+ if writeErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("write room: %v", writeErr)})
+ return
+ }
+
+ s.world.AddRoomPath(id, path)
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("create room %d", id),
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+
+ writeJSON(w, map[string]any{"room": m})
+
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
+ idStr := strings.TrimPrefix(r.URL.Path, "/api/rooms/")
+ if idx := strings.IndexByte(idStr, '/'); idx >= 0 {
+ idStr = idStr[:idx]
+ }
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "invalid room id"})
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ path, ok := s.world.GetRoomPath(id)
+ if !ok {
+ writeJSON(w, map[string]any{"error": "room not found"})
+ return
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ m, err := yamlToMap(data)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "yaml parse error"})
+ return
+ }
+ m["id"] = id
+ writeJSON(w, map[string]any{"room": m, "path": path, "file": path, "raw": string(data)})
+
+ case http.MethodPut:
+ path, ok := s.world.GetRoomPath(id)
+ if !ok {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)})
+ return
+ }
+
+ oldContent, snapErr := snapshotFile(path)
+ if snapErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("snapshot room: %v", snapErr)})
+ return
+ }
+
+ var m map[string]any
+ body, readErr := io.ReadAll(r.Body)
+ r.Body.Close()
+ if readErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("read body: %v", readErr)})
+ return
+ }
+ if err := json.Unmarshal(body, &m); err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("invalid json: %v", err)})
+ return
+ }
+ if m == nil {
+ m = map[string]any{}
+ }
+ m["id"] = id
+
+ newContent, writeErr := writeMapAsYAML(path, m)
+ if writeErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("write room: %v", writeErr)})
+ return
+ }
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("update room %d", id),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ })
+
+ writeJSON(w, map[string]any{"room": m})
+
+ case http.MethodDelete:
+ path, ok := s.world.GetRoomPath(id)
+ if !ok {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)})
+ return
+ }
+
+ oldContent, backupErr := snapshotFile(path)
+ if backupErr != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("backup room: %v", backupErr)})
+ return
+ }
+
+ if err := os.Remove(path); err != nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("delete room: %v", err)})
+ return
+ }
+
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.world.ClearRoomState(id)
+
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("delete room %d", id),
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+
+ writeJSON(w, map[string]any{"ok": true})
+
+ default:
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ }
+}
+
+func listRoomIDs(dataDir string) ([]int, error) {
+ var ids []int
+ base := filepath.Join(dataDir, "rooms")
+ err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() && filepath.Ext(d.Name()) == ".yaml" {
+ idStr := d.Name()[:len(d.Name())-5]
+ id, convErr := strconv.Atoi(idStr)
+ if convErr == nil {
+ ids = append(ids, id)
+ }
+ }
+ return nil
+ })
+ if os.IsNotExist(err) {
+ return []int{}, nil
+ }
+ return ids, err
+}
+
+func (s *AdminServer) handleRoomDirs(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ entries, err := os.ReadDir(filepath.Join(s.dataDir, "rooms"))
+ if err != nil {
+ writeJSON(w, []string{""})
+ return
+ }
+ dirs := []string{""}
+ for _, e := range entries {
+ if e.IsDir() {
+ dirs = append(dirs, e.Name())
+ }
+ }
+ writeJSON(w, dirs)
+}
+
+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"`
+ }
+ if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 {
+ writeJSON(w, map[string]any{"error": "invalid body, need from and to"})
+ return
+ }
+ grid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) {
+ room, err := s.world.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return room, true
+ }, nil, nil)
+
+ cA, okA := grid.Coord[body.From]
+ cB, okB := grid.Coord[body.To]
+ if !okA || !okB || cA[2] != cB[2] {
+ writeJSON(w, map[string]any{"error": "rooms must be on same z-level"})
+ return
+ }
+ dx, dy := cB[0]-cA[0], cB[1]-cA[1]
+ var dir, oppDir world.ExitDir
+ for d, delta := range world.DirectionDeltas3D {
+ if delta[0] == dx && delta[1] == dy && delta[2] == 0 {
+ dir = d
+ oppDir = world.OppositeExit[d]
+ break
+ }
+ }
+ if dir == "" {
+ writeJSON(w, map[string]any{"error": "rooms are not adjacent"})
+ return
+ }
+
+ roomA, _ := s.world.LoadRoom(body.From)
+ roomB, _ := s.world.LoadRoom(body.To)
+ if roomA.Exits == nil {
+ roomA.Exits = make(map[world.ExitDir]world.ExitDef)
+ }
+ if roomB.Exits == nil {
+ roomB.Exits = make(map[world.ExitDir]world.ExitDef)
+ }
+ roomA.Exits[dir] = world.ExitDef{Room: body.To}
+ roomB.Exits[oppDir] = world.ExitDef{Room: body.From}
+
+ pathA, okA2 := s.world.GetRoomPath(body.From)
+ pathB, okB2 := s.world.GetRoomPath(body.To)
+ if !okA2 || !okB2 {
+ writeJSON(w, map[string]any{"error": "room path not found"})
+ return
+ }
+
+ oldA, _ := snapshotFile(pathA)
+ oldB, _ := snapshotFile(pathB)
+ newA, err := writeYAMLFile(pathA, roomA)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write A: " + err.Error()})
+ return
+ }
+ newB, err := writeYAMLFile(pathB, roomB)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write B: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("link %d %s %d", body.From, dir, body.To),
+ FilePath: pathA,
+ OldContent: oldA,
+ NewContent: newA,
+ })
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("link %d %s %d (reverse)", body.To, oppDir, body.From),
+ FilePath: pathB,
+ OldContent: oldB,
+ NewContent: newB,
+ })
+ writeJSON(w, map[string]any{"ok": true})
+
+ case http.MethodDelete:
+ var body struct {
+ From int `json:"from"`
+ To int `json:"to"`
+ }
+ if err := readJSON(r, &body); err != nil || body.From <= 0 || body.To <= 0 {
+ if r.Body != nil {
+ r.Body.Close()
+ }
+ writeJSON(w, map[string]any{"error": "invalid body"})
+ return
+ }
+ grid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) {
+ room, err := s.world.LoadRoom(id)
+ if err != nil {
+ return nil, false
+ }
+ return room, true
+ }, nil, nil)
+
+ cA, okA := grid.Coord[body.From]
+ cB, okB := grid.Coord[body.To]
+ if !okA || !okB || cA[2] != cB[2] {
+ writeJSON(w, map[string]any{"error": "rooms must be on same z-level"})
+ return
+ }
+ dx, dy := cB[0]-cA[0], cB[1]-cA[1]
+ var dir, oppDir world.ExitDir
+ for d, delta := range world.DirectionDeltas3D {
+ if delta[0] == dx && delta[1] == dy && delta[2] == 0 {
+ dir = d
+ oppDir = world.OppositeExit[d]
+ break
+ }
+ }
+ if dir == "" {
+ writeJSON(w, map[string]any{"error": "rooms are not adjacent"})
+ return
+ }
+
+ roomA, _ := s.world.LoadRoom(body.From)
+ roomB, _ := s.world.LoadRoom(body.To)
+ if roomA.Exits != nil {
+ delete(roomA.Exits, dir)
+ }
+ if roomB.Exits != nil {
+ delete(roomB.Exits, oppDir)
+ }
+
+ pathA, okA2 := s.world.GetRoomPath(body.From)
+ pathB, okB2 := s.world.GetRoomPath(body.To)
+ if !okA2 || !okB2 {
+ writeJSON(w, map[string]any{"error": "room path not found"})
+ return
+ }
+
+ oldA, _ := snapshotFile(pathA)
+ oldB, _ := snapshotFile(pathB)
+ newA, err := writeYAMLFile(pathA, roomA)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write A: " + err.Error()})
+ return
+ }
+ newB, err := writeYAMLFile(pathB, roomB)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write B: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("unlink %d %s %d", body.From, dir, body.To),
+ FilePath: pathA,
+ OldContent: oldA,
+ NewContent: newA,
+ })
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, oppDir, body.From),
+ FilePath: pathB,
+ OldContent: oldB,
+ NewContent: newB,
+ })
+ writeJSON(w, map[string]any{"ok": true})
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleRoomMove(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ ID int `json:"id"`
+ Dir string `json:"dir"`
+ }
+ if err := readJSON(r, &body); err != nil || body.ID <= 0 {
+ writeJSON(w, map[string]any{"error": "invalid"})
+ return
+ }
+ oldPath, ok := s.world.GetRoomPath(body.ID)
+ if !ok {
+ writeJSON(w, map[string]any{"error": "room not found"})
+ return
+ }
+ dir := strings.TrimSpace(body.Dir)
+ destDir := filepath.Join(s.dataDir, "rooms", dir)
+ if _, err := os.Stat(destDir); os.IsNotExist(err) {
+ writeJSON(w, map[string]any{"error": "directory does not exist: " + dir})
+ return
+ }
+ fileName := strconv.Itoa(body.ID) + ".yaml"
+ newPath := filepath.Join(destDir, fileName)
+ if _, err := os.Stat(newPath); err == nil {
+ writeJSON(w, map[string]any{"error": "room already exists in target directory"})
+ return
+ }
+ data, err := os.ReadFile(oldPath)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "read: " + err.Error()})
+ return
+ }
+ if err := os.WriteFile(newPath, data, 0644); err != nil {
+ writeJSON(w, map[string]any{"error": "write: " + err.Error()})
+ return
+ }
+ if err := os.Remove(oldPath); err != nil {
+ writeJSON(w, map[string]any{"error": "remove old: " + err.Error()})
+ return
+ }
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("move room %d to %s/", body.ID, dir),
+ FilePath: oldPath,
+ NewFilePath: newPath,
+ OldContent: data,
+ NewContent: data,
+ })
+ writeJSON(w, map[string]any{"ok": true, "path": newPath})
+}
+
+func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ ID int `json:"id"`
+ NewID int `json:"new_id"`
+ }
+ if err := readJSON(r, &body); err != nil || body.ID <= 0 || body.NewID <= 0 {
+ writeJSON(w, map[string]any{"error": "invalid"})
+ return
+ }
+ if body.ID == body.NewID {
+ writeJSON(w, map[string]any{"ok": true})
+ return
+ }
+ oldPath, ok := s.world.GetRoomPath(body.ID)
+ if !ok {
+ writeJSON(w, map[string]any{"error": "room not found"})
+ return
+ }
+ dir := filepath.Dir(oldPath)
+ newPath := filepath.Join(dir, strconv.Itoa(body.NewID)+".yaml")
+ if _, err := os.Stat(newPath); err == nil {
+ writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists", body.NewID)})
+ return
+ }
+ data, err := os.ReadFile(oldPath)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "read: " + err.Error()})
+ return
+ }
+ if err := os.WriteFile(newPath, data, 0644); err != nil {
+ writeJSON(w, map[string]any{"error": "write: " + err.Error()})
+ return
+ }
+ if err := os.Remove(oldPath); err != nil {
+ writeJSON(w, map[string]any{"error": "remove old: " + err.Error()})
+ return
+ }
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.world.ClearRoomState(body.ID)
+ s.undoStack.Push(ChangeDesc{
+ Description: fmt.Sprintf("rename room %d to %d", body.ID, body.NewID),
+ FilePath: oldPath,
+ NewFilePath: newPath,
+ OldContent: data,
+ NewContent: data,
+ })
+ writeJSON(w, map[string]any{"ok": true})
+}
diff --git a/internal/admin/api_search.go b/internal/admin/api_search.go
new file mode 100644
index 0000000..d47dc0e
--- /dev/null
+++ b/internal/admin/api_search.go
@@ -0,0 +1,82 @@
+package admin
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+func (s *AdminServer) handleSearch(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
+ if q == "" {
+ writeJSON(w, map[string]any{"error": "missing query parameter 'q'"})
+ return
+ }
+
+ type searchResult struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ }
+
+ type searchResponse struct {
+ Rooms []searchResult `json:"rooms"`
+ Items []searchResult `json:"items"`
+ Mobs []searchResult `json:"mobs"`
+ Objects []searchResult `json:"objects"`
+ }
+
+ var resp searchResponse
+
+ roomIDs, _ := listYAMLFiles(s.dataDir, "rooms")
+ for _, idStr := range roomIDs {
+ roomID, err := strconv.Atoi(idStr)
+ if err != nil {
+ continue
+ }
+ room, err := s.world.LoadRoom(roomID)
+ if err != nil {
+ continue
+ }
+ if strings.Contains(strings.ToLower(room.Name), q) || strings.Contains(strings.ToLower(idStr), q) {
+ resp.Rooms = append(resp.Rooms, searchResult{ID: idStr, Name: room.Name})
+ }
+ }
+
+ for id := range s.itemStore.PathIndex() {
+ itemDef, err := s.itemStore.Load(id)
+ if err != nil {
+ continue
+ }
+ if strings.Contains(strings.ToLower(itemDef.Name), q) || strings.Contains(strings.ToLower(itemDef.ID), q) {
+ resp.Items = append(resp.Items, searchResult{ID: itemDef.ID, Name: itemDef.Name})
+ }
+ }
+
+ mobIDs, _ := listYAMLFiles(s.dataDir, "mobs")
+ for _, id := range mobIDs {
+ mobDef, err := s.mobStore.LoadDef(id)
+ if err != nil {
+ continue
+ }
+ if strings.Contains(strings.ToLower(mobDef.Name), q) || strings.Contains(strings.ToLower(mobDef.ID), q) {
+ resp.Mobs = append(resp.Mobs, searchResult{ID: mobDef.ID, Name: mobDef.Name})
+ }
+ }
+
+ for id := range s.objectStore.PathIndex() {
+ objDef, err := s.objectStore.Load(id)
+ if err != nil {
+ continue
+ }
+ if strings.Contains(strings.ToLower(objDef.Name), q) || strings.Contains(strings.ToLower(objDef.ID), q) {
+ resp.Objects = append(resp.Objects, searchResult{ID: objDef.ID, Name: objDef.Name})
+ }
+ }
+
+ writeJSON(w, resp)
+}
diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go
new file mode 100644
index 0000000..9b29647
--- /dev/null
+++ b/internal/admin/api_techs.go
@@ -0,0 +1,138 @@
+package admin
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ ids, err := listYAMLFiles(s.dataDir, "techs")
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ if ids == nil {
+ ids = []string{}
+ }
+ writeJSON(w, ids)
+ case http.MethodPost:
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid json"})
+ return
+ }
+ id, _ := m["id"].(string)
+ if strings.TrimSpace(id) == "" {
+ writeJSON(w, map[string]any{"error": "missing id"})
+ return
+ }
+ delete(m, "id")
+ path := filepath.Join(s.dataDir, "techs", id+".yaml")
+ if _, err := os.Stat(path); err == nil {
+ writeJSON(w, map[string]any{"error": "tech already exists"})
+ return
+ }
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Created tech " + id,
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/techs/")
+ if id == "" {
+ http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ data, path, err := findYAMLFileInSubdirs(s.dataDir, "techs", id)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + id})
+ return
+ }
+ raw, err := yamlToMap(data)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "parse error: " + err.Error()})
+ return
+ }
+ raw["id"] = id
+ raw["_path"] = path
+ raw["_raw"] = string(data)
+ writeJSON(w, raw)
+
+ case http.MethodPost, http.MethodPut:
+ var body map[string]any
+ if err := readJSON(r, &body); err != nil {
+ writeJSON(w, map[string]any{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ delete(body, "id")
+ delete(body, "_path")
+ delete(body, "_raw")
+
+ path := filepath.Join(s.dataDir, "techs", id+".yaml")
+ var oldContent []byte
+ if existing, err := snapshotFile(path); err == nil {
+ oldContent = existing
+ }
+
+ newContent, err := writeMapAsYAML(path, body)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
+ return
+ }
+
+ isCreate := oldContent == nil
+ desc := "Updated tech " + id
+ if isCreate {
+ desc = "Created tech " + id
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: desc,
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ IsCreate: isCreate,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ case http.MethodDelete:
+ _, path, err := findYAMLFileInSubdirs(s.dataDir, "techs", id)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": "not found: " + id})
+ return
+ }
+ oldContent, _ := snapshotFile(path)
+ if err := os.Remove(path); err != nil {
+ writeJSON(w, map[string]any{"error": "delete error: " + err.Error()})
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: "Deleted tech " + id,
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/internal/admin/id_alloc.go b/internal/admin/id_alloc.go
new file mode 100644
index 0000000..d79718b
--- /dev/null
+++ b/internal/admin/id_alloc.go
@@ -0,0 +1,64 @@
+package admin
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+)
+
+func nextRoomIDInDir(dataDir string, fromRoomID int) (int, string, error) {
+ base := filepath.Join(dataDir, "rooms")
+ subdir, err := findRoomSubdir(base, fromRoomID)
+ if err != nil {
+ subdir = base
+ }
+ used := map[int]bool{}
+ scanDir(subdir, used)
+ if len(used) == 0 {
+ return 1, subdir, nil
+ }
+ minID := 1<<31 - 1
+ for id := range used {
+ if id < minID {
+ minID = id
+ }
+ }
+ id := minID
+ for used[id] {
+ id++
+ }
+ return id, subdir, nil
+}
+
+func findRoomSubdir(base string, roomID int) (string, error) {
+ fileName := strconv.Itoa(roomID) + ".yaml"
+ var found string
+ filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
+ if err != nil || found != "" {
+ return nil
+ }
+ if !d.IsDir() && d.Name() == fileName {
+ found = filepath.Dir(path)
+ }
+ return nil
+ })
+ if found == "" {
+ return "", os.ErrNotExist
+ }
+ return found, nil
+}
+
+func scanDir(dir string, used map[int]bool) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, e := range entries {
+ if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" {
+ idStr := e.Name()[:len(e.Name())-5]
+ if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
+ used[id] = true
+ }
+ }
+ }
+}
diff --git a/internal/admin/server.go b/internal/admin/server.go
new file mode 100644
index 0000000..418a693
--- /dev/null
+++ b/internal/admin/server.go
@@ -0,0 +1,395 @@
+package admin
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "embed"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "log"
+ "math/big"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "thehouseoficarus/internal/config"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+//go:embed templates static
+var embedded embed.FS
+
+type AdminServer struct {
+ cfg *config.Config
+ accountStore *player.AccountStore
+ world *world.World
+ itemStore *item.ItemStore
+ objectStore *object.ObjectStore
+ mobStore *world.MobStore
+ dataDir string
+ httpServer *http.Server
+ undoStack *UndoStack
+ tmpl *template.Template
+ cookieSecret []byte
+}
+
+func NewServer(cfg *config.Config, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string) (*AdminServer, error) {
+ secret := make([]byte, 32)
+ if _, err := rand.Read(secret); err != nil {
+ return nil, fmt.Errorf("cookie secret: %w", err)
+ }
+
+ tmpl, err := template.New("").Funcs(template.FuncMap{
+ "json": func(v any) string {
+ b, _ := json.Marshal(v)
+ return string(b)
+ },
+ }).ParseFS(embedded, "templates/*.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse templates: %w", err)
+ }
+
+ cert, err := loadOrGenerateCert(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("admin cert: %w", err)
+ }
+
+ s := &AdminServer{
+ cfg: cfg,
+ accountStore: accountStore,
+ world: w,
+ itemStore: is,
+ objectStore: os,
+ mobStore: ms,
+ dataDir: dataDir,
+ undoStack: NewUndoStack(dataDir),
+ tmpl: tmpl,
+ cookieSecret: secret,
+ }
+
+ mux := http.NewServeMux()
+
+ staticFS, err := fs.Sub(embedded, "static")
+ if err != nil {
+ return nil, fmt.Errorf("static subfs: %w", err)
+ }
+ mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
+
+ mux.HandleFunc("/login", s.handleLogin)
+ mux.HandleFunc("/logout", s.handleLogout)
+
+ apiMux := http.NewServeMux()
+ apiMux.HandleFunc("/api/map", s.handleMap)
+ apiMux.HandleFunc("/api/room-dirs", s.handleRoomDirs)
+ apiMux.HandleFunc("/api/rooms/link", s.handleRoomLink)
+ apiMux.HandleFunc("/api/rooms/move", s.handleRoomMove)
+ apiMux.HandleFunc("/api/rooms/rename", s.handleRoomRename)
+ apiMux.HandleFunc("/api/rooms", s.handleRooms)
+ apiMux.HandleFunc("/api/rooms/", s.handleRoomByID)
+ apiMux.HandleFunc("/api/objects", s.handleObjects)
+ apiMux.HandleFunc("/api/objects/", s.handleObjectByID)
+ apiMux.HandleFunc("/api/items", s.handleItems)
+ apiMux.HandleFunc("/api/items/", s.handleItemByID)
+ apiMux.HandleFunc("/api/mobs", s.handleMobs)
+ apiMux.HandleFunc("/api/mobs/", s.handleMobByID)
+ apiMux.HandleFunc("/api/drops", s.handleDrops)
+ apiMux.HandleFunc("/api/drops/", s.handleDropByID)
+ apiMux.HandleFunc("/api/hazards", s.handleHazards)
+ apiMux.HandleFunc("/api/hazards/", s.handleHazardByID)
+ apiMux.HandleFunc("/api/techs", s.handleTechs)
+ apiMux.HandleFunc("/api/techs/", s.handleTechByID)
+ apiMux.HandleFunc("/api/courses", s.handleCourses)
+ apiMux.HandleFunc("/api/courses/", s.handleCourseByID)
+ apiMux.HandleFunc("/api/modules", s.handleModules)
+ apiMux.HandleFunc("/api/modules/", s.handleModuleByID)
+ apiMux.HandleFunc("/api/players", s.handlePlayers)
+ apiMux.HandleFunc("/api/dashboard", s.handleDashboard)
+ apiMux.HandleFunc("/api/flags", s.handleFlags)
+ apiMux.HandleFunc("/api/search", s.handleSearch)
+ apiMux.HandleFunc("/api/undo/state", s.handleUndoState)
+ apiMux.HandleFunc("/api/undo/undo", s.doUndo)
+ apiMux.HandleFunc("/api/undo/redo", s.doRedo)
+ apiMux.HandleFunc("/api/next-room-id", s.handleNextRoomID)
+ apiMux.HandleFunc("/api/files", s.handleFiles)
+
+ mux.Handle("/api/", s.authMiddleware(apiMux))
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if !s.checkAuth(r) {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+ page := strings.TrimPrefix(r.URL.Path, "/")
+ if page == "" {
+ page = "map"
+ }
+ data := map[string]any{
+ "Account": s.accountFromCookie(r),
+ "UndoStack": s.undoStack.Info(),
+ "Page": page,
+ }
+ s.renderPage(w, r, "layout", data)
+ })
+
+ mux.HandleFunc("/editor/", func(w http.ResponseWriter, r *http.Request) {
+ if !s.checkAuth(r) {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+ page := strings.TrimPrefix(r.URL.Path, "/editor/")
+ data := map[string]any{
+ "Account": s.accountFromCookie(r),
+ "UndoStack": s.undoStack.Info(),
+ "Page": page,
+ }
+ s.renderPage(w, r, "layout", data)
+ })
+
+ s.httpServer = &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.AdminHTTPS.Port),
+ Handler: mux,
+ TLSConfig: &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ MinVersion: tls.VersionTLS12,
+ },
+ }
+
+ return s, nil
+}
+
+func (s *AdminServer) ListenAndServe() error {
+ ln, err := net.Listen("tcp", s.httpServer.Addr)
+ if err != nil {
+ return fmt.Errorf("admin listen: %w", err)
+ }
+ tlsLn := tls.NewListener(ln, s.httpServer.TLSConfig)
+ return s.httpServer.Serve(tlsLn)
+}
+
+func (s *AdminServer) authMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !s.checkAuth(r) {
+ http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (s *AdminServer) checkAuth(r *http.Request) bool {
+ cookie, err := r.Cookie("admin_session")
+ if err != nil {
+ return false
+ }
+ parts := strings.SplitN(cookie.Value, ":", 2)
+ if len(parts) != 2 {
+ return false
+ }
+ account := parts[0]
+ sig := parts[1]
+
+ if !s.isAdminAccount(account) {
+ return false
+ }
+
+ mac := hmac.New(sha256.New, s.cookieSecret)
+ mac.Write([]byte(account))
+ expected := hex.EncodeToString(mac.Sum(nil))
+ return hmac.Equal([]byte(sig), []byte(expected))
+}
+
+func (s *AdminServer) isAdminAccount(name string) bool {
+ for _, a := range s.cfg.AdminHTTPS.AdminAccounts {
+ if strings.EqualFold(a, name) {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) {
+ mac := hmac.New(sha256.New, s.cookieSecret)
+ mac.Write([]byte(account))
+ sig := hex.EncodeToString(mac.Sum(nil))
+ http.SetCookie(w, &http.Cookie{
+ Name: "admin_session",
+ Value: account + ":" + sig,
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteStrictMode,
+ MaxAge: 86400,
+ })
+}
+
+func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ data := map[string]any{}
+ s.renderPage(w, r, "login.html", data)
+ return
+ }
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ account := strings.TrimSpace(r.FormValue("account"))
+ password := r.FormValue("password")
+
+ if !s.isAdminAccount(account) {
+ s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."})
+ return
+ }
+
+ acc, err := s.accountStore.LoadAccount(account)
+ if err != nil || !player.CheckPassword(password, acc.PasswordHash) {
+ s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."})
+ return
+ }
+
+ s.setAuthCookie(w, account)
+ http.Redirect(w, r, "/", http.StatusFound)
+}
+
+func (s *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "admin_session",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: true,
+ })
+ http.Redirect(w, r, "/login", http.StatusFound)
+}
+
+func (s *AdminServer) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
+ if data == nil {
+ data = map[string]any{}
+ }
+ if _, ok := data["Account"]; !ok {
+ if acc := s.accountFromCookie(r); acc != "" {
+ data["Account"] = acc
+ }
+ }
+ if _, ok := data["UndoStack"]; !ok {
+ data["UndoStack"] = s.undoStack.Info()
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
+ log.Printf("template error (%s): %v", name, err)
+ }
+}
+
+func (s *AdminServer) accountFromCookie(r *http.Request) string {
+ cookie, err := r.Cookie("admin_session")
+ if err != nil {
+ return ""
+ }
+ parts := strings.SplitN(cookie.Value, ":", 2)
+ if len(parts) != 2 {
+ return ""
+ }
+ return parts[0]
+}
+
+func loadOrGenerateCert(cfg *config.Config) (tls.Certificate, error) {
+ if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" {
+ return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
+ }
+ log.Printf("admin_https: using self-signed certificate (no shared tls cert configured)")
+ return generateSelfSignedCert()
+}
+
+func generateSelfSignedCert() (tls.Certificate, error) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err)
+ }
+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("serial number: %w", err)
+ }
+ now := time.Now()
+ tmpl := &x509.Certificate{
+ SerialNumber: serial,
+ Subject: pkix.Name{
+ CommonName: "THOI Admin Self-Signed Certificate",
+ },
+ NotBefore: now.Add(-1 * time.Hour),
+ NotAfter: now.Add(365 * 24 * time.Hour),
+ KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ IsCA: true,
+ DNSNames: []string{"localhost"},
+ IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
+ }
+ certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err)
+ }
+ return tls.Certificate{
+ Certificate: [][]byte{certDER},
+ PrivateKey: key,
+ }, nil
+}
+
+func writeJSON(w http.ResponseWriter, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(data); err != nil {
+ log.Printf("json encode error: %v", err)
+ }
+}
+
+func readJSON(r *http.Request, v any) error {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ return err
+ }
+ defer r.Body.Close()
+ return json.Unmarshal(body, v)
+}
+
+func (s *AdminServer) handleUndoState(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, s.undoStack.Info())
+}
+
+func (s *AdminServer) doUndo(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ change := s.undoStack.Undo()
+ if change == nil {
+ writeJSON(w, map[string]any{"message": "Nothing to undo"})
+ return
+ }
+ writeJSON(w, map[string]any{"message": "Undid: " + change.Description})
+}
+
+func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ change := s.undoStack.Redo()
+ if change == nil {
+ writeJSON(w, map[string]any{"message": "Nothing to redo"})
+ return
+ }
+ writeJSON(w, map[string]any{"message": "Redid: " + change.Description})
+}
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
new file mode 100644
index 0000000..c9d0ed5
--- /dev/null
+++ b/internal/admin/static/admin.css
@@ -0,0 +1,102 @@
+:root{--bg:#1a1a2e;--panel:#16213e;--text:#e0e0e0;--accent:#0f3460;--border:#2a2a4a;--hover:#1a3a6e;--danger:#c0392b;--success:#27ae60;--warn:#f39c12;--input-bg:#0d1b36;--input-border:#3a3a6a}
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100vh}
+.nav{background:var(--panel);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
+.nav a{color:var(--text);text-decoration:none;padding:4px 10px;border-radius:3px;font-size:13px}
+.nav a:hover,.nav a.active{background:var(--accent)}
+.nav .undo-bar{display:flex;gap:6px;margin-left:auto;align-items:center;font-size:12px}
+.nav .undo-bar button{padding:3px 8px;font-size:12px;border:1px solid var(--border);border-radius:3px;background:var(--input-bg);color:var(--text);cursor:pointer}
+.nav .undo-bar button:disabled{opacity:.4;cursor:default}
+.nav .undo-bar button:not(:disabled):hover{background:var(--accent)}
+.nav .undo-bar span{color:#888}
+.layout{display:flex;height:calc(100vh - 38px)}
+.main{flex:1;overflow:auto;position:relative}
+.panel{width:380px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0}
+.panel h2{font-size:15px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)}
+.form-group{margin-bottom:10px}
+.form-group label{display:block;font-size:11px;color:#aaa;margin-bottom:3px;text-transform:uppercase;letter-spacing:.5px}
+.form-group input,.form-group textarea,.form-group select{width:100%;padding:6px 8px;font-size:13px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.form-group textarea{resize:vertical;min-height:60px}
+.form-group input[type=checkbox]{width:auto;margin-right:6px}
+.btn{padding:6px 14px;border:none;border-radius:3px;cursor:pointer;font-size:12px;font-weight:bold;font-family:monospace}
+.btn-primary{background:var(--accent);color:var(--text);border:1px solid #1a5a8e}
+.btn-primary:hover{background:var(--hover)}
+.btn-danger{background:var(--danger);color:#fff}
+.btn-danger:hover{opacity:.9}
+.btn-success{background:var(--success);color:#fff}
+.btn-sm{padding:3px 8px;font-size:11px}
+.btn-row{display:flex;gap:6px;margin-top:12px}
+.table{width:100%;border-collapse:collapse;font-size:12px}
+.table th,.table td{padding:4px 8px;text-align:left;border-bottom:1px solid var(--border)}
+.table th{color:#aaa;font-weight:bold;font-size:10px;text-transform:uppercase}
+.table tr:hover{background:rgba(15,52,96,.3)}
+.table a{color:var(--text);text-decoration:none}
+.table a:hover{color:#fff}
+.search-bar{margin-bottom:12px}
+.search-bar input{width:100%;padding:6px 8px;font-size:13px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px}
+.login{max-width:360px;margin:80px auto;background:var(--panel);padding:30px;border:1px solid var(--border);border-radius:6px}
+.login h1{font-size:18px;margin-bottom:20px;text-align:center}
+.z-controls{position:absolute;top:12px;left:12px;display:flex;gap:6px;align-items:center;background:var(--panel);padding:6px 10px;border-radius:4px;border:1px solid var(--border);z-index:10}
+.z-controls button{width:28px;height:28px;font-size:16px;line-height:1;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
+.z-controls button:hover{background:var(--accent)}
+.z-controls .z-label{font-size:13px;min-width:40px;text-align:center;font-weight:bold}
+.room-tooltip{position:absolute;background:var(--panel);border:1px solid var(--border);padding:6px 10px;border-radius:3px;font-size:11px;pointer-events:none;z-index:20;white-space:nowrap}
+.notification{position:fixed;bottom:16px;right:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s}
+.notification.error{border-color:var(--danger)}
+.notification.success{border-color:var(--success)}
+@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
+.talk-tree .node{margin-left:16px;margin-bottom:6px;border-left:2px solid var(--accent);padding-left:10px}
+.talk-tree .node-header{display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer;font-size:12px}
+.talk-tree .node-header .key{color:#6cf;font-weight:bold}
+.talk-tree .node-header .preview{color:#aaa;font-size:11px}
+.talk-tree .node-children{display:none;padding:4px 0}
+.talk-tree .node.open>.node-children,.talk-tree .node.root>.node-children{display:block}
+.talk-tree .option{margin-left:10px;padding:3px 6px;font-size:11px;color:#8cf;border-left:1px solid #444}
+.talk-tree .add-btn{font-size:10px;padding:2px 6px;background:var(--accent);color:var(--text);border:none;border-radius:2px;cursor:pointer;margin:4px 0}
+.talk-tree .add-btn:hover{opacity:.8}
+.tabs{display:flex;gap:2px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0}
+.tab{padding:6px 14px;font-size:12px;background:transparent;color:#aaa;border:none;border-bottom:2px solid transparent;cursor:pointer;font-family:monospace}
+.tab:hover{color:var(--text)}
+.tab.active{color:var(--text);border-bottom-color:var(--accent)}
+.kv-row{display:flex;gap:6px;align-items:center;margin-bottom:6px}
+.kv-row input{flex:1;min-width:0}
+.kv-row button{flex-shrink:0}
+.color-swatch{display:inline-block;width:16px;height:16px;border-radius:2px;border:1px solid #555;vertical-align:middle;margin-right:4px}
+.color-picker{display:flex;flex-wrap:wrap;gap:2px;margin-top:4px;max-height:200px;overflow-y:auto}
+.color-picker .swatch{width:18px;height:18px;border:1px solid transparent;cursor:pointer;border-radius:1px}
+.color-picker .swatch:hover,.color-picker .swatch.selected{border-color:#fff;transform:scale(1.2);z-index:1}
+.editor-container{display:flex;height:calc(100vh - 38px)}
+.editor-list{width:260px;background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;padding:10px;flex-shrink:0}
+.editor-main{flex:1;overflow-y:auto;padding:16px}
+.editor-list .item{padding:5px 8px;font-size:12px;cursor:pointer;border-radius:3px;margin-bottom:2px}
+.editor-list .item:hover{background:rgba(15,52,96,.3)}
+.editor-list .item.active{background:var(--accent)}
+.editor-list h3{font-size:13px;margin-bottom:8px;color:#aaa}
+.editor-main pre{background:var(--input-bg);padding:12px;border-radius:4px;font-size:12px;overflow-x:auto;border:1px solid var(--border)}
+.form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 10px}
+.form-grid .form-group{margin-bottom:4px}
+.form-grid .form-group input,.form-grid .form-group select{font-size:11px;padding:4px 6px}
+.form-grid .form-group label{font-size:9px;margin-bottom:1px}
+.form-grid .form-group textarea{font-size:11px;padding:4px 6px;min-height:40px}
+.form-grid .form-group.full{grid-column:1/-1}
+.form-grid .form-group.full textarea{min-height:60px}
+.cp-grid{display:grid;grid-template-columns:repeat(16,1fr);gap:2px}
+.cp-swatch{aspect-ratio:1;border:1px solid rgba(255,255,255,.15);cursor:pointer;border-radius:2px;min-width:14px}
+.cp-swatch:hover{transform:scale(1.3);z-index:1;border-color:#fff}
+.cp-swatch.selected{outline:2px solid #fff;outline-offset:1px;z-index:1}
+.color-field{display:flex;gap:6px;align-items:center}
+.color-field input{flex:1;min-width:0;font-family:monospace;text-transform:uppercase}
+.color-field .color-swatch{width:24px;height:24px;flex-shrink:0;border-radius:3px;border:1px solid #555;cursor:pointer}
+.ghost-hover{cursor:pointer;transition:opacity .1s}
+.ghost-hover:hover{opacity:1!important}
+rect.ghost:hover{fill:rgba(100,200,255,0.28)!important;stroke:rgba(100,200,255,0.55)!important}
+g.ud-hover{cursor:pointer}
+g.ud-hover:hover rect{opacity:0.35!important}
+g.ud-hover text{font-size:11px}
+g.ud-hover:hover text{font-weight:bold}
+.cm-overlay{position:fixed;inset:0;z-index:199}
+.cm-menu{position:fixed;z-index:200;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 0;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.4)}
+.cm-menu button{display:block;width:100%;padding:6px 12px;text-align:left;font-size:12px;font-family:monospace;background:none;border:none;color:var(--text);cursor:pointer}
+.cm-menu button:hover{background:var(--accent)}
+.cm-menu button.danger{color:#f55}
+.cm-menu button.danger:hover{background:var(--danger)}
diff --git a/internal/admin/static/admin.js b/internal/admin/static/admin.js
new file mode 100644
index 0000000..cce3bc9
--- /dev/null
+++ b/internal/admin/static/admin.js
@@ -0,0 +1,55 @@
+window.API = {
+ async get(url) { const r = await fetch(url,{credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); },
+ async post(url,data) { const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); },
+ async put(url,data) { const r = await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); },
+ async del(url) { const r = await fetch(url,{method:'DELETE',credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); }
+};
+
+window.$ = (sel) => document.querySelector(sel);
+window.$$ = (sel) => document.querySelectorAll(sel);
+
+window.notify = function(msg, type) {
+ var el = document.createElement('div');
+ el.className = 'notification ' + (type || '');
+ el.textContent = msg;
+ document.body.appendChild(el);
+ setTimeout(function() { el.remove(); }, 3000);
+};
+
+window.undoState = null;
+
+window.updateUndoBar = function() {
+ API.get('/api/undo/state').then(function(s) {
+ undoState = s;
+ var uBtn = $('.undo-btn');
+ var rBtn = $('.redo-btn');
+ var uLabel = $('.undo-label');
+ if (uBtn) { uBtn.disabled = !s.can_undo; uBtn.title = s.undo_desc || ''; }
+ if (rBtn) { rBtn.disabled = !s.can_redo; rBtn.title = s.redo_desc || ''; }
+ if (uLabel) { uLabel.textContent = s.undo_desc || ''; }
+ }).catch(function() {});
+};
+
+window.doUndo = function() {
+ API.post('/api/undo/undo').then(function(r) { notify(r.message || 'Undo complete', 'success'); updateUndoBar(); if(window.refreshPage)refreshPage(); }).catch(function(e) { notify('Undo failed: '+e.message, 'error'); });
+};
+
+window.doRedo = function() {
+ API.post('/api/undo/redo').then(function(r) { notify(r.message || 'Redo complete', 'success'); updateUndoBar(); if(window.refreshPage)refreshPage(); }).catch(function(e) { notify('Redo failed: '+e.message, 'error'); });
+};
+
+window.addEventListener('keydown', function(e) {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
+ e.preventDefault();
+ if (e.shiftKey) { doRedo(); } else { doUndo(); }
+ }
+});
+
+window.saveForm = function(url, data, msg) {
+ API.put(url, data).then(function() {
+ updateUndoBar();
+ notify(msg || 'Saved', 'success');
+ }).catch(function(e) { notify('Save failed: '+e.message, 'error'); });
+};
+
+document.addEventListener('DOMContentLoaded', function() { updateUndoBar(); });
diff --git a/internal/admin/static/colorpicker.js b/internal/admin/static/colorpicker.js
new file mode 100644
index 0000000..9431e83
--- /dev/null
+++ b/internal/admin/static/colorpicker.js
@@ -0,0 +1,197 @@
+window.xtermToRGB = function(idx) {
+ idx = parseInt(idx);
+ if (idx < 0 || idx > 255) return [0,0,0];
+ if (idx < 16) {
+ var a = [[0,0,0],[170,0,0],[0,170,0],[170,170,0],[0,0,170],[170,0,170],[0,170,170],[170,170,170],
+ [85,85,85],[255,85,85],[85,255,85],[255,255,85],[85,85,255],[255,85,255],[85,255,255],[255,255,255]];
+ return a[idx];
+ }
+ if (idx < 232) {
+ var n = idx - 16;
+ var cube = [0,95,135,175,215,255];
+ return [cube[Math.floor(n/36)], cube[Math.floor((n%36)/6)], cube[n%6]];
+ }
+ var g = 8 + (idx-232)*10;
+ return [g,g,g];
+};
+
+window.xtermIdxToHex = function(idx) {
+ idx = parseInt(idx) || 0;
+ return idx.toString(16).toUpperCase().padStart(2,'0');
+};
+
+window.hexToXtermIdx = function(hex) {
+ return parseInt(hex, 16);
+};
+
+window.xtermToCss = function(idx) {
+ var rgb = xtermToRGB(idx);
+ return '#' + ((rgb[0]<<16)|(rgb[1]<<8)|rgb[2]).toString(16).padStart(6,'0').toUpperCase();
+};
+
+window.resolveColor = function(v) {
+ if (!v) return '#3a3a6a';
+ v = v.toUpperCase().replace(/[^0-9A-F]/g,'');
+ if (v.length === 2) return xtermToCss(parseInt(v,16));
+ return '#' + v.substring(0,6).padEnd(6,'0');
+};
+
+window.hexLuminance = function(hex) {
+ hex = hex.replace('#','').toUpperCase();
+ var r = parseInt(hex.substring(0,2),16)/255;
+ var g = parseInt(hex.substring(2,4),16)/255;
+ var b = parseInt(hex.substring(4,6),16)/255;
+ return 0.299*r + 0.587*g + 0.114*b;
+};
+
+window.cssToXtermIdx = function(css) {
+ css = css.replace('#','').toUpperCase();
+ if (css.length < 6) return 0;
+ var tr = parseInt(css.substring(0,2),16);
+ var tg = parseInt(css.substring(2,4),16);
+ var tb = parseInt(css.substring(4,6),16);
+ var best = 0, bestDist = Infinity;
+ for (var i = 0; i < 256; i++) {
+ var srgb = xtermToRGB(i);
+ var dr = tr-srgb[0], dg = tg-srgb[1], db = tb-srgb[2];
+ var d = dr*dr + dg*dg + db*db;
+ if (d < bestDist) { bestDist = d; best = i; }
+ }
+ return best;
+};
+
+(function() {
+ var pickerEl = null, boundInput = null, boundSwatch = null;
+
+ function ensurePicker() {
+ if (pickerEl) return;
+ pickerEl = document.createElement('div');
+ pickerEl.id = '__cp_popover';
+ pickerEl.style.cssText = 'display:none;position:fixed;z-index:200;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,.5);min-width:280px';
+ pickerEl.innerHTML = buildPickerHTML();
+ document.body.appendChild(pickerEl);
+
+ pickerEl.addEventListener('click', function(e) {
+ var sw = e.target.closest('.cp-swatch');
+ if (sw) {
+ var idx = parseInt(sw.getAttribute('data-idx'));
+ var hex = xtermIdxToHex(idx);
+ if (boundInput) boundInput.value = hex;
+ if (boundSwatch) boundSwatch.style.background = xtermToCss(idx);
+ if (boundInput) boundInput.dispatchEvent(new Event('input', {bubbles:true}));
+ hidePicker();
+ }
+ });
+ }
+
+ function buildPickerHTML() {
+ var h = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">';
+ h += '<span id="__cp_preview" class="color-swatch" style="width:24px;height:24px;flex-shrink:0"></span>';
+ h += '<input id="__cp_hex" style="flex:1;padding:3px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;text-transform:uppercase" maxlength="2" placeholder="00">';
+ h += '<button id="__cp_close" style="background:none;border:none;color:#aaa;cursor:pointer;font-size:14px;padding:0 4px">&times;</button>';
+ h += '</div>';
+ h += '<div class="cp-grid">';
+ for (var i = 0; i < 256; i++) {
+ var css = xtermToCss(i);
+ h += '<div class="cp-swatch" data-idx="'+i+'" style="background:'+css+'" title="'+xtermIdxToHex(i)+' ('+i+')"></div>';
+ }
+ h += '</div>';
+ return h;
+ }
+
+ window.showColorPicker = function(inputEl, swatchEl) {
+ ensurePicker();
+ boundInput = inputEl;
+ boundSwatch = swatchEl;
+
+ var raw = (inputEl.value || '').toUpperCase().replace(/[^0-9A-F]/g,'');
+ var idx;
+ if (raw.length === 2) {
+ idx = parseInt(raw, 16);
+ } else if (raw.length >= 6) {
+ idx = cssToXtermIdx(raw);
+ } else {
+ idx = parseInt(raw, 16) || 0;
+ }
+ var hex = xtermIdxToHex(idx);
+ inputEl.value = hex;
+ if (swatchEl) swatchEl.style.background = xtermToCss(idx);
+
+ $('#__cp_hex').value = hex;
+ $('#__cp_preview').style.background = xtermToCss(idx);
+ updatePickerSelection(idx);
+
+ var rect = inputEl.getBoundingClientRect();
+ pickerEl.style.display = 'block';
+ var pw = pickerEl.offsetWidth;
+ var left = rect.left;
+ if (left + pw > window.innerWidth - 10) left = Math.max(10, window.innerWidth - pw - 10);
+ pickerEl.style.left = left + 'px';
+ pickerEl.style.top = (rect.bottom + 4) + 'px';
+
+ $('#__cp_hex').oninput = function() {
+ var v = this.value.toUpperCase().replace(/[^0-9A-F]/g,'').substring(0,2);
+ this.value = v;
+ if (v.length === 2) {
+ var i = parseInt(v, 16);
+ inputEl.value = v;
+ if (swatchEl) swatchEl.style.background = xtermToCss(i);
+ updatePickerSelection(i);
+ }
+ };
+
+ $('#__cp_close').onclick = hidePicker;
+ };
+
+ function updatePickerSelection(idx) {
+ var swatches = document.querySelectorAll('.cp-swatch');
+ for (var i = 0; i < swatches.length; i++) swatches[i].classList.remove('selected');
+ var s = document.querySelector('.cp-swatch[data-idx="'+idx+'"]');
+ if (s) s.classList.add('selected');
+ }
+
+ window.hideColorPicker = hidePicker;
+ function hidePicker() {
+ if (pickerEl) pickerEl.style.display = 'none';
+ boundInput = null; boundSwatch = null;
+ }
+
+ document.addEventListener('click', function(e) {
+ if (!pickerEl || pickerEl.style.display === 'none') return;
+ if (!pickerEl.contains(e.target) && e.target !== boundInput && e.target !== (boundSwatch || {}).parentElement) {
+ hidePicker();
+ }
+ });
+
+ window.bindColorField = function(fieldEl) {
+ var input = fieldEl.querySelector('input[type=text]') || fieldEl.querySelector('input:not([type])');
+ var swatch = fieldEl.querySelector('.color-swatch');
+ if (!input) return;
+
+ fieldEl.addEventListener('click', function(e) {
+ if (e.target === input || e.target === swatch) {
+ showColorPicker(input, swatch);
+ }
+ });
+
+ var raw = (input.value || '').toUpperCase().replace(/[^0-9A-F]/g,'');
+ if (raw.length === 2) {
+ if (swatch) swatch.style.background = xtermToCss(parseInt(raw, 16));
+ } else if (raw.length >= 6) {
+ var idx = cssToXtermIdx(raw);
+ input.value = xtermIdxToHex(idx);
+ if (swatch) swatch.style.background = xtermToCss(idx);
+ }
+
+ input.addEventListener('input', function() {
+ var v = this.value.toUpperCase().replace(/[^0-9A-F]/g,'').substring(0,2);
+ this.value = v;
+ if (v.length === 2 && swatch) {
+ swatch.style.background = xtermToCss(parseInt(v, 16));
+ }
+ });
+ input.setAttribute('maxlength', '2');
+ input.setAttribute('placeholder', '00');
+ input.style.textTransform = 'uppercase';
+ };
+})();
diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js
new file mode 100644
index 0000000..d9ec36f
--- /dev/null
+++ b/internal/admin/static/editor.js
@@ -0,0 +1,174 @@
+var editorType = '';
+var editorFields = [];
+var currentID = null;
+var allIDs = [];
+
+function initEditor(type, fields) {
+ editorType = type;
+ editorFields = fields;
+ loadList();
+ var hash = window.location.hash.substring(1);
+ if (hash) { loadItem(hash); }
+}
+
+function loadList() {
+ API.get('/api/' + editorType).then(function(data) {
+ allIDs = data.ids || data || [];
+ renderList('');
+ }).catch(function(e) {
+ notify('Failed to load list: ' + e.message, 'error');
+ });
+}
+
+function renderList(filter) {
+ var el = $('#listEntries');
+ var f = filter.toLowerCase();
+ var filtered = allIDs.filter(function(id) { return !f || String(id).toLowerCase().indexOf(f) >= 0; });
+ el.innerHTML = filtered.map(function(id) {
+ return '<div class="item' + (id === currentID ? ' active' : '') + '" onclick="loadItem(\'' + esc(id) + '\')">' + esc(id) + '</div>';
+ }).join('');
+}
+
+function filterList(val) {
+ renderList(val);
+}
+
+function loadItem(id) {
+ currentID = id;
+ renderList('');
+ window.location.hash = id;
+ API.get('/api/' + editorType + '/' + encodeURIComponent(id)).then(function(data) {
+ renderEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function renderEditor(id, data) {
+ var obj = data;
+ var html = '<h2>' + esc(editorType.charAt(0).toUpperCase() + editorType.slice(1)) + ': ' + esc(id) + '</h2>';
+
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+ html += '<div class="form-grid">';
+ editorFields.forEach(function(f) {
+ var val = getNested(obj, f.key);
+ val = val === undefined || val === null ? '' : val;
+ var cls = f.type === 'textarea' ? 'form-group full' : 'form-group';
+ if (f.type === 'textarea') {
+ var displayVal;
+ if (f.encode) {
+ displayVal = f.encode(val);
+ } else if (typeof val === 'object') {
+ displayVal = JSON.stringify(val, null, 2);
+ } else {
+ displayVal = String(val);
+ }
+ html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><textarea id="f_' + esc(f.key) + '" rows="4">' + esc(displayVal) + '</textarea></div>';
+ } else if (f.type === 'checkbox') {
+ html += '<div class="'+cls+'"><label><input type="checkbox" id="f_' + esc(f.key) + '"' + (val ? ' checked' : '') + '> ' + esc(f.label) + '</label></div>';
+ } else if (f.type === 'number') {
+ html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><input type="number" id="f_' + esc(f.key) + '" value="' + escAttr(String(val)) + '"></div>';
+ } else if (f.type === 'color') {
+ html += '<div class="'+cls+' color-field"><label>' + esc(f.label) + '</label><span class="color-swatch" style="background:#' + escAttr(String(val) || '808080') + '"></span><input id="f_' + esc(f.key) + '" value="' + escAttr(String(val) || '') + '"></div>';
+ } else {
+ html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><input id="f_' + esc(f.key) + '" value="' + escAttr(String(val)) + '"></div>';
+ }
+ });
+ html += '</div>';
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(obj._raw || JSON.stringify(obj, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveItem(\'' + esc(id) + '\')">Save</button>';
+ html += '<button class="btn btn-danger" onclick="deleteItem(\'' + esc(id) + '\')">Delete</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ setTimeout(function() {
+ document.querySelectorAll('.color-field').forEach(function(el) { bindColorField(el); });
+ }, 50);
+}
+
+function saveItem(id) {
+ var data = {};
+ editorFields.forEach(function(f) {
+ var el = $('#f_' + f.key);
+ if (!el) return;
+ var val;
+ if (f.type === 'checkbox') {
+ val = el.checked;
+ } else if (f.type === 'number') {
+ val = parseFloat(el.value) || 0;
+ } else if (f.type === 'textarea') {
+ val = el.value;
+ } else {
+ val = el.value;
+ }
+ if (f.parser) {
+ val = f.parser(val);
+ }
+ setNested(data, f.key, val);
+ });
+ data.id = id;
+
+ API.put('/api/' + editorType + '/' + encodeURIComponent(id), data).then(function() {
+ notify(editorType + ' ' + id + ' saved', 'success');
+ updateUndoBar();
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+function deleteItem(id) {
+ if (!confirm('Delete ' + editorType + ' "' + id + '"?')) return;
+ API.del('/api/' + editorType + '/' + encodeURIComponent(id)).then(function() {
+ notify(editorType + ' ' + id + ' deleted', 'success');
+ updateUndoBar();
+ currentID = null;
+ $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
+
+function createNew() {
+ var name = prompt(editorType + ' ID:');
+ if (!name) return;
+ API.post('/api/' + editorType, {id: name}).then(function() {
+ notify(editorType + ' ' + name + ' created', 'success');
+ updateUndoBar();
+ loadList();
+ loadItem(name);
+ }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); });
+}
+
+function switchTab(e, tab) {
+ e.target.parentElement.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
+ e.target.classList.add('active');
+ var tabs = $('#editorMain').querySelectorAll('.tab-content');
+ tabs.forEach(function(t) { t.style.display = 'none'; });
+ var target = document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1));
+ if (target) target.style.display = 'block';
+}
+
+function getNested(obj, path) {
+ return path.split('.').reduce(function(o, k) { return o && o[k] !== undefined ? o[k] : undefined; }, obj);
+}
+
+function setNested(obj, path, val) {
+ var keys = path.split('.');
+ var last = keys.pop();
+ var target = keys.reduce(function(o, k) {
+ if (!o[k]) o[k] = {};
+ return o[k];
+ }, obj);
+ target[last] = val;
+}
+
+function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+function escAttr(s) { return String(s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;'); }
+
+window.refreshPage = function() { loadList(); if (currentID) loadItem(currentID); };
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
new file mode 100644
index 0000000..3443339
--- /dev/null
+++ b/internal/admin/static/map.js
@@ -0,0 +1,775 @@
+var CELL = 100;
+var currentZ = 0, currentDir = '', selectedRoom = null, mapData = null;
+var panX = 0, panY = 0, scale = 1;
+var dragging = false, startX = 0, startY = 0, prevX = 0, prevY = 0;
+var dragRoom = false;
+var linkDrag = false, linkFromId = null, linkTargetId = null, linkGhostDir = null;
+var delDrag = false, delFromId = null, delTargetId = null;
+var roomMap = {}, occupied = {};
+var gridDirs = [
+ {dx:0,dy:-1,dir:'north'},{dx:0,dy:1,dir:'south'},
+ {dx:1,dy:0,dir:'east'},{dx:-1,dy:0,dir:'west'},
+ {dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'},
+ {dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'}
+];
+var saveTimer = null;
+var upTarget = {}, downTarget = {};
+
+function changeZ(dz) {
+ if (dz === 0) currentZ = 0; else currentZ += dz;
+ loadMap();
+}
+
+function changeDir(dir) {
+ currentDir = dir;
+ loadMap();
+}
+
+function loadMap() {
+ $('#zLabel').textContent = 'Z=' + currentZ;
+ var url = '/api/map?z=' + currentZ;
+ if (currentDir) url += '&dir=' + encodeURIComponent(currentDir);
+ API.get(url).then(function(data) {
+ mapData = data;
+ if (!currentDir && data.dir) {
+ currentDir = data.dir;
+ }
+ updateDirSelect();
+ renderMap(data);
+ }).catch(function(e) {
+ notify('Failed to load map: ' + e.message, 'error');
+ });
+}
+
+function renderMap(data) {
+ var svg = $('#mapSvg');
+ var container = $('#mapContainer');
+ var W = container.clientWidth;
+ var H = container.clientHeight;
+
+ roomMap = {};
+ if (data.rooms) data.rooms.forEach(function(r) { roomMap[r.id] = r; });
+
+ occupied = {};
+ if (data.rooms) data.rooms.forEach(function(r) { occupied[r.x+','+r.y] = r.id; });
+
+ upTarget = {}; downTarget = {};
+ if (data.upLinks) data.upLinks.forEach(function(l) { upTarget[l.from] = l.to; });
+ if (data.downLinks) data.downLinks.forEach(function(l) { downTarget[l.from] = l.to; });
+
+ var svgNS = 'http://www.w3.org/2000/svg';
+ var g = '';
+
+ if (data.links) {
+ data.links.forEach(function(l) {
+ var fr = roomMap[l.from], tr = roomMap[l.to];
+ if (!fr || !tr) return;
+ var x1 = fr.x * CELL + CELL/2, y1 = fr.y * CELL + CELL/2;
+ var x2 = tr.x * CELL + CELL/2, y2 = tr.y * CELL + CELL/2;
+ var c = fr.color ? resolveColor(fr.color) : '#666';
+ c = c.replace('#','');
+ g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#'+c+'" stroke-width="6" opacity="0.35"'+ (l.bidirectional ? '' : ' stroke-dasharray="4,3"')+' vector-effect="non-scaling-stroke"/>';
+ });
+ }
+
+ var hasUp = {}, hasDown = {};
+ if (data.upLinks) data.upLinks.forEach(function(l) { hasUp[l.from] = true; });
+ if (data.downLinks) data.downLinks.forEach(function(l) { hasDown[l.from] = true; });
+
+ var roomHTML = '';
+ if (data.rooms) {
+ data.rooms.forEach(function(r) {
+ var x = r.x * CELL + 8, y = r.y * CELL + 8, w = CELL-16, h = CELL-16;
+ var fill = resolveColor(r.color);
+ var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff';
+ var sel = selectedRoom === r.id;
+ roomHTML += '<rect class="rm" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="5" fill="'+fill+'" stroke="'+(sel?'#fff':fill)+'" stroke-width="'+(sel?3:1.5)+'" data-id="'+r.id+'" data-name="'+esc(r.name)+'"/>';
+ roomHTML += wrapText(r.name, x+w/2, y+h/2, w, txtColor);
+
+ var btnY = y+h-4;
+ var topY = y+4;
+ if (hasUp[r.id] && upTarget[r.id]) {
+ roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+3)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="1" data-target="'+upTarget[r.id]+'" title="Go up to #'+upTarget[r.id]+'">\u2191</text></g>';
+ }
+ if (hasDown[r.id] && downTarget[r.id]) {
+ roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+w-17)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+w-10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="-1" data-target="'+downTarget[r.id]+'" title="Go down to #'+downTarget[r.id]+'">\u2193</text></g>';
+ }
+
+ if (sel) {
+ var btnX = x + 6, btnBW = 18;
+ roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(btnX-2)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(btnX+btnBW/2-2)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="up" title="Create room up">+\u2191</text></g>';
+ roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(x+w-20)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(x+w-11)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="down" title="Create room down">+\u2193</text></g>';
+ }
+ });
+ }
+
+ var ghostHTML = '';
+ if (selectedRoom && roomMap[selectedRoom]) {
+ var sr = roomMap[selectedRoom];
+ gridDirs.forEach(function(d) {
+ var nx = sr.x + d.dx, ny = sr.y + d.dy;
+ var key = nx+','+ny;
+ if (!occupied[key]) {
+ ghostHTML += '<rect class="ghost ghost-hover" x="'+(nx*CELL+8)+'" y="'+(ny*CELL+8)+'" width="'+(CELL-16)+'" height="'+(CELL-16)+'" rx="5" fill="rgba(100,200,255,0.12)" stroke="rgba(100,200,255,0.25)" stroke-width="1.5" stroke-dasharray="4,4" data-dir="'+d.dir+'" data-gx="'+nx+'" data-gy="'+ny+'"/>';
+ ghostHTML += '<text x="'+(nx*CELL+CELL/2)+'" y="'+(ny*CELL+CELL/2+4)+'" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.35)" pointer-events="none" font-family="monospace">+</text>';
+ }
+ });
+ }
+
+ var innerHTML = g + roomHTML + ghostHTML;
+ var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale];
+ var svgHTML = '<svg width="'+W+'" height="'+H+'" viewBox="'+vb.join(' ')+'" xmlns="'+svgNS+'"><g>'+innerHTML+'</g></svg>';
+ svg.innerHTML = svgHTML;
+
+ svg.addEventListener('contextmenu', function(e) { e.preventDefault(); });
+
+ svg.onmousedown = function(e) {
+ var onRoom = e.target.classList.contains('rm');
+ var onGhost = e.target.classList.contains('ghost');
+ var onUdBtn = e.target.classList.contains('ud-btn');
+ var onNavBtn = e.target.classList.contains('nav-btn');
+
+ if (onNavBtn) {
+ e.stopPropagation();
+ var dz = parseInt(e.target.getAttribute('data-dz'));
+ var tid = parseInt(e.target.getAttribute('data-target'));
+ currentZ += dz;
+ selectedRoom = tid;
+ loadMap();
+ selectRoom(tid);
+ return;
+ }
+
+ if (e.button === 2 && onRoom) {
+ delFromId = parseInt(e.target.getAttribute('data-id'));
+ delDrag = false; delTargetId = null;
+ dragging = true;
+ startX = e.clientX; startY = e.clientY;
+ prevX = e.clientX; prevY = e.clientY;
+ dragRoom = true; linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null;
+ clearLinkHighlight();
+ return;
+ }
+
+ if (e.button !== 0) return;
+
+ if (onUdBtn) {
+ e.stopPropagation();
+ var dir = e.target.getAttribute('data-dir');
+ createRoom(selectedRoom, dir);
+ return;
+ }
+
+ dragRoom = onRoom || onGhost;
+ dragging = true;
+ startX = e.clientX; startY = e.clientY;
+ prevX = e.clientX; prevY = e.clientY;
+ linkFromId = onRoom ? parseInt(e.target.getAttribute('data-id')) : null;
+ linkDrag = false;
+ linkTargetId = null;
+ linkGhostDir = null;
+ delDrag = false; delFromId = null; delTargetId = null;
+ clearLinkHighlight();
+ };
+
+ svg.onmousemove = function(e) {
+ if (!dragging) return;
+ var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4;
+
+ if (delFromId && moved && !delDrag) {
+ delDrag = true;
+ }
+ if (delDrag && moved) {
+ updateDelDragTarget(e);
+ return;
+ }
+
+ if (!dragRoom && !delDrag && !delFromId) {
+ panX += e.clientX - prevX;
+ panY += e.clientY - prevY;
+ prevX = e.clientX; prevY = e.clientY;
+ updateView();
+ }
+
+ if (linkFromId && moved) {
+ linkDrag = true;
+ updateLinkDragTarget(e);
+ }
+ };
+
+ svg.onmouseup = function(e) {
+ if (!dragging) return;
+
+ var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4;
+
+ if (delFromId && !delDrag && !moved) {
+ showRoomContextMenu(startX, startY, delFromId);
+ clearDelHighlight();
+ dragging = false; dragRoom = false; delFromId = null; delTargetId = null; delDrag = false;
+ return;
+ }
+
+ if (delDrag && delFromId && delTargetId) {
+ deleteLink(delFromId, delTargetId);
+ clearDelHighlight();
+ dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null;
+ return;
+ }
+
+ if (linkDrag && linkFromId) {
+ if (linkTargetId) {
+ createLink(linkFromId, linkTargetId);
+ } else if (linkGhostDir) {
+ createRoom(linkFromId, linkGhostDir);
+ }
+ } else if (!moved && !delDrag) {
+ if (e.target.classList.contains('rm')) {
+ selectRoom(parseInt(e.target.getAttribute('data-id')));
+ } else if (e.target.classList.contains('ghost')) {
+ var dir = e.target.getAttribute('data-dir');
+ createRoom(selectedRoom, dir);
+ }
+ }
+
+ clearLinkHighlight();
+ clearDelHighlight();
+ dragging = false; dragRoom = false;
+ linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null;
+ delDrag = false; delFromId = null; delTargetId = null;
+ };
+
+ svg.onwheel = function(e) {
+ e.preventDefault();
+ var zf = e.deltaY < 0 ? 1.1 : 0.9;
+ scale *= zf;
+ if (scale < 0.1) scale = 0.1;
+ if (scale > 5) scale = 5;
+ updateView();
+ };
+
+ svg.querySelectorAll('rect.rm').forEach(function(r) {
+ r.addEventListener('mousemove', function(ev) {
+ var tip = $('#tooltip');
+ tip.style.display = 'block';
+ tip.style.left = (ev.clientX+12)+'px';
+ tip.style.top = (ev.clientY+12)+'px';
+ tip.textContent = '#'+r.getAttribute('data-id')+' '+r.getAttribute('data-name');
+ });
+ r.addEventListener('mouseleave', function() {
+ $('#tooltip').style.display = 'none';
+ });
+ });
+}
+
+function wrapText(name, cx, cy, maxWidth, color) {
+ if (!name) name = '';
+ color = color || '#fff';
+ var charW = 6;
+ var maxLen = Math.max(4, Math.floor((maxWidth - 4) / charW));
+ var lines = [];
+ for (var i = 0; i < name.length; i += maxLen) {
+ lines.push(name.substring(i, i + maxLen));
+ }
+ if (lines.length > 3) lines = lines.slice(0, 3);
+ var dy = -(lines.length - 1) * 6;
+ return lines.map(function(l, i) {
+ return '<text x="'+cx+'" y="'+(cy+dy+i*12)+'" text-anchor="middle" font-size="10" fill="'+color+'" pointer-events="none" font-family="monospace">'+esc(l)+'</text>';
+ }).join('');
+}
+
+function updateView() {
+ var svg = document.querySelector('#mapSvg svg');
+ if (!svg) return;
+ var W = svg.parentElement.clientWidth;
+ var H = svg.parentElement.clientHeight;
+ var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale];
+ svg.setAttribute('viewBox', vb.join(' '));
+}
+
+function selectRoom(id) {
+ selectedRoom = id;
+ loadMap();
+ API.get('/api/rooms/' + id).then(function(data) {
+ renderSidePanel(data);
+ }).catch(function(e) {
+ notify('Failed to load room: '+e.message, 'error');
+ });
+}
+
+function renderSidePanel(data) {
+ var panel = $('#panelContent');
+ if (!data || !data.room) {
+ panel.innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room not found</p>';
+ return;
+ }
+ var r = data.room;
+ var file = data.file || '';
+ var html = '<div class="form-group"><label>File</label><span style="font-size:11px;color:#888">' + esc(file) + '</span></div>';
+
+ html += '<div class="form-group"><label>ID</label><input id="roomID" type="number" value="' + r.id + '" onchange="autoSave()"></div>';
+ html += '<div class="form-group"><label>Name</label><input id="roomName" value="' + escAttr(r.name || '') + '" oninput="autoSave()"></div>';
+ html += '<div class="form-group color-field"><label>Color</label><span class="color-swatch" style="background:'+resolveColor(r.color)+'"></span><input id="roomColor" value="' + escAttr(r.color || '') + '" oninput="autoSave()"></div>';
+ html += '<div class="form-group"><label>Directory</label><select id="roomDir" onchange="autoSave()"></select></div>';
+ var descText = '';
+ if (Array.isArray(r.description)) {
+ descText = r.description.map(function(d) { return d.text || ''; }).join('\n\n');
+ } else if (typeof r.description === 'string') {
+ descText = r.description;
+ }
+ html += '<div class="form-group"><label>Description</label><textarea id="roomDesc" rows="4" oninput="autoSave()">' + esc(descText) + '</textarea></div>';
+ html += '<div class="form-group"><label>Hazard</label><input id="roomHazard" value="' + escAttr(r.hazard || '') + '" oninput="autoSave()"></div>';
+ html += '<div class="form-group"><label><input type="checkbox" id="roomBlockTransport" onchange="autoSave()"' + (r.block_transport ? ' checked' : '') + '> Block Transport</label></div>';
+
+ panel.innerHTML = html;
+ setTimeout(function() {
+ var cf = document.querySelector('#panelContent .color-field');
+ if (cf) {
+ bindColorField(cf);
+ var ci = cf.querySelector('input');
+ if (ci) ci.addEventListener('input', function() { autoSave(); });
+ }
+ loadRoomDirs(function(dirs) {
+ var sel = $('#roomDir');
+ if (!sel) return;
+ var current = (file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || '';
+ dirs.forEach(function(d) {
+ var opt = document.createElement('option');
+ opt.value = d;
+ opt.textContent = d || '(root)';
+ if (d === current) opt.selected = true;
+ sel.appendChild(opt);
+ });
+ });
+ }, 50);
+}
+
+function autoSave() {
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(function() { doSavePanel(); }, 600);
+}
+
+function doSavePanel() {
+ var id = selectedRoom;
+ if (!id) return;
+ API.get('/api/rooms/' + id).then(function(data) {
+ var r = data.room || {};
+ r.id = id;
+ var el = $('#roomName'); if (el) r.name = el.value;
+ el = $('#roomColor'); if (el) r.color = el.value;
+ el = $('#roomDesc'); if (el && el.value) r.description = [{text: el.value}];
+ el = $('#roomHazard'); if (el) r.hazard = el.value;
+ el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked;
+
+ var newID = parseInt($('#roomID').value) || id;
+ var newDir = $('#roomDir').value;
+ var currentDir = (data.file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || '';
+
+ var save = function() {
+ API.put('/api/rooms/' + id, r).then(function() {
+ updateUndoBar();
+ }).catch(function(e) { notify('Save failed: '+e.message, 'error'); });
+ };
+
+ if (newDir !== currentDir && newDir !== undefined) {
+ API.post('/api/rooms/move', {id: id, dir: newDir}).then(function() {
+ if (newID !== id) {
+ API.post('/api/rooms/rename', {id: id, new_id: newID}).then(function() {
+ selectRoom(newID);
+ }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(); });
+ } else { save(); }
+ }).catch(function(e) { notify('Move failed: '+e.message, 'error'); save(); });
+ } else if (newID !== id) {
+ API.post('/api/rooms/rename', {id: id, new_id: newID}).then(function() {
+ selectRoom(newID);
+ }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(); });
+ } else { save(); }
+ });
+}
+
+function deleteRoom(id) {
+ if (!confirm('Delete room #' + id + '?')) return;
+ if (mapData && mapData.rooms) {
+ var neighbors = [];
+ if (mapData.links) {
+ mapData.links.forEach(function(l) {
+ if (l.from === id && l.to !== id) neighbors.push(l.to);
+ if (l.to === id && l.from !== id) neighbors.push(l.from);
+ });
+ }
+ if (neighbors.length > 1) {
+ var remaining = mapData.rooms.filter(function(r) { return r.id !== id; });
+ var adj = {};
+ remaining.forEach(function(r) { adj[r.id] = []; });
+ if (mapData.links) {
+ mapData.links.forEach(function(l) {
+ if (l.from === id || l.to === id) return;
+ if (adj[l.from]) adj[l.from].push(l.to);
+ if (adj[l.to]) adj[l.to].push(l.from);
+ });
+ }
+ if (remaining.length > 0) {
+ var visited = {}, queue = [remaining[0].id];
+ visited[remaining[0].id] = true;
+ while (queue.length > 0) {
+ var cur = queue.shift();
+ (adj[cur] || []).forEach(function(n) { if (!visited[n]) { visited[n] = true; queue.push(n); } });
+ }
+ if (Object.keys(visited).length < remaining.length) {
+ if (!confirm('Deleting room #'+id+' will split the map into disconnected areas. Continue?')) return;
+ }
+ }
+ }
+ }
+ API.del('/api/rooms/' + id).then(function() {
+ notify('Room #' + id + ' deleted', 'success');
+ updateUndoBar();
+ selectedRoom = null;
+ $('#panelContent').innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>';
+ loadMap();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
+
+function createRoom(fromID, dir) {
+ 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 };
+ var srcRoom = roomMap[fromID];
+ if (srcRoom && srcRoom.color) body.color = srcRoom.color;
+ API.post('/api/rooms', body).then(function(resp) {
+ var createdId = (resp && resp.room && resp.room.id) || newID;
+ notify('Room #' + createdId + ' created', 'success');
+ updateUndoBar();
+ selectRoom(createdId);
+ }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); });
+ });
+}
+
+function showRoomContextMenu(x, y, id) {
+ var overlay = document.createElement('div');
+ overlay.className = 'cm-overlay';
+ overlay.onclick = function() { overlay.remove(); menu.remove(); };
+
+ var menu = document.createElement('div');
+ menu.className = 'cm-menu';
+ menu.style.left = x + 'px';
+ menu.style.top = y + 'px';
+
+ var delBtn = document.createElement('button');
+ delBtn.textContent = 'Delete Room';
+ delBtn.className = 'danger';
+ delBtn.onclick = function() {
+ overlay.remove(); menu.remove();
+ deleteRoom(id);
+ };
+ menu.appendChild(delBtn);
+
+ var selBtn = document.createElement('button');
+ selBtn.textContent = 'Room Details';
+ selBtn.onclick = function() {
+ overlay.remove(); menu.remove();
+ selectRoom(id);
+ };
+ menu.appendChild(selBtn);
+
+ document.body.appendChild(overlay);
+ document.body.appendChild(menu);
+}
+
+function mouseToGrid(e) {
+ var svgEl = document.querySelector('#mapSvg svg');
+ if (!svgEl) return null;
+ var rect = svgEl.getBoundingClientRect();
+ var mx = e.clientX - rect.left;
+ var my = e.clientY - rect.top;
+ if (mx < 0 || my < 0 || mx > rect.width || my > rect.height) return null;
+ var vb = svgEl.getAttribute('viewBox');
+ if (!vb) return null;
+ var parts = vb.split(' ');
+ var vbX = parseFloat(parts[0]), vbY = parseFloat(parts[1]);
+ var vbW = parseFloat(parts[2]), vbH = parseFloat(parts[3]);
+ var wx = vbX + mx * vbW / rect.width;
+ var wy = vbY + my * vbH / rect.height;
+ return {
+ x: Math.floor(wx / CELL),
+ y: Math.floor(wy / CELL)
+ };
+}
+
+function gridDeltaToDir(dx, dy) {
+ for (var i = 0; i < gridDirs.length; i++) {
+ if (gridDirs[i].dx === dx && gridDirs[i].dy === dy) return gridDirs[i].dir;
+ }
+ return null;
+}
+
+function updateLinkDragTarget(e) {
+ clearLinkHighlight();
+ linkTargetId = null;
+ linkGhostDir = null;
+
+ var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === linkFromId;}) : null;
+ if (!fromRoom) return;
+
+ var gp = mouseToGrid(e);
+ if (!gp) return;
+
+ var neighbor = occupied[gp.x+','+gp.y];
+ var gdx = gp.x - fromRoom.x;
+ var gdy = gp.y - fromRoom.y;
+ if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return;
+ if (gdx === 0 && gdy === 0) return;
+ var dir = gridDeltaToDir(gdx, gdy);
+ if (!dir) return;
+
+ if (neighbor && neighbor !== linkFromId) {
+ linkTargetId = neighbor;
+ linkGhostDir = null;
+ applyLinkHighlight(linkFromId, linkTargetId);
+ } else if (!neighbor) {
+ linkGhostDir = dir;
+ linkTargetId = null;
+ applyGhostLinkHighlight(fromRoom, gp.x, gp.y);
+ } else {
+ linkGhostDir = null;
+ }
+}
+
+function applyGhostLinkHighlight(fromRoom, gx, gy) {
+ clearLinkHighlight();
+ linkTargetId = null;
+
+ var fromEl = document.querySelector('.rm[data-id="'+linkFromId+'"]');
+ if (!fromEl) return;
+ fromEl.setAttribute('stroke', '#0ff');
+ fromEl.setAttribute('stroke-width', '3');
+
+ var g = document.querySelector('#mapSvg svg g');
+ if (!g) return;
+ var svgNS = 'http://www.w3.org/2000/svg';
+ var line = document.createElementNS(svgNS, 'line');
+ line.id = '__hl_line';
+ line.setAttribute('x1', fromRoom.x * CELL + CELL/2);
+ line.setAttribute('y1', fromRoom.y * CELL + CELL/2);
+ line.setAttribute('x2', gx * CELL + CELL/2);
+ line.setAttribute('y2', gy * CELL + CELL/2);
+ line.setAttribute('stroke', '#0ff');
+ line.setAttribute('stroke-width', '3');
+ line.setAttribute('stroke-dasharray', '6,3');
+ line.setAttribute('pointer-events', 'none');
+ g.appendChild(line);
+}
+
+function applyLinkHighlight(fromId, toId) {
+ clearLinkHighlight();
+
+ var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]');
+ var toEl = document.querySelector('.rm[data-id="'+toId+'"]');
+ var fromRoom = roomMap[fromId];
+ var toRoom = roomMap[toId];
+ if (!fromEl || !toEl || !fromRoom || !toRoom) return;
+
+ fromEl.setAttribute('stroke', '#0ff');
+ fromEl.setAttribute('stroke-width', '3');
+ toEl.setAttribute('stroke', '#f0f');
+ toEl.setAttribute('stroke-width', '3');
+
+ var g = document.querySelector('#mapSvg svg g');
+ if (!g) return;
+ var svgNS = 'http://www.w3.org/2000/svg';
+ var line = document.createElementNS(svgNS, 'line');
+ line.id = '__hl_line';
+ line.setAttribute('x1', fromRoom.x * CELL + CELL/2);
+ line.setAttribute('y1', fromRoom.y * CELL + CELL/2);
+ line.setAttribute('x2', toRoom.x * CELL + CELL/2);
+ line.setAttribute('y2', toRoom.y * CELL + CELL/2);
+ line.setAttribute('stroke', '#f0f');
+ line.setAttribute('stroke-width', '3');
+ line.setAttribute('stroke-dasharray', '6,3');
+ line.setAttribute('pointer-events', 'none');
+ g.appendChild(line);
+}
+
+function clearLinkHighlight() {
+ var line = document.getElementById('__hl_line');
+ if (line) line.remove();
+
+ if (linkFromId) {
+ var el = document.querySelector('.rm[data-id="'+linkFromId+'"]');
+ if (el) {
+ el.setAttribute('stroke', selectedRoom === linkFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a'));
+ el.setAttribute('stroke-width', selectedRoom === linkFromId ? '3' : '1.5');
+ }
+ }
+ if (linkTargetId) {
+ var el = document.querySelector('.rm[data-id="'+linkTargetId+'"]');
+ if (el) {
+ el.setAttribute('stroke', selectedRoom === linkTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a'));
+ el.setAttribute('stroke-width', selectedRoom === linkTargetId ? '3' : '1.5');
+ }
+ }
+}
+
+function createLink(fromId, toId) {
+ API.post('/api/rooms/link', {from: fromId, to: toId}).then(function() {
+ notify('Linked rooms #'+fromId+' '+toId, 'success');
+ updateUndoBar();
+ loadMap();
+ if (selectedRoom) {
+ API.get('/api/rooms/' + selectedRoom).then(function(data) {
+ renderSidePanel(data);
+ });
+ }
+ }).catch(function(e) {
+ notify('Link failed: ' + e.message, 'error');
+ });
+}
+
+function updateDelDragTarget(e) {
+ clearDelHighlight();
+ delTargetId = null;
+
+ var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === delFromId;}) : null;
+ if (!fromRoom) return;
+
+ var gp = mouseToGrid(e);
+ if (!gp) return;
+
+ var neighbor = occupied[gp.x+','+gp.y];
+ if (!neighbor || neighbor === delFromId) return;
+
+ var gdx = gp.x - fromRoom.x;
+ var gdy = gp.y - fromRoom.y;
+ if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return;
+ if (gdx === 0 && gdy === 0) return;
+ if (!gridDeltaToDir(gdx, gdy)) return;
+
+ delTargetId = neighbor;
+ applyDelHighlight(delFromId, delTargetId);
+}
+
+function applyDelHighlight(fromId, toId) {
+ clearDelHighlight();
+
+ var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]');
+ var toEl = document.querySelector('.rm[data-id="'+toId+'"]');
+ var fromRoom = roomMap[fromId];
+ var toRoom = roomMap[toId];
+ if (!fromEl || !toEl || !fromRoom || !toRoom) return;
+
+ fromEl.setAttribute('stroke', '#f55');
+ fromEl.setAttribute('stroke-width', '3');
+ toEl.setAttribute('stroke', '#f55');
+ toEl.setAttribute('stroke-width', '3');
+
+ var g = document.querySelector('#mapSvg svg g');
+ if (!g) return;
+ var svgNS = 'http://www.w3.org/2000/svg';
+ var line = document.createElementNS(svgNS, 'line');
+ line.id = '__dl_line';
+ line.setAttribute('x1', fromRoom.x * CELL + CELL/2);
+ line.setAttribute('y1', fromRoom.y * CELL + CELL/2);
+ line.setAttribute('x2', toRoom.x * CELL + CELL/2);
+ line.setAttribute('y2', toRoom.y * CELL + CELL/2);
+ line.setAttribute('stroke', '#f55');
+ line.setAttribute('stroke-width', '3');
+ line.setAttribute('stroke-dasharray', '6,3');
+ line.setAttribute('pointer-events', 'none');
+ g.appendChild(line);
+}
+
+function clearDelHighlight() {
+ var line = document.getElementById('__dl_line');
+ if (line) line.remove();
+
+ if (delFromId) {
+ var el = document.querySelector('.rm[data-id="'+delFromId+'"]');
+ if (el) {
+ el.setAttribute('stroke', selectedRoom === delFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a'));
+ el.setAttribute('stroke-width', selectedRoom === delFromId ? '3' : '1.5');
+ }
+ }
+ if (delTargetId) {
+ var el = document.querySelector('.rm[data-id="'+delTargetId+'"]');
+ if (el) {
+ el.setAttribute('stroke', selectedRoom === delTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a'));
+ el.setAttribute('stroke-width', selectedRoom === delTargetId ? '3' : '1.5');
+ }
+ }
+}
+
+function deleteLink(fromId, toId) {
+ if (mapData && mapData.links) {
+ var checkOrphan = function(roomId, otherId) {
+ var room = roomMap[roomId];
+ if (!room) return false;
+ var otherLinks = mapData.links.filter(function(l) {
+ return (l.from === roomId || l.to === roomId) && !((l.from === roomId && l.to === otherId) || (l.from === otherId && l.to === roomId));
+ });
+ if (otherLinks.length === 0) {
+ if (!confirm('Removing this link will orphan room #'+roomId+' and it will disappear from the map. Continue?')) return false;
+ }
+ return true;
+ };
+ if (!checkOrphan(toId, fromId)) return;
+ if (!checkOrphan(fromId, toId)) return;
+ }
+
+ fetch('/api/rooms/link', {
+ method: 'DELETE',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({from: fromId, to: toId}),
+ credentials: 'same-origin'
+ }).then(function(r) {
+ if (!r.ok) throw new Error('unlink failed');
+ return r.json();
+ }).then(function() {
+ notify('Removed link #'+fromId+' '+toId, 'success');
+ updateUndoBar();
+ loadMap();
+ if (selectedRoom) {
+ API.get('/api/rooms/' + selectedRoom).then(function(data) {
+ renderSidePanel(data);
+ });
+ }
+ }).catch(function(e) {
+ notify('Delete link failed: ' + e.message, 'error');
+ });
+}
+
+function esc(s) {
+ if (!s) return '';
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+function escAttr(s) {
+ if (!s) return '';
+ return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
+}
+
+window.refreshPage = function() { loadMap(); if (selectedRoom) selectRoom(selectedRoom); };
+
+function loadRoomDirs(cb) {
+ API.get('/api/room-dirs').then(function(dirs) {
+ var sel = $('#dirSelect');
+ if (sel && sel.options.length === 0) {
+ dirs.forEach(function(d) {
+ var opt = document.createElement('option');
+ opt.value = d;
+ opt.textContent = d || '(root)';
+ sel.appendChild(opt);
+ });
+ }
+ updateDirSelect();
+ if (cb) cb(dirs);
+ });
+}
+
+function updateDirSelect() {
+ var sel = $('#dirSelect');
+ if (sel && currentDir !== undefined) {
+ sel.value = currentDir;
+ }
+}
+
+document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); });
diff --git a/internal/admin/static/talktree.js b/internal/admin/static/talktree.js
new file mode 100644
index 0000000..f254c21
--- /dev/null
+++ b/internal/admin/static/talktree.js
@@ -0,0 +1,92 @@
+function renderTalkTree(data) {
+ var container = document.getElementById('talktree');
+ if (!container) return;
+ if (!data || !data.talk || !data.talk.nodes) {
+ container.innerHTML = '<p style="color:#888">No conversation tree defined.</p>';
+ return;
+ }
+ window._talkData = data;
+ var nodes = data.talk.nodes;
+ var html = '<div class="talk-tree">';
+ html += renderTreeNodes(nodes, 'start', {});
+ html += '</div>';
+ container.innerHTML = html;
+}
+
+function renderTreeNodes(nodes, rootKey, seen) {
+ var node = nodes[rootKey];
+ if (!node) return '';
+ if (seen[rootKey]) {
+ return '<div class="node ref"><span class="key">' + esc(rootKey) + '</span> <span class="preview">(already shown)</span></div>';
+ }
+ seen[rootKey] = true;
+
+ var html = '';
+ html += '<div class="node root' + (rootKey === 'start' ? ' open' : '') + '">';
+ html += '<div class="node-header" onclick="this.parentElement.classList.toggle(\'open\')">';
+ html += '<span class="arrow"></span>';
+ html += '<span class="key">' + esc(rootKey) + '</span>';
+ html += '<span class="preview">' + esc((node.message || '').substring(0, 60)) + '</span>';
+ html += '</div>';
+ html += '<div class="node-children">';
+
+ html += '<div style="margin-left:8px;font-size:11px">';
+ if (node.message) html += '<div><b>Message:</b> ' + esc(node.message) + '</div>';
+ if (node.condition) html += '<div><b>Condition:</b> ' + esc(JSON.stringify(node.condition)) + '</div>';
+ if (node.goto) html += '<div><b>Goto:</b> ' + esc(node.goto) + '</div>';
+ html += '</div>';
+
+ html += '<div style="margin-left:10px"><b style="font-size:11px;color:#aaa">Options:</b>';
+ if (node.options && node.options.length > 0) {
+ node.options.forEach(function(opt, idx) {
+ html += '<div class="option">';
+ html += '<span>' + esc(opt.text || '') + '</span> \u2192 <span style="color:#8cf">' + esc(opt.goto || '') + '</span>';
+ if (opt.condition) html += ' <span style="color:#888;font-size:10px">[cond]</span>';
+ if (opt.action) html += ' <span style="color:#888;font-size:10px">[action]</span>';
+ html += ' <button class="btn btn-danger btn-sm" onclick="deleteOption(event,\'' + escAttr(rootKey) + '\',' + idx + ')">Delete Option</button>';
+ html += '</div>';
+ });
+ } else {
+ html += '<div class="option" style="color:#888">No options</div>';
+ }
+ html += '<div style="margin-top:4px"><button class="btn btn-primary btn-sm" onclick="addOption(event,\'' + escAttr(rootKey) + '\')">+ Add Option</button></div>';
+ html += '</div>';
+
+ html += '</div></div>';
+
+ if (node.options) {
+ node.options.forEach(function(opt) {
+ if (opt.goto && nodes[opt.goto]) {
+ html += renderTreeNodes(nodes, opt.goto, seen);
+ }
+ });
+ }
+
+ return html;
+}
+
+function addOption(e, nodeKey) {
+ if (e) e.stopPropagation();
+ var data = window._talkData;
+ if (!data || !data.talk || !data.talk.nodes || !data.talk.nodes[nodeKey]) return;
+ var text = prompt('Option text:');
+ if (text === null) return;
+ var dest = prompt('Goto node key:') || '';
+ var node = data.talk.nodes[nodeKey];
+ if (!node.options) node.options = [];
+ node.options.push({ text: text, goto: dest });
+ renderTalkTree(data);
+ if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(data);
+}
+
+function deleteOption(e, nodeKey, idx) {
+ if (e) e.stopPropagation();
+ var data = window._talkData;
+ if (!data || !data.talk || !data.talk.nodes || !data.talk.nodes[nodeKey]) return;
+ var node = data.talk.nodes[nodeKey];
+ if (!node.options || idx < 0 || idx >= node.options.length) return;
+ if (!confirm('Delete this option?')) return;
+ node.options.splice(idx, 1);
+ renderTalkTree(data);
+ if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(data);
+}
diff --git a/internal/admin/templates/courses.html b/internal/admin/templates/courses.html
new file mode 100644
index 0000000..60e07f8
--- /dev/null
+++ b/internal/admin/templates/courses.html
@@ -0,0 +1,14 @@
+{{define "body-courses"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search courses..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Course</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a course to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script>initEditor('courses', [{key:'name',label:'Name',type:'text'},{key:'skill',label:'Skill',type:'text'},{key:'level',label:'Level',type:'number'},{key:'cost',label:'Cost',type:'number'},{key:'description',label:'Description',type:'textarea'}]);</script>
+{{end}}
diff --git a/internal/admin/templates/dashboard.html b/internal/admin/templates/dashboard.html
new file mode 100644
index 0000000..198746d
--- /dev/null
+++ b/internal/admin/templates/dashboard.html
@@ -0,0 +1,22 @@
+{{define "body-dashboard"}}
+<div style="padding:20px">
+ <h2>Dashboard</h2>
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:16px" id="dashGrid"></div>
+</div>
+<script>
+API.get('/api/dashboard').then(function(d){
+ var grid = $('#dashGrid');
+ var items = [
+ {label:'Rooms',value:d.roomCount},
+ {label:'Items',value:d.itemCount},
+ {label:'Objects',value:d.objectCount},
+ {label:'Mobs',value:d.mobCount},
+ {label:'Players',value:d.playerCount},
+ {label:'Accounts',value:d.accountCount}
+ ];
+ grid.innerHTML = items.map(function(i){
+ return '<div style="background:var(--panel);border:1px solid var(--border);padding:16px;border-radius:4px;text-align:center"><div style="font-size:28px;font-weight:bold">'+i.value+'</div><div style="font-size:11px;color:#888;margin-top:4px">'+i.label+'</div></div>';
+ }).join('');
+}).catch(function(e){notify('Failed: '+e.message,'error')});
+</script>
+{{end}}
diff --git a/internal/admin/templates/drops.html b/internal/admin/templates/drops.html
new file mode 100644
index 0000000..c040ef2
--- /dev/null
+++ b/internal/admin/templates/drops.html
@@ -0,0 +1,18 @@
+{{define "body-drops"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search drop tables..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Drop Table</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a drop table to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script>
+initEditor('drops', [
+ {key:'drops',label:'Drops (JSON array)',type:'textarea',parser:function(v){try{return JSON.parse(v)}catch(e){return []}}}
+]);
+</script>
+{{end}}
diff --git a/internal/admin/templates/files.html b/internal/admin/templates/files.html
new file mode 100644
index 0000000..b89a142
--- /dev/null
+++ b/internal/admin/templates/files.html
@@ -0,0 +1,81 @@
+{{define "body-files"}}
+<div class="editor-container">
+ <div class="editor-list" id="fileList">
+ <div class="search-bar"><input placeholder="Filter files..." oninput="filterFiles(this.value)"></div>
+ <div id="listEntries"></div>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a file to edit</p>
+ </div>
+</div>
+<script>
+var currentPath = '';
+window._allFiles = [];
+
+function loadFiles(path) {
+ currentPath = path || '';
+ API.get('/api/files' + (path ? '?path=' + encodeURIComponent(path) : '')).then(function(r){
+ var files = r.files || r || [];
+ window._allFiles = files;
+ renderFileList('');
+ }).catch(function(e){notify('Failed: '+e.message,'error')});
+}
+
+function renderFileList(filter){
+ var el = $('#listEntries');
+ var f = filter.toLowerCase();
+ var items = window._allFiles.filter(function(x){ return !f || x.name.toLowerCase().indexOf(f) >= 0; });
+ var html = '<div class="item" onclick="loadFiles(\'\')" style="color:#6cf">[root]</div>';
+ if (currentPath) {
+ var parent = currentPath.split('/').slice(0,-1).join('/');
+ html += '<div class="item" onclick="loadFiles(\''+escJs(parent)+'\')" style="color:#6cf">[..]</div>';
+ }
+ items.forEach(function(x){
+ if (x.dir) {
+ var sub = currentPath ? currentPath + '/' + x.name : x.name;
+ html += '<div class="item" onclick="loadFiles(\''+escJs(sub)+'\')" style="color:#fc6">'+escHtml(x.name)+'/</div>';
+ } else {
+ var fp = currentPath ? currentPath + '/' + x.name : x.name;
+ html += '<div class="item" onclick="loadFileContent(\''+escJs(fp)+'\',\''+escJs(x.name)+'\')">'+escHtml(x.name)+'</div>';
+ }
+ });
+ el.innerHTML = html;
+}
+
+function filterFiles(val) { renderFileList(val); }
+
+function loadFileContent(path, name) {
+ currentPath = path;
+ API.get('/api/files?path=' + encodeURIComponent(path)).then(function(r){
+ var content = r.content || r.raw || JSON.stringify(r,null,2);
+ var html = '<h2>'+escHtml(name)+'</h2>';
+ html += '<div class="form-group"><label>Path</label><span style="font-size:11px;color:#888">'+escHtml(path)+'</span></div>';
+ html += '<textarea id="fileContent" rows="20" style="width:100%;font-family:monospace;font-size:12px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);padding:8px">'+escHtml(content)+'</textarea>';
+ html += '<div class="btn-row"><button class="btn btn-primary" onclick="saveFile(\''+escJs(path)+'\')">Save</button></div>';
+ $('#editorMain').innerHTML = html;
+ }).catch(function(e){notify('Failed: '+e.message,'error')});
+}
+
+function saveFile(path) {
+ var content = $('#fileContent').value;
+ fetch('/api/files?path=' + encodeURIComponent(path), {
+ method: 'PUT',
+ headers: {'Content-Type': 'text/plain'},
+ body: content,
+ credentials: 'same-origin'
+ }).then(function(r) {
+ if (!r.ok) throw new Error('Save failed');
+ return r.json();
+ }).then(function() {
+ notify('File saved', 'success');
+ updateUndoBar();
+ }).catch(function(e) { notify('Save failed: '+e.message, 'error'); });
+}
+
+window.refreshPage = function() { loadFiles(currentPath); };
+function escHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'\\"'); }
+
+loadFiles('');
+</script>
+{{end}}
diff --git a/internal/admin/templates/hazards.html b/internal/admin/templates/hazards.html
new file mode 100644
index 0000000..0d60df6
--- /dev/null
+++ b/internal/admin/templates/hazards.html
@@ -0,0 +1,14 @@
+{{define "body-hazards"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search hazards..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Hazard</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a hazard to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script>initEditor('hazards', [{key:'name',label:'Name',type:'text'},{key:'damage',label:'Damage',type:'number'},{key:'speed',label:'Speed',type:'number'},{key:'required_item',label:'Required Item',type:'text'},{key:'message',label:'Message',type:'text'},{key:'lethal',label:'Lethal',type:'checkbox'}]);</script>
+{{end}}
diff --git a/internal/admin/templates/items.html b/internal/admin/templates/items.html
new file mode 100644
index 0000000..f8f3b66
--- /dev/null
+++ b/internal/admin/templates/items.html
@@ -0,0 +1,45 @@
+{{define "body-items"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search items..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Item</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select an item to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script>
+initEditor('items', [
+ {key:'name',label:'Name',type:'text'},
+ {key:'color',label:'Color',type:'color'},
+ {key:'description',label:'Description',type:'textarea'},
+ {key:'value',label:'Value',type:'number'},
+ {key:'stackable',label:'Stackable',type:'checkbox'},
+ {key:'equip_slot',label:'Equip Slot',type:'text'},
+ {key:'weapon_type',label:'Weapon Type',type:'text'},
+ {key:'attack_type',label:'Attack Type',type:'text'},
+ {key:'speed',label:'Speed',type:'number'},
+ {key:'tool_type',label:'Tool Type',type:'text'},
+ {key:'tool_speed',label:'Tool Speed',type:'number'},
+ {key:'burn_ticks',label:'Burn Ticks',type:'number'},
+ {key:'fire_level',label:'Fire Level',type:'number'},
+ {key:'fire_xp',label:'Fire XP',type:'number'},
+ {key:'quality',label:'Quality',type:'number'},
+ {key:'max_quality',label:'Max Quality',type:'number'},
+ {key:'search_table',label:'Search Table',type:'text'},
+ {key:'search_ticks',label:'Search Ticks',type:'number'},
+ {key:'heal_value',label:'Heal Value',type:'number'},
+ {key:'eat_message',label:'Eat Message',type:'text'},
+ {key:'farm_patch_type',label:'Farm Patch',type:'text'},
+ {key:'farm_level',label:'Farm Level',type:'number'},
+ {key:'farm_stages',label:'Farm Stages',type:'number'},
+ {key:'farm_product',label:'Farm Product',type:'text'},
+ {key:'potion_effect',label:'Potion Effect',type:'text'},
+ {key:'potion_bonus',label:'Potion Bonus',type:'number'},
+ {key:'potion_duration',label:'Potion Duration',type:'number'},
+ {key:'recoverable',label:'Recoverable',type:'checkbox'}
+]);
+</script>
+{{end}}
diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html
new file mode 100644
index 0000000..99c1f0d
--- /dev/null
+++ b/internal/admin/templates/layout.html
@@ -0,0 +1,48 @@
+{{define "layout"}}<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>THOI Admin{{if .Page}} — {{.Page}}{{end}}</title>
+<link rel="stylesheet" href="/static/admin.css">
+<script src="/static/admin.js"></script>
+<script src="/static/colorpicker.js"></script>
+</head>
+<body>
+<nav class="nav">
+ <a href="/" class="{{if eq .Page "map"}}active{{end}}">Map</a>
+ <a href="/editor/objects" class="{{if eq .Page "objects"}}active{{end}}">Objects</a>
+ <a href="/editor/items" class="{{if eq .Page "items"}}active{{end}}">Items</a>
+ <a href="/editor/mobs" class="{{if eq .Page "mobs"}}active{{end}}">Mobs</a>
+ <a href="/editor/drops" class="{{if eq .Page "drops"}}active{{end}}">Drops</a>
+ <a href="/editor/hazards" class="{{if eq .Page "hazards"}}active{{end}}">Hazards</a>
+ <a href="/editor/techs" class="{{if eq .Page "techs"}}active{{end}}">Techs</a>
+ <a href="/editor/courses" class="{{if eq .Page "courses"}}active{{end}}">Courses</a>
+ <a href="/editor/modules" class="{{if eq .Page "modules"}}active{{end}}">Modules</a>
+ <a href="/editor/players" class="{{if eq .Page "players"}}active{{end}}">Players</a>
+ <a href="/editor/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
+ <a href="/editor/files" class="{{if eq .Page "files"}}active{{end}}">Files</a>
+ <div class="undo-bar">
+ <button class="undo-btn" onclick="doUndo()" title="">⟲ Undo</button>
+ <button class="redo-btn" onclick="doRedo()" title="">⟳ Redo</button>
+ <span class="undo-label"></span>
+ </div>
+ <span style="font-size:11px;color:#888">| {{.Account}}</span>
+ <a href="/logout" style="font-size:11px">Logout</a>
+</nav>
+{{if eq .Page "map"}}{{template "body-map" .}}
+{{else if eq .Page "objects"}}{{template "body-objects" .}}
+{{else if eq .Page "items"}}{{template "body-items" .}}
+{{else if eq .Page "mobs"}}{{template "body-mobs" .}}
+{{else if eq .Page "drops"}}{{template "body-drops" .}}
+{{else if eq .Page "hazards"}}{{template "body-hazards" .}}
+{{else if eq .Page "techs"}}{{template "body-techs" .}}
+{{else if eq .Page "courses"}}{{template "body-courses" .}}
+{{else if eq .Page "modules"}}{{template "body-modules" .}}
+{{else if eq .Page "players"}}{{template "body-players" .}}
+{{else if eq .Page "dashboard"}}{{template "body-dashboard" .}}
+{{else if eq .Page "files"}}{{template "body-files" .}}
+{{else}}<p style="color:#888;text-align:center;margin-top:60px">Unknown page: {{.Page}}</p>{{end}}
+</body>
+</html>
+{{end}}
diff --git a/internal/admin/templates/login.html b/internal/admin/templates/login.html
new file mode 100644
index 0000000..ffc59c6
--- /dev/null
+++ b/internal/admin/templates/login.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>THOI Admin — Login</title>
+<link rel="stylesheet" href="/static/admin.css">
+</head>
+<body>
+<div class="login">
+ <h1>THOI Admin Portal</h1>
+ {{if .Error}}<p style="color:var(--danger);text-align:center;margin-bottom:12px;font-size:13px">{{.Error}}</p>{{end}}
+ <form method="post" action="/login">
+ <div class="form-group">
+ <label>Account Name</label>
+ <input type="text" name="account" required autofocus autocomplete="username">
+ </div>
+ <div class="form-group">
+ <label>Password</label>
+ <input type="password" name="password" required autocomplete="current-password">
+ </div>
+ <div class="btn-row">
+ <button type="submit" class="btn btn-primary" style="width:100%">Login</button>
+ </div>
+ </form>
+</div>
+</body>
+</html>
diff --git a/internal/admin/templates/map.html b/internal/admin/templates/map.html
new file mode 100644
index 0000000..7f8d5a1
--- /dev/null
+++ b/internal/admin/templates/map.html
@@ -0,0 +1,21 @@
+{{define "body-map"}}
+<div class="layout">
+ <div class="main" id="mapContainer">
+ <div class="z-controls">
+ <button onclick="changeZ(-1)">-</button>
+ <span class="z-label" id="zLabel">Z=0</span>
+ <button onclick="changeZ(1)">+</button>
+ <button onclick="changeZ(0)" style="width:auto;font-size:11px">Reset</button>
+ <select id="dirSelect" onchange="changeDir(this.value)" style="margin-left:8px;padding:3px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace"></select>
+ </div>
+ <svg id="mapSvg" style="width:100%;height:100%"></svg>
+ <div class="room-tooltip" id="tooltip" style="display:none"></div>
+ </div>
+ <div class="panel" id="sidePanel">
+ <div id="panelContent">
+ <p style="color:#888;text-align:center;margin-top:40px">Click a room to view details</p>
+ </div>
+ </div>
+</div>
+<script src="/static/map.js"></script>
+{{end}}
diff --git a/internal/admin/templates/mobs.html b/internal/admin/templates/mobs.html
new file mode 100644
index 0000000..d6191fa
--- /dev/null
+++ b/internal/admin/templates/mobs.html
@@ -0,0 +1,52 @@
+{{define "body-mobs"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search mobs..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Mob</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a mob to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script src="/static/talktree.js"></script>
+<script>
+initEditor('mobs', [
+ {key:'name',label:'Name',type:'text'},
+ {key:'description',label:'Description',type:'textarea'},
+ {key:'attack',label:'Attack',type:'number'},
+ {key:'strength',label:'Strength',type:'number'},
+ {key:'defense',label:'Defense',type:'number'},
+ {key:'hp',label:'HP',type:'number'},
+ {key:'ranged',label:'Ranged',type:'number'},
+ {key:'science',label:'Science',type:'number'},
+ {key:'speed',label:'Speed',type:'number'},
+ {key:'aggressive',label:'Aggressive',type:'checkbox'},
+ {key:'protected',label:'Protected',type:'checkbox'},
+ {key:'unique',label:'Unique',type:'checkbox'},
+ {key:'respawn_ticks',label:'Respawn Ticks',type:'number'},
+ {key:'attack_bonus',label:'Attack Bonus',type:'number'},
+ {key:'strength_bonus',label:'Strength Bonus',type:'number'},
+ {key:'attack_type',label:'Attack Type',type:'text'},
+ {key:'stab_defense',label:'Stab Defense',type:'number'},
+ {key:'slash_defense',label:'Slash Defense',type:'number'},
+ {key:'crush_defense',label:'Crush Defense',type:'number'},
+ {key:'science_defense',label:'Science Defense',type:'number'},
+ {key:'ranged_defense',label:'Ranged Defense',type:'number'},
+ {key:'weakness',label:'Weakness',type:'text'},
+ {key:'steal_table',label:'Steal Table',type:'text'},
+ {key:'steal_level',label:'Steal Level',type:'number'},
+ {key:'steal_xp',label:'Steal XP',type:'number'},
+ {key:'steal_speed',label:'Steal Speed',type:'number'},
+ {key:'assassin_level',label:'Assassin Level',type:'number'},
+ {key:'finishing_blow',label:'Finishing Blow',type:'text'},
+ {key:'damage_without',label:'Damage Without',type:'text'},
+ {key:'size',label:'Size',type:'text'},
+ {key:'kind',label:'Kind',type:'text'},
+ {key:'verb',label:'Verb',type:'text'},
+ {key:'progress_noun',label:'Progress Noun',type:'text'},
+ {key:'complete_message',label:'Complete Message',type:'text'}
+]);
+</script>
+{{end}}
diff --git a/internal/admin/templates/modules.html b/internal/admin/templates/modules.html
new file mode 100644
index 0000000..0f8ca7c
--- /dev/null
+++ b/internal/admin/templates/modules.html
@@ -0,0 +1,14 @@
+{{define "body-modules"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search modules..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Module</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a module to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script>initEditor('modules', [{key:'name',label:'Name',type:'text'},{key:'type',label:'Type',type:'text'},{key:'effect',label:'Effect',type:'text'},{key:'description',label:'Description',type:'textarea'}]);</script>
+{{end}}
diff --git a/internal/admin/templates/objects.html b/internal/admin/templates/objects.html
new file mode 100644
index 0000000..91aca09
--- /dev/null
+++ b/internal/admin/templates/objects.html
@@ -0,0 +1,29 @@
+{{define "body-objects"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search objects..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Object</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select an object to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script>
+initEditor('objects', [
+ {key:'name',label:'Name',type:'text'},
+ {key:'color',label:'Color',type:'color'},
+ {key:'aliases',label:'Aliases (comma sep)',type:'text'},
+ {key:'hidden',label:'Hidden',type:'checkbox'},
+ {key:'inroom_description',label:'In-Room Description',type:'text'},
+ {key:'description',label:'Description',type:'textarea'},
+ {key:'removal_item',label:'Removal Item ID',type:'text'},
+ {key:'steal_table',label:'Steal Table ID',type:'text'},
+ {key:'steal_level',label:'Steal Level',type:'number'},
+ {key:'steal_xp',label:'Steal XP',type:'number'},
+ {key:'steal_speed',label:'Steal Speed',type:'number'},
+ {key:'guard_mob',label:'Guard Mob ID',type:'text'}
+]);
+</script>
+{{end}}
diff --git a/internal/admin/templates/players.html b/internal/admin/templates/players.html
new file mode 100644
index 0000000..43627d8
--- /dev/null
+++ b/internal/admin/templates/players.html
@@ -0,0 +1,33 @@
+{{define "body-players"}}
+<div style="padding:20px">
+ <h2>Players</h2>
+ <div class="search-bar" style="max-width:400px"><input placeholder="Search players..." id="playerSearch" oninput="searchPlayers()"></div>
+ <table class="table" style="margin-top:12px">
+ <thead><tr><th>Name</th><th>Level</th><th>Room</th><th>Account</th></tr></thead>
+ <tbody id="playerTableBody"></tbody>
+ </table>
+</div>
+<script>
+API.get('/api/players').then(function(r){
+ var tbody = $('#playerTableBody');
+ var players = r.players || r || [];
+ window._players = players;
+ renderPlayerTable('');
+}).catch(function(e){notify('Failed: '+e.message,'error')});
+function searchPlayers(){
+ var q = ($('#playerSearch').value||'').toLowerCase();
+ renderPlayerTable(q);
+}
+function renderPlayerTable(q){
+ var tbody = $('#playerTableBody');
+ var rows = (window._players||[]).filter(function(p){
+ var name = (p.name||p.Name||'').toLowerCase();
+ return !q || name.indexOf(q) >= 0;
+ }).map(function(p){
+ return '<tr><td>'+esc(p.name||p.Name)+'</td><td>'+esc(p.level||p.Level)+'</td><td>'+esc(p.room||p.Room)+'</td><td>'+esc(p.account||p.Account)+'</td></tr>';
+ });
+ tbody.innerHTML = rows.join('') || '<tr><td colspan="4" style="color:#888">No players found</td></tr>';
+}
+function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+</script>
+{{end}}
diff --git a/internal/admin/templates/techs.html b/internal/admin/templates/techs.html
new file mode 100644
index 0000000..084d564
--- /dev/null
+++ b/internal/admin/templates/techs.html
@@ -0,0 +1,14 @@
+{{define "body-techs"}}
+<div class="editor-container">
+ <div class="editor-list" id="itemList">
+ <div class="search-bar"><input placeholder="Search techs..." oninput="filterList(this.value)"></div>
+ <div id="listEntries"></div>
+ <button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%" onclick="createNew()">+ New Tech</button>
+ </div>
+ <div class="editor-main" id="editorMain">
+ <p style="color:#888;text-align:center;margin-top:60px">Select a tech to edit</p>
+ </div>
+</div>
+<script src="/static/editor.js"></script>
+<script>initEditor('techs', [{key:'name',label:'Name',type:'text'},{key:'category',label:'Category',type:'text'},{key:'tier',label:'Tier',type:'number'},{key:'drain',label:'Drain Rate',type:'number'},{key:'bonus',label:'Bonus',type:'number'},{key:'description',label:'Description',type:'textarea'}]);</script>
+{{end}}
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
new file mode 100644
index 0000000..39e8c4e
--- /dev/null
+++ b/internal/admin/undo.go
@@ -0,0 +1,247 @@
+package admin
+
+import (
+ "encoding/json"
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+type ChangeDesc struct {
+ Time string `json:"time"`
+ Description string `json:"description"`
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent []byte `json:"old_content"`
+ NewContent []byte `json:"new_content"`
+ IsDelete bool `json:"is_delete"`
+ IsCreate bool `json:"is_create"`
+}
+
+type UndoInfo struct {
+ CanUndo bool `json:"can_undo"`
+ CanRedo bool `json:"can_redo"`
+ UndoDesc string `json:"undo_desc"`
+ RedoDesc string `json:"redo_desc"`
+ StackSize int `json:"stack_size"`
+ RedoSize int `json:"redo_size"`
+}
+
+type UndoStack struct {
+ mu sync.Mutex
+ dataDir string
+ history []ChangeDesc
+ redo []ChangeDesc
+ maxSize int
+ filePath string
+}
+
+func NewUndoStack(dataDir string) *UndoStack {
+ us := &UndoStack{
+ dataDir: dataDir,
+ maxSize: 100,
+ filePath: filepath.Join(dataDir, ".admin_history.yaml"),
+ }
+ us.load()
+ return us
+}
+
+func (us *UndoStack) Push(desc ChangeDesc) {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ desc.Time = time.Now().Format(time.RFC3339)
+ us.history = append(us.history, desc)
+ us.redo = nil
+ if len(us.history) > us.maxSize {
+ us.history = us.history[len(us.history)-us.maxSize:]
+ }
+ us.save()
+}
+
+func (us *UndoStack) Undo() *ChangeDesc {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ if len(us.history) == 0 {
+ return nil
+ }
+ last := us.history[len(us.history)-1]
+ us.history = us.history[:len(us.history)-1]
+
+ if last.NewFilePath != "" {
+ os.Remove(last.NewFilePath)
+ }
+ if last.IsDelete {
+ if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil {
+ log.Printf("undo: failed to restore deleted file %s: %v", last.FilePath, err)
+ return nil
+ }
+ } else if last.IsCreate {
+ os.Remove(last.FilePath)
+ } else {
+ if last.NewFilePath != "" {
+ if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil {
+ log.Printf("undo: failed to revert move %s: %v", last.FilePath, err)
+ return nil
+ }
+ } else {
+ if err := os.WriteFile(last.FilePath, last.OldContent, 0644); err != nil {
+ log.Printf("undo: failed to revert file %s: %v", last.FilePath, err)
+ return nil
+ }
+ }
+ }
+
+ us.redo = append(us.redo, last)
+ us.save()
+ return &last
+}
+
+func (us *UndoStack) Redo() *ChangeDesc {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ if len(us.redo) == 0 {
+ return nil
+ }
+ last := us.redo[len(us.redo)-1]
+ us.redo = us.redo[:len(us.redo)-1]
+
+ if last.NewFilePath != "" {
+ os.Remove(last.FilePath)
+ }
+ if last.IsCreate {
+ if last.NewFilePath != "" {
+ if err := os.WriteFile(last.NewFilePath, last.NewContent, 0644); err != nil {
+ log.Printf("redo: failed to recreate file %s: %v", last.NewFilePath, err)
+ return nil
+ }
+ } else {
+ if err := os.WriteFile(last.FilePath, last.NewContent, 0644); err != nil {
+ log.Printf("redo: failed to recreate file %s: %v", last.FilePath, err)
+ return nil
+ }
+ }
+ } else if last.IsDelete {
+ os.Remove(last.FilePath)
+ } else {
+ if last.NewFilePath != "" {
+ if err := os.WriteFile(last.NewFilePath, last.NewContent, 0644); err != nil {
+ log.Printf("redo: failed to reapply move %s: %v", last.NewFilePath, err)
+ return nil
+ }
+ } else {
+ if err := os.WriteFile(last.FilePath, last.NewContent, 0644); err != nil {
+ log.Printf("redo: failed to reapply file %s: %v", last.FilePath, err)
+ return nil
+ }
+ }
+ }
+
+ us.history = append(us.history, last)
+ us.save()
+ return &last
+}
+
+func (us *UndoStack) Info() UndoInfo {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ info := UndoInfo{
+ StackSize: len(us.history),
+ RedoSize: len(us.redo),
+ }
+ if len(us.history) > 0 {
+ info.CanUndo = true
+ info.UndoDesc = us.history[len(us.history)-1].Description
+ }
+ if len(us.redo) > 0 {
+ info.CanRedo = true
+ info.RedoDesc = us.redo[len(us.redo)-1].Description
+ }
+ return info
+}
+
+func (us *UndoStack) save() {
+ type entry struct {
+ Time string `json:"time"`
+ Description string `json:"description"`
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete"`
+ IsCreate bool `json:"is_create"`
+ }
+ type saveData struct {
+ History []entry `json:"history"`
+ Redo []entry `json:"redo"`
+ }
+ toEntries := func(changes []ChangeDesc) []entry {
+ var entries []entry
+ for _, c := range changes {
+ entries = append(entries, entry{
+ Time: c.Time,
+ Description: c.Description,
+ FilePath: c.FilePath,
+ NewFilePath: c.NewFilePath,
+ OldContent: string(c.OldContent),
+ NewContent: string(c.NewContent),
+ IsDelete: c.IsDelete,
+ IsCreate: c.IsCreate,
+ })
+ }
+ return entries
+ }
+ data := saveData{
+ History: toEntries(us.history),
+ Redo: toEntries(us.redo),
+ }
+ b, err := json.Marshal(data)
+ if err != nil {
+ log.Printf("undo: failed to marshal history: %v", err)
+ return
+ }
+ os.WriteFile(us.filePath, b, 0644)
+}
+
+func (us *UndoStack) load() {
+ data, err := os.ReadFile(us.filePath)
+ if err != nil {
+ return
+ }
+ type entry struct {
+ Time string `json:"time"`
+ Description string `json:"description"`
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete"`
+ IsCreate bool `json:"is_create"`
+ }
+ var saveData struct {
+ History []entry `json:"history"`
+ Redo []entry `json:"redo"`
+ }
+ if err := json.Unmarshal(data, &saveData); err != nil {
+ return
+ }
+ fromEntries := func(entries []entry) []ChangeDesc {
+ var changes []ChangeDesc
+ for _, e := range entries {
+ changes = append(changes, ChangeDesc{
+ Time: e.Time,
+ Description: e.Description,
+ FilePath: e.FilePath,
+ NewFilePath: e.NewFilePath,
+ OldContent: []byte(e.OldContent),
+ NewContent: []byte(e.NewContent),
+ IsDelete: e.IsDelete,
+ IsCreate: e.IsCreate,
+ })
+ }
+ return changes
+ }
+ us.history = fromEntries(saveData.History)
+ us.redo = fromEntries(saveData.Redo)
+}
diff --git a/internal/admin/xterm_to_css.go b/internal/admin/xterm_to_css.go
new file mode 100644
index 0000000..098ce62
--- /dev/null
+++ b/internal/admin/xterm_to_css.go
@@ -0,0 +1,40 @@
+package admin
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/color"
+)
+
+func XtermToCSS(xtermHex string) string {
+ xtermHex = strings.TrimPrefix(xtermHex, "#")
+ idx, err := strconv.ParseUint(xtermHex, 16, 8)
+ if err != nil || idx > 255 {
+ return "#808080"
+ }
+ r, g, b := color.Xterm256ToRGB(int(idx))
+ return fmt.Sprintf("#%02x%02x%02x", r, g, b)
+}
+
+func XtermColorForCSS(idx int) string {
+ if idx < 0 || idx > 255 {
+ return "#808080"
+ }
+ r, g, b := color.Xterm256ToRGB(idx)
+ return fmt.Sprintf("#%02x%02x%02x", r, g, b)
+}
+
+func xtermColorList() []map[string]any {
+ var list []map[string]any
+ for i := 0; i < 256; i++ {
+ r, g, b := color.Xterm256ToRGB(i)
+ list = append(list, map[string]any{
+ "index": i,
+ "hex": fmt.Sprintf("%02X%02X%02X", r, g, b),
+ "color": fmt.Sprintf("#%02x%02x%02x", r, g, b),
+ })
+ }
+ return list
+}
diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go
new file mode 100644
index 0000000..b97a4f2
--- /dev/null
+++ b/internal/admin/yaml_util.go
@@ -0,0 +1,117 @@
+package admin
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "path/filepath"
+
+ "gopkg.in/yaml.v3"
+)
+
+func readYAMLFile(dataDir, subdir, id string) ([]byte, string, error) {
+ path := filepath.Join(dataDir, subdir, id+".yaml")
+ data, err := os.ReadFile(path)
+ return data, path, err
+}
+
+func writeYAMLFile(path string, data any) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(data); err != nil {
+ return nil, err
+ }
+ enc.Close()
+ content := buf.Bytes()
+ if err := os.WriteFile(path, content, 0644); err != nil {
+ return nil, err
+ }
+ return content, nil
+}
+
+func findYAMLFileInSubdirs(dataDir, subdir, id string) ([]byte, string, error) {
+ base := filepath.Join(dataDir, subdir)
+ entries, err := os.ReadDir(base)
+ if err != nil {
+ return nil, "", err
+ }
+ for _, e := range entries {
+ fullPath := filepath.Join(base, e.Name(), id+".yaml")
+ if data, err := os.ReadFile(fullPath); err == nil {
+ return data, fullPath, nil
+ }
+ subPath := filepath.Join(base, e.Name())
+ subEntries, err := os.ReadDir(subPath)
+ if err != nil {
+ continue
+ }
+ for _, se := range subEntries {
+ fullPath := filepath.Join(subPath, se.Name(), id+".yaml")
+ if data, err := os.ReadFile(fullPath); err == nil {
+ return data, fullPath, nil
+ }
+ }
+ }
+ return nil, "", os.ErrNotExist
+}
+
+func listYAMLFiles(dataDir, subdir string) ([]string, error) {
+ var ids []string
+ base := filepath.Join(dataDir, subdir)
+
+ entries, err := os.ReadDir(base)
+ if err != nil {
+ return nil, err
+ }
+ for _, e := range entries {
+ if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" {
+ ids = append(ids, e.Name()[:len(e.Name())-5])
+ }
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ subPath := filepath.Join(base, e.Name())
+ subEntries, err := os.ReadDir(subPath)
+ if err != nil {
+ continue
+ }
+ for _, se := range subEntries {
+ if !se.IsDir() && filepath.Ext(se.Name()) == ".yaml" {
+ ids = append(ids, se.Name()[:len(se.Name())-5])
+ }
+ }
+ }
+ }
+ return ids, nil
+}
+
+func listYAMLFilesDeep(dataDir, subdir string) ([]string, error) {
+ var ids []string
+ base := filepath.Join(dataDir, subdir)
+ err := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() && filepath.Ext(d.Name()) == ".yaml" {
+ rel, _ := filepath.Rel(base, path)
+ id := rel[:len(rel)-5]
+ ids = append(ids, id)
+ }
+ return nil
+ })
+ return ids, err
+}
+
+func backupFile(path string) []byte {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ log.Printf("backup: failed to read %s: %v", path, err)
+ return nil
+ }
+ return data
+}
+
+func snapshotFile(path string) ([]byte, error) {
+ return os.ReadFile(path)
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 716f7ec..15f75d4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -7,14 +7,16 @@ import (
)
type Config struct {
- TickLength int `yaml:"tick_length"`
- StartingRoom int `yaml:"starting_room"`
- StartupValidation ValidationConfig `yaml:"startup_validation"`
- DefaultColors ColorsConfig `yaml:"default_colors"`
- Telnet TelnetConfig `yaml:"telnet"`
- TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"`
- HTTP HTTPConfig `yaml:"http"`
- HTTPS HTTPSConfig `yaml:"https"`
+ TickLength int `yaml:"tick_length"`
+ StartingRoom int `yaml:"starting_room"`
+ StartupValidation ValidationConfig `yaml:"startup_validation"`
+ DefaultColors ColorsConfig `yaml:"default_colors"`
+ TLS TLSConfig `yaml:"tls"`
+ Telnet TelnetConfig `yaml:"telnet"`
+ TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"`
+ HTTP HTTPConfig `yaml:"http"`
+ HTTPS HTTPSConfig `yaml:"https"`
+ AdminHTTPS AdminHTTPSConfig `yaml:"admin_https"`
}
type ColorsConfig map[string]string
@@ -77,23 +79,30 @@ type TelnetConfig struct {
Port int `yaml:"port"`
}
-type TelnetTLSConfig struct {
- Enabled bool `yaml:"enabled"`
- Port int `yaml:"port"`
+type TLSConfig struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
}
+type TelnetTLSConfig struct {
+ Enabled bool `yaml:"enabled"`
+ Port int `yaml:"port"`
+}
+
type HTTPConfig struct {
Enabled bool `yaml:"enabled"`
Port int `yaml:"port"`
}
type HTTPSConfig struct {
- Enabled bool `yaml:"enabled"`
- Port int `yaml:"port"`
- CertFile string `yaml:"cert_file"`
- KeyFile string `yaml:"key_file"`
+ Enabled bool `yaml:"enabled"`
+ Port int `yaml:"port"`
+}
+
+type AdminHTTPSConfig struct {
+ Enabled bool `yaml:"enabled"`
+ Port int `yaml:"port"`
+ AdminAccounts []string `yaml:"admin_accounts"`
}
func Default() *Config {
@@ -105,6 +114,10 @@ func Default() *Config {
IgnoreUnreachable: []int{},
},
DefaultColors: DefaultColors(),
+ TLS: TLSConfig{
+ CertFile: "",
+ KeyFile: "",
+ },
Telnet: TelnetConfig{
Enabled: true,
Port: 4000,
@@ -121,6 +134,11 @@ func Default() *Config {
Enabled: false,
Port: 8443,
},
+ AdminHTTPS: AdminHTTPSConfig{
+ Enabled: false,
+ Port: 9090,
+ AdminAccounts: []string{},
+ },
}
}
diff --git a/internal/net/server.go b/internal/net/server.go
index 345122e..d212dbf 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -199,20 +199,9 @@ func NewServer(cfg *config.Config) (*Server, error) {
}
if cfg.TelnetTLS.Enabled {
- var cert tls.Certificate
- if cfg.TelnetTLS.CertFile == "" || cfg.TelnetTLS.KeyFile == "" {
- var err error
- cert, err = generateSelfSignedCert()
- if err != nil {
- return nil, fmt.Errorf("telnet_tls self-signed cert: %w", err)
- }
- log.Printf("telnet_tls: using self-signed certificate (no cert_file/key_file configured)")
- } else {
- var err error
- cert, err = tls.LoadX509KeyPair(cfg.TelnetTLS.CertFile, cfg.TelnetTLS.KeyFile)
- if err != nil {
- return nil, fmt.Errorf("telnet_tls cert: %w", err)
- }
+ cert, err := loadOrGenerateCert(cfg, "telnet_tls")
+ if err != nil {
+ return nil, fmt.Errorf("telnet_tls cert: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
@@ -234,20 +223,9 @@ func NewServer(cfg *config.Config) (*Server, error) {
}
if cfg.HTTPS.Enabled {
- var cert tls.Certificate
- if cfg.HTTPS.CertFile == "" || cfg.HTTPS.KeyFile == "" {
- var err error
- cert, err = generateSelfSignedCert()
- if err != nil {
- return nil, fmt.Errorf("https self-signed cert: %w", err)
- }
- log.Printf("https: using self-signed certificate (no cert_file/key_file configured)")
- } else {
- var err error
- cert, err = tls.LoadX509KeyPair(cfg.HTTPS.CertFile, cfg.HTTPS.KeyFile)
- if err != nil {
- return nil, fmt.Errorf("https cert: %w", err)
- }
+ cert, err := loadOrGenerateCert(cfg, "https")
+ if err != nil {
+ return nil, fmt.Errorf("https cert: %w", err)
}
s.httpsTLSConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
@@ -262,6 +240,14 @@ func NewServer(cfg *config.Config) (*Server, error) {
return s, nil
}
+func loadOrGenerateCert(cfg *config.Config, service string) (tls.Certificate, error) {
+ if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" {
+ return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
+ }
+ log.Printf("%s: using self-signed certificate (no shared tls cert configured)", service)
+ return generateSelfSignedCert()
+}
+
func generateSelfSignedCert() (tls.Certificate, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {