diff options
66 files changed, 2607 insertions, 184 deletions
@@ -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 ©, 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) + } + } +} |
