aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md2
-rw-r--r--building_guide/admin.md204
-rw-r--r--building_guide/objects.md24
-rw-r--r--building_guide/triggers.md2
-rw-r--r--data/help/close.yaml6
-rw-r--r--data/help/dig.yaml9
-rw-r--r--data/help/god.yaml7
-rw-r--r--data/help/goto.yaml5
-rw-r--r--data/help/inspect.yaml23
-rw-r--r--data/help/reload.yaml8
-rw-r--r--data/help/room.yaml18
-rw-r--r--data/help/setflag.yaml7
-rw-r--r--data/help/setplayerflag.yaml8
-rw-r--r--data/help/shutdown.yaml8
-rw-r--r--data/help/summon.yaml5
-rw-r--r--data/help/swapid.yaml7
-rw-r--r--data/help/undig.yaml9
-rw-r--r--data/help/ungod.yaml6
-rw-r--r--internal/behavior/store.go6
-rw-r--r--internal/game/act.go2
-rw-r--r--internal/game/cmd_close.go90
-rw-r--r--internal/game/cmd_dig.go281
-rw-r--r--internal/game/cmd_farm.go105
-rw-r--r--internal/game/cmd_god.go63
-rw-r--r--internal/game/cmd_goto.go74
-rw-r--r--internal/game/cmd_inspect.go147
-rw-r--r--internal/game/cmd_move.go6
-rw-r--r--internal/game/cmd_quit.go1
-rw-r--r--internal/game/cmd_registry.go13
-rw-r--r--internal/game/cmd_reload.go37
-rw-r--r--internal/game/cmd_room.go118
-rw-r--r--internal/game/cmd_room_mob.go82
-rw-r--r--internal/game/cmd_room_object.go235
-rw-r--r--internal/game/cmd_room_spawn.go101
-rw-r--r--internal/game/cmd_setflag.go38
-rw-r--r--internal/game/cmd_setplayerflag.go60
-rw-r--r--internal/game/cmd_shutdown.go102
-rw-r--r--internal/game/cmd_summon.go92
-rw-r--r--internal/game/cmd_swapid.go126
-rw-r--r--internal/game/cmd_undig.go248
-rw-r--r--internal/game/cmd_verbs.go5
-rw-r--r--internal/game/combat_mob.go6
-rw-r--r--internal/game/core_admin.go7
-rw-r--r--internal/game/core_course.go8
-rw-r--r--internal/game/core_login_account.go1
-rw-r--r--internal/game/flagstore.go10
-rw-r--r--internal/game/game.go9
-rw-r--r--internal/game/look_entities.go24
-rw-r--r--internal/game/look_target.go32
-rw-r--r--internal/game/object_def.go28
-rw-r--r--internal/game/render_map.go2
-rw-r--r--internal/game/sys_combat.go4
-rw-r--r--internal/game/sys_hazard.go4
-rw-r--r--internal/game/tick.go1
-rw-r--r--internal/item/store.go5
-rw-r--r--internal/net/server.go3
-rw-r--r--internal/object/store.go5
-rw-r--r--internal/player/account.go1
-rw-r--r--internal/player/player.go2
-rw-r--r--internal/validate/checks.go26
-rw-r--r--internal/validate/local_test.go (renamed from internal/validate/inline_test.go)32
-rw-r--r--internal/world/mob.go17
-rw-r--r--internal/world/room.go94
-rw-r--r--internal/world/room_test.go34
-rw-r--r--internal/world/trigger_store.go9
-rw-r--r--internal/world/world.go37
66 files changed, 2607 insertions, 184 deletions
diff --git a/AGENTS.md b/AGENTS.md
index eb8c218..ef87de7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ File prefixes in `internal/game/`: `game.go/cmd_registry.go/tick.go` (core), `co
## Key Conventions
-**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses under `data/`. Read from disk on every access — edit and it takes effect immediately. **The filename is the ID** for every data type: lookup by filename stem. **Do not put a top-level `id:` field** (ignored). A nested `- id:` under a room's `objects:`/`mobs:` is a *reference* and is still required. Subdirectories are organizational only. **Inline objects:** a room `objects:` entry that carries content fields (`name`/`description`/`aliases`/`inroom_description`/`color`/`hidden`/`on_look`) is a fully inline, room-scoped object def instead of a reference — passive subset only (no gather/talk/use/safespot/steal/guard_mob/use_interactions). Inline identity derives from the normalized `name` (lowercased, whitespace-collapsed, spaces kept); `id:` is reserved for references and ignored+warned on inline. Each object in a room must have a unique name (startup error on collision, incl. inline-name vs referenced file id); names may repeat across different rooms.
+**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses under `data/`. Read from disk on every access — edit and it takes effect immediately. **The filename is the ID** for every data type: lookup by filename stem. **Do not put a top-level `id:` field** (ignored). A nested `- id:` under a room's `objects:`/`mobs:` is a *reference* and is still required. Subdirectories are organizational only. **Local objects:** a room `objects:` entry that carries content fields (`name`/`description`/`aliases`/`inroom_description`/`color`/`hidden`/`on_look`) is a fully local, room-scoped object def instead of a reference — passive subset only (no gather/talk/use/safespot/steal/guard_mob/use_interactions). Local identity derives from the normalized `name` (lowercased, whitespace-collapsed, spaces kept); `id:` is reserved for references and ignored+warned on local objects. Each object in a room must have a unique name (startup error on collision, incl. local-name vs referenced file id); names may repeat across different rooms.
**Live state.** Player data persisted to YAML on every change. Ground items, mob instances, object states are in-memory only.
diff --git a/building_guide/admin.md b/building_guide/admin.md
new file mode 100644
index 0000000..4ec36c1
--- /dev/null
+++ b/building_guide/admin.md
@@ -0,0 +1,204 @@
+## Admin Commands
+
+Admin commands are available to accounts with `admin: true` set in their account YAML file
+(`data/players/accounts/<name>.yaml`). Non-admin players see "Unknown command." when
+attempting these, so their existence is hidden.
+
+### Enabling Admin
+
+Edit the account YAML and add `admin: true`:
+```yaml
+name: yourname
+password_hash: sha256:...
+characters: [...]
+aliases: {}
+colors: {}
+options: {}
+admin: true
+```
+
+The character must log out and back in for the change to take effect.
+
+### Commands
+
+#### goto
+```
+goto <room_id>
+```
+Instantly teleports you to the specified room by numeric ID. Cancels any active combat,
+action, rest, enter sequence, or safespot. Your departure and arrival are broadcast to
+players in both rooms.
+
+#### summon
+```
+summon <player_name>
+```
+Teleports a player to your current room. Their active state is cleaned up (combat, actions,
+etc.) and they receive a notification. Case-insensitive name matching.
+
+#### dig
+```
+dig <direction> [name]
+```
+Creates a new room in the given direction (north/south/east/west/up/down) from your
+current room. The new room ID is auto-generated as the lowest available integer at-or-above
+the minimum room ID in the same directory as your current room's YAML file.
+
+Both the new room and your current room are updated with reciprocal exits. The admin is
+teleported into the new room. The new room starts with a generic "A featureless room."
+description and no spawns, mobs, or objects — edit its YAML file to flesh it out.
+
+If the direction would place the new room on a grid cell already occupied by an existing
+room (a grid overlap), no new room is created. Instead, a two-way link is automatically
+established between your current room and the existing room.
+
+Examples:
+- `dig north` — creates a room north of you with auto-generated ID and name "New Room"
+- `dig east "Dark Forest"` — creates a room east of you named "Dark Forest"
+
+#### undig
+```
+undig <direction>
+```
+Deletes the room in the given direction and removes all exits leading to it from
+every room. Requires confirmation — type `UNDIG <direction>` to proceed, anything
+else to cancel. The target room's name and ID are shown before deletion. A warning
+is displayed if deleting the room would create orphaned (unreachable) rooms.
+
+Any players currently in the deleted room are teleported to your current room.
+
+#### close
+```
+close <direction>
+```
+Deletes an exit in the given direction from your current room. Also removes the
+reciprocal exit from the target room if it points back to your room.
+
+#### swapid
+```
+swapid <id>
+swapid <id1> <id2>
+```
+Swaps the numeric IDs of two rooms. The first form swaps your current room with
+the given room ID. The second form swaps two arbitrary rooms. All exit references
+across the entire world are updated automatically to keep the world consistent.
+
+#### room
+```
+room <action> [args...]
+```
+Modify the current room's data directly from inside the game. Type `room` alone
+to see all available actions:
+
+| Action | Usage | Description |
+|--------|-------|-------------|
+| `name` | `room name <text>` | Set the room's name |
+| `desc` | `room desc <text>` | Set the room's description |
+| `addobj` | `room addobj <object_id>` | Add a referenced object (must exist in data/objects/) |
+| `remobj` | `room remobj <object_id>` | Remove a referenced object |
+| `addlocalobj` | `room addlocalobj <name> <description>` | Add a local object |
+| `remlocalobj` | `room remlocalobj <name>` | Remove a local object |
+| `hide` | `room hide <name_or_id>` | Hide all matching objects (referenced or local) |
+| `unhide` | `room unhide <name_or_id>` | Unhide all matching objects (referenced or local) |
+| `addmob` | `room addmob <mob_id>` | Add a mob spawn (must exist in data/mobs/) |
+| `remmob` | `room remmob <mob_id>` | Remove a mob spawn |
+| `addspawn` | `room addspawn <item_id> [qty] [respawn]` | Add an item spawn (must exist in data/items/) |
+| `remspawn` | `room remspawn <item_id>` | Remove an item spawn |
+
+All room commands are admin-only. Referenced objects, mobs, and items are validated
+before being added.
+
+#### god
+```
+god
+```
+Temporarily sets all your skills to level 99, prevents mobs from aggressing you,
+allows walking through blocked exits, and makes you immune to hazard damage and
+death. Does NOT persist across reconnects — your original stats are restored
+automatically when you quit, disconnect, or use the `ungod` command.
+
+#### ungod
+```
+ungod
+```
+Restores your original skills and removes god-mode privileges. This also happens
+automatically if you disconnect or quit while in god mode, so your inflated stats
+are never saved to disk.
+
+#### setflag
+```
+setflag <flag_name> [value]
+```
+Sets a world flag. World flags are shared by all players and stored in memory (lost on
+server restart). Setting a flag fires any triggers watching that flag. Values default to
+`true` if omitted. Accepted value types: `true`/`false` (bool), integer, or string.
+
+#### setplayerflag
+```
+setplayerflag <player_name> <flag_name> [value]
+```
+Sets a player-specific flag on a character. Player flags are persisted to the character
+YAML file and survive restarts. Setting a flag fires any triggers watching that flag.
+The character is saved immediately. Values work the same as setflag.
+
+#### reload
+```
+reload
+```
+Reloads all data caches from disk. Use this after editing YAML files (items, objects,
+mobs, drops, techs, modules, courses, hazards, triggers) to pick up changes without
+restarting the server. Rooms already hot-reload automatically.
+
+The reload process:
+1. Clears item, object, and mob definition caches
+2. Rebuilds path indices for items, objects, mobs, drops, and rooms
+3. Rebuilds the craft index
+4. Reloads techs and modules
+5. Reloads courses
+6. Clears hazard caches
+7. Clears and re-seeds triggers from disk
+
+Live state (mob instances, player data, ground items, active sequences) is NOT affected.
+
+#### shutdown
+```
+shutdown <minutes>
+shutdown cancel
+```
+Schedules a graceful shutdown announcement. All online players receive a message every
+30 seconds with the remaining time. When the countdown reaches zero, the server exits
+immediately. Use `shutdown cancel` to abort a pending shutdown.
+
+#### inspect
+```
+inspect
+```
+Dumps detailed diagnostic information about your current room:
+- Room ID, name, color, hazard, BlockTransport status
+- All exits with conditions noted
+- Objects (with local/global, hidden, and wander status)
+- Mobs with wander intervals
+- Item spawns with respawn timers
+- On-enter step and trigger counts
+- All world flags
+- Your player flags
+- Connected players in the room
+- Live mob instances with HP
+
+### Workflow
+
+The typical admin worldbuilding workflow:
+
+1. **Lay out rooms:** Use `dig` to create connected rooms quickly. If `dig` would create a
+ grid overlap, a two-way link is created instead — no duplicate rooms.
+2. **Flesh out rooms:** Use `room` commands to set names, descriptions, add objects/mobs/spawns
+ directly from inside the game. For deeper edits, edit the YAML files directly in
+ `data/rooms/` to add on-enter scripts, triggers, hazards, etc.
+3. **Rearrange:** Use `swapid` to renumber rooms and `undig` to delete rooms you no longer want
+ (with full exit cleanup). Use `close` to remove individual exits.
+4. **Reload:** Run `reload` to pick up YAML changes without restarting (rooms auto-reload).
+5. **Test:** Use `goto` to jump around while debugging room connections and content.
+6. **God mode:** Use `god` to test dangerous areas safely — full stats, no aggro, pass through
+ blocked exits. `ungod` restores normal state.
+7. **Debug:** Use `setflag` / `setplayerflag` to test quest logic and conditional content.
+ Use `inspect` to check room state, flags, and triggers.
diff --git a/building_guide/objects.md b/building_guide/objects.md
index 37b8e23..d8761ae 100644
--- a/building_guide/objects.md
+++ b/building_guide/objects.md
@@ -16,8 +16,8 @@ and is still required there.)
- **Generic, shared objects** (rocks, trees, altars, stations) use a semantic name and live
flat in `data/objects/` — e.g. `data/objects/copper_rock.yaml`.
- **Room-specific one-offs** (signs, set-dressing, scenery for a single room): if they are
- description-only (the passive subset), prefer defining them **inline in the room** (see
- [Inline objects](#inline-objects-defined-in-the-room) below). If a one-off needs
+ description-only (the passive subset), prefer defining them **locally in the room** (see
+ [Local objects](#local-objects-defined-in-the-room) below). If a one-off needs
interactable behavior (`gather`/`talk`/`use`/etc.) it must be a file — prefix it with the
room number and place it in `data/objects/unique/`, e.g. `data/objects/unique/1002_sign.yaml`
(ID `1002_sign`, referenced from room `1002`).
@@ -27,20 +27,20 @@ need no changes to room references when a file is moved. They do **not** namespa
filename stem must be globally unique across all of `data/objects/`. Use `hidden: true` for
signage/scenery so it doesn't appear in room listings.
-### Inline objects (defined in the room)
+### Local objects (defined in the room)
Room-specific **description / scenery** objects can be defined directly inside the room's
`objects:` list instead of as separate files in `data/objects/unique/`. An entry in the list
-is treated as **inline** when it carries a `name:` (or any other content field —
+is treated as **local** when it carries a `name:` (or any other content field —
`description`, `aliases`, `inroom_description`, `color`, `hidden`, `on_look`). An entry with
only `- id: <file>` is a plain **reference** to a file object, as before. `id:` is reserved
-for references; **inline objects do not take an `id:`** — their identity comes from the name.
+for references; **local objects do not take an `id:`** — their identity comes from the name.
```yaml
# in data/rooms/intro/1001.yaml
objects:
- id: workbench # reference: resolves to data/objects/workbench.yaml
- - name: window # inline: identity derived from the name, no file
+ - name: window # local: identity derived from the name, no file
hidden: true
description: "A thick trim with rounded bolt heads frames the tiny window."
- name: sign
@@ -55,26 +55,26 @@ objects:
- **Identity comes from `name`.** Internally the object's id is the name, normalized
(lowercased, surrounding/redundant spaces trimmed, spaces kept — `"Instrument Panel"` →
- `instrument panel`). You never write an `id:`; doing so on an inline entry is ignored and
+ `instrument panel`). You never write an `id:`; doing so on a local entry is ignored and
warned about at startup.
- **Identity is room-scoped.** Two different rooms may each have an object named `sign`
without conflict — no room-number prefixing needed (unlike the old `data/objects/unique/`
files).
- **Each object in a room must have a unique name.** Two *distinct* objects in the same room
- with the same exact name — whether both inline, both file references, or one of each — is a
+ with the same exact name — whether both local, both file references, or one of each — is a
startup **error** (the player could not disambiguate them). Multiple instances of the *same*
object are still fine via repeated references (e.g. two `- id: copper_rock`).
- **Partial-name siblings are fine.** `rusty sign` and `shiny sign` can coexist; `look sign`
matches both and prompts *"which one?"*, while `look rusty sign` resolves directly.
-- Inline objects support only the **passive subset**: `name`, `aliases`, `color`, `hidden`,
+- Local objects support only the **passive subset**: `name`, `aliases`, `color`, `hidden`,
`inroom_description`, `description` (including conditional variants), and `on_look`.
- Interactable / stateful behavior (`gather`, `talk`, `use`, `safespot`, `steal`, `guard_mob`,
`removal_item`, `use_interactions`, craft stations) **must** be a standalone object file;
- startup validation errors if those appear inline.
-- Inline objects are re-read from the room file on every access, so edits take effect
+ startup validation errors if those appear locally.
+- Local objects are re-read from the room file on every access, so edits take effect
immediately (file objects are cached after first load).
-Prefer inline for one-room flavor; keep generic/reusable/interactable objects as files.
+Prefer local for one-room flavor; keep generic/reusable/interactable objects as files.
Gathering object (mining):
diff --git a/building_guide/triggers.md b/building_guide/triggers.md
index 8d6454c..b88f16d 100644
--- a/building_guide/triggers.md
+++ b/building_guide/triggers.md
@@ -32,7 +32,7 @@ triggers:
```
The sign object itself is unchanged — its `on_look` simply sets the flag. The trigger
-and the object are cleanly separated. The sign is an inline object in room `1001`:
+and the object are cleanly separated. The sign is a local object in room `1001`:
```yaml
# data/rooms/intro/1001.yaml
diff --git a/data/help/close.yaml b/data/help/close.yaml
new file mode 100644
index 0000000..5a8a144
--- /dev/null
+++ b/data/help/close.yaml
@@ -0,0 +1,6 @@
+name: close
+description: |-
+ Admin-only. Deletes an exit in the given direction from your current room,
+ and also removes the reciprocal exit from the target room if it points back.
+ Usage: close <direction>
+ Example: close east
diff --git a/data/help/dig.yaml b/data/help/dig.yaml
new file mode 100644
index 0000000..7e0cd4c
--- /dev/null
+++ b/data/help/dig.yaml
@@ -0,0 +1,9 @@
+name: dig
+description: |-
+ Admin-only. Creates a new room in a direction from your current room.
+ The new room receives a reciprocal exit back to your current room,
+ and your current room gains an exit to the new room.
+ The room ID is auto-generated as the lowest available number in the
+ same directory as the current room's YAML file.
+ Usage: dig <direction> [name]
+ Example: dig north "Dark Forest"
diff --git a/data/help/god.yaml b/data/help/god.yaml
new file mode 100644
index 0000000..d68e045
--- /dev/null
+++ b/data/help/god.yaml
@@ -0,0 +1,7 @@
+name: god
+description: |-
+ Admin-only. Temporarily sets all your skills to level 99, prevents mobs
+ from aggressing you, lets you walk through blocked exits, and makes you
+ immune to hazard damage and death. Does not persist across reconnects.
+ Usage: god
+ Use 'ungod' to restore your normal stats and privileges.
diff --git a/data/help/goto.yaml b/data/help/goto.yaml
new file mode 100644
index 0000000..fdff16a
--- /dev/null
+++ b/data/help/goto.yaml
@@ -0,0 +1,5 @@
+name: goto
+description: |-
+ Admin-only. Teleports you instantly to a room by ID.
+ Usage: goto <room_id>
+ Example: goto 1001
diff --git a/data/help/inspect.yaml b/data/help/inspect.yaml
index f81dc6b..14dfe45 100644
--- a/data/help/inspect.yaml
+++ b/data/help/inspect.yaml
@@ -1,17 +1,6 @@
-name: "inspect"
-category: "Skills"
-description: |
- Check the status of farming patches in the current room.
-
- Usage: inspect [patch]
-
- Shows the current state of each farming patch including:
- - What is planted
- - Growth stage
- - Whether it has been watered
- - Disease status
- - Whether it is ready to harvest
-
- If no patch is specified, all patches in the room are shown.
-
- See also: help farming, help plant, help harvest
+name: inspect
+description: |-
+ Admin-only. Dumps detailed diagnostic information about your current
+ room, including exits, objects, mobs, item spawns, on-enter steps,
+ triggers, world flags, your player flags, and connected players.
+ Usage: inspect
diff --git a/data/help/reload.yaml b/data/help/reload.yaml
new file mode 100644
index 0000000..8c8c46d
--- /dev/null
+++ b/data/help/reload.yaml
@@ -0,0 +1,8 @@
+name: reload
+description: |-
+ Admin-only. Reloads all data caches from YAML files on disk.
+ This includes items, objects, mobs, drops, craft recipes, techs,
+ modules, courses, room index, hazards, and triggers.
+ Use this after editing YAML files to pick up changes without
+ restarting the server. (Rooms already hot-reload automatically.)
+ Usage: reload
diff --git a/data/help/room.yaml b/data/help/room.yaml
new file mode 100644
index 0000000..c5c18b0
--- /dev/null
+++ b/data/help/room.yaml
@@ -0,0 +1,18 @@
+name: room
+description: |-
+ Admin-only. Modify the current room's data directly from within the game.
+ Usage: room <action> [args...]
+ Actions:
+ name <text> — set room name
+ desc <text> — set room description
+ addobj <object_id> — add a referenced object
+ remobj <object_id> — remove a referenced object
+ addlocalobj <name> <description> — add a local object
+ remlocalobj <name> — remove a local object
+ hide <name_or_id> — hide all matching objects
+ unhide <name_or_id> — unhide all matching objects
+ addmob <mob_id> — add a mob spawn
+ remmob <mob_id> — remove a mob spawn
+ addspawn <item_id> [qty] [respawn_ticks] — add an item spawn
+ remspawn <item_id> — remove an item spawn
+ Type 'room' alone to see this list in-game.
diff --git a/data/help/setflag.yaml b/data/help/setflag.yaml
new file mode 100644
index 0000000..7a164d6
--- /dev/null
+++ b/data/help/setflag.yaml
@@ -0,0 +1,7 @@
+name: setflag
+description: |-
+ Admin-only. Sets a world flag. World flags are shared by all players.
+ If no value is given, sets the flag to true.
+ Values can be: true, false, an integer, or a string.
+ Usage: setflag <flag_name> [value]
+ Example: setflag gate_open true
diff --git a/data/help/setplayerflag.yaml b/data/help/setplayerflag.yaml
new file mode 100644
index 0000000..11a3eca
--- /dev/null
+++ b/data/help/setplayerflag.yaml
@@ -0,0 +1,8 @@
+name: setplayerflag
+description: |-
+ Admin-only. Sets a player-specific flag on a character.
+ Useful for quest progress, permissions, or testing.
+ If no value is given, sets the flag to true.
+ Values can be: true, false, an integer, or a string.
+ Usage: setplayerflag <player_name> <flag_name> [value]
+ Example: setplayerflag bob has_vault_key true
diff --git a/data/help/shutdown.yaml b/data/help/shutdown.yaml
new file mode 100644
index 0000000..4a57113
--- /dev/null
+++ b/data/help/shutdown.yaml
@@ -0,0 +1,8 @@
+name: shutdown
+description: |-
+ Admin-only. Schedules a server shutdown after a countdown.
+ All online players receive periodic announcements.
+ Can be cancelled with shutdown cancel.
+ Usage: shutdown <minutes> - schedule shutdown
+ shutdown cancel - cancel pending shutdown
+ Example: shutdown 5
diff --git a/data/help/summon.yaml b/data/help/summon.yaml
new file mode 100644
index 0000000..2a9268d
--- /dev/null
+++ b/data/help/summon.yaml
@@ -0,0 +1,5 @@
+name: summon
+description: |-
+ Admin-only. Teleports another player to your current room.
+ Usage: summon <player_name>
+ Example: summon bob
diff --git a/data/help/swapid.yaml b/data/help/swapid.yaml
new file mode 100644
index 0000000..8698767
--- /dev/null
+++ b/data/help/swapid.yaml
@@ -0,0 +1,7 @@
+name: swapid
+description: |-
+ Admin-only. Swaps the numeric IDs (filenames) of two rooms. All exits
+ referencing either room are updated automatically so the world remains
+ consistent.
+ Usage: swapid <id> — swap your current room with another
+ swapid <id1> <id2> — swap two rooms by ID
diff --git a/data/help/undig.yaml b/data/help/undig.yaml
new file mode 100644
index 0000000..f7d3119
--- /dev/null
+++ b/data/help/undig.yaml
@@ -0,0 +1,9 @@
+name: undig
+description: |-
+ Admin-only. Deletes the room in the given direction and removes all exits
+ leading to it from every room in the game. Requires confirmation.
+ Usage: undig <direction>
+ Example: undig north
+ The target room's name is shown for confirmation. Type UNDIG <DIRECTION>
+ to confirm. A warning is shown if deleting the room would create orphaned
+ (unreachable) rooms.
diff --git a/data/help/ungod.yaml b/data/help/ungod.yaml
new file mode 100644
index 0000000..ce041ed
--- /dev/null
+++ b/data/help/ungod.yaml
@@ -0,0 +1,6 @@
+name: ungod
+description: |-
+ Admin-only. Restores your skills and removes god-mode privileges.
+ Usage: ungod
+ This is also applied automatically if you disconnect or quit while in god
+ mode, so your stats are never saved with inflated values.
diff --git a/internal/behavior/store.go b/internal/behavior/store.go
index 7695b8d..710506e 100644
--- a/internal/behavior/store.go
+++ b/internal/behavior/store.go
@@ -103,6 +103,12 @@ func loadDropIndex(dataDir string) map[string]string {
return dropIndex
}
+func ClearDropIndex() {
+ dropIndexMu.Lock()
+ defer dropIndexMu.Unlock()
+ dropIndex = nil
+}
+
func LoadDropTable(dataDir, id string) (*DropTableDef, error) {
index := loadDropIndex(dataDir)
path, ok := index[id]
diff --git a/internal/game/act.go b/internal/game/act.go
index 654b65c..aa25625 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -105,7 +105,7 @@ func (g *Game) startAction(sess *net.Session, verb, target string) {
return
}
var err error
- obj, err = g.resolveObjectDef(p.RoomID, chosen.DefID)
+ obj, _, err = g.resolveObjectDef(p.RoomID, chosen.DefID)
if err != nil {
sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb))
return
diff --git a/internal/game/cmd_close.go b/internal/game/cmd_close.go
new file mode 100644
index 0000000..8dab0d9
--- /dev/null
+++ b/internal/game/cmd_close.go
@@ -0,0 +1,90 @@
+package game
+
+import (
+ "fmt"
+ "os"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) executeClose(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ if len(args) == 0 {
+ sess.WriteLine("Close which direction?")
+ return
+ }
+
+ dir := g.World.ResolveExit(args[0])
+ if dir == "" {
+ sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.")
+ return
+ }
+
+ curRoom, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ sess.WriteLine("Error loading current room.")
+ return
+ }
+
+ curPath, ok := g.World.GetRoomPath(p.RoomID)
+ if !ok {
+ sess.WriteLine("Error: can't find current room file.")
+ return
+ }
+
+ exitDef, ok := curRoom.Exits[dir]
+ if !ok {
+ sess.WriteLine(fmt.Sprintf("There is no exit to the %s from here.", dir))
+ return
+ }
+
+ targetID := exitDef.Room
+ targetRoom, err := g.World.LoadRoom(targetID)
+ targetName := fmt.Sprintf("#%d", targetID)
+ if err == nil {
+ targetName = fmt.Sprintf("#%d (%s)", targetID, targetRoom.Name)
+ }
+
+ delete(curRoom.Exits, dir)
+ curData, err := yaml.Marshal(curRoom)
+ if err != nil {
+ sess.WriteLine("Error updating current room YAML.")
+ return
+ }
+ if err := os.WriteFile(curPath, curData, 0644); err != nil {
+ sess.WriteLine("Error writing current room file.")
+ return
+ }
+
+ reciprocalRemoved := false
+ if targetRoom != nil {
+ oppositeDir := world.OppositeExit[dir]
+ if targetExit, tok := targetRoom.Exits[oppositeDir]; tok && targetExit.Room == p.RoomID {
+ delete(targetRoom.Exits, oppositeDir)
+ targetPath, tok2 := g.World.GetRoomPath(targetID)
+ if tok2 {
+ targetData, terr := yaml.Marshal(targetRoom)
+ if terr == nil {
+ if werr := os.WriteFile(targetPath, targetData, 0644); werr == nil {
+ reciprocalRemoved = true
+ }
+ }
+ }
+ }
+ }
+
+ msg := fmt.Sprintf("Closed exit %s to %s.", dir, targetName)
+ if reciprocalRemoved {
+ msg += fmt.Sprintf(" Removed reciprocal exit from %s as well.", targetName)
+ }
+ sess.WriteLine(msg)
+}
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
new file mode 100644
index 0000000..e7023b2
--- /dev/null
+++ b/internal/game/cmd_dig.go
@@ -0,0 +1,281 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ if len(args) == 0 {
+ sess.WriteLine("Dig where? (north/south/east/west/up/down)")
+ return
+ }
+
+ dir := g.World.ResolveExit(args[0])
+ if dir == "" {
+ sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.")
+ return
+ }
+
+ curPath, ok := g.World.GetRoomPath(p.RoomID)
+ if !ok {
+ sess.WriteLine("Error: can't find current room file.")
+ return
+ }
+
+ curRoom, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ sess.WriteLine("Error loading current room.")
+ return
+ }
+ if _, hasExit := curRoom.Exits[dir]; hasExit {
+ sess.WriteLine(fmt.Sprintf("There is already an exit to the %s.", dir))
+ return
+ }
+
+ if conflictID := g.findGridConflict(p.RoomID, dir); conflictID != 0 {
+ conflictRoom, err := g.World.LoadRoom(conflictID)
+ conflictName := fmt.Sprintf("#%d", conflictID)
+ if err == nil {
+ conflictName = fmt.Sprintf("#%d (%s)", conflictID, conflictRoom.Name)
+ }
+
+ oppositeDir := world.OppositeExit[dir]
+ curRoom.Exits[dir] = world.ExitDef{Room: conflictID}
+ curData, err := yaml.Marshal(curRoom)
+ if err != nil {
+ sess.WriteLine("Error updating current room YAML.")
+ return
+ }
+ if err := os.WriteFile(curPath, curData, 0644); err != nil {
+ sess.WriteLine("Error writing current room file.")
+ return
+ }
+
+ if conflictRoom != nil {
+ if _, exists := conflictRoom.Exits[oppositeDir]; !exists {
+ conflictRoom.Exits[oppositeDir] = world.ExitDef{Room: p.RoomID}
+ conflictPath, ok := g.World.GetRoomPath(conflictID)
+ if ok {
+ conflictData, cerr := yaml.Marshal(conflictRoom)
+ if cerr == nil {
+ os.WriteFile(conflictPath, conflictData, 0644)
+ }
+ }
+ }
+ }
+
+ sess.WriteLine(fmt.Sprintf("That spot is already occupied by %s. Created a two-way link instead.", conflictName))
+ return
+ }
+
+ newID, err := findNextRoomID(curPath)
+ if err != nil {
+ sess.WriteLine("Error scanning room directory.")
+ return
+ }
+
+ var name string
+ if len(args) > 1 {
+ name = strings.Join(args[1:], " ")
+ } else {
+ name = "New Room"
+ }
+
+ oppositeDir := world.OppositeExit[dir]
+ dirPath := filepath.Dir(curPath)
+ newPath := filepath.Join(dirPath, strconv.Itoa(newID)+".yaml")
+
+ newRoom := &world.Room{
+ Name: name,
+ Description: behavior.DescList{
+ {Text: "A featureless room."},
+ },
+ Exits: map[world.ExitDir]world.ExitDef{
+ oppositeDir: {Room: p.RoomID},
+ },
+ }
+
+ data, err := yaml.Marshal(newRoom)
+ if err != nil {
+ sess.WriteLine("Error creating room YAML.")
+ return
+ }
+ if err := os.MkdirAll(dirPath, 0755); err != nil {
+ sess.WriteLine("Error creating directory.")
+ return
+ }
+ if err := os.WriteFile(newPath, data, 0644); err != nil {
+ sess.WriteLine("Error writing room file.")
+ return
+ }
+
+ g.World.AddRoomPath(newID, newPath)
+
+ curRoom.Exits[dir] = world.ExitDef{Room: newID}
+ curData, err := yaml.Marshal(curRoom)
+ if err != nil {
+ sess.WriteLine("Error updating current room YAML.")
+ return
+ }
+ if err := os.WriteFile(curPath, curData, 0644); err != nil {
+ sess.WriteLine("Error writing current room file.")
+ return
+ }
+
+ oldRoom := p.RoomID
+ p.ClearMoveState()
+ if g.Combat.Get(p.Name) != nil {
+ g.stopCombat(p.Name)
+ }
+ p.Action = nil
+ g.cancelRest(p.Name)
+ g.cancelEnterSeq(p.Name)
+ if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
+ p.EnterSeqRoom = 0
+ }
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "")
+ }
+
+ p.RoomID = newID
+ p.HazardTimer = 0
+ p.Stats.RecordRoomVisit(newID)
+ g.AccountStore.SaveCharacter(p)
+
+ g.World.SeedGroundItems(newID)
+ g.seedRoomMobs(newID)
+ g.seedRoomObjects(newID)
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(oldRoom) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("%s digs to the %s and vanishes.", p.Name, dir))
+ }
+ }
+ g.Hub.EnterRoom(sess, newID)
+ for _, other := range g.Hub.PlayersInRoom(newID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("%s appears from a freshly dug tunnel.", p.Name))
+ }
+ }
+ }
+
+ sess.WriteLine(fmt.Sprintf("You dig %s and create Room #%d (%s).", dir, newID, name))
+ g.doLook(sess)
+ g.runEnterSteps(sess, newID)
+ g.checkAggro(sess)
+}
+
+func findNextRoomID(currentRoomPath string) (int, error) {
+ dir := filepath.Dir(currentRoomPath)
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return 0, err
+ }
+
+ used := make(map[int]bool)
+ minID := 0
+ first := true
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
+ continue
+ }
+ idStr := strings.TrimSuffix(entry.Name(), ".yaml")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ continue
+ }
+ used[id] = true
+ if first || id < minID {
+ minID = id
+ first = false
+ }
+ }
+
+ if first {
+ return 1, nil
+ }
+
+ for id := minID; ; id++ {
+ if !used[id] {
+ return id, nil
+ }
+ }
+}
+
+var gridDeltas = map[world.ExitDir][2]int{
+ world.North: {0, -1},
+ world.South: {0, 1},
+ world.East: {1, 0},
+ world.West: {-1, 0},
+}
+
+func (g *Game) findGridConflict(fromRoomID int, dir world.ExitDir) int {
+ delta, ok := gridDeltas[dir]
+ if !ok {
+ return 0
+ }
+
+ roomIndex := g.World.RoomIndex()
+ coord := map[int][2]int{fromRoomID: {0, 0}}
+ roomAt := map[[2]int]int{{0, 0}: fromRoomID}
+ queue := []int{fromRoomID}
+
+ for len(queue) > 0 {
+ rid := queue[0]
+ queue = queue[1:]
+ room, err := g.World.LoadRoom(rid)
+ if err != nil {
+ continue
+ }
+ c := coord[rid]
+ for _, ed := range world.ExitOrder {
+ exit, ok := room.Exits[ed]
+ if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
+ continue
+ }
+ target := exit.Room
+ if ed == world.Up || ed == world.Down {
+ continue
+ }
+ gd, ok := gridDeltas[ed]
+ if !ok {
+ continue
+ }
+ want := [2]int{c[0] + gd[0], c[1] + gd[1]}
+
+ if _, exists := coord[target]; exists {
+ continue
+ }
+ if occupier, exists := roomAt[want]; exists && occupier != target {
+ continue
+ }
+ coord[target] = want
+ roomAt[want] = target
+ queue = append(queue, target)
+ }
+ }
+
+ targetCoord := [2]int{delta[0], delta[1]}
+ if occupier, ok := roomAt[targetCoord]; ok && occupier != fromRoomID {
+ return occupier
+ }
+ return 0
+}
diff --git a/internal/game/cmd_farm.go b/internal/game/cmd_farm.go
index 87d3d81..19cdf12 100644
--- a/internal/game/cmd_farm.go
+++ b/internal/game/cmd_farm.go
@@ -59,14 +59,6 @@ func (g *Game) executeCure(sess *net.Session, args []string, rawInput string) {
}
}
-func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string) {
- if len(args) == 0 {
- g.doInspect(sess, "")
- } else {
- g.doInspect(sess, strings.Join(args, " "))
- }
-}
-
func (g *Game) doPlant(sess *net.Session, input string) {
p := sess.Player
@@ -447,61 +439,64 @@ func (g *Game) doInspect(sess *net.Session, input string) {
}
sess.WriteLine(fmt.Sprintf("=== %s%s ===", patchName, indexStr))
+ g.ShowFarmPatchDetail(sess, p, fp.prefix, fp.defID, fp.index)
+ }
+}
- weeds, _ := p.Flags[fp.prefix+"_weeds"].(bool)
- if weeds {
- sess.WriteLine(" Status: Weeds")
- sess.WriteLine(" (Rake to clear before planting)")
- continue
- }
-
- seedID, _ := p.Flags[fp.prefix+"_seed"].(string)
- if seedID == "" {
- sess.WriteLine(" Status: Empty")
- sess.WriteLine(" (Ready to plant)")
- continue
- }
-
- seedName := seedID
- if def, err := g.ItemStore.Load(seedID); err == nil {
- seedName = def.Name
- }
+func (g *Game) ShowFarmPatchDetail(sess *net.Session, p *player.Player, prefix, defID string, index int) {
+ weeds, _ := p.Flags[prefix+"_weeds"].(bool)
+ if weeds {
+ sess.WriteLine(" Status: Weeds")
+ sess.WriteLine(" (Rake to clear before planting)")
+ return
+ }
- dead, _ := p.Flags[fp.prefix+"_dead"].(bool)
- if dead {
- sess.WriteLine(fmt.Sprintf(" Status: Dead"))
- sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName))
- sess.WriteLine(" (Rake to clear)")
- continue
- }
+ seedID, _ := p.Flags[prefix+"_seed"].(string)
+ if seedID == "" {
+ sess.WriteLine(" Status: Empty")
+ sess.WriteLine(" (Ready to plant)")
+ return
+ }
- diseased, _ := p.Flags[fp.prefix+"_diseased"].(bool)
- if diseased {
- sess.WriteLine(fmt.Sprintf(" Status: Diseased!"))
- sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName))
- sess.WriteLine(" (Use plant cure to save it)")
- continue
- }
+ seedName := seedID
+ if def, err := g.ItemStore.Load(seedID); err == nil {
+ seedName = def.Name
+ }
- ready, _ := p.Flags[fp.prefix+"_ready"].(bool)
- if ready {
- sess.WriteLine(fmt.Sprintf(" Status: Ready to harvest!"))
- sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName))
- continue
- }
+ dead, _ := p.Flags[prefix+"_dead"].(bool)
+ if dead {
+ sess.WriteLine(fmt.Sprintf(" Status: Dead"))
+ sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName))
+ sess.WriteLine(" (Rake to clear)")
+ return
+ }
- stage := intFromFlag(p.Flags, fp.prefix+"_stage")
- maxStages := 4
- if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 {
- maxStages = def.FarmStages
- }
- watered, _ := p.Flags[fp.prefix+"_watered"].(bool)
+ diseased, _ := p.Flags[prefix+"_diseased"].(bool)
+ if diseased {
+ sess.WriteLine(fmt.Sprintf(" Status: Diseased!"))
+ sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName))
+ sess.WriteLine(" (Use plant cure to save it)")
+ return
+ }
- sess.WriteLine(fmt.Sprintf(" Status: Growing (stage %d/%d)", stage, maxStages))
+ ready, _ := p.Flags[prefix+"_ready"].(bool)
+ if ready {
+ sess.WriteLine(fmt.Sprintf(" Status: Ready to harvest!"))
sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName))
- sess.WriteLine(fmt.Sprintf(" Watered: %s", boolToYes(watered)))
- sess.WriteLine(fmt.Sprintf(" Diseased: %s", boolToYes(diseased)))
+ return
+ }
+
+ stage := intFromFlag(p.Flags, prefix+"_stage")
+ maxStages := 4
+ if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 {
+ maxStages = def.FarmStages
}
+ watered, _ := p.Flags[prefix+"_watered"].(bool)
+
+ sess.WriteLine(fmt.Sprintf(" Status: Growing (stage %d/%d)", stage, maxStages))
+ sess.WriteLine(fmt.Sprintf(" Planted: %s", seedName))
+ sess.WriteLine(fmt.Sprintf(" Watered: %s", boolToYes(watered)))
+ sess.WriteLine(fmt.Sprintf(" Diseased: %s", boolToYes(diseased)))
}
func boolToYes(b bool) string {
diff --git a/internal/game/cmd_god.go b/internal/game/cmd_god.go
new file mode 100644
index 0000000..0545c3f
--- /dev/null
+++ b/internal/game/cmd_god.go
@@ -0,0 +1,63 @@
+package game
+
+import (
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func (g *Game) executeGod(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ if p.GodMode {
+ sess.WriteLine("You are already in god mode.")
+ return
+ }
+
+ p.GodBackup = make(map[player.SkillName]int, len(p.Skills))
+ for k, v := range p.Skills {
+ p.GodBackup[k] = v
+ }
+
+ lvl99xp := player.XPForLevel(99)
+ for _, s := range player.AllSkills {
+ p.Skills[s] = lvl99xp
+ }
+ p.GodMode = true
+ p.HP = p.MaxHP()
+
+ sess.WriteLine("God mode activated. All skills set to 99.")
+}
+
+func (g *Game) executeUnGod(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ if !p.GodMode {
+ sess.WriteLine("You are not in god mode.")
+ return
+ }
+ g.restoreGodPlayer(p)
+ sess.WriteLine("God mode deactivated. Skills restored.")
+}
+
+func (g *Game) restoreGodPlayer(p *player.Player) {
+ if p == nil || !p.GodMode {
+ return
+ }
+ if p.GodBackup != nil {
+ p.Skills = p.GodBackup
+ }
+ p.GodMode = false
+ p.GodBackup = nil
+}
diff --git a/internal/game/cmd_goto.go b/internal/game/cmd_goto.go
new file mode 100644
index 0000000..41e7b37
--- /dev/null
+++ b/internal/game/cmd_goto.go
@@ -0,0 +1,74 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) executeGoto(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ if len(args) == 0 {
+ sess.WriteLine("Goto where?")
+ return
+ }
+ roomID, err := strconv.Atoi(args[0])
+ if err != nil {
+ sess.WriteLine("Invalid room ID.")
+ return
+ }
+ if _, err := g.World.LoadRoom(roomID); err != nil {
+ sess.WriteLine(fmt.Sprintf("Room %d not found.", roomID))
+ return
+ }
+
+ oldRoom := p.RoomID
+ p.ClearMoveState()
+ if g.Combat.Get(p.Name) != nil {
+ g.stopCombat(p.Name)
+ }
+ p.Action = nil
+ g.cancelRest(p.Name)
+ g.cancelEnterSeq(p.Name)
+ if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
+ p.EnterSeqRoom = 0
+ }
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "")
+ }
+
+ p.RoomID = roomID
+ p.HazardTimer = 0
+ p.Stats.RecordRoomVisit(roomID)
+ g.AccountStore.SaveCharacter(p)
+
+ g.World.SeedGroundItems(roomID)
+ g.seedRoomMobs(roomID)
+ g.seedRoomObjects(roomID)
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(oldRoom) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("%s vanishes.", p.Name))
+ }
+ }
+ g.Hub.EnterRoom(sess, roomID)
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("%s appears.", p.Name))
+ }
+ }
+ }
+
+ g.doLook(sess)
+ g.runEnterSteps(sess, roomID)
+ g.checkAggro(sess)
+}
diff --git a/internal/game/cmd_inspect.go b/internal/game/cmd_inspect.go
new file mode 100644
index 0000000..62392b7
--- /dev/null
+++ b/internal/game/cmd_inspect.go
@@ -0,0 +1,147 @@
+package game
+
+import (
+ "fmt"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+
+ room, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ sess.WriteLine("Error loading room.")
+ return
+ }
+
+ sess.WriteLine(fmt.Sprintf("=== Room #%d: %s ===", room.ID, room.Name))
+ if room.Color != "" {
+ sess.WriteLine(fmt.Sprintf("Color: %s", room.Color))
+ }
+ if room.Hazard != "" {
+ hazardName := room.Hazard
+ if hz, herr := g.World.LoadHazard(room.Hazard); herr == nil && hz.Name != "" {
+ hazardName = hz.Name
+ }
+ sess.WriteLine(fmt.Sprintf("Hazard: %s (%s)", room.Hazard, hazardName))
+ }
+ if room.BlockTransport {
+ sess.WriteLine("BlockTransport: true")
+ }
+
+ sess.WriteLine(fmt.Sprintf("\nExits (%d):", len(room.Exits)))
+ for _, dir := range world.ExitOrder {
+ if exit, ok := room.Exits[dir]; ok {
+ cond := ""
+ if exit.Condition != nil {
+ cond = " [conditional]"
+ }
+ sess.WriteLine(fmt.Sprintf(" %s -> Room #%d%s", dir, exit.Room, cond))
+ }
+ }
+
+ sess.WriteLine(fmt.Sprintf("\nObjects (%d):", len(room.Objects)))
+ for _, obj := range room.Objects {
+ kind := "global"
+ if obj.Local != nil {
+ kind = "local"
+ }
+ hidden := ""
+ if obj.Local != nil && obj.Local.Hidden {
+ hidden = " [hidden]"
+ } else if obj.HiddenOverride {
+ hidden = " [hidden]"
+ }
+ wander := ""
+ if len(obj.WanderRooms) > 0 {
+ wander = fmt.Sprintf(" [wanders:%d,%.0fs]", len(obj.WanderRooms), obj.WanderInterval)
+ }
+ sess.WriteLine(fmt.Sprintf(" %s (%s)%s%s", obj.ID, kind, hidden, wander))
+ }
+
+ sess.WriteLine(fmt.Sprintf("\nMobs (%d):", len(room.Mobs)))
+ for _, mob := range room.Mobs {
+ wander := ""
+ if mob.WanderInterval > 0 {
+ wander = fmt.Sprintf(" [wander:%.0ft]", mob.WanderInterval)
+ }
+ sess.WriteLine(fmt.Sprintf(" %s%s", mob.ID, wander))
+ }
+
+ if len(room.ItemSpawns) > 0 {
+ sess.WriteLine(fmt.Sprintf("\nItem Spawns (%d):", len(room.ItemSpawns)))
+ for _, spawn := range room.ItemSpawns {
+ sess.WriteLine(fmt.Sprintf(" %s x%d (respawn: %.0ft)", spawn.ID, spawn.Quantity, spawn.RespawnTicks))
+ }
+ }
+
+ if len(room.OnEnter) > 0 {
+ sess.WriteLine(fmt.Sprintf("\nOn-Enter Steps: %d", len(room.OnEnter)))
+ }
+
+ if len(room.Triggers) > 0 {
+ sess.WriteLine(fmt.Sprintf("\nRoom Triggers: %d", len(room.Triggers)))
+ for _, trigger := range room.Triggers {
+ kind := ""
+ if trigger.OnFlag != "" {
+ kind = fmt.Sprintf("world flag %q", trigger.OnFlag)
+ } else if trigger.OnPlayerFlag != "" {
+ kind = fmt.Sprintf("player flag %q", trigger.OnPlayerFlag)
+ }
+ sess.WriteLine(fmt.Sprintf(" %s: %s (%d steps)", trigger.ID, kind, len(trigger.Steps)))
+ }
+ }
+
+ sess.WriteLine("\n--- World Flags ---")
+ allFlags := g.Flags.All()
+ if len(allFlags) == 0 {
+ sess.WriteLine(" (none)")
+ } else {
+ for k, v := range allFlags {
+ sess.WriteLine(fmt.Sprintf(" %s = %v", k, v))
+ }
+ }
+
+ sess.WriteLine("\n--- Your Flags ---")
+ p.EnsureFlags()
+ if len(p.Flags) == 0 {
+ sess.WriteLine(" (none)")
+ } else {
+ for k, v := range p.Flags {
+ sess.WriteLine(fmt.Sprintf(" %s = %v", k, v))
+ }
+ }
+
+ if g.Hub != nil {
+ players := g.Hub.PlayersInRoom(p.RoomID)
+ sess.WriteLine(fmt.Sprintf("\n--- Players Here (%d) ---", len(players)))
+ for _, ps := range players {
+ if ps.Player != nil {
+ marker := ""
+ if ps.Player.Name == p.Name {
+ marker = " (you)"
+ }
+ sess.WriteLine(fmt.Sprintf(" %s%s", ps.Player.Name, marker))
+ }
+ }
+ }
+
+ mobs := g.MobStore.MobsInRoom(p.RoomID)
+ if len(mobs) > 0 {
+ sess.WriteLine(fmt.Sprintf("\n--- Live Mobs Here (%d) ---", len(mobs)))
+ for _, m := range mobs {
+ sess.WriteLine(fmt.Sprintf(" %s (HP: %d/%d, def: %s)", m.Name, m.HP, m.MaxHP, m.DefID))
+ }
+ }
+
+ sess.WriteLine("")
+}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 4bf227c..54c94c4 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -31,7 +31,7 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
return
}
- if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
+ if !p.GodMode && exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
msg := exitDef.BlockedMessage
if msg == "" {
msg = fmt.Sprintf("The way %s is blocked.", exitDir)
@@ -247,8 +247,8 @@ func (g *Game) seedRoomObjects(roomID int) {
g.World.EnsureObjectStates(roomID, ids)
for _, obj := range room.Objects {
var def *object.ObjectDef
- if obj.Inline != nil {
- def = obj.Inline
+ if obj.Local != nil {
+ def = obj.Local
} else {
var err error
def, err = g.ObjectStore.Load(obj.ID)
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index 60b1495..12d3879 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -33,6 +33,7 @@ func (g *Game) doQuit(sess *net.Session) {
sess.WriteLine("You close your eyes...")
case 0:
g.cancelRest(p.Name)
+ g.restoreGodPlayer(p)
g.AccountStore.SaveCharacter(p)
g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index 2cf0de1..562fc71 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -135,6 +135,19 @@ var commandRegistry = map[string]commandDef{
"safespot": {(*Game).executeHide, ClassActive},
"unhide": {(*Game).executeUnhide, ClassActive},
"unsafespot": {(*Game).executeUnhide, ClassActive},
+ "goto": {(*Game).executeGoto, ClassInstant},
+ "summon": {(*Game).executeSummon, ClassInstant},
+ "dig": {(*Game).executeDig, ClassInstant},
+ "setflag": {(*Game).executeSetFlag, ClassInstant},
+ "setplayerflag": {(*Game).executeSetPlayerFlag, ClassInstant},
+ "reload": {(*Game).executeReload, ClassInstant},
+ "shutdown": {(*Game).executeShutdown, ClassInstant},
+ "room": {(*Game).executeRoom, ClassInstant},
+ "swapid": {(*Game).executeSwapID, ClassInstant},
+ "undig": {(*Game).executeUndig, ClassInstant},
+ "close": {(*Game).executeClose, ClassInstant},
+ "god": {(*Game).executeGod, ClassInstant},
+ "ungod": {(*Game).executeUnGod, ClassInstant},
}
func classifyCommand(cmd string) CommandClass {
diff --git a/internal/game/cmd_reload.go b/internal/game/cmd_reload.go
new file mode 100644
index 0000000..2b8230a
--- /dev/null
+++ b/internal/game/cmd_reload.go
@@ -0,0 +1,37 @@
+package game
+
+import (
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) executeReload(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+
+ g.ItemStore.Reload(g.DataDir)
+ g.ObjectStore.Reload(g.DataDir)
+ g.MobStore.ReloadDefs(g.DataDir)
+ behavior.ClearDropIndex()
+
+ items, err := g.ItemStore.LoadAll()
+ if err == nil {
+ g.CraftIndex.Build(items)
+ }
+
+ g.LoadTechs()
+ g.LoadMods()
+
+ g.CourseStore.ReloadAll()
+
+ g.World.RebuildRoomIndex(g.DataDir)
+ g.World.ClearHazardCache()
+
+ g.TriggerStore.ClearTriggers()
+ g.TriggerStore.LoadGlobal(g.DataDir)
+ g.seedRoomTriggers()
+
+ sess.WriteLine("All caches reloaded.")
+}
diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go
new file mode 100644
index 0000000..11f919a
--- /dev/null
+++ b/internal/game/cmd_room.go
@@ -0,0 +1,118 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) executeRoom(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ if len(args) == 0 {
+ g.showRoomHelp(sess)
+ return
+ }
+
+ action := strings.ToLower(args[0])
+ rest := args[1:]
+
+ switch action {
+ case "name":
+ g.roomSetName(sess, rest)
+ case "desc":
+ g.roomSetDesc(sess, rest)
+ case "addobj", "remobj", "addlocalobj", "remlocalobj", "hide", "unhide":
+ g.roomObject(sess, action, rest)
+ case "addmob", "remmob":
+ g.roomMob(sess, action, rest)
+ case "addspawn", "remspawn":
+ g.roomSpawn(sess, action, rest)
+ default:
+ sess.WriteLine(fmt.Sprintf("Unknown room action: %s", action))
+ g.showRoomHelp(sess)
+ }
+}
+
+func (g *Game) showRoomHelp(sess *net.Session) {
+ sess.WriteLine("room actions:")
+ sess.WriteLine(" room name <text> — set room name")
+ sess.WriteLine(" room desc <text> — set room description")
+ sess.WriteLine(" room addobj <object_id> — add a referenced object")
+ sess.WriteLine(" room remobj <object_id> — remove a referenced object")
+ sess.WriteLine(" room addlocalobj <name> <description> — add a local object")
+ sess.WriteLine(" room remlocalobj <name> — remove a local object")
+ sess.WriteLine(" room hide <name_or_id> — hide all matching objects")
+ sess.WriteLine(" room unhide <name_or_id> — unhide all matching objects")
+ sess.WriteLine(" room addmob <mob_id> — add a mob spawn")
+ sess.WriteLine(" room remmob <mob_id> — remove a mob spawn")
+ sess.WriteLine(" room addspawn <item_id> [qty] [respawn_ticks] — add an item spawn")
+ sess.WriteLine(" room remspawn <item_id> — remove an item spawn")
+}
+
+func (g *Game) roomSetName(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room name <text>")
+ return
+ }
+ text := strings.Join(args, " ")
+ g.writeRoom(sess, func(room *world.Room) {
+ room.Name = text
+ })
+ sess.WriteLine(fmt.Sprintf("Room name set to: %s", text))
+}
+
+func (g *Game) roomSetDesc(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room desc <text>")
+ return
+ }
+ text := strings.Join(args, " ")
+ g.writeRoom(sess, func(room *world.Room) {
+ room.Description = behavior.DescList{{Text: text}}
+ })
+ sess.WriteLine("Room description updated.")
+}
+
+func (g *Game) loadRoomWrite(p *player.Player) (*world.Room, string, error) {
+ path, ok := g.World.GetRoomPath(p.RoomID)
+ if !ok {
+ return nil, "", fmt.Errorf("room file not found")
+ }
+ room, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ return nil, "", err
+ }
+ return room, path, nil
+}
+
+func (g *Game) writeRoom(sess *net.Session, fn func(*world.Room)) {
+ p := sess.Player
+ room, path, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+ fn(room)
+ data, err := yaml.Marshal(room)
+ if err != nil {
+ sess.WriteLine("Error marshaling room YAML.")
+ return
+ }
+ if err := os.WriteFile(path, data, 0644); err != nil {
+ sess.WriteLine("Error writing room file.")
+ return
+ }
+}
diff --git a/internal/game/cmd_room_mob.go b/internal/game/cmd_room_mob.go
new file mode 100644
index 0000000..ced170f
--- /dev/null
+++ b/internal/game/cmd_room_mob.go
@@ -0,0 +1,82 @@
+package game
+
+import (
+ "fmt"
+ "os"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) roomMob(sess *net.Session, action string, args []string) {
+ switch action {
+ case "addmob":
+ g.roomAddMob(sess, args)
+ case "remmob":
+ g.roomRemMob(sess, args)
+ }
+}
+
+func (g *Game) roomAddMob(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room addmob <mob_id>")
+ return
+ }
+ mobID := args[0]
+ if _, err := g.MobStore.LoadDef(mobID); err != nil {
+ sess.WriteLine(fmt.Sprintf("Mob '%s' not found in data/mobs/.", mobID))
+ return
+ }
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+ for _, m := range room.Mobs {
+ if m.ID == mobID {
+ sess.WriteLine(fmt.Sprintf("Mob '%s' is already in this room.", mobID))
+ return
+ }
+ }
+ room.Mobs = append(room.Mobs, world.RoomMob{ID: mobID})
+
+ path, _ := g.World.GetRoomPath(p.RoomID)
+ data, _ := yaml.Marshal(room)
+ os.WriteFile(path, data, 0644)
+ sess.WriteLine(fmt.Sprintf("Added mob '%s' to room.", mobID))
+}
+
+func (g *Game) roomRemMob(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room remmob <mob_id>")
+ return
+ }
+ mobID := args[0]
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+ found := false
+ filtered := room.Mobs[:0]
+ for _, m := range room.Mobs {
+ if m.ID == mobID {
+ found = true
+ } else {
+ filtered = append(filtered, m)
+ }
+ }
+ if !found {
+ sess.WriteLine(fmt.Sprintf("Mob '%s' not found in this room.", mobID))
+ return
+ }
+ room.Mobs = filtered
+
+ path, _ := g.World.GetRoomPath(p.RoomID)
+ data, _ := yaml.Marshal(room)
+ os.WriteFile(path, data, 0644)
+ sess.WriteLine(fmt.Sprintf("Removed mob '%s' from room.", mobID))
+}
diff --git a/internal/game/cmd_room_object.go b/internal/game/cmd_room_object.go
new file mode 100644
index 0000000..f682d62
--- /dev/null
+++ b/internal/game/cmd_room_object.go
@@ -0,0 +1,235 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) roomObject(sess *net.Session, action string, args []string) {
+ switch action {
+ case "addobj":
+ g.roomAddObj(sess, args)
+ case "remobj":
+ g.roomRemObj(sess, args)
+ case "addlocalobj":
+ g.roomAddLocalObj(sess, args)
+ case "remlocalobj":
+ g.roomRemLocalObj(sess, args)
+ case "hide":
+ g.roomHide(sess, args)
+ case "unhide":
+ g.roomUnhide(sess, args)
+ }
+}
+
+func (g *Game) roomAddObj(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room addobj <object_id>")
+ return
+ }
+ objID := args[0]
+ if _, err := g.ObjectStore.Load(objID); err != nil {
+ sess.WriteLine(fmt.Sprintf("Object '%s' not found in data/objects/.", objID))
+ return
+ }
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+ for _, obj := range room.Objects {
+ if obj.ID == objID && obj.Local == nil {
+ sess.WriteLine(fmt.Sprintf("Object '%s' is already in this room.", objID))
+ return
+ }
+ }
+ room.Objects = append(room.Objects, world.RoomObject{ID: objID})
+ g.writeRoom(sess, func(r *world.Room) { *r = *room })
+ sess.WriteLine(fmt.Sprintf("Added object '%s' to room.", objID))
+}
+
+func (g *Game) roomRemObj(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room remobj <object_id>")
+ return
+ }
+ objID := args[0]
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+ found := false
+ filtered := room.Objects[:0]
+ for _, obj := range room.Objects {
+ if obj.ID == objID && obj.Local == nil {
+ found = true
+ } else {
+ filtered = append(filtered, obj)
+ }
+ }
+ if !found {
+ sess.WriteLine(fmt.Sprintf("Referenced object '%s' not found in this room.", objID))
+ return
+ }
+ room.Objects = filtered
+ g.writeRoom(sess, func(r *world.Room) { *r = *room })
+ sess.WriteLine(fmt.Sprintf("Removed object '%s' from room.", objID))
+}
+
+func (g *Game) roomAddLocalObj(sess *net.Session, args []string) {
+ if len(args) < 2 {
+ sess.WriteLine("Usage: room addlocalobj <name> <description>")
+ return
+ }
+ name := args[0]
+ desc := strings.Join(args[1:], " ")
+
+ normName := world.NormalizeObjectName(name)
+ if normName == "" {
+ sess.WriteLine("Invalid object name.")
+ return
+ }
+
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+
+ for _, obj := range room.Objects {
+ if obj.Local != nil && world.NormalizeObjectName(obj.Local.Name) == normName {
+ sess.WriteLine(fmt.Sprintf("A local object named '%s' already exists in this room.", name))
+ return
+ }
+ }
+
+ room.Objects = append(room.Objects, world.RoomObject{
+ ID: normName,
+ Local: &object.ObjectDef{
+ Name: name,
+ Description: behavior.DescList{{Text: desc}},
+ Aliases: []string{name},
+ },
+ })
+
+ path, _ := g.World.GetRoomPath(p.RoomID)
+ data, _ := yaml.Marshal(room)
+ os.WriteFile(path, data, 0644)
+ sess.WriteLine(fmt.Sprintf("Added local object '%s' to room.", name))
+}
+
+func (g *Game) roomRemLocalObj(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room remlocalobj <name>")
+ return
+ }
+ name := args[0]
+ normName := world.NormalizeObjectName(name)
+
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+
+ found := false
+ filtered := room.Objects[:0]
+ for _, obj := range room.Objects {
+ if obj.Local != nil && world.NormalizeObjectName(obj.Local.Name) == normName {
+ found = true
+ } else {
+ filtered = append(filtered, obj)
+ }
+ }
+ if !found {
+ sess.WriteLine(fmt.Sprintf("No local object named '%s' found in this room.", name))
+ return
+ }
+ room.Objects = filtered
+
+ path, _ := g.World.GetRoomPath(p.RoomID)
+ data, _ := yaml.Marshal(room)
+ os.WriteFile(path, data, 0644)
+ sess.WriteLine(fmt.Sprintf("Removed local object '%s' from room.", name))
+}
+
+func (g *Game) roomHide(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room hide <name_or_object_id>")
+ return
+ }
+ g.roomToggleHidden(sess, args[0], true)
+}
+
+func (g *Game) roomUnhide(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room unhide <name_or_object_id>")
+ return
+ }
+ g.roomToggleHidden(sess, args[0], false)
+}
+
+func (g *Game) roomToggleHidden(sess *net.Session, input string, hidden bool) {
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+
+ norm := world.NormalizeObjectName(input)
+ var count int
+
+ for i := range room.Objects {
+ obj := &room.Objects[i]
+ if obj.Local != nil {
+ if world.NormalizeObjectName(obj.Local.Name) == norm {
+ obj.Local.Hidden = hidden
+ count++
+ }
+ continue
+ }
+ if strings.EqualFold(obj.ID, input) {
+ obj.HiddenOverride = hidden
+ count++
+ continue
+ }
+ if def, err := g.ObjectStore.Load(obj.ID); err == nil {
+ if world.NormalizeObjectName(def.Name) == norm {
+ obj.HiddenOverride = hidden
+ count++
+ }
+ }
+ }
+
+ if count == 0 {
+ label := "hide"
+ if !hidden {
+ label = "unhide"
+ }
+ sess.WriteLine(fmt.Sprintf("No objects matching '%s' found for %s.", input, label))
+ return
+ }
+
+ path, _ := g.World.GetRoomPath(p.RoomID)
+ data, _ := yaml.Marshal(room)
+ os.WriteFile(path, data, 0644)
+
+ action := "Hidden"
+ if !hidden {
+ action = "Unhidden"
+ }
+ sess.WriteLine(fmt.Sprintf("%s %d object(s) matching '%s'.", action, count, input))
+}
diff --git a/internal/game/cmd_room_spawn.go b/internal/game/cmd_room_spawn.go
new file mode 100644
index 0000000..d4353e4
--- /dev/null
+++ b/internal/game/cmd_room_spawn.go
@@ -0,0 +1,101 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) roomSpawn(sess *net.Session, action string, args []string) {
+ switch action {
+ case "addspawn":
+ g.roomAddSpawn(sess, args)
+ case "remspawn":
+ g.roomRemSpawn(sess, args)
+ }
+}
+
+func (g *Game) roomAddSpawn(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room addspawn <item_id> [quantity] [respawn_ticks]")
+ return
+ }
+ itemID := args[0]
+ if _, err := g.ItemStore.Load(itemID); err != nil {
+ sess.WriteLine(fmt.Sprintf("Item '%s' not found in data/items/.", itemID))
+ return
+ }
+
+ quantity := 1
+ respawnTicks := float64(0)
+ if len(args) >= 2 {
+ if n, err := strconv.Atoi(args[1]); err == nil && n > 0 {
+ quantity = n
+ }
+ }
+ if len(args) >= 3 {
+ if n, err := strconv.ParseFloat(args[2], 64); err == nil && n >= 0 {
+ respawnTicks = n
+ }
+ }
+
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+ for _, s := range room.ItemSpawns {
+ if s.ID == itemID {
+ sess.WriteLine(fmt.Sprintf("Item spawn '%s' already exists in this room.", itemID))
+ return
+ }
+ }
+ room.ItemSpawns = append(room.ItemSpawns, world.SpawnDef{
+ ID: itemID,
+ Quantity: quantity,
+ RespawnTicks: respawnTicks,
+ })
+
+ path, _ := g.World.GetRoomPath(p.RoomID)
+ data, _ := yaml.Marshal(room)
+ os.WriteFile(path, data, 0644)
+ sess.WriteLine(fmt.Sprintf("Added item spawn '%s' x%d (respawn: %.0f ticks) to room.", itemID, quantity, respawnTicks))
+}
+
+func (g *Game) roomRemSpawn(sess *net.Session, args []string) {
+ if len(args) == 0 {
+ sess.WriteLine("Usage: room remspawn <item_id>")
+ return
+ }
+ itemID := args[0]
+ p := sess.Player
+ room, _, err := g.loadRoomWrite(p)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error loading room: %v", err))
+ return
+ }
+ found := false
+ filtered := room.ItemSpawns[:0]
+ for _, s := range room.ItemSpawns {
+ if s.ID == itemID {
+ found = true
+ } else {
+ filtered = append(filtered, s)
+ }
+ }
+ if !found {
+ sess.WriteLine(fmt.Sprintf("Item spawn '%s' not found in this room.", itemID))
+ return
+ }
+ room.ItemSpawns = filtered
+
+ path, _ := g.World.GetRoomPath(p.RoomID)
+ data, _ := yaml.Marshal(room)
+ os.WriteFile(path, data, 0644)
+ sess.WriteLine(fmt.Sprintf("Removed item spawn '%s' from room.", itemID))
+}
diff --git a/internal/game/cmd_setflag.go b/internal/game/cmd_setflag.go
new file mode 100644
index 0000000..a18c635
--- /dev/null
+++ b/internal/game/cmd_setflag.go
@@ -0,0 +1,38 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) executeSetFlag(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ if len(args) == 0 {
+ sess.WriteLine("Usage: setflag <flag_name> [value]")
+ return
+ }
+ name := args[0]
+ var value any = true
+ if len(args) > 1 {
+ valStr := args[1]
+ switch valStr {
+ case "true":
+ value = true
+ case "false":
+ value = false
+ default:
+ if n, err := strconv.Atoi(valStr); err == nil {
+ value = n
+ } else {
+ value = valStr
+ }
+ }
+ }
+ g.Flags.Set(name, value)
+ sess.WriteLine(fmt.Sprintf("Flag '%s' set to %v.", name, value))
+}
diff --git a/internal/game/cmd_setplayerflag.go b/internal/game/cmd_setplayerflag.go
new file mode 100644
index 0000000..3d0bdbf
--- /dev/null
+++ b/internal/game/cmd_setplayerflag.go
@@ -0,0 +1,60 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) executeSetPlayerFlag(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ if len(args) < 2 {
+ sess.WriteLine("Usage: setplayerflag <player_name> <flag_name> [value]")
+ return
+ }
+ targetName := args[0]
+ flagName := args[1]
+
+ g.charsMu.Lock()
+ targetSess, ok := g.loggedInChars[targetName]
+ g.charsMu.Unlock()
+ if !ok {
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil && strings.EqualFold(other.Player.Name, targetName) {
+ targetSess = other
+ break
+ }
+ }
+ }
+ if targetSess == nil || targetSess.Player == nil {
+ sess.WriteLine("Player not found.")
+ return
+ }
+
+ tp := targetSess.Player
+ var value any = true
+ if len(args) > 2 {
+ valStr := args[2]
+ switch valStr {
+ case "true":
+ value = true
+ case "false":
+ value = false
+ default:
+ if n, err := strconv.Atoi(valStr); err == nil {
+ value = n
+ } else {
+ value = valStr
+ }
+ }
+ }
+
+ g.setPlayerFlag(tp, flagName, value)
+ g.AccountStore.SaveCharacter(tp)
+ sess.WriteLine(fmt.Sprintf("Player flag '%s' set to %v for %s.", flagName, value, tp.Name))
+}
diff --git a/internal/game/cmd_shutdown.go b/internal/game/cmd_shutdown.go
new file mode 100644
index 0000000..0bb0f8f
--- /dev/null
+++ b/internal/game/cmd_shutdown.go
@@ -0,0 +1,102 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) executeShutdown(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ if len(args) == 0 {
+ sess.WriteLine("Usage: shutdown <minutes> or shutdown cancel")
+ return
+ }
+
+ if args[0] == "cancel" {
+ g.shutdownMu.Lock()
+ if !g.shutdownActive {
+ g.shutdownMu.Unlock()
+ sess.WriteLine("No shutdown in progress.")
+ return
+ }
+ close(g.shutdownCancel)
+ g.shutdownActive = false
+ g.shutdownMu.Unlock()
+
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil {
+ other.WriteLine("[Server] Shutdown cancelled.")
+ }
+ }
+ return
+ }
+
+ minutes, err := strconv.Atoi(args[0])
+ if err != nil || minutes <= 0 {
+ sess.WriteLine("Invalid minutes.")
+ return
+ }
+
+ g.shutdownMu.Lock()
+ if g.shutdownActive {
+ g.shutdownMu.Unlock()
+ sess.WriteLine("A shutdown is already in progress.")
+ return
+ }
+ g.shutdownActive = true
+ g.shutdownCancel = make(chan struct{})
+ g.shutdownMu.Unlock()
+
+ go func() {
+ remaining := minutes * 60
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil {
+ other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s).", minutes))
+ }
+ }
+
+ for {
+ select {
+ case <-ticker.C:
+ remaining -= 30
+ if remaining <= 0 {
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil {
+ other.WriteLine("[Server] Shutting down NOW.")
+ }
+ }
+ os.Exit(0)
+ }
+ mins := remaining / 60
+ secs := remaining % 60
+ if secs == 0 {
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil {
+ other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s).", mins))
+ }
+ }
+ } else {
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil {
+ other.WriteLine(fmt.Sprintf("[Server] Shutting down in %d minute(s) %d seconds.", mins, secs))
+ }
+ }
+ }
+ case <-g.shutdownCancel:
+ return
+ }
+ }
+ }()
+
+ sess.WriteLine(fmt.Sprintf("Shutdown scheduled in %d minute(s).", minutes))
+}
diff --git a/internal/game/cmd_summon.go b/internal/game/cmd_summon.go
new file mode 100644
index 0000000..3aa8d7b
--- /dev/null
+++ b/internal/game/cmd_summon.go
@@ -0,0 +1,92 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) executeSummon(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ adminP := sess.Player
+ if adminP == nil {
+ return
+ }
+ if len(args) == 0 {
+ sess.WriteLine("Summon whom?")
+ return
+ }
+ targetName := args[0]
+
+ g.charsMu.Lock()
+ targetSess, ok := g.loggedInChars[targetName]
+ g.charsMu.Unlock()
+ if !ok {
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil && strings.EqualFold(other.Player.Name, targetName) {
+ targetSess = other
+ break
+ }
+ }
+ }
+ if targetSess == nil || targetSess.Player == nil {
+ sess.WriteLine("Player not found.")
+ return
+ }
+ if targetSess == sess {
+ sess.WriteLine("You can't summon yourself.")
+ return
+ }
+
+ tp := targetSess.Player
+ adminRoom := adminP.RoomID
+ oldRoom := tp.RoomID
+
+ tp.ClearMoveState()
+ if g.Combat.Get(tp.Name) != nil {
+ g.stopCombat(tp.Name)
+ }
+ tp.Action = nil
+ g.cancelRest(tp.Name)
+ g.cancelEnterSeq(tp.Name)
+ if tp.EnterSeqRoom != 0 && tp.EnterSeqRoom == oldRoom {
+ tp.EnterSeqRoom = 0
+ }
+ if ss, ok := g.safespot.Get(tp.Name); ok {
+ g.forceLeaveSafespot(targetSess, tp, &ss, "")
+ }
+
+ tp.RoomID = adminRoom
+ tp.HazardTimer = 0
+ tp.Stats.RecordRoomVisit(adminRoom)
+ g.AccountStore.SaveCharacter(tp)
+
+ g.World.SeedGroundItems(adminRoom)
+ g.seedRoomMobs(adminRoom)
+ g.seedRoomObjects(adminRoom)
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(oldRoom) {
+ if other != targetSess {
+ other.WriteLine(fmt.Sprintf("%s vanishes.", tp.Name))
+ }
+ }
+ g.Hub.EnterRoom(targetSess, adminRoom)
+ for _, other := range g.Hub.PlayersInRoom(adminRoom) {
+ if other != targetSess {
+ other.WriteLine(fmt.Sprintf("%s appears.", tp.Name))
+ }
+ }
+ }
+
+ targetSess.WriteLine(fmt.Sprintf("You have been summoned by %s.", adminP.Name))
+ sess.WriteLine(fmt.Sprintf("Summoned %s.", tp.Name))
+
+ g.doLook(targetSess)
+ g.runEnterSteps(targetSess, adminRoom)
+ g.checkAggro(targetSess)
+}
diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go
new file mode 100644
index 0000000..6ee1118
--- /dev/null
+++ b/internal/game/cmd_swapid.go
@@ -0,0 +1,126 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+
+ "thehouseoficarus/internal/net"
+)
+
+func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+
+ var id1, id2 int
+ switch len(args) {
+ case 1:
+ id1 = p.RoomID
+ var err error
+ id2, err = strconv.Atoi(args[0])
+ if err != nil {
+ sess.WriteLine("Invalid room ID.")
+ return
+ }
+ case 2:
+ var err error
+ id1, err = strconv.Atoi(args[0])
+ if err != nil {
+ sess.WriteLine("Invalid room ID.")
+ return
+ }
+ id2, err = strconv.Atoi(args[1])
+ if err != nil {
+ sess.WriteLine("Invalid room ID.")
+ return
+ }
+ default:
+ sess.WriteLine("Usage: swapid <id> or swapid <id1> <id2>")
+ return
+ }
+
+ if id1 == id2 {
+ sess.WriteLine("Cannot swap a room with itself.")
+ return
+ }
+
+ path1, ok1 := g.World.GetRoomPath(id1)
+ path2, ok2 := g.World.GetRoomPath(id2)
+ if !ok1 {
+ sess.WriteLine(fmt.Sprintf("Room %d not found.", id1))
+ return
+ }
+ if !ok2 {
+ sess.WriteLine(fmt.Sprintf("Room %d not found.", id2))
+ return
+ }
+
+ roomsDir := filepath.Join(g.DataDir, "rooms")
+ re1 := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, id1))
+ re2 := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, id2))
+ placeholder := fmt.Sprintf("__SWAPID_TEMP_%d__", id1^id2)
+
+ var modified []string
+ err := filepath.WalkDir(roomsDir, func(roomPath string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || filepath.Ext(roomPath) != ".yaml" {
+ return nil
+ }
+ data, rerr := os.ReadFile(roomPath)
+ if rerr != nil {
+ return nil
+ }
+ text := string(data)
+ if !re1.MatchString(text) && !re2.MatchString(text) {
+ return nil
+ }
+ text = re1.ReplaceAllString(text, placeholder)
+ text = re2.ReplaceAllString(text, strconv.Itoa(id1))
+ text = regexp.MustCompile(placeholder).ReplaceAllString(text, strconv.Itoa(id2))
+ if werr := os.WriteFile(roomPath, []byte(text), 0644); werr != nil {
+ return werr
+ }
+ modified = append(modified, roomPath)
+ return nil
+ })
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error updating room files: %v", err))
+ return
+ }
+
+ tempPath := path1 + ".swapid-tmp"
+ if rerr := os.Rename(path1, tempPath); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error renaming room %d: %v", id1, rerr))
+ return
+ }
+ if rerr := os.Rename(path2, path1); rerr != nil {
+ os.Rename(tempPath, path1)
+ sess.WriteLine(fmt.Sprintf("Error renaming room %d: %v", id2, rerr))
+ return
+ }
+ if rerr := os.Rename(tempPath, path2); rerr != nil {
+ os.Rename(path2, path1)
+ os.Rename(tempPath, path1)
+ sess.WriteLine(fmt.Sprintf("Error finishing rename: %v", rerr))
+ return
+ }
+
+ g.World.RebuildRoomIndex(g.DataDir)
+ g.World.LoadRoom(id1)
+ g.World.LoadRoom(id2)
+
+ if p.RoomID == id1 {
+ p.RoomID = id2
+ } else if p.RoomID == id2 {
+ p.RoomID = id1
+ }
+
+ sess.WriteLine(fmt.Sprintf("Swapped room IDs %d and %d (%d files updated).", id1, id2, len(modified)))
+}
diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go
new file mode 100644
index 0000000..2cfae70
--- /dev/null
+++ b/internal/game/cmd_undig.go
@@ -0,0 +1,248 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) executeUndig(sess *net.Session, args []string, rawInput string) {
+ if !g.checkAdmin(sess) {
+ sess.WriteLine("Unknown command.")
+ return
+ }
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ if len(args) == 0 {
+ sess.WriteLine("Undig which direction?")
+ return
+ }
+
+ dir := g.World.ResolveExit(args[0])
+ if dir == "" {
+ sess.WriteLine("Invalid direction. Use north/south/east/west/up/down.")
+ return
+ }
+
+ curRoom, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ sess.WriteLine("Error loading current room.")
+ return
+ }
+
+ exitDef, ok := curRoom.Exits[dir]
+ if !ok {
+ sess.WriteLine(fmt.Sprintf("There is no exit to the %s from here.", dir))
+ return
+ }
+
+ targetID := exitDef.Room
+ targetRoom, err := g.World.LoadRoom(targetID)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Target room %d not found.", targetID))
+ return
+ }
+
+ sess.PendingUndigDir = string(dir)
+ sess.PendingUndigRoom = targetID
+ sess.State = net.StateUndigConfirm
+
+ sess.WriteLine(fmt.Sprintf("You are about to DELETE Room #%d (%s) and all exits leading to it.", targetID, targetRoom.Name))
+ sess.WriteLine("")
+ g.listUndigOrphans(sess, targetID)
+ sess.WriteLine("")
+ sess.WriteLine(fmt.Sprintf("Type UNDIG %s to confirm, or anything else to cancel.", strings.ToUpper(string(dir))))
+}
+
+func (g *Game) listUndigOrphans(sess *net.Session, targetID int) {
+ roomIndex := g.World.RoomIndex()
+
+ inbound := make(map[int]bool)
+ roomsDir := filepath.Join(g.DataDir, "rooms")
+ re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, targetID))
+ _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
+ return nil
+ }
+ data, rerr := os.ReadFile(path)
+ if rerr != nil {
+ return nil
+ }
+ if re.MatchString(string(data)) {
+ idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
+ if rid, serr := strconv.Atoi(idStr); serr == nil && rid != targetID {
+ inbound[rid] = true
+ }
+ }
+ return nil
+ })
+
+ if len(inbound) == 0 {
+ return
+ }
+
+ var orphans []int
+ for rid := range inbound {
+ room, err := g.World.LoadRoom(rid)
+ if err != nil {
+ continue
+ }
+ hasOtherExit := false
+ for _, exitDef := range room.Exits {
+ if roomIndex[exitDef.Room] && exitDef.Room != targetID {
+ hasOtherExit = true
+ break
+ }
+ }
+ if !hasOtherExit {
+ orphans = append(orphans, rid)
+ }
+ }
+
+ if len(orphans) > 0 {
+ sess.WriteLine(g.colorize(sess, "warning",
+ fmt.Sprintf("WARNING: The following rooms will become unreachable after deletion:")))
+ for _, rid := range orphans {
+ room, err := g.World.LoadRoom(rid)
+ name := fmt.Sprintf("#%d", rid)
+ if err == nil {
+ name = fmt.Sprintf("#%d (%s)", rid, room.Name)
+ }
+ sess.WriteLine(fmt.Sprintf(" %s", name))
+ }
+ }
+}
+
+func (g *Game) handleUndigConfirm(sess *net.Session, input string) {
+ p := sess.Player
+ if p == nil {
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+ return
+ }
+
+ choice := strings.TrimSpace(input)
+ dir := sess.PendingUndigDir
+ targetID := sess.PendingUndigRoom
+ sess.PendingUndigDir = ""
+ sess.PendingUndigRoom = 0
+ sess.State = net.StateGame
+
+ if dir == "" || targetID == 0 {
+ g.writePrompt(sess)
+ return
+ }
+
+ expected := "UNDIG " + strings.ToUpper(dir)
+ if strings.ToUpper(choice) != expected {
+ sess.WriteLine("Undig cancelled.")
+ g.writePrompt(sess)
+ return
+ }
+
+ g.performUndig(sess, p, targetID)
+ g.writePrompt(sess)
+}
+
+func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) {
+ targetRoom, err := g.World.LoadRoom(targetID)
+ roomName := fmt.Sprintf("#%d", targetID)
+ if err == nil {
+ roomName = fmt.Sprintf("#%d (%s)", targetID, targetRoom.Name)
+ } else {
+ sess.WriteLine(fmt.Sprintf("Target room %d no longer exists.", targetID))
+ return
+ }
+
+ roomsDir := filepath.Join(g.DataDir, "rooms")
+ re := regexp.MustCompile(fmt.Sprintf(`\b%d\b`, targetID))
+ var cleaned int
+ _ = filepath.WalkDir(roomsDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || filepath.Ext(path) != ".yaml" {
+ return nil
+ }
+ data, rerr := os.ReadFile(path)
+ if rerr != nil {
+ return nil
+ }
+ if !re.MatchString(string(data)) {
+ return nil
+ }
+
+ idStr := strings.TrimSuffix(filepath.Base(path), ".yaml")
+ rid, serr := strconv.Atoi(idStr)
+ if serr != nil || rid == targetID {
+ return nil
+ }
+
+ var room world.Room
+ if yerr := yaml.Unmarshal(data, &room); yerr != nil {
+ return nil
+ }
+ if room.Exits == nil {
+ return nil
+ }
+
+ changed := false
+ for ed, exitDef := range room.Exits {
+ if exitDef.Room == targetID {
+ delete(room.Exits, ed)
+ changed = true
+ }
+ }
+
+ if changed {
+ newData, merr := yaml.Marshal(&room)
+ if merr != nil {
+ return nil
+ }
+ if werr := os.WriteFile(path, newData, 0644); werr != nil {
+ return werr
+ }
+ cleaned++
+ }
+ return nil
+ })
+
+ targetPath, ok := g.World.GetRoomPath(targetID)
+ if !ok {
+ sess.WriteLine(fmt.Sprintf("Room file for %d not found.", targetID))
+ return
+ }
+ if rerr := os.Remove(targetPath); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error deleting room file: %v", rerr))
+ return
+ }
+
+ g.World.RebuildRoomIndex(g.DataDir)
+
+ if p.RoomID == targetID {
+ p.RoomID = 0
+ }
+
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player != nil && other.Player.RoomID == targetID {
+ other.Player.RoomID = p.RoomID
+ if g.Hub != nil {
+ g.Hub.EnterRoom(other, p.RoomID)
+ }
+ other.WriteLine(fmt.Sprintf("The room around you dissolves. You find yourself elsewhere."))
+ g.doLook(other)
+ }
+ }
+
+ g.MobStore.RemoveMobsInRoom(targetID)
+ g.World.ClearRoomState(targetID)
+
+ sess.WriteLine(fmt.Sprintf("Deleted room %s and %d exit(s) pointing to it.", roomName, cleaned))
+}
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index f2c4a18..d07ef2f 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -27,7 +27,6 @@ var alwaysAvailable = []string{
"prompt",
"alias", "unalias",
"help",
- "inspect",
"queued",
"stop",
"aps",
@@ -56,8 +55,8 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
var sectionGround []string
for _, st := range g.World.AllObjInstances(p.RoomID) {
- def, err := g.resolveObjectDef(p.RoomID, st.DefID)
- if err != nil || def.Hidden {
+ def, _, err := g.resolveObjectDef(p.RoomID, st.DefID)
+ if err != nil || (def.Hidden && !p.GodMode) {
continue
}
name := strings.ToLower(def.Name)
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index 7096b66..a4bf435 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -237,6 +237,12 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
// mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch
// retribution.
func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ if p.GodMode {
+ p.HP = 1
+ sess.WriteLine(g.colorize(sess, "miss", "Your divine power prevents death."))
+ g.writePrompt(sess)
+ return
+ }
g.Combat.Leave(p.Name)
if p.HasActiveTech("retribution") {
diff --git a/internal/game/core_admin.go b/internal/game/core_admin.go
new file mode 100644
index 0000000..e1236ab
--- /dev/null
+++ b/internal/game/core_admin.go
@@ -0,0 +1,7 @@
+package game
+
+import "thehouseoficarus/internal/net"
+
+func (g *Game) checkAdmin(sess *net.Session) bool {
+ return sess.Account != nil && sess.Account.Admin
+}
diff --git a/internal/game/core_course.go b/internal/game/core_course.go
index 2b6b28f..5d5c7c5 100644
--- a/internal/game/core_course.go
+++ b/internal/game/core_course.go
@@ -77,6 +77,14 @@ func (cs *CourseStore) LoadAll() {
cs.loadAllLocked()
}
+func (cs *CourseStore) ReloadAll() {
+ cs.mu.Lock()
+ cs.loaded = false
+ cs.courses = make(map[string]*CourseConfig)
+ cs.mu.Unlock()
+ cs.LoadAll()
+}
+
// AllCourses returns the full course config map, loading on first access.
func (cs *CourseStore) AllCourses() map[string]*CourseConfig {
cs.mu.Lock()
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index fc4a764..45dc2e8 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -71,6 +71,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
Aliases: acc.Aliases,
Colors: acc.Colors,
Options: acc.Options,
+ Admin: acc.Admin,
}
if sess.Account.Aliases == nil {
sess.Account.Aliases = make(map[string]string)
diff --git a/internal/game/flagstore.go b/internal/game/flagstore.go
index bc72c60..ecefccf 100644
--- a/internal/game/flagstore.go
+++ b/internal/game/flagstore.go
@@ -71,3 +71,13 @@ func (f *FlagStore) Delete(name string) {
defer f.mu.Unlock()
delete(f.flags, name)
}
+
+func (f *FlagStore) All() map[string]any {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := make(map[string]any, len(f.flags))
+ for k, v := range f.flags {
+ out[k] = v
+ }
+ return out
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index 22dd74d..bed75b0 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -62,11 +62,15 @@ type Game struct {
loggedInChars map[string]*net.Session
restTimers map[string]uint64
guardWatchTimers map[string]int
- hackingStates map[string]*hacking.Session
+ hackingStates map[string]*hacking.Session
pendingDepletions []pendingDepletion
farmTickCounter int
enterMu sync.Mutex
enterSeqs map[string]*enterSeq
+
+ shutdownCancel chan struct{}
+ shutdownActive bool
+ shutdownMu sync.Mutex
}
func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.ValidationConfig) *Game {
@@ -108,6 +112,7 @@ func (g *Game) SetHub(hub *net.Hub) {
g.Hub = hub
hub.OnRemove(func(sess *net.Session) {
if p := sess.Player; p != nil {
+ g.restoreGodPlayer(p)
p.DeactivateAllTechs()
g.cancelEnterSeq(p.Name)
g.charsMu.Lock()
@@ -173,6 +178,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleHackingInput(sess, input)
case net.StateDangerConfirm:
g.handleDangerConfirm(sess, input)
+ case net.StateUndigConfirm:
+ g.handleUndigConfirm(sess, input)
}
}
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index def505a..2a9a360 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -88,12 +88,12 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
}
for _, objID := range order {
count := grouped[objID]
- def, err := g.resolveObjectDef(p.RoomID, objID)
+ def, isLocal, err := g.resolveObjectDef(p.RoomID, objID)
if err != nil {
continue
}
- if def.Hidden {
+ if def.Hidden && !p.GodMode {
continue
}
@@ -101,6 +101,20 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
continue
}
+ var godPrefix string
+ if p.GodMode {
+ var tags []string
+ if isLocal {
+ tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Bold: true}, "[LOCAL]"))
+ } else {
+ tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Bold: true}, "[GLOBAL]"))
+ }
+ if def.Hidden {
+ tags = append(tags, color.Render(g.colorMode(sess), color.ColorSpec{Bold: true}, "(HIDDEN)"))
+ }
+ godPrefix = strings.Join(tags, " ") + " "
+ }
+
type instInfo struct {
idx int
depleted bool
@@ -163,7 +177,7 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
if showTimers && len(instances) > 0 && instances[0].quality > 0 {
qualityTimer = fmt.Sprintf(" (Burning for %d more ticks)", instances[0].quality)
}
- lines = append(lines, fmt.Sprintf("%s%s%s", line, suffix, qualityTimer))
+ lines = append(lines, fmt.Sprintf("%s%s%s%s", godPrefix, line, suffix, qualityTimer))
}
for _, ins := range timed {
@@ -181,7 +195,7 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
if showTimers {
timer = fmt.Sprintf(" (despawn: %d/%d)", ins.sharedCur, ins.sharedMax)
}
- lines = append(lines, fmt.Sprintf("%s%s%s", line, tag, timer))
+ lines = append(lines, fmt.Sprintf("%s%s%s%s", godPrefix, line, tag, timer))
}
for _, ins := range depleted {
@@ -199,7 +213,7 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
if showTimers {
timer = fmt.Sprintf(" (respawns in %d ticks)", ins.respawnIn)
}
- lines = append(lines, fmt.Sprintf("%s (depleted)%s%s", line, tag, timer))
+ lines = append(lines, fmt.Sprintf("%s%s (depleted)%s%s", godPrefix, line, tag, timer))
}
}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 743844f..915e4a7 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -27,12 +27,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
- msg := exitDef.BlockedMessage
- if msg == "" {
- msg = fmt.Sprintf("The way %s is blocked.", exitDir)
+ if !p.GodMode {
+ msg := exitDef.BlockedMessage
+ if msg == "" {
+ msg = fmt.Sprintf("The way %s is blocked.", exitDir)
+ }
+ sess.WriteLine(msg)
+ return
}
- sess.WriteLine(msg)
- return
}
g.World.SeedGroundItems(exitDef.Room)
g.seedRoomMobs(exitDef.Room)
@@ -104,7 +106,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
byDef := map[string][]world.ObjState{}
descByDef := map[string]string{}
for _, ist := range rawInstances {
- def, err := g.resolveObjectDef(p.RoomID, ist.DefID)
+ def, _, err := g.resolveObjectDef(p.RoomID, ist.DefID)
if err != nil {
continue
}
@@ -122,7 +124,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
if len(presentDefs) > 1 {
sess.WriteLine("That's ambiguous, which one?")
for _, defID := range presentDefs {
- if def, err := g.resolveObjectDef(p.RoomID, defID); err == nil {
+ if def, _, err := g.resolveObjectDef(p.RoomID, defID); err == nil {
sess.WriteLine(fmt.Sprintf(" %s", def.Name))
}
}
@@ -133,7 +135,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
instances := byDef[presentDefs[0]]
objDescText := descByDef[presentDefs[0]]
st := &instances[0]
- def, _ := g.resolveObjectDef(p.RoomID, st.DefID)
+ def, _, _ := g.resolveObjectDef(p.RoomID, st.DefID)
if st.DefID == "estate_directory" {
g.lookEstateDirectory(sess)
@@ -199,9 +201,17 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
}
- farmSuffix := g.farmObjSuffix(p, st.DefID, st.Index)
- if farmSuffix != "" {
- sess.WriteLine(farmSuffix)
+ if farmPatchDefIDs[st.DefID] {
+ prefix := farmFlagPrefix(st.DefID, st.Index)
+ if prefix != "" {
+ g.ensureFarmState(p, prefix)
+ g.ShowFarmPatchDetail(sess, p, prefix, st.DefID, st.Index)
+ }
+ } else {
+ farmSuffix := g.farmObjSuffix(p, st.DefID, st.Index)
+ if farmSuffix != "" {
+ sess.WriteLine(farmSuffix)
+ }
}
return
}
diff --git a/internal/game/object_def.go b/internal/game/object_def.go
index e27aeb0..6cf2464 100644
--- a/internal/game/object_def.go
+++ b/internal/game/object_def.go
@@ -4,30 +4,40 @@ import "thehouseoficarus/internal/object"
// resolveObjectDef returns the ObjectDef for a room object instance. File
// objects (the common case) resolve through the cached object store with no
-// disk I/O on a hit, and a store miss is just a map lookup; only inline objects
+// disk I/O on a hit, and a store miss is just a map lookup; only local objects
// — which never live in the store — fall through to a freshly-read room, so
// they keep live-edit semantics without slowing file-object lookups. defID is
-// the ObjState DefID (a normalized name for inline objects, a file id
+// the ObjState DefID (a normalized name for local objects, a file id
// otherwise).
//
// Use this from display or generic-handling sites. Sites that look up a
// specific interactable behavior (gather/use-station/safespot/steal/etc.) may
-// call ObjectStore.Load directly: inline objects are guaranteed non-interactable
+// call ObjectStore.Load directly: local objects are guaranteed non-interactable
// and simply fall through those sites' existing error guards.
-func (g *Game) resolveObjectDef(roomID int, defID string) (*object.ObjectDef, error) {
+func (g *Game) resolveObjectDef(roomID int, defID string) (*object.ObjectDef, bool, error) {
def, err := g.ObjectStore.Load(defID)
if err == nil {
- return def, nil
+ if room, rerr := g.World.LoadRoom(roomID); rerr == nil {
+ for i := range room.Objects {
+ ro := &room.Objects[i]
+ if ro.Local == nil && ro.ID == defID && ro.HiddenOverride {
+ copy := *def
+ copy.Hidden = true
+ return &copy, false, nil
+ }
+ }
+ }
+ return def, false, nil
}
if room, rerr := g.World.LoadRoom(roomID); rerr == nil {
for i := range room.Objects {
ro := &room.Objects[i]
- if ro.Inline != nil && ro.ID == defID {
- d := *ro.Inline
+ if ro.Local != nil && ro.ID == defID {
+ d := *ro.Local
d.ID = defID
- return &d, nil
+ return &d, true, nil
}
}
}
- return nil, err
+ return nil, false, err
}
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index ff9ffa5..58e0613 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -407,7 +407,7 @@ func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighb
if exit.Condition == nil || sess == nil || sess.Player == nil {
return exitOpen
}
- if g.checkCondition(sess, exit.Condition) {
+ if sess.Player.GodMode || g.checkCondition(sess, exit.Condition) {
return exitOpen
}
return exitBlocked
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index 19dbb98..bf4f882 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -71,6 +71,10 @@ func (g *Game) dropItemsOnDeath(p *player.Player) {
func (g *Game) checkAggro(sess *net.Session) {
p := sess.Player
+ if p.GodMode {
+ return
+ }
+
if g.Combat.Get(p.Name) != nil {
return
}
diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go
index c3b52b7..971da99 100644
--- a/internal/game/sys_hazard.go
+++ b/internal/game/sys_hazard.go
@@ -26,6 +26,10 @@ func (g *Game) HazardTick() {
continue
}
+ if p.GodMode {
+ continue
+ }
+
room, err := g.World.LoadRoom(p.RoomID)
if err != nil || room.Hazard == "" {
p.HazardTimer = 0
diff --git a/internal/game/tick.go b/internal/game/tick.go
index 7f593a4..eba8438 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -27,6 +27,7 @@ func (g *Game) DisconnectTick() {
}
sess.DisconnectTicks--
if sess.DisconnectTicks <= 0 {
+ g.restoreGodPlayer(p)
g.AccountStore.SaveCharacter(p)
g.Hub.HardRemove(sess)
}
diff --git a/internal/item/store.go b/internal/item/store.go
index d73d6fe..b6e5567 100644
--- a/internal/item/store.go
+++ b/internal/item/store.go
@@ -76,3 +76,8 @@ func (s *ItemStore) IDSet() map[string]bool {
}
return ids
}
+
+func (s *ItemStore) Reload(dataDir string) {
+ s.cache = make(map[string]*ItemDef)
+ s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "items"))
+}
diff --git a/internal/net/server.go b/internal/net/server.go
index 8114df2..851a4b5 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -45,6 +45,7 @@ const (
StateBank
StateHacking
StateDangerConfirm
+ StateUndigConfirm
)
type Session struct {
@@ -62,6 +63,8 @@ type Session struct {
Disconnecting bool
DisconnectTicks int
Shop *behavior.ShopConfig
+ PendingUndigDir string
+ PendingUndigRoom int
promptVisible bool
writeMu sync.Mutex
}
diff --git a/internal/object/store.go b/internal/object/store.go
index a441705..61cb007 100644
--- a/internal/object/store.go
+++ b/internal/object/store.go
@@ -55,3 +55,8 @@ func (s *ObjectStore) IDSet() map[string]bool {
}
return ids
}
+
+func (s *ObjectStore) Reload(dataDir string) {
+ s.cache = make(map[string]*ObjectDef)
+ s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "objects"))
+}
diff --git a/internal/player/account.go b/internal/player/account.go
index 99a052c..f8da433 100644
--- a/internal/player/account.go
+++ b/internal/player/account.go
@@ -7,4 +7,5 @@ type Account struct {
Aliases map[string]string `yaml:"aliases"`
Colors map[string]string `yaml:"colors"`
Options map[string]any `yaml:"options"`
+ Admin bool `yaml:"admin"`
}
diff --git a/internal/player/player.go b/internal/player/player.go
index ea2487c..7d9b397 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -200,6 +200,8 @@ type Player struct {
ActiveBuffs []PotionBuff `yaml:"-"`
MapSymbols map[int]MapSymbolData `yaml:"map_symbols,omitempty"`
Stats PlayerStats `yaml:"stats"`
+ GodMode bool `yaml:"-"`
+ GodBackup map[SkillName]int `yaml:"-"`
}
type MapSymbolData struct {
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 7a495a0..7da2e4a 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -105,13 +105,13 @@ func validateRooms(s Source) []Issue {
var collEntries []roomObjEntry
for idx, robj := range room.Objects {
- if robj.Inline != nil {
+ if robj.Local != nil {
collEntries = append(collEntries, roomObjEntry{
- defKey: fmt.Sprintf("inline:#%d", idx),
- displayName: world.NormalizeObjectName(robj.Inline.Name),
+ defKey: fmt.Sprintf("local:#%d", idx),
+ displayName: world.NormalizeObjectName(robj.Local.Name),
effDefID: robj.ID,
})
- issues = append(issues, validateInlineObject(id, robj, itemIDs, roomIndex)...)
+ issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex)...)
} else if robj.ID != "" && !objIDs[robj.ID] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -155,14 +155,14 @@ func validateRooms(s Source) []Issue {
return issues
}
-// validateInlineObject checks a room-inline object definition. Inline objects
+// validateLocalObject checks a room-local object definition. Local objects
// are restricted to the passive subset (name/aliases/color/hidden/
// inroom_description/description/on_look); interactable or stateful behavior
// must be defined as a standalone object file instead.
-func validateInlineObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool) []Issue {
+func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool) []Issue {
var issues []Issue
- def := robj.Inline
- prefix := fmt.Sprintf("Room %d: inline object %q", roomID, robj.ID)
+ def := robj.Local
+ prefix := fmt.Sprintf("Room %d: local object %q", roomID, robj.ID)
if def.Name == "" {
issues = append(issues, Issue{
@@ -175,7 +175,7 @@ func validateInlineObject(roomID int, robj world.RoomObject, itemIDs map[string]
issues = append(issues, Issue{
Level: "WARN",
Type: "reference",
- Message: prefix + ": `id:` is ignored on inline objects — identity derives from `name`",
+ Message: prefix + ": `id:` is ignored on local objects — identity derives from `name`",
})
}
@@ -208,7 +208,7 @@ func validateInlineObject(roomID int, robj world.RoomObject, itemIDs map[string]
issues = append(issues, Issue{
Level: "ERROR",
Type: "reference",
- Message: prefix + fmt.Sprintf(": inline objects may not define interactable behavior (%s) — define it as an object file instead",
+ Message: prefix + fmt.Sprintf(": local objects may not define interactable behavior (%s) — define it as an object file instead",
strings.Join(bad, ", ")),
})
}
@@ -230,7 +230,7 @@ func validateInlineObject(roomID int, robj world.RoomObject, itemIDs map[string]
// roomObjEntry is a single object slot in a room, reduced to what the per-room
// collision check needs. defKey identifies the distinct definition behind the
-// slot ("file:<id>" for references, "inline:#<index>" for inline defs), so
+// slot ("file:<id>" for references, "local:#<index>" for local defs), so
// repeated references to the same file object collapse to one definition.
type roomObjEntry struct {
defKey string
@@ -241,7 +241,7 @@ type roomObjEntry struct {
// validateRoomObjectCollisions reports two per-room problems: distinct object
// definitions that share a display name (a player typing the exact name could
// not disambiguate) and distinct definitions that resolve to the same ObjState
-// DefID (their runtime instances would be conflated — e.g. an inline object
+// DefID (their runtime instances would be conflated — e.g. a local object
// whose name matches a referenced file object's id). Repeated references to the
// same file object share a defKey and are allowed (e.g. multiple copper_rock).
func validateRoomObjectCollisions(roomID int, entries []roomObjEntry) []Issue {
@@ -281,7 +281,7 @@ func validateRoomObjectCollisions(roomID int, entries []roomObjEntry) []Issue {
issues = append(issues, Issue{
Level: "ERROR",
Type: "duplicate",
- Message: fmt.Sprintf("Room %d: object id collision %q (an inline object's name matches another object's id)",
+ Message: fmt.Sprintf("Room %d: object id collision %q (a local object's name matches another object's id)",
roomID, defID),
})
}
diff --git a/internal/validate/inline_test.go b/internal/validate/local_test.go
index 5816c26..23bf8ca 100644
--- a/internal/validate/inline_test.go
+++ b/internal/validate/local_test.go
@@ -11,43 +11,43 @@ import (
"thehouseoficarus/internal/world"
)
-func TestValidateInlineObjectPassiveOK(t *testing.T) {
- robj := world.RoomObject{ID: "window", Inline: &object.ObjectDef{
+func TestValidateLocalObjectPassiveOK(t *testing.T) {
+ robj := world.RoomObject{ID: "window", Local: &object.ObjectDef{
Name: "window",
Hidden: true,
Description: behavior.DescList{{Text: "A small window."}},
}}
- issues := validateInlineObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil)
if len(issues) != 0 {
- t.Errorf("expected no issues for passive inline object, got: %+v", issues)
+ t.Errorf("expected no issues for passive local object, got: %+v", issues)
}
}
-func TestValidateInlineObjectRejectsInteractable(t *testing.T) {
- robj := world.RoomObject{ID: "rock", Inline: &object.ObjectDef{
+func TestValidateLocalObjectRejectsInteractable(t *testing.T) {
+ robj := world.RoomObject{ID: "rock", Local: &object.ObjectDef{
Name: "rock",
Gather: &behavior.GatherConfig{},
}}
- issues := validateInlineObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil)
if !containsMsg(issues, "interactable behavior") {
t.Errorf("expected interactable-behavior error, got: %+v", issues)
}
}
-func TestValidateInlineObjectRequiresName(t *testing.T) {
- robj := world.RoomObject{ID: "x", Inline: &object.ObjectDef{
+func TestValidateLocalObjectRequiresName(t *testing.T) {
+ robj := world.RoomObject{ID: "x", Local: &object.ObjectDef{
Description: behavior.DescList{{Text: "no name"}},
}}
- issues := validateInlineObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil)
if !containsMsg(issues, "has no name") {
t.Errorf("expected has-no-name error, got: %+v", issues)
}
}
-func TestValidateInlineObjectStrayIDWarns(t *testing.T) {
- robj := world.RoomObject{ID: "anvil", Inline: &object.ObjectDef{Name: "anvil", ID: "anvil"}}
- issues := validateInlineObject(1001, robj, nil, nil)
- if !containsMsg(issues, "ignored on inline objects") {
+func TestValidateLocalObjectStrayIDWarns(t *testing.T) {
+ robj := world.RoomObject{ID: "anvil", Local: &object.ObjectDef{Name: "anvil", ID: "anvil"}}
+ issues := validateLocalObject(1001, robj, nil, nil)
+ if !containsMsg(issues, "ignored on local objects") {
t.Errorf("expected stray-id warning, got: %+v", issues)
}
}
@@ -102,11 +102,11 @@ objects:
- id: copper_rock
- id: anvil
- name: anvil
- description: "an inline thing that collides with the file id"
+ description: "a local thing that collides with the file id"
`)
issues := validateRooms(newSource(dir))
- // Inline name "anvil" normalizes to the referenced file id "anvil".
+ // Local name "anvil" normalizes to the referenced file id "anvil".
if !containsMsg(issues, "object id collision \"anvil\"") {
t.Errorf("expected defID collision error, got: %+v", issues)
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index d3ea05b..e02da53 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -289,6 +289,13 @@ func (s *MobStore) AllDefIDs() map[string]bool {
return ids
}
+func (s *MobStore) ReloadDefs(dataDir string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.defs = make(map[string]*MobDef)
+ s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "mobs"))
+}
+
func (s *MobStore) GetInstance(id string) *MobInstance {
s.mu.Lock()
defer s.mu.Unlock()
@@ -371,6 +378,16 @@ func (s *MobStore) RemoveInstance(instanceID string) {
delete(s.instances, instanceID)
}
+func (s *MobStore) RemoveMobsInRoom(roomID int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for id, inst := range s.instances {
+ if inst.RoomID == roomID {
+ delete(s.instances, id)
+ }
+ }
+}
+
func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
type defWrapper struct {
def *MobDef
diff --git a/internal/world/room.go b/internal/world/room.go
index 8b8830e..21e1277 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -49,10 +49,10 @@ type SpawnDef struct {
type ExitDef struct {
Room int `yaml:"room"`
- Condition *behavior.Condition `yaml:"condition"`
- BlockedMessage string `yaml:"blocked_message"`
- SetFlags map[string]any `yaml:"set_flags"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags"`
+ Condition *behavior.Condition `yaml:"condition,omitempty"`
+ BlockedMessage string `yaml:"blocked_message,omitempty"`
+ SetFlags map[string]any `yaml:"set_flags,omitempty"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"`
}
func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
@@ -85,8 +85,8 @@ type Room struct {
type RoomMob struct {
ID string `yaml:"id"`
- WanderRooms []int `yaml:"wander_rooms"`
- WanderInterval float64 `yaml:"wander_interval"`
+ WanderRooms []int `yaml:"wander_rooms,omitempty"`
+ WanderInterval float64 `yaml:"wander_interval,omitempty"`
}
func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
@@ -129,16 +129,17 @@ func (e EnterStep) IsTimed() bool {
}
// RoomObject is either a reference to a file-backed object definition (only
-// `id`/wander fields set) or a fully inline object definition (Inline != nil).
-// An entry is treated as inline when it carries any passive content field
+// `id`/wander fields set) or a fully local object definition (Local != nil).
+// An entry is treated as local when it carries any passive content field
// (name, description, aliases, inroom_description, color, hidden, on_look).
-// Inline objects are room-scoped: their identity (ID, used as the ObjState
+// Local objects are room-scoped: their identity (ID, used as the ObjState
// DefID) is derived from the normalized name; `id:` is reserved for references.
type RoomObject struct {
ID string
WanderRooms []int
WanderInterval float64
- Inline *object.ObjectDef
+ Local *object.ObjectDef
+ HiddenOverride bool `yaml:"hidden,omitempty"`
}
func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
@@ -146,6 +147,7 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
ID string `yaml:"id"`
WanderRooms []int `yaml:"wander_rooms"`
WanderInterval float64 `yaml:"wander_interval"`
+ Hidden bool `yaml:"hidden"`
}
var ref rawRef
if err := value.Decode(&ref); err != nil {
@@ -158,24 +160,80 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
if err := value.Decode(&def); err != nil {
return err
}
- inline := def.Name != "" || len(def.Description) > 0 || def.InRoomDescription != "" ||
- len(def.Aliases) > 0 || def.Color != "" || def.Hidden || def.OnLook != nil
- if inline {
- // Identity derives from the name. def.ID still holds any explicit `id:`
- // the builder wrote; it is left in place only so validation can flag it
- // as a stray field (runtime resolution overrides the copy's ID anyway).
+ isLocal := def.Name != "" || len(def.Description) > 0 || def.InRoomDescription != "" ||
+ len(def.Aliases) > 0 || def.Color != "" || def.OnLook != nil
+ if isLocal {
ro.ID = NormalizeObjectName(def.Name)
- ro.Inline = &def
+ ro.Local = &def
return nil
}
ro.ID = ref.ID
+ ro.HiddenOverride = ref.Hidden
return nil
}
+func (ro RoomObject) MarshalYAML() (interface{}, error) {
+ if ro.Local != nil {
+ type inlineObj struct {
+ Name string `yaml:"name"`
+ Aliases []string `yaml:"aliases,omitempty"`
+ Color string `yaml:"color,omitempty"`
+ Hidden bool `yaml:"hidden,omitempty"`
+ InRoomDescription string `yaml:"inroom_description,omitempty"`
+ RemovalItem string `yaml:"removal_item,omitempty"`
+ Description behavior.DescList `yaml:"description,omitempty"`
+ UseInteractions []object.UseInteraction `yaml:"use_interactions,omitempty"`
+ StealTable string `yaml:"steal_table,omitempty"`
+ StealLevel int `yaml:"steal_level,omitempty"`
+ StealXP int `yaml:"steal_xp,omitempty"`
+ StealSpeed float64 `yaml:"steal_speed,omitempty"`
+ GuardMob string `yaml:"guard_mob,omitempty"`
+ Gather *behavior.GatherConfig `yaml:"gather,omitempty"`
+ Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
+ Use *behavior.UseConfig `yaml:"use,omitempty"`
+ Safespot *object.SafespotConfig `yaml:"safespot,omitempty"`
+ OnLook *behavior.NodeAction `yaml:"on_look,omitempty"`
+ }
+ def := ro.Local
+ return inlineObj{
+ Name: def.Name,
+ Aliases: def.Aliases,
+ Color: def.Color,
+ Hidden: def.Hidden,
+ InRoomDescription: def.InRoomDescription,
+ RemovalItem: def.RemovalItem,
+ Description: def.Description,
+ UseInteractions: def.UseInteractions,
+ StealTable: def.StealTable,
+ StealLevel: def.StealLevel,
+ StealXP: def.StealXP,
+ StealSpeed: def.StealSpeed,
+ GuardMob: def.GuardMob,
+ Gather: def.Gather,
+ Talk: def.Talk,
+ Use: def.Use,
+ Safespot: def.Safespot,
+ OnLook: def.OnLook,
+ }, nil
+ }
+ type rawRef struct {
+ ID string `yaml:"id"`
+ WanderRooms []int `yaml:"wander_rooms,omitempty"`
+ WanderInterval float64 `yaml:"wander_interval,omitempty"`
+ Hidden bool `yaml:"hidden,omitempty"`
+ }
+ return rawRef{
+ ID: ro.ID,
+ WanderRooms: ro.WanderRooms,
+ WanderInterval: ro.WanderInterval,
+ Hidden: ro.HiddenOverride,
+ }, nil
+}
+
// NormalizeObjectName lowercases, trims, and collapses internal whitespace,
// keeping spaces (not underscores) so the word-prefix matcher treats the name
// as a single token in its `_`-split fallback. It is the canonical derivation
-// of an inline object's ObjState DefID from its name.
+// of a local object's ObjState DefID from its name.
func NormalizeObjectName(name string) string {
return strings.Join(strings.Fields(strings.ToLower(name)), " ")
}
diff --git a/internal/world/room_test.go b/internal/world/room_test.go
index 472fe4f..e7b1718 100644
--- a/internal/world/room_test.go
+++ b/internal/world/room_test.go
@@ -18,7 +18,7 @@ func writeRoom(t *testing.T, dir string, body string) {
}
}
-func TestRoomObjectReferenceVsInline(t *testing.T) {
+func TestRoomObjectReferenceVsLocal(t *testing.T) {
dir := t.TempDir()
body := `name: Test Room
objects:
@@ -44,37 +44,37 @@ objects:
}
// reference
- if ref := room.Objects[0]; ref.Inline != nil || ref.ID != "workbench" {
- t.Errorf("object 0 should be a reference to workbench, got %+v (inline=%v)", ref, ref.Inline)
+ if ref := room.Objects[0]; ref.Local != nil || ref.ID != "workbench" {
+ t.Errorf("object 0 should be a reference to workbench, got %+v (local=%v)", ref, ref.Local)
}
- // inline, id derived from the normalized name
+ // local, id derived from the normalized name
in := room.Objects[1]
- if in.Inline == nil {
- t.Fatalf("object 1 should be inline")
+ if in.Local == nil {
+ t.Fatalf("object 1 should be local")
}
if in.ID != "window" {
- t.Errorf("inline id should derive from name to %q, got %q", "window", in.ID)
+ t.Errorf("local id should derive from name to %q, got %q", "window", in.ID)
}
- if !in.Inline.Hidden || in.Inline.Name != "window" {
- t.Errorf("inline fields not parsed: %+v", in.Inline)
+ if !in.Local.Hidden || in.Local.Name != "window" {
+ t.Errorf("local fields not parsed: %+v", in.Local)
}
- // inline with a multi-word name: id is the normalized name (spaces kept),
+ // local with a multi-word name: id is the normalized name (spaces kept),
// and an explicit `id:` is NOT used as identity (it is preserved on the
// def only so validation can flag it).
ex := room.Objects[2]
- if ex.Inline == nil {
- t.Fatalf("object 2 should be inline")
+ if ex.Local == nil {
+ t.Fatalf("object 2 should be local")
}
if ex.ID != "instrument panel" {
- t.Errorf("inline id should be normalized name %q, got %q", "instrument panel", ex.ID)
+ t.Errorf("local id should be normalized name %q, got %q", "instrument panel", ex.ID)
}
- if ex.Inline.ID != "control" {
- t.Errorf("explicit id should be preserved on def for validation, got %q", ex.Inline.ID)
+ if ex.Local.ID != "control" {
+ t.Errorf("explicit id should be preserved on def for validation, got %q", ex.Local.ID)
}
- if len(ex.Inline.Aliases) != 1 || ex.Inline.Aliases[0] != "cockpit" {
- t.Errorf("inline aliases not parsed: %+v", ex.Inline.Aliases)
+ if len(ex.Local.Aliases) != 1 || ex.Local.Aliases[0] != "cockpit" {
+ t.Errorf("local aliases not parsed: %+v", ex.Local.Aliases)
}
}
diff --git a/internal/world/trigger_store.go b/internal/world/trigger_store.go
index f00d256..b5fac5b 100644
--- a/internal/world/trigger_store.go
+++ b/internal/world/trigger_store.go
@@ -51,6 +51,15 @@ func NewTriggerStore() *TriggerStore {
}
}
+func (ts *TriggerStore) ClearTriggers() {
+ ts.mu.Lock()
+ defer ts.mu.Unlock()
+ ts.globalByPlayerFlag = make(map[string][]*TriggerDef)
+ ts.globalByWorldFlag = make(map[string][]*TriggerDef)
+ ts.roomByPlayerFlag = make(map[int]map[string][]*TriggerDef)
+ ts.roomByWorldFlag = make(map[int]map[string][]*TriggerDef)
+}
+
func (ts *TriggerStore) LoadGlobal(dataDir string) error {
dir := filepath.Join(dataDir, "triggers")
if _, err := os.Stat(dir); os.IsNotExist(err) {
diff --git a/internal/world/world.go b/internal/world/world.go
index ee06142..0c992ad 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -126,9 +126,46 @@ func (w *World) RoomIndex() map[int]bool {
return ids
}
+func (w *World) RebuildRoomIndex(dataDir string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.roomPathIndex = behavior.BuildRoomIndex(filepath.Join(dataDir, "rooms"))
+}
+
+func (w *World) AddRoomPath(id int, path string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.roomPathIndex[id] = path
+}
+
+func (w *World) GetRoomPath(id int) (string, bool) {
+ path, ok := w.roomPathIndex[id]
+ return path, ok
+}
+
+func (w *World) ClearHazardCache() {
+ w.hazardMu.Lock()
+ defer w.hazardMu.Unlock()
+ w.hazardPathIndex = nil
+ w.hazardDefs = make(map[string]*HazardDef)
+}
+
func (w *World) Tick() {
w.mu.Lock()
defer w.mu.Unlock()
w.tickGroundItems()
w.tickObjStatesLocked()
}
+
+func (w *World) ClearRoomState(roomID int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ delete(w.groundItems, roomID)
+ delete(w.seeded, roomID)
+ prefix := fmt.Sprintf("%d:", roomID)
+ for k := range w.objStates {
+ if strings.HasPrefix(k, prefix) {
+ delete(w.objStates, k)
+ }
+ }
+}