aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-08 19:59:14 -0400
committerhistoria <[not public]>2026-07-08 19:59:14 -0400
commit09d325dcf5e779eab5550d3fd3377bde50101428 (patch)
treea433be903aabbf1d2eadce2aacdac12a9eb8dde0
parent9184377301c2604e003f36426788c032fb0ca524 (diff)
downloadthehouseoficarus-09d325dcf5e779eab5550d3fd3377bde50101428.tar.gz
feat: unify on use, on look, and on kill. all support same conditions/actions now.
-rw-r--r--AGENTS.md30
-rw-r--r--building_guide/admin.md4
-rw-r--r--building_guide/behaviors.md147
-rw-r--r--building_guide/conditions.md5
-rw-r--r--building_guide/doors.md4
-rw-r--r--building_guide/hidden_objects.md4
-rw-r--r--building_guide/objects.md116
-rw-r--r--building_guide/triggers.md18
-rw-r--r--data/help/use.yaml2
-rw-r--r--data/mobs/examples/client.yaml226
-rw-r--r--data/objects/anvil.yaml2
-rw-r--r--data/objects/aps_tower.yaml2
-rw-r--r--data/objects/iron_gate.yaml2
-rw-r--r--data/objects/tutorial_lever.yaml2
-rw-r--r--data/rooms/intro/1001.yaml11
-rw-r--r--internal/admin/static/cardeditor.js2
-rw-r--r--internal/admin/static/itemeditor.js2
-rw-r--r--internal/admin/static/mobeditor.js163
-rw-r--r--internal/admin/static/objecteditor.js183
-rw-r--r--internal/behavior/behavior.go92
-rw-r--r--internal/behavior/types.go2
-rw-r--r--internal/game/act_effects.go268
-rw-r--r--internal/game/act_room.go92
-rw-r--r--internal/game/act_talk.go76
-rw-r--r--internal/game/act_use_interaction.go8
-rw-r--r--internal/game/cmd_move.go35
-rw-r--r--internal/game/cmd_room_insert.go6
-rw-r--r--internal/game/cmd_room_remove.go6
-rw-r--r--internal/game/cmd_use.go6
-rw-r--r--internal/game/cmd_verbs.go4
-rw-r--r--internal/game/combat_mob.go5
-rw-r--r--internal/game/look_target.go4
-rw-r--r--internal/game/sys_triggers.go124
-rw-r--r--internal/object/object.go33
-rw-r--r--internal/player/player.go29
-rw-r--r--internal/validate/checks.go239
-rw-r--r--internal/validate/local_test.go8
-rw-r--r--internal/world/grid_test.go4
-rw-r--r--internal/world/insert_remove.go3
-rw-r--r--internal/world/mob.go24
-rw-r--r--internal/world/room.go71
-rw-r--r--internal/world/room_migrate.go15
-rw-r--r--internal/world/room_migrate_test.go26
-rw-r--r--internal/world/trigger.go65
-rw-r--r--internal/world/trigger_store.go9
45 files changed, 1266 insertions, 913 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 893ba59..9016e73 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ File prefixes in `internal/game/`: `game.go/cmd_registry.go/tick.go` (core), `co
## Key Conventions
-**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses under `data/`. Read from disk on every access — edit and it takes effect immediately. **The filename is the ID** for every data type: lookup by filename stem. **Do not put a top-level `id:` field** (ignored). A nested `- id:` under a room's `objects:`/`mobs:` is a *reference* and is still required. Subdirectories are organizational only. **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.
+**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/on_use). 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.
@@ -38,9 +38,9 @@ File prefixes in `internal/game/`: `game.go/cmd_registry.go/tick.go` (core), `co
## Room Scripting
-- **`on_enter` steps**: message + optional condition. A step may have `delay` and/or `set_global_flags`/`set_player_flags`. Timed steps run as scheduled enter sequences; otherwise messages print synchronously. Conditions snapshotted once at entry. Resumes on reconnect if interrupted.
+- **`on_enter` steps** (`[]behavior.StepAction`): message + optional `condition` + `delay` + any of the shared `StepAction` effects (`set_global_flags`/`set_player_flags`, `give_item`/`take_item`, `teleport`, `heal`, `credits`, `aps_node`, `broadcast`, `broadcast_global`, `spawn_mob`, `despawn_mob`). Timed steps (any non-zero field triggers the timed path) run as scheduled enter sequences; otherwise messages print synchronously. Conditions snapshotted once at entry. Resumes on reconnect if interrupted.
- **Conditional descriptions** (`description: [{text, condition}]`): first passing variant wins. For objects, if no variant matches the object is absent for that player. Accepts plain string or list.
-- **Exits** can `set_global_flags`/`set_player_flags` on successful traverse.
+- **Exits** carry an `on_traverse:` list of `behavior.Interaction` (same shape as on_use) — first passing entry fires AFTER `p.RoomID` is updated to the destination, so `aps_node`/`teleport` resolve against the new room. `ExitDef.Condition` still gates whether the exit is passable at all; each `on_traverse` entry has its own additional `Condition`.
## Data Files
@@ -77,9 +77,29 @@ Actions route through `startAction()` in `act.go`: normalize verb → resolve ta
**Behavior types (YAML-driven):** `gather` (mine/chop/cut/fish), `use` (crafting stations), `talk` (NPC dialog). **Hardcoded actions:** burn/stoke, search, production (cook/smelt/smith/craft/mix/fletch/clean), steal, agility, farm, identify, finishing blow. Gather behaviors use SuccessChance = Base + (level-required)*PerLevel, clamped `[0, Cap]`. Action types and all YAML config fields are self-documenting in the source (`act_state.go`, `behavior.go`, `item/item.go`).
+### Interactions (unified conditional-action model)
+
+`behavior.Interaction` (struct in `behavior/behavior.go`) is the shared per-entry shape for every "when X happens, do Y with optional condition" feature in the game:
+
+- **On Use** (`on_use:` on objects) — fires on `use <obj>` (bare, no `item_id`) or `use <item> on <obj>` (item-keyed). Triggered in `cmd_use.go`; queued as a one-tick action; tick executor in `act_use_interaction.go`.
+- **On Look** (`on_look:` on objects) — fires after the object's description is shown on `look <obj>`. Triggered in `look_target.go`; runs the first passing entry inline (no queue). An entry with an `item_id` only fires if the player currently carries that item in their inventory.
+- **On Kill** (`on_kill:` on mobs) — fires (additively over standard loot) when a combat kill of the mob completes OR when a `kind: task` mob's work drains its HP to zero (the "completion" of the task). Triggered from `endCombat` in `combat_mob.go`. An entry with an `item_id` only fires if the player is wielding that item in a weapon-hand slot (main_hand or off_hand) at the moment of the kill.
+- **Exit On Traverse** (`on_traverse:` on exits) — fires AFTER `p.RoomID` is set to the destination, so `aps_node`/`teleport` resolve against the new room. Stashed in `Player.MovePendingInteraction` at move-classify time, applied in `completeMove` (`cmd_move.go`).
+- **On Enter steps** (`on_enter:` on rooms) — list of `behavior.StepAction`; each step has its own `Condition` + `Message`/`Delay` + full effect set. Reconnect-resume. Driven by `enterSeq`/`EnterSeqTick` in `act_room.go`.
+- **Trigger steps** (`steps:` on room/global `TriggerDef`) — same `StepAction` per step, newly supports per-step `Condition`. Trigger watcher + async playback in `sys_triggers.go` / `trigger_store.go`. The event-listener / cascade / re-entry-guard machinery is separate from interactions' inline dispatch.
+- **Talk** — `TalkNode.Action` and `TalkOption.Action` are `*NodeAction` (inline subset). Wrap via `handleNodeAction` → `applyNodeAction`.
+
+Each list of Interactions is walked top-to-bottom; the **first** entry whose item filter and `Condition` pass wins and fires (one entry per triggering event). `applyInteraction` (`act_effects.go`) is the convenience wrapper for that walk + fire; individual callers can loop directly. The item filter is interaction-kind-specific: on_use matches the held item, on_look requires `p.HasItem`, on_kill requires `p.IsWielding`; nil disables the filter (exit traversal, on_traverse).
+
+**Effect superset — `behavior.StepAction`.** The universal effect container embedded by all interaction `action` fields. Holds the inline-only `NodeAction` fields (`set_global_flags`, `set_player_flags`, `give_item`, `take_item`, `teleport`, `heal`, `credits`, `aps_node`) PLUS the sequence-only fields (`message`, `broadcast`, `broadcast_global`, `spawn_mob`, `despawn_mob`, `delay`) and a per-step `Condition`. Inline callers (use/look/kill/talk) ignore the sequence-only fields; on_enter / trigger steps use them.
+
+**One executor — `applyStepAction(sess, p, step, roomID, scope, flagValue)` in `act_effects.go`.** Scopes: `scopePlayer` (inline: flags + items + heal + credits + teleport + aps_node + broadcast), `scopePlayerSeq` (on_enter / trigger player seq: above + broadcast_global + spawn_mob + despawn_mob), `scopeGlobal` (trigger global seq: broadcasts + global flags + world-owned spawn/despawn only). `applyNodeAction` (`act_talk.go`) is a thin wrapper passing a NodeAction-wrapped StepAction at scopePlayer; `fireEnterStep` / `executeTriggerStep` / `executeGlobalTriggerStep` are thin wrappers at scopePlayerSeq / scopeGlobal. The old duplicate executors (`applyNodeAction` body, `fireEnterStep` body, `executeTriggerStep`, `executeGlobalTriggerStep`) collapsed into one in `act_effects.go`.
+
+`aps_node` retains its dedicated bool field — it stamps `aps_node_<currentRoomID>: true` on the player, which the `aps` command (`cmd_aps.go`) reads to build the datapad. (The room-id is dynamic, so it can't be expressed as a static `set_player_flags` entry without template substitution.) `reputation_cost` was removed (assassin reputation shop is not yet wired up).
+
### Condition System
-Used by exits, talk options, on-enter, use_interactions: `global_flag` (global), `player_flag` (per-char), `value` (exact match), `has_item`, `min_credits`, `all_of`, `any_of`, `not`. Bare global_flag/player_flag passes on truthy.
+Used by exits (exit gate + `on_traverse`), talk options/nodes, on-enter steps, on_use / on_look (objects), on_kill (mobs), trigger steps: `global_flag` (global), `player_flag` (per-char), `value` (exact match), `has_item`, `min_credits`, `all_of`, `any_of`, `not`. Bare global_flag/player_flag passes on truthy.
## Two Depletion Mechanics
@@ -145,7 +165,7 @@ Full field reference in `item/item.go`. Key fields: equip_slot, weapon_type, att
- `startAction` searches objects first, then mobs. Mobs need `behavior:` on YAML def to be interactable.
- Exits: old `north: 2` still works via custom `UnmarshalYAML`. Same for `RoomMob`.
-- `checkCondition` is the single condition evaluator — shared across exits, talk, on-enter, use_interactions.
+- `checkCondition` is the single condition evaluator — shared across exits, talk, on-enter, on_use/on_look (objects), on_kill (mobs), trigger steps. Every interaction type and every timed step shares one condition language.
- Object `hidden: true` = not in room listings but still interactable.
- Alias expansion happens before command dispatch and can shadow built-ins.
- `say` preserves case from raw input.
diff --git a/building_guide/admin.md b/building_guide/admin.md
index af2184c..d66aabe 100644
--- a/building_guide/admin.md
+++ b/building_guide/admin.md
@@ -251,8 +251,8 @@ the game server and is configured in `config.yaml` under `admin_http` (plain HTT
|------|-----|-------------|
| Map | `/` | Interactive SVG map showing rooms on a 3D grid. Zoom/pan, click rooms to edit, create/delete rooms and exits, relink exits. Up/down links shown alongside horizontal connections. |
| Items | `/editor/items` | Full CRUD editor with sections for equipment (type/slot/attack/defense/other), tool, craft (type/level/XP/ingredients/stations/tool/steps), firemaking, farming, potions, and requirements. Undo/redo supported. |
-| Objects | `/editor/objects` | CRUD editor with sections for gather (tools/drops/success formula), use (message/wait/consumes), talk (visual talk tree editor), safespot, steal, use_interactions, and on_look. |
-| Mobs | `/editor/mobs` | CRUD for mob definitions — stats, drops, talk, behavior, assassin/steal/task fields. |
+| Objects | `/editor/objects` | CRUD editor with sections for gather (tools/drops/success formula), use (message/wait/consumes), talk (visual talk tree editor), safespot, steal, on_use (interactions — see Effects help in-card), on_look (interactions, same row UI), and the raw-file editor covers on_use/on_look + on_kill on mobs. |
+| Mobs | `/editor/mobs` | CRUD for mob definitions — stats, drops, talk, shop, behavior, assassin/steal/task fields, plus on_kill interaction cards. |
| Drops | `/editor/drops` | CRUD for shared drop tables (gem tables, bird's nests, etc.). |
| Hazards | `/editor/hazards` | CRUD for hazard definitions. |
| Techs | `/editor/techs` | CRUD for technology definitions (buffs, drain rates, categories). |
diff --git a/building_guide/behaviors.md b/building_guide/behaviors.md
index 1857681..3b7d769 100644
--- a/building_guide/behaviors.md
+++ b/building_guide/behaviors.md
@@ -266,9 +266,13 @@ talk:
| `teleport` | Moves the player to a room ID. |
| `heal` | Restores that many hitpoints. |
| `credits` | Credits charged (negative) or awarded (positive). |
-| `reputation_cost` | Deducts reputation from `assassin_reputation` flag before other actions. |
| `aps_node` | Marks this room's APS node as unlocked. |
+The full effect vocabulary (used by all interaction types and on_enter/trigger
+steps) additionally includes `message`, `broadcast`, `broadcast_global`,
+`spawn_mob`, `despawn_mob`, and `delay` — see [interaction
+reference](#interaction-reference) below.
+
> **Planned (not yet implemented):** `assign_task`, `skip_task`, `extend_task`, and `sawmill` are under design and will be added in a future update.
All fields in a single action are processed together — give an item, take an item, set flags,
@@ -521,19 +525,26 @@ talk:
A specialty shop that only buys back its own stock sets `buys_anything: false`.
-### Object Interactions (levers, switches, gates)
+### Object Interactions (levers, switches, gates) — `on_use` and `on_look`
+
+Interactions are the unified, conditional action system shared by objects
+(`on_use`, `on_look`), mobs (`on_kill`), exit traversal
+(`on_traverse`), on_enter steps, room/global triggers, talk nodes, and talk
+options. Each interaction has an optional condition, an optional message,
+and an optional `action` (the full effect vocabulary). When a list of
+interactions is evaluated, the first whose condition passes wins and fires.
-Interaction configs go under the `use_interactions:` key on objects.
-Entries with no `item` field are bare interactions triggered by "use"
-or "push"/"pull":
+On objects, `on_use` runs when the player types `use <obj>` (bare) or
+`use <item> on <obj>` (item-specific). Entries with no `item_id` are bare (fire when `use <obj>` is typed) —
+triggered by `use <obj>`:
```yaml
name: iron gate
hidden: true
description: "A heavy iron gate set into the north wall."
-use_interactions:
+on_use:
- condition:
- flag: gate_open
+ global_flag: gate_open
value: true
not: true
message: "You push the heavy iron gate open."
@@ -545,7 +556,7 @@ use_interactions:
Item-specific interaction:
```yaml
name: bookshelf
-use_interactions:
+on_use:
- item_id: dusty_tome
message: "The bookshelf slides aside, revealing a secret passage!"
action:
@@ -553,4 +564,124 @@ use_interactions:
secret_passage_open: true
```
+`on_look` runs after the description is shown when the player types
+`look <obj>`. It is a list of interactions (same shape), with `item_id`
+acting as a *has_item* gate: an entry with an `item_id` only fires if the
+player currently carries that item in their inventory (empty = always fires
+on look).
+typically empty:
+
+```yaml
+name: sign
+on_look:
+ - set_player_flags:
+ read_sign: true
+```
+
+---
+
+### Interaction Reference
+
+The `Interaction` struct is the shared per-entry shape:
+
+| Field | Type | Description |
+| ----------- | ---------- | --------------------------------------------------------------- |
+| `item_id` | string | `on_use`: required item id (empty = bare `use <object>`). `on_look`: only fires if you carry this item. `on_kill`: only fires if you wield this weapon (main_hand or off_hand) when the mob is defeated. |
+| `condition` | Condition | Optional gate (see [conditions](conditions.md)) |
+| `message` | string | Message shown to the player when this entry fires |
+| `action` | StepAction | Optional effects — the full superset below |
+
+The `StepAction` superset is the universal effect container used by every
+interaction type, talk node actions, on_enter steps, and trigger steps. The
+inline-only `NodeAction` fields are the subset historically used by talk
+nodes; the additional sequence-only fields (`message`, `broadcast`,
+`broadcast_global`, `spawn_mob`, `despawn_mob`, `delay`) are available on
+all of them but primarily meaningful for on_enter/triggers (inline callers
+may use `broadcast` to announce to the room — handy for `on_kill`
+announcements).
+
+| Field | Type | Description |
+| ------------------ | -------------- | -------------------------------------------------------------------- |
+| `set_global_flags` | map[string]any | Set global flags (shared by all players; cascades other triggers) |
+| `set_player_flags` | map[string]any | Set player flags (per-character, saved to YAML; cascades) |
+| `give_item` | string | Give one unit of an item to inventory |
+| `take_item` | string | Remove one unit of an item from inventory |
+| `teleport` | int | Move player to a room ID; runs `look` + `on_enter` of the destination |
+| `heal` | int | Restore hitpoints (clamped to MaxHP) |
+| `credits` | int | Add (positive) or deduct (negative) credits (gated by affordability) |
+| `aps_node` | bool | Mark the current room as a discovered APS node on the datapad |
+| `message` | string | Direct message to the player (template vars `%p`/`%v` supported) |
+| `broadcast` | string | Announce to all in the room (`%p`/`%v` substituted per recipient) |
+| `broadcast_global` | string | Announce to all online (`%p`/`%v` substituted per recipient) |
+| `spawn_mob` | string/map | Spawn a transient mob — bare string (id) or full [config](#spawn-mob-config) |
+| `despawn_mob` | string | Despawn all transient mobs of this id (optionally owner-filtered) |
+| `delay` | int | Ticks to wait before this step fires (on_enter/triggers only) |
+| `condition` | Condition | Per-step gate (on_enter steps and trigger steps; evaluated before firing) |
+
+### Mob Interactions (On Kill)
+
+Mobs can define `on_kill` (fires when this mob is defeated — either by a
+combat kill or by a `kind: task` mob's HP draining to zero, the
+"completion" of the work). It is additive — standard loot `drops` still
+hit the ground first, then the first matching interaction fires. An
+entry with an `item_id` only fires if the player is wielding that item
+in a weapon-hand slot (main_hand or off_hand) at the moment of the kill.
+
+```yaml
+name: boss
+combat: ...
+on_kill:
+ - condition:
+ global_flag: boss_quest_active
+ message: "The boss crumbles to dust. The Guardian Stone reverberates!"
+ action:
+ set_global_flags:
+ boss_slain: true
+ broadcast_global: "%p has slain the World Boss!"
+ spawn_mob: boss_add
+ - item_id: dragon_slayer
+ action:
+ give_item: boss_heart
+ set_global_flags:
+ dragon_slain: true
+```
+
+```yaml
+name: reactor panel
+task: ...
+on_kill:
+ - message: "With a final burst of effort, the panel snaps into place."
+ action:
+ set_global_flags:
+ reactor_repaired: true
+ set_player_flags:
+ repaired_reactor: true
+```
+
+### Spawn Mob Config
+
+For transient mob spawning (in `spawn_mob:` fields), pass either a bare
+string (the mob ID) or a map with these fields:
+
+```yaml
+spawn_mob: rat
+```
+
+```yaml
+spawn_mob:
+ id: boss_add
+ owner_only: true
+ despawn_on_leave: true
+ despawn_rooms: [100, 101]
+ despawn_ticks: 30.0
+```
+
+| Field | Description |
+| ----------------- | ------------------------------------------------------------------------ |
+| `id` | Mob ID to spawn (required when a map) |
+| `owner_only` | If true, the spawn is private to the triggering player |
+| `despawn_on_leave`| Trigger-spawned mob fades after leaving the owner's room (grace window) |
+| `despawn_rooms` | List of rooms where the spawn survives without ticking the despawn timer |
+| `despawn_ticks` | Despawn countdown in ticks (float OK; rounded probabilistically via `engine.ToTicks`) |
+
---
diff --git a/building_guide/conditions.md b/building_guide/conditions.md
index acfdf57..2fb3d7a 100644
--- a/building_guide/conditions.md
+++ b/building_guide/conditions.md
@@ -1,8 +1,9 @@
## Conditions Reference
Conditions are used in talk option guards, talk node conditions, exit gates,
-on-enter scripts, use_interactions checks, room descriptions, object
-descriptions, and trigger value matching.
+on-enter scripts, on_use / on_look / on_kill interactions,
+exit on_traverse interactions, room descriptions, object descriptions, and
+trigger value matching.
### Simple conditions
diff --git a/building_guide/doors.md b/building_guide/doors.md
index 358001f..c987d0f 100644
--- a/building_guide/doors.md
+++ b/building_guide/doors.md
@@ -10,9 +10,9 @@ A button in room 3 opens a door in room 7. Anyone can press it. Once pressed, th
```yaml
name: stone button
hidden: true
-use_interactions:
+on_use:
- condition:
- flag: secret_door_open
+ global_flag: secret_door_open
not: true
message: "You press the stone button. You hear grinding stone in the distance."
action:
diff --git a/building_guide/hidden_objects.md b/building_guide/hidden_objects.md
index b1ed7b4..1866d67 100644
--- a/building_guide/hidden_objects.md
+++ b/building_guide/hidden_objects.md
@@ -9,9 +9,9 @@ Objects with `hidden: true` don't appear in the room's object listing. Players d
name: stone lever
hidden: true
description: "A cleverly concealed lever behind a loose stone."
-use_interactions:
+on_use:
- condition:
- flag: secret_passage_open
+ global_flag: secret_passage_open
value: true
not: true
message: "You pull the lever. A grinding sound echoes from the east."
diff --git a/building_guide/objects.md b/building_guide/objects.md
index a8877a7..db72cf3 100644
--- a/building_guide/objects.md
+++ b/building_guide/objects.md
@@ -49,8 +49,8 @@ objects:
description: |-
Conditional or multi-line descriptions work exactly like file objects.
on_look:
- set_player_flags:
- 1001_look_sign: true
+ - set_player_flags:
+ 1001_look_sign: true
```
- **Identity comes from `name`.** Internally the object's id is the name, normalized
@@ -69,7 +69,7 @@ objects:
- 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;
+ `removal_item`, `on_use`, craft stations) **must** be a standalone object file;
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).
@@ -137,14 +137,14 @@ Color accepts xterm-256 indices with optional modifiers (`bold`, `dim`, `underli
and gradients (`g:C4,52`). In ANSI mode, extended colors downgrade to the nearest
ANSI color.
-Object interaction (gate, lever — uses `use_interactions:` key):
+Object interaction (gate, lever — uses `on_use:` key):
```yaml
name: iron gate
hidden: true
description: "A heavy iron gate set into the north wall."
-use_interactions:
+on_use:
- condition:
- flag: gate_open
+ global_flag: gate_open
value: true
not: true
message: "You push the heavy iron gate open."
@@ -173,35 +173,40 @@ talk:
- text: "\"Goodbye.\""
```
-On-look action (runs when a player examines an object with `look <name>`):
+On-look interaction (runs when a player examines an object with `look <name>`):
```yaml
name: sign
aliases: [notice, board]
description: "A wooden signpost with faded writing."
on_look:
- set_player_flags:
+ - set_player_flags:
read_sign: true
```
-`on_look` fires the action AFTER showing the object's description. It uses the same
-`NodeAction` type as talk nodes — supports `set_player_flags`, `set_global_flags`,
-`give_item`, `take_item`, `teleport`, `heal`, `cost`, and everything else in the
-[node action reference](behaviors.md#node-action-reference).
+`on_look` is a list of interactions (same shape as `on_use`). The first entry
+whose `item_id` and `condition` pass wins, and fires AFTER the object's
+description is shown. An entry with an `item_id` only fires if the player
+currently carries that item in their inventory (empty `item_id` = always
+fires on look). It uses the same `interaction` shape as `on_use` — supports
+`condition`, `message`, and a full `action` (`set_global_flags`,
+`set_player_flags`, `give_item`, `take_item`, `teleport`, `heal`, `credits`,
+`aps_node`, `broadcast`, `broadcast_global`, `spawn_mob`, `despawn_mob`,
+`delay`), per the [interaction reference](behaviors.md#interaction-reference).
See the [Talk section of behaviors](behaviors.md#talk-dialog-trees) for the full
dialog tree format. See [Stealable Objects](#stealable-objects) for theft mechanics.
---
-## Use Interactions
-
-Objects can define `use_interactions` to handle when a player uses a specific item on the object. Each interaction can check conditions, show a message, and execute actions — using the same condition and action primitives as the talk system.
+## Interactions (On Use / On Look)
-Entries are checked top-to-bottom; the first match with a passing condition wins.
+Objects can define `on_use` and/or `on_look` to react to player actions. Both take a
+list of interactions (entries are checked top-to-bottom; the first whose
+`condition` passes wins — one fires per use/look).
Simple message (no action):
```yaml
name: anvil
-use_interactions:
+on_use:
- item_id: silver_bar
message: "You should use this with a mold at a furnace."
- item_id: gold_bar
@@ -211,10 +216,10 @@ use_interactions:
Puzzle interaction (take item, set flag):
```yaml
name: crystal slot
-use_interactions:
+on_use:
- item_id: crystal_key
condition:
- flag: crystal_inserted
+ global_flag: crystal_inserted
message: "The crystal key is already in the slot."
- item_id: crystal_key
message: "You insert the crystal key into the slot. It clicks into place."
@@ -226,7 +231,7 @@ use_interactions:
Quest item exchange:
```yaml
-use_interactions:
+on_use:
- item_id: ancient_scroll
condition:
player_flag: quest_started
@@ -239,37 +244,46 @@ use_interactions:
temple_door_open: true
```
-### UseInteraction Fields
-
-| Field | Type | Description |
-| ----------- | ---------- | ------------------------------------------------ |
-| `item_id` | string | Item ID that triggers this interaction |
-| `condition` | Condition | Optional condition (same as exits/talk/use_interactions) |
-| `message` | string | Message shown to the player |
-| `action` | NodeAction | Optional actions (same as talk node actions) |
-
-### Available Actions (same as talk)
-
-| Field | Type | Description |
-| ------------------ | -------------- | ---------------------------------- |
-| `set_global_flags` | map[string]any | Set global flags (shared) |
-| `set_player_flags` | map[string]any | Set player flags (per-character) |
-| `give_item` | string | Give an item to inventory |
-| `take_item` | string | Remove an item from inventory |
-| `teleport` | int | Move player to a room ID |
-| `heal` | int | Restore hitpoints |
-
-### Available Conditions (same as exits/talk)
-
-| Field | Description |
-| ------------- | -------------------------------- |
-| `global_flag` | Global flag check |
-| `player_flag` | Per-character flag check |
-| `has_item` | Inventory item check |
-| `value` | Expected value for flag checks |
-| `not` | Invert the condition |
-| `all_of` | All sub-conditions must pass |
-| `any_of` | Any sub-condition must pass |
+### Interaction Fields
+
+| Field | Type | Description |
+| ----------- | ---------- | --------------------------------------------------------------- |
+| `item_id` | string | `on_use`: required item id (empty = bare `use <object>`). `on_look`: only fires if you carry this item. (Mobs' `on_kill`: only fires if you wield this weapon in main_hand or off_hand.) |
+| `condition` | Condition | Optional condition (same as exits/talk/on_enter/triggers) |
+| `message` | string | Message shown to the player when this entry fires |
+| `action` | StepAction | Optional actions (see [interaction reference](behaviors.md#interaction-reference)) |
+
+### Available Actions (same superset as on_enter steps/triggers/talk)
+
+| Field | Type | Description |
+| ------------------ | -------------- | ----------------------------------------------------- |
+| `set_global_flags` | map[string]any | Set global flags (shared by all players) |
+| `set_player_flags` | map[string]any | Set player flags (per-character, saved to YAML) |
+| `give_item` | string | Give an item to inventory (1 unit) |
+| `take_item` | string | Remove an item from inventory (1 unit) |
+| `teleport` | int | Move player to a room ID |
+| `heal` | int | Restore hitpoints (clamped to MaxHP) |
+| `credits` | int | Add (positive) or deduct (negative) credits |
+| `aps_node` | bool | Mark current room as a discovered APS node |
+| `broadcast` | string | Announce to all in the room (uses `%p`, `%v`) |
+| `broadcast_global` | string | Announce to all online (uses `%p`, `%v`) |
+| `spawn_mob` | string/map | Spawn a transient mob (`id` or full [config](behaviors.md#spawn-mob-config)) |
+| `despawn_mob` | string | Despawn all transient mobs of this id |
+| `message` | string | Direct message to the player (also valid here) |
+| `delay` | int | Ticks to wait before this step (only meaningful in on_enter/triggers) |
+
+### Available Conditions (same as exits/talk/on_enter/triggers)
+
+| Field | Description |
+| ------------- | ------------------------------------------------------- |
+| `global_flag` | Global flag check |
+| `player_flag` | Per-character flag check |
+| `has_item` | Inventory item check |
+| `min_credits` | Minimum credits |
+| `value` | Expected value for flag checks |
+| `not` | Invert the condition |
+| `all_of` | All sub-conditions must pass |
+| `any_of` | Any sub-condition must pass |
---
diff --git a/building_guide/triggers.md b/building_guide/triggers.md
index 0d227bd..69cd3d1 100644
--- a/building_guide/triggers.md
+++ b/building_guide/triggers.md
@@ -39,13 +39,13 @@ and the object are cleanly separated. The sign is a local object in room `1001`:
objects:
- name: sign
on_look:
- set_player_flags:
- 1001_look_sign: true
+ - set_player_flags:
+ 1001_look_sign: true
```
This separation is deliberate. The object sets flags. The trigger watches flags.
-Any system that sets a player flag (`on_look`, talk nodes, on-enter steps, exits,
-use interactions) can activate a trigger watching that flag.
+Any system that sets a player flag (`on_look`, `on_use`, `on_kill`, talk nodes,
+on-enter steps, exit `on_traverse`) can activate a trigger watching that flag.
---
@@ -396,8 +396,8 @@ advances a numeric flag through stages. The last stage spawns a boss.
# data/objects/unique/5001_body.yaml
name: body
on_look:
- set_player_flags:
- investigation: 1
+ - set_player_flags:
+ investigation: 1
```
**Step 2: Blood trail in room 5002** — room trigger fires on value 1
@@ -504,12 +504,12 @@ All of these paths activate triggers:
| Source | Example |
|---|---|
-| Object `on_look` | `look sign` sets `1001_look_sign: true` |
+| Object `on_look` / `on_use` | `look sign` sets `1001_look_sign: true` |
+| Mob `on_kill` | Killing a boss sets `boss_slain: true` |
| Talk node actions | NPC sets `quest_started: true` after accepting |
| Talk option actions | Player selects a choice that sets a flag |
| On-enter steps | Room entry sets `1001_welcome: true` |
-| Exit traversal | Walking through an exit sets `boarded_shuttle: true` |
-| Use interactions | `push button` sets `gate_open: true` |
+| Exit `on_traverse` | Walking through an exit sets `boarded_shuttle: true` |
| Trigger steps | One trigger chain-sets a flag for another trigger |
---
diff --git a/data/help/use.yaml b/data/help/use.yaml
index 8139f8e..d9bcfd4 100644
--- a/data/help/use.yaml
+++ b/data/help/use.yaml
@@ -13,7 +13,7 @@ description: |
Station object (furnace, anvil, fire, range, workbench, etc.)
Shows a combined production menu of all recipes you can make.
- Object with use_interactions
+ Object with on_use interactions
Shows a message or triggers an action (flags, teleport, etc.).
Some interactions need a specific item; bare interactions run
immediately.
diff --git a/data/mobs/examples/client.yaml b/data/mobs/examples/client.yaml
index 2442a8a..da3aede 100644
--- a/data/mobs/examples/client.yaml
+++ b/data/mobs/examples/client.yaml
@@ -12,13 +12,13 @@ idle_descriptions:
talk:
nodes:
assign_task:
- action:
+ action: null
messages:
- '"Let me check what''s available..." The Client scrolls through their data pad.'
options:
- - text: '"Understood."'
- - goto: start
- text: '"What else do you have?"'
+ - text: '"Understood."'
+ - goto: start
+ text: '"What else do you have?"'
buy_acid:
action:
credits: -10
@@ -26,42 +26,12 @@ talk:
messages:
- '"Handle with care." The Client passes you a vial of corrosive acid.'
options:
- - condition:
- min_credits: 10
- goto: buy_acid
- text: '"Buy more."'
- - goto: supplies
- text: '"Thanks."'
- buy_auto_acid:
- action:
- reputation_cost: 200
- set_player_flags:
- assassin_unlocked_auto_acid_vial: true
- messages:
- - '"Auto-acid purchased. Acid vials will no longer be consumed."'
- options:
- - goto: rep_shop
- text: '"Thanks."'
- buy_auto_salt:
- action:
- reputation_cost: 200
- set_player_flags:
- assassin_unlocked_auto_salt: true
- messages:
- - '"Auto-salt purchased. You''ll no longer consume salt on finishing blows."'
- options:
- - goto: rep_shop
- text: '"Thanks."'
- buy_extend_unlock:
- action:
- reputation_cost: 100
- set_player_flags:
- assassin_unlocked_extend: true
- messages:
- - '"You can now extend tasks via our conversation."'
- options:
- - goto: rep_shop
- text: '"Thanks."'
+ - condition:
+ min_credits: 10
+ goto: buy_acid
+ text: '"Buy more."'
+ - goto: supplies
+ text: '"Thanks."'
buy_gloves:
action:
credits: -50
@@ -69,8 +39,8 @@ talk:
messages:
- '"These''ll keep the current from frying your hands." The Client tosses you a pair of thick rubber gloves.'
options:
- - goto: supplies
- text: '"Thanks."'
+ - goto: supplies
+ text: '"Thanks."'
buy_salt:
action:
credits: -5
@@ -78,22 +48,12 @@ talk:
messages:
- '"Here you go." The Client slides a packet of salt across the table.'
options:
- - condition:
- min_credits: 5
- goto: buy_salt
- text: '"Buy more."'
- - goto: supplies
- text: '"Thanks."'
- buy_superiors:
- action:
- reputation_cost: 300
- set_player_flags:
- assassin_unlocked_superiors: true
- messages:
- - '"Superior encounters unlocked. Watch yourself out there."'
- options:
- - goto: rep_shop
- text: '"Thanks."'
+ - condition:
+ min_credits: 5
+ goto: buy_salt
+ text: '"Buy more."'
+ - goto: supplies
+ text: '"Thanks."'
buy_visor:
action:
credits: -75
@@ -101,120 +61,92 @@ talk:
messages:
- '"Spectral frequency filter. Makes the invisible visible — and keeps their attacks from scrambling your brain."'
options:
- - goto: supplies
- text: '"Thanks."'
+ - goto: supplies
+ text: '"Thanks."'
current_task:
messages:
- '"Let me check your file." The Client pulls up your record.'
options:
- - text: '"Okay."'
+ - text: '"Okay."'
extend_confirm:
messages:
- '"Extending your task costs 30 Reputation and adds more kills. Want to proceed?"'
options:
- - goto: extend_done
- text: '"Yes, extend it."'
- - goto: start
- text: '"Never mind."'
+ - goto: extend_done
+ text: '"Yes, extend it."'
+ - goto: start
+ text: '"Never mind."'
extend_done:
- action:
+ action: null
messages:
- '"Done. I''ve added more targets to your contract."'
options:
- - text: '"Thanks."'
- rep_shop:
- messages:
- - '"Here''s what I''ve got. All purchases are permanent."'
- options:
- - condition:
- not: true
- player_flag: assassin_unlocked_auto_salt
- goto: buy_auto_salt
- text: '"Auto-finish: Salt (200 Rep) - Never consume salt on finishing blows."'
- - condition:
- not: true
- player_flag: assassin_unlocked_auto_acid_vial
- goto: buy_auto_acid
- text: '"Auto-finish: Acid Vial (200 Rep) - Never consume acid vials on finishing blows."'
- - condition:
- not: true
- player_flag: assassin_unlocked_superiors
- goto: buy_superiors
- text: '"Unlock Superior Mobs (300 Rep) - Rare superior variants may spawn."'
- - condition:
- not: true
- player_flag: assassin_unlocked_extend
- goto: buy_extend_unlock
- text: '"Unlock Extended Tasks (100 Rep) - Allows extending tasks."'
- - goto: start
- text: '"Back."'
+ - text: '"Thanks."'
skip_confirm:
messages:
- '"Skipping a task costs 30 Reputation and resets your streak. Are you sure?"'
options:
- - goto: skip_done
- text: '"Yes, skip it."'
- - goto: start
- text: '"Never mind."'
+ - goto: skip_done
+ text: '"Yes, skip it."'
+ - goto: start
+ text: '"Never mind."'
skip_done:
- action:
+ action: null
messages:
- '"Task cancelled. Your streak has been reset."'
options:
- - goto: assign_task
- text: '"Give me a new one."'
- - text: '"Goodbye."'
+ - goto: assign_task
+ text: '"Give me a new one."'
+ - text: '"Goodbye."'
start:
messages:
- The Client looks up from their data pad. "What do you need?"
options:
- - condition:
- not: true
- player_flag: assassin_task_mob
- goto: assign_task
- text: '"I need a job."'
- - condition:
- not: false
- player_flag: assassin_task_mob
- goto: current_task
- text: '"What''s my current task?"'
- - condition:
- not: false
- player_flag: assassin_task_mob
- goto: skip_confirm
- text: '"I want to skip my task."'
- - condition:
- all_of:
- - not: false
- player_flag: assassin_task_mob
- - player_flag: assassin_unlocked_extend
- value: true
- goto: extend_confirm
- text: '"I want to extend my task."'
- - goto: rep_shop
- text: '"I''d like to browse the Reputation Shop."'
- - goto: supplies
- text: '"I need supplies."'
- - text: '"Goodbye."'
+ - condition:
+ not: true
+ player_flag: assassin_task_mob
+ goto: assign_task
+ text: '"I need a job."'
+ - condition:
+ not: false
+ player_flag: assassin_task_mob
+ goto: current_task
+ text: '"What''s my current task?"'
+ - condition:
+ not: false
+ player_flag: assassin_task_mob
+ goto: skip_confirm
+ text: '"I want to skip my task."'
+ - condition:
+ all_of:
+ - not: false
+ player_flag: assassin_task_mob
+ - player_flag: assassin_unlocked_extend
+ value: true
+ goto: extend_confirm
+ text: '"I want to extend my task."'
+ - goto: supplies
+ text: '"I need supplies."'
+ - text: '"Goodbye."'
supplies:
messages:
- '"I stock everything you need for the job. Cheap, too."'
options:
- - condition:
- min_credits: 5
- goto: buy_salt
- text: '"Buy salt (5 credits each)."'
- - condition:
- min_credits: 10
- goto: buy_acid
- text: '"Buy acid vial (10 credits each)."'
- - condition:
- min_credits: 50
- goto: buy_gloves
- text: '"Buy insulated gloves (50 credits)."'
- - condition:
- min_credits: 75
- goto: buy_visor
- text: '"Buy spectral visor (75 credits)."'
- - goto: start
- text: '"Back."'
+ - condition:
+ min_credits: 5
+ goto: buy_salt
+ text: '"Buy salt (5 credits each)."'
+ - condition:
+ min_credits: 10
+ goto: buy_acid
+ text: '"Buy acid vial (10 credits each)."'
+ - condition:
+ min_credits: 50
+ goto: buy_gloves
+ text: '"Buy insulated gloves (50 credits)."'
+ - condition:
+ min_credits: 75
+ goto: buy_visor
+ text: '"Buy spectral visor (75 credits)."'
+ - goto: start
+ text: '"Back."'
diff --git a/data/objects/anvil.yaml b/data/objects/anvil.yaml
index b7b50bf..4737573 100644
--- a/data/objects/anvil.yaml
+++ b/data/objects/anvil.yaml
@@ -1,7 +1,7 @@
name: anvil
color: "FA"
description: "A heavy iron anvil for hammering metal into shape."
-use_interactions:
+on_use:
- item_id: silver_bar
message: "You should use this with a mold at a furnace."
- item_id: gold_bar
diff --git a/data/objects/aps_tower.yaml b/data/objects/aps_tower.yaml
index 5d18b4d..68b34fc 100644
--- a/data/objects/aps_tower.yaml
+++ b/data/objects/aps_tower.yaml
@@ -2,7 +2,7 @@ name: APS Tower
color: "21"
description: "A tall, skeletal metal tower with a pulsing cyan beacon at its apex. A datapad sync panel glows softly at the base."
inroom_description: "An APS Tower looms here, its beacon pulsing steadily."
-use_interactions:
+on_use:
- message: "You connect your datapad to the APS Tower. Coordinates triangulated and stored."
action:
aps_node: true
diff --git a/data/objects/iron_gate.yaml b/data/objects/iron_gate.yaml
index 44599ae..b5c7de6 100644
--- a/data/objects/iron_gate.yaml
+++ b/data/objects/iron_gate.yaml
@@ -2,7 +2,7 @@ color: "FA"
description: A heavy iron gate. It looks like it can be pushed open.
hidden: true
name: iron gate
-use_interactions:
+on_use:
- condition:
global_flag: gate_open
not: true
diff --git a/data/objects/tutorial_lever.yaml b/data/objects/tutorial_lever.yaml
index ffcfa07..73166bd 100644
--- a/data/objects/tutorial_lever.yaml
+++ b/data/objects/tutorial_lever.yaml
@@ -3,7 +3,7 @@ color: "FA"
hidden: true
inroom_description: "A stone lever protrudes from the base of the rock formation."
description: "A weathered stone lever set into a metal bracket. It looks like it can be pulled."
-use_interactions:
+on_use:
- condition:
player_flag: unlocked_rock_cover
value: 1
diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml
index 93bf7ac..8960ea5 100644
--- a/data/rooms/intro/1001.yaml
+++ b/data/rooms/intro/1001.yaml
@@ -32,15 +32,8 @@ objects:
hidden: true
name: framed sign
on_look:
- credits: 0
- give_item: ""
- heal: 0
- reputation_cost: 0
- set_global_flags: {}
- set_player_flags:
- 1001_look_sign: true
- take_item: ""
- teleport: 0
+ - set_player_flags:
+ 1001_look_sign: true
- description:
- text: A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level.
hidden: true
diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js
index 5e0c7cb..a11388b 100644
--- a/internal/admin/static/cardeditor.js
+++ b/internal/admin/static/cardeditor.js
@@ -10,7 +10,7 @@ function ced_cardPath(sec) {
function ced_getVal(data, sec, key) {
var p = ced_cardPath(sec);
- if (sec.isArray || p === 'on_look') {
+ if (sec.isArray) {
return data[p] && data[p][key] !== undefined ? data[p][key] : '';
}
var fp = p ? p + '.' + key : key;
diff --git a/internal/admin/static/itemeditor.js b/internal/admin/static/itemeditor.js
index ed32267..a86d240 100644
--- a/internal/admin/static/itemeditor.js
+++ b/internal/admin/static/itemeditor.js
@@ -602,7 +602,7 @@ function renderArraySection(sec, data) {
h += '<button class="btn btn-sm btn-danger obj-sub-remove" onclick="removeArrayItem(\'' + sec.id + '\',' + idx + ')">X</button>';
h += '</div>';
});
- h += '<button class="btn btn-sm obj-sub-add" onclick="addArrayItem(\'' + sec.id + '\')">+ Add ' + esc(sec.label).slice(0,-1) + '</button>';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addArrayItem(\'' + sec.id + '\')">+ Add ' + esc(sec.label) + '</button>';
return h;
}
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index b5d2395..7f8c0e3 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -120,6 +120,16 @@ var MOB_SECTIONS = [
]}
]
},
+ {
+ id: 'on_kill', label: 'On Kill', labelHint: '(fired when this mob is defeated or its task completes — additive over drops)', path: 'on_kill', isArray: true, fullWidth: true, interactionStyle: true,
+ detect: function(d) { return d.on_kill && Array.isArray(d.on_kill); },
+ fields: [
+ ['item_id', 'Item ID', 'search', '(optional — only fires if you wield this weapon when the mob is defeated)', '', null, 'items'],
+ ['condition', 'Condition', 'json', 'e.g. {"global_flag":"quest_active"}'],
+ ['action', 'Action', 'json', 'e.g. {"set_global_flags":{"boss_killed":true},"broadcast":"%p has slain the boss!","spawn_mob":"mini_boss"}'],
+ ['message', 'Message', 'text', 'Shown to the killer when this fires...'],
+ ]
+ },
];
function mobCardPath(sec) { return ced_cardPath(sec); }
@@ -236,6 +246,8 @@ function renderMobCard(sec, data) {
h += renderMobCoreCard(data);
} else if (sec.id === 'combat') {
h += renderMobCombatCard(data);
+ } else if (sec.isArray) {
+ h += renderMobArraySection(sec, data);
} else if (sec.subtables) {
h += '<div class="obj-card-grid">';
sec.fields.forEach(function(f) {
@@ -423,6 +435,135 @@ function renderMobField(sec, f, data, idx, subPath, optStyle) {
return ced_renderField(sec, f, data, idx, subPath, optStyle, mobCardPath, mobFieldID, mobGetVal);
}
+// ---- Interaction-style array rendering (On Kill) ----
+
+function renderMobArraySection(sec, data) {
+ var arr = data[mobCardPath(sec)] || [];
+ var h = '';
+ var isInter = !!sec.interactionStyle;
+ if (isInter) {
+ window._mobInterVis = window._mobInterVis || {};
+ if (!window._mobInterVis[sec.id]) window._mobInterVis[sec.id] = {};
+ }
+ arr.forEach(function(item, idx) {
+ var visMap = window._mobInterVis[sec.id];
+ var vis = visMap[idx] || {};
+ visMap[idx] = vis;
+ var showItem = vis.item_id || (item.item_id && item.item_id !== '');
+ var showCond = vis.condition || (item.condition && item.condition !== '');
+ var showAct = vis.action || (item.action && item.action !== '');
+ var showMsg = vis.message || (item.message && item.message !== '');
+
+ h += '<div class="obj-sub-row" data-idx="' + idx + '" style="flex-direction:column;align-items:stretch;position:relative;gap:6px">';
+ h += '<button class="btn btn-sm btn-danger" style="position:absolute;top:0;right:0;white-space:nowrap;z-index:1" onclick="mobRemoveArrayItem(\'' + sec.id + '\',' + idx + ')">Remove Interaction</button>';
+ h += '<div style="display:flex;gap:4px;align-items:flex-end;flex-wrap:wrap;padding-right:135px">';
+ if (!showItem) {
+ h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="mobShowInterField(\'' + sec.id + '\',' + idx + ',\'item_id\')">+ Add Required Item</button>';
+ h += '<div style="width:1px;background:var(--border);align-self:stretch;margin:2px 0;flex-shrink:0"></div>';
+ }
+ if (!showCond) {
+ h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="mobShowInterField(\'' + sec.id + '\',' + idx + ',\'condition\')">+ Add Condition</button>';
+ }
+ if (!showAct) {
+ h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="mobShowInterField(\'' + sec.id + '\',' + idx + ',\'action\')">+ Add Action</button>';
+ }
+ if (!showMsg) {
+ h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="mobShowInterField(\'' + sec.id + '\',' + idx + ',\'message\')">+ Add Message</button>';
+ }
+ h += '</div>';
+ h += '<div style="display:' + (showItem ? 'flex' : 'none') + ';gap:6px;align-items:flex-start">';
+ h += renderMobField(sec, sec.fields[0], data, idx, null, 'flex:1;min-width:0');
+ h += '<button class="btn btn-sm btn-danger" onclick="mobHideInterField(\'' + sec.id + '\',' + idx + ',\'item_id\')" style="margin-top:14px">X</button>';
+ h += '</div>';
+ h += '<div style="display:' + (showCond ? 'flex' : 'none') + ';gap:6px;align-items:flex-start">';
+ h += renderMobField(sec, sec.fields[1], data, idx, null, 'flex:1;min-width:0');
+ h += '<button class="btn btn-sm btn-danger" onclick="mobHideInterField(\'' + sec.id + '\',' + idx + ',\'condition\')" style="margin-top:14px">X</button>';
+ h += '</div>';
+ h += '<div style="display:' + (showAct ? 'flex' : 'none') + ';gap:6px;align-items:flex-start">';
+ h += renderMobField(sec, sec.fields[2], data, idx, null, 'flex:1;min-width:0');
+ h += '<button class="btn btn-sm btn-danger" onclick="mobHideInterField(\'' + sec.id + '\',' + idx + ',\'action\')" style="margin-top:14px">X</button>';
+ h += '</div>';
+ h += '<div style="display:' + (showMsg ? 'flex' : 'none') + ';gap:6px;align-items:flex-start">';
+ h += renderMobField(sec, sec.fields[3], data, idx, null, 'flex:1;min-width:0');
+ h += '<button class="btn btn-sm btn-danger" onclick="mobHideInterField(\'' + sec.id + '\',' + idx + ',\'message\')" style="margin-top:14px">X</button>';
+ h += '</div>';
+ h += '</div>';
+ });
+ h += '<button class="btn btn-sm obj-sub-add" onclick="mobAddArrayItem(\'' + sec.id + '\')">+ Add ' + esc(sec.label) + '</button>';
+ if (isInter) {
+ var helpKey = 'mob_' + sec.id + '_helpCollapsed';
+ var helpCollapsed = window[helpKey] === undefined ? true : window[helpKey];
+ h += '<div style="margin-top:8px;border:1px solid rgba(100,160,255,.15);border-radius:4px;overflow:hidden">';
+ h += '<div style="padding:6px 10px;background:rgba(100,160,255,.08);cursor:pointer;font-size:10px;color:#6af;font-weight:bold;text-transform:uppercase;letter-spacing:.5px" onclick="mobToggleInterHelp(\'' + sec.id + '\')">';
+ h += '<span style="font-size:8px;margin-right:4px">' + (helpCollapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += 'How Conditions &amp; Actions Work';
+ h += '</div>';
+ if (!helpCollapsed) {
+ h += '<div style="padding:8px;background:rgba(100,160,255,.05);font-size:10px;color:#aaa;line-height:1.6">';
+ h += 'Entries checked top-to-bottom, first match wins (one fires per kill; additive to standard loot).<br><br>';
+ h += '<b>Item ID</b>: only fires if you wield this weapon (in main_hand or off_hand) when the mob is defeated. Leave empty to fire unconditionally.<br>';
+ h += '<b>Condition</b>: JSON gate. <code>{"global_flag":"boss_quest"}</code>, <code>{"player_flag":"step","value":2}</code>, <code>{"has_item":"trophy"}</code>, <code>{"all_of":[...]}</code>, <code>{"any_of":[...]}</code>.<br>';
+ h += '<b>Action</b>: JSON effects. <code>{"set_global_flags":{...}}</code>, <code>{"set_player_flags":{...}}</code>, <code>{"give_item":"gem"}</code>, <code>{"take_item":"key"}</code>, <code>{"teleport":5}</code>, <code>{"heal":10}</code>, <code>{"credits":100}</code>, <code>{"aps_node":true}</code>, <code>{"spawn_mob":"mini_boss"}</code>, <code>{"despawn_mob":"boss"}</code>, <code>{"broadcast":"%p vanquished the boss!"}</code>, <code>{"broadcast_global":"..."}</code>.<br>';
+ h += '<b>Message</b>: shown to the killer when this interaction fires.';
+ h += '</div>';
+ }
+ h += '</div>';
+ }
+ return h;
+}
+
+function mobAddArrayItem(cardID) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ var arr = mobData[sec.path] || [];
+ var empty = {};
+ sec.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ arr.push(empty);
+ mobData[sec.path] = arr;
+ renderCurrent();
+}
+
+function mobRemoveArrayItem(cardID, idx) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec || !sec.isArray) return;
+ ced_syncDOMToData(mobData);
+ var arr = mobData[sec.path] || [];
+ arr.splice(idx, 1);
+ renderMobEditor(mobID, mobData);
+}
+
+function mobShowInterField(secId, idx, key) {
+ window._mobInterVis = window._mobInterVis || {};
+ if (!window._mobInterVis[secId]) window._mobInterVis[secId] = {};
+ if (!window._mobInterVis[secId][idx]) window._mobInterVis[secId][idx] = {};
+ window._mobInterVis[secId][idx][key] = true;
+ renderCurrent();
+}
+
+function mobHideInterField(secId, idx, key) {
+ window._mobInterVis = window._mobInterVis || {};
+ if (!window._mobInterVis[secId]) window._mobInterVis[secId] = {};
+ if (!window._mobInterVis[secId][idx]) window._mobInterVis[secId][idx] = {};
+ window._mobInterVis[secId][idx][key] = false;
+ ced_syncDOMToData(mobData);
+ if (mobData[secId] && mobData[secId][idx]) {
+ mobData[secId][idx][key] = '';
+ }
+ renderMobEditor(mobID, mobData);
+}
+
+function mobToggleInterHelp(secId) {
+ var key = 'mob_' + secId + '_helpCollapsed';
+ if (window[key] === undefined) {
+ window[key] = false;
+ } else {
+ window[key] = !window[key];
+ }
+ renderCurrent();
+}
+
// ---- Subtable rendering ----
function renderMobSubField(sec, stKey, idx, f, val) {
@@ -602,7 +743,9 @@ function mobAddSection() {
if (!sec) return;
if (sec.path) {
- if (id === 'combat') {
+ if (sec.isArray) {
+ mobData[sec.path] = [];
+ } else if (id === 'combat') {
mobData[sec.path] = {
kind: 'combat',
aggressive: false,
@@ -694,6 +837,24 @@ function saveMob() {
return;
}
+ if (sec.isArray) {
+ var arr = [];
+ var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]');
+ if (!card) return;
+ var rows = card.querySelectorAll('.obj-sub-row');
+ rows.forEach(function(row, idx) {
+ var item = {};
+ sec.fields.forEach(function(f) {
+ var fid = mobFieldID(sec, f[0], idx);
+ var el = document.getElementById(fid);
+ if (el) item[f[0]] = ced_collectFieldValue(el, f[2]);
+ });
+ if (Object.keys(item).length > 0) arr.push(item);
+ });
+ if (arr.length > 0) out[sec.path] = arr;
+ return;
+ }
+
var sectionObj = {};
var hasValues = sec.always;
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js
index 6abec3e..ecb3ced 100644
--- a/internal/admin/static/objecteditor.js
+++ b/internal/admin/static/objecteditor.js
@@ -86,26 +86,23 @@ var SECTIONS = [
]
},
{
- id: 'use_interactions', label: 'Use Interactions', labelHint: '(what happens when you use items on this)', path: 'use_interactions', isArray: true, fullWidth: true,
- detect: function(d) { return d.use_interactions && Array.isArray(d.use_interactions); },
+ id: 'on_use', label: 'On Use', labelHint: '(what happens when you use items on this)', path: 'on_use', isArray: true, fullWidth: true, interactionStyle: true,
+ detect: function(d) { return d.on_use && Array.isArray(d.on_use); },
fields: [
- ['item_id', 'Item ID', 'search', 'e.g. keycard', '', null, 'items'],
+ ['item_id', 'Item ID', 'search', 'e.g. keycard (empty = bare use)', '', null, 'items'],
['condition', 'Condition', 'json', 'e.g. {"flag":"my_flag"} or {"player_flag":"x","value":1} or {"all_of":[{"flag":"a"},{"has_item":"key"}]}'],
- ['action', 'Action', 'json', 'e.g. {"set_flags":{"my_flag":true},"message":"You activate it.","give_item":"gem"}'],
+ ['action', 'Action', 'json', 'e.g. {"set_global_flags":{"my_flag":true},"message":"You activate it.","give_item":"gem","teleport":5,"heal":10,"credits":100,"aps_node":true,"spawn_mob":"rat","despawn_mob":"rat","broadcast":"%p activates it.","broadcast_global":"%p did it!","delay":3}'],
['message', 'Message', 'text', 'You use the object...'],
]
},
{
- id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look <this>")', path: 'on_look',
- detect: function(d) { return d.on_look; },
+ id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look <this>")', path: 'on_look', isArray: true, fullWidth: true, interactionStyle: true,
+ detect: function(d) { return d.on_look && Array.isArray(d.on_look); },
fields: [
- ['set_flags', 'Set Global Flags', 'json', '{"flag":"value"}'],
- ['set_player_flags', 'Set Player Flags', 'json', '{"pflag":"value"}'],
- ['give_item', 'Give Item', 'search', 'e.g. keycard', '', null, 'items'],
- ['take_item', 'Take Item', 'search', 'e.g. bomb', '', null, 'items'],
- ['teleport', 'Teleport', 'number', '5', 'narrow'],
- ['heal', 'Heal', 'number', '10', 'narrow'],
- ['credits', 'Credits', 'number', '100', 'narrow'],
+ ['item_id', 'Item ID', 'search', '(optional — only fires if you carry this item)', '', null, 'items'],
+ ['condition', 'Condition', 'json', 'e.g. {"player_flag":"read_sign","value":true}'],
+ ['action', 'Action', 'json', 'e.g. {"set_player_flags":{"read_sign":true},"give_item":"gem"}'],
+ ['message', 'Message', 'text', 'Shown to the player after the description...'],
]
},
];
@@ -191,7 +188,7 @@ function cardPath(sec) {
function getVal(data, sec, key) {
var p = cardPath(sec);
- if (p === 'on_look' || sec.isArray) {
+ if (sec.isArray) {
return data[p] && data[p][key] !== undefined ? data[p][key] : '';
}
var fp = p ? p + '.' + key : key;
@@ -234,8 +231,6 @@ function renderCard(sec, data) {
h += renderCoreCard(data);
} else if (sec.id === 'steal') {
h += renderStealCard(data);
- } else if (sec.id === 'on_look') {
- h += renderOnLookCard(data);
} else {
h += '<div class="obj-card-grid">';
sec.fields.forEach(function(f) {
@@ -507,56 +502,20 @@ function renderStealCard(data) {
return h;
}
-function renderOnLookCard(data) {
- var sec = SECTIONS.find(function(s) { return s.id === 'on_look'; });
- var h = '<div style="display:flex;gap:10px">';
- h += renderField(sec, sec.fields[0], data, -1, null, 'flex:1');
- h += renderField(sec, sec.fields[1], data, -1, null, 'flex:1');
- h += '</div>';
- h += '<div style="display:flex;gap:10px;margin-top:8px">';
- h += renderField(sec, sec.fields[2], data, -1, null, 'flex:1');
- h += renderField(sec, sec.fields[3], data, -1, null, 'flex:1');
- h += '</div>';
- h += '<div style="display:flex;gap:10px;margin-top:8px">';
- h += renderField(sec, sec.fields[4], data, -1);
- h += renderField(sec, sec.fields[5], data, -1);
- h += renderField(sec, sec.fields[6], data, -1);
- h += '</div>';
- var helpCollapsed = window._onLookHelpCollapsed === undefined ? true : window._onLookHelpCollapsed;
- h += '<div style="margin-top:8px;border:1px solid rgba(100,160,255,.15);border-radius:4px;overflow:hidden">';
- h += '<div style="padding:6px 10px;background:rgba(100,160,255,.08);cursor:pointer;font-size:10px;color:#6af;font-weight:bold;text-transform:uppercase;letter-spacing:.5px" onclick="toggleOnLookHelp()">';
- h += '<span style="font-size:8px;margin-right:4px">' + (helpCollapsed ? '&#9654;' : '&#9660;') + '</span>';
- h += 'How Set Global Flags &amp; Set Player Flags Work';
- h += '</div>';
- if (!helpCollapsed) {
- h += '<div style="padding:8px;background:rgba(100,160,255,.05);font-size:10px;color:#aaa;line-height:1.6">';
- h += 'When a player looks at this object, these fields apply effects.<br><br>';
- h += '<b>Set Global Flags</b> &mdash; Sets world flags shared by all players. JSON object mapping flag names to values:<br>';
- h += '&nbsp;&nbsp;<code>{"gate_open":true,"boss_summoned":false}</code><br>';
- h += '&nbsp;&nbsp;Flags persist until changed by another on_look, room script, or interact action.<br><br>';
- h += '<b>Set Player Flags</b> &mdash; Sets per-character flags saved to the player\'s YAML. JSON object mapping flag names to values:<br>';
- h += '&nbsp;&nbsp;<code>{"has_seen_cave":true,"dialog_spoke":1}</code><br>';
- h += '&nbsp;&nbsp;These flags can be used in conditions (<code>player_flag</code>) to track player progress.<br><br>';
- h += '<b>Other Fields</b> &mdash; <code>Give Item</code> grants an item, <code>Take Item</code> removes one, ';
- h += '<code>Teleport</code> sends player to a room, <code>Heal</code> restores HP, <code>Credits</code> adds/removes credits (use negative for cost).<br><br>';
- h += 'All effects fire simultaneously when the player looks at this object.';
- h += '</div>';
- }
- h += '</div>';
- return h;
-}
-
function renderArraySection(sec, data) {
var arr = data[cardPath(sec)] || [];
var h = '';
- var isUseInter = sec.id === 'use_interactions';
- if (isUseInter) {
- window._useInterVis = window._useInterVis || {};
+ var isInter = !!sec.interactionStyle;
+ if (isInter) {
+ window._interVis = window._interVis || {};
+ if (!window._interVis[sec.id]) window._interVis[sec.id] = {};
}
arr.forEach(function(item, idx) {
- if (isUseInter) {
- var vis = window._useInterVis[idx] || {};
- window._useInterVis[idx] = vis;
+ if (isInter) {
+ var visMap = window._interVis[sec.id];
+ var vis = visMap[idx] || {};
+ visMap[idx] = vis;
+ var showItem = vis.item_id || (item.item_id && item.item_id !== '');
var showCond = vis.condition || (item.condition && item.condition !== '');
var showAct = vis.action || (item.action && item.action !== '');
var showMsg = vis.message || (item.message && item.message !== '');
@@ -566,32 +525,39 @@ function renderArraySection(sec, data) {
h += '<button class="btn btn-sm btn-danger" style="position:absolute;top:0;right:0;white-space:nowrap;z-index:1" onclick="removeArrayItem(\'' + sec.id + '\',' + idx + ')">Remove Interaction</button>';
h += '<div style="display:flex;gap:4px;align-items:flex-end;flex-wrap:wrap;padding-right:125px">';
- h += renderField(sec, sec.fields[0], data, idx, null, 'max-width:260px');
- h += '<div style="width:1px;background:var(--border);align-self:stretch;margin:2px 0;flex-shrink:0"></div>';
+ if (!showItem) {
+ h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="showInterField(\'' + sec.id + '\',' + idx + ',\'item_id\')">+ Add Required Item</button>';
+ h += '<div style="width:1px;background:var(--border);align-self:stretch;margin:2px 0;flex-shrink:0"></div>';
+ }
if (!showCond) {
- h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="showUseInterField(' + idx + ',\'condition\')">+ Add Condition</button>';
+ h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="showInterField(\'' + sec.id + '\',' + idx + ',\'condition\')">+ Add Condition</button>';
}
if (!showAct) {
- h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="showUseInterField(' + idx + ',\'action\')">+ Add Action</button>';
+ h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="showInterField(\'' + sec.id + '\',' + idx + ',\'action\')">+ Add Action</button>';
}
if (!showMsg) {
- h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="showUseInterField(' + idx + ',\'message\')">+ Add Message</button>';
+ h += '<button class="btn btn-sm" style="background:var(--accent);color:var(--text);border:1px solid #1a5a8e;align-self:flex-end;margin-bottom:3px;white-space:nowrap" onclick="showInterField(\'' + sec.id + '\',' + idx + ',\'message\')">+ Add Message</button>';
}
h += '</div>';
+ h += '<div style="display:' + (showItem ? 'flex' : 'none') + ';gap:6px;align-items:flex-start">';
+ h += renderField(sec, sec.fields[0], data, idx, null, 'flex:1;min-width:0');
+ h += '<button class="btn btn-sm btn-danger" onclick="hideInterField(\'' + sec.id + '\',' + idx + ',\'item_id\')" style="margin-top:14px">X</button>';
+ h += '</div>';
+
h += '<div style="display:' + (showCond ? 'flex' : 'none') + ';gap:6px;align-items:flex-start">';
h += renderField(sec, sec.fields[1], data, idx, null, 'flex:1;min-width:0');
- h += '<button class="btn btn-sm btn-danger" onclick="hideUseInterField(' + idx + ',\'condition\')" style="margin-top:14px">X</button>';
+ h += '<button class="btn btn-sm btn-danger" onclick="hideInterField(\'' + sec.id + '\',' + idx + ',\'condition\')" style="margin-top:14px">X</button>';
h += '</div>';
h += '<div style="display:' + (showAct ? 'flex' : 'none') + ';gap:6px;align-items:flex-start">';
h += renderField(sec, sec.fields[2], data, idx, null, 'flex:1;min-width:0');
- h += '<button class="btn btn-sm btn-danger" onclick="hideUseInterField(' + idx + ',\'action\')" style="margin-top:14px">X</button>';
+ h += '<button class="btn btn-sm btn-danger" onclick="hideInterField(\'' + sec.id + '\',' + idx + ',\'action\')" style="margin-top:14px">X</button>';
h += '</div>';
h += '<div style="display:' + (showMsg ? 'flex' : 'none') + ';gap:6px;align-items:flex-start">';
h += renderField(sec, sec.fields[3], data, idx, null, 'flex:1;min-width:0');
- h += '<button class="btn btn-sm btn-danger" onclick="hideUseInterField(' + idx + ',\'message\')" style="margin-top:14px">X</button>';
+ h += '<button class="btn btn-sm btn-danger" onclick="hideInterField(\'' + sec.id + '\',' + idx + ',\'message\')" style="margin-top:14px">X</button>';
h += '</div>';
h += '</div>';
@@ -606,32 +572,37 @@ function renderArraySection(sec, data) {
h += '</div>';
}
});
- h += '<button class="btn btn-sm obj-sub-add" onclick="addArrayItem(\'' + sec.id + '\')">+ Add ' + esc(sec.label).slice(0,-1) + '</button>';
- if (isUseInter) {
- var helpCollapsed = window._useInterHelpCollapsed === undefined ? true : window._useInterHelpCollapsed;
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addArrayItem(\'' + sec.id + '\')">+ Add ' + esc(sec.label) + '</button>';
+ if (isInter) {
+ var helpKey = sec.id + '_helpCollapsed';
+ var helpCollapsed = window[helpKey] === undefined ? true : window[helpKey];
h += '<div style="margin-top:8px;border:1px solid rgba(100,160,255,.15);border-radius:4px;overflow:hidden">';
- h += '<div style="padding:6px 10px;background:rgba(100,160,255,.08);cursor:pointer;font-size:10px;color:#6af;font-weight:bold;text-transform:uppercase;letter-spacing:.5px" onclick="toggleUseInterHelp()">';
+ h += '<div style="padding:6px 10px;background:rgba(100,160,255,.08);cursor:pointer;font-size:10px;color:#6af;font-weight:bold;text-transform:uppercase;letter-spacing:.5px" onclick="toggleInterHelp(\'' + sec.id + '\')">';
h += '<span style="font-size:8px;margin-right:4px">' + (helpCollapsed ? '&#9654;' : '&#9660;') + '</span>';
h += 'How Conditions &amp; Actions Work';
h += '</div>';
if (!helpCollapsed) {
h += '<div style="padding:8px;background:rgba(100,160,255,.05);font-size:10px;color:#aaa;line-height:1.6">';
- h += 'Entries checked top-to-bottom, first match wins.<br><br><b>Item ID</b>: required for <code>use &lt;item&gt; on &lt;object&gt;</code>; omit for plain <code>use &lt;object&gt;</code>.<br>';
- h += '<b>Condition</b>: JSON. When this interaction is available:<br>';
- h += '&nbsp;&nbsp;<code>{"flag":"gate_open","not":true}</code> — world flag is NOT set<br>';
+ h += 'Entries checked top-to-bottom, first match wins (one fires per use/look/kill).<br><br>';
+ h += '<b>Item ID</b>: <b>on_use</b> requires <code>use &lt;item&gt; on &lt;object&gt;</code>; empty = bare <code>use &lt;object&gt;</code>. <b>on_look</b> = only fires if you carry this item. <b>on_kill</b> = only fires if you wield this weapon when the mob is defeated.<br>';
+ h += '<b>Condition</b>: JSON. Available gates:<br>';
+ h += '&nbsp;&nbsp;<code>{"global_flag":"gate_open","not":true}</code> — world flag is NOT set<br>';
h += '&nbsp;&nbsp;<code>{"player_flag":"has_key","value":1}</code> — player flag matches value<br>';
h += '&nbsp;&nbsp;<code>{"has_item":"silver_bar"}</code> — player carries item<br>';
- h += '&nbsp;&nbsp;<code>{"all_of":[{"flag":"a"},{"has_item":"b"}]}</code> — ALL sub-conditions<br>';
- h += '&nbsp;&nbsp;<code>{"any_of":[...]}</code> — ANY sub-condition<br>';
+ h += '&nbsp;&nbsp;<code>{"min_credits":50}</code> — minimum credits<br>';
+ h += '&nbsp;&nbsp;<code>{"all_of":[{"global_flag":"a"},{"has_item":"b"}]}</code> — ALL<br>';
+ h += '&nbsp;&nbsp;<code>{"any_of":[...]}</code> — ANY<br>';
h += '&nbsp;&nbsp;Omit for an unconditional interaction.<br>';
- h += '<b>Action</b>: JSON. What happens when the interaction fires:<br>';
- h += '&nbsp;&nbsp;<code>{"set_flags":{"gate_open":true}}</code> — set world flags<br>';
+ h += '<b>Action</b>: JSON. Effects that fire when the interaction runs:<br>';
+ h += '&nbsp;&nbsp;<code>{"set_global_flags":{"gate_open":true}}</code> — set world flags<br>';
h += '&nbsp;&nbsp;<code>{"set_player_flags":{"spoke":true}}</code> — set player flags<br>';
- h += '&nbsp;&nbsp;<code>{"give_item":"keycard","teleport":5}</code> — give item + teleport<br>';
- h += '&nbsp;&nbsp;<code>{"take_item":"bomb","heal":10}</code> — take item + heal<br>';
- h += '&nbsp;&nbsp;<code>{"credits":-50}</code> — deduct credits<br>';
- h += '&nbsp;&nbsp;<code>{"reputation_cost":1,"aps_node":true}</code> — rep cost + mark APS<br>';
- h += '<b>Message</b>: Sent to the player when the interaction fires (1-tick action).';
+ h += '&nbsp;&nbsp;<code>{"give_item":"keycard","take_item":"bomb"}</code> — give/take item<br>';
+ h += '&nbsp;&nbsp;<code>{"teleport":5,"heal":10,"credits":100}</code> — move, heal, give credits<br>';
+ h += '&nbsp;&nbsp;<code>{"aps_node":true}</code> — mark current room as APS node<br>';
+ h += '&nbsp;&nbsp;<code>{"spawn_mob":"rat","despawn_mob":"rat"}</code> — spawn/despawn transient mob<br>';
+ h += '&nbsp;&nbsp;<code>{"broadcast":"%p activates it.","broadcast_global":"%p did it!"}</code> — announce (%p = player, %v = flag value)<br>';
+ h += '&nbsp;&nbsp;<code>{"message":"You feel energized.","delay":3}</code> — only meaningful inside on_enter steps / triggers (delay in ticks)<br>';
+ h += '<b>Message</b>: Sent to the player when the interaction fires (one-tick action).';
h += '</div>';
}
h += '</div>';
@@ -906,8 +877,6 @@ function addSection() {
if (sec.path) {
if (sec.isArray) {
objectData[sec.path] = [];
- } else if (sec.id === 'on_look') {
- objectData[sec.path] = {};
} else if (sec.id === 'gather') {
objectData[sec.path] = {skill:'', tools:[], success:{base:0, per_level:0, cap:1}, drops:[], gather_message:'', fail_message:'', respawn_timer:0, respawn_broadcast:'', deplete_timer:0, nest_chance:0};
} else if (sec.id === 'safespot') {
@@ -1009,20 +978,22 @@ function renderCurrent() {
renderObjectEditor(objectID, objectData);
}
-function showUseInterField(idx, key) {
- window._useInterVis = window._useInterVis || {};
- if (!window._useInterVis[idx]) window._useInterVis[idx] = {};
- window._useInterVis[idx][key] = true;
+function showInterField(secId, idx, key) {
+ window._interVis = window._interVis || {};
+ if (!window._interVis[secId]) window._interVis[secId] = {};
+ if (!window._interVis[secId][idx]) window._interVis[secId][idx] = {};
+ window._interVis[secId][idx][key] = true;
renderCurrent();
}
-function hideUseInterField(idx, key) {
- window._useInterVis = window._useInterVis || {};
- if (!window._useInterVis[idx]) window._useInterVis[idx] = {};
- window._useInterVis[idx][key] = false;
+function hideInterField(secId, idx, key) {
+ window._interVis = window._interVis || {};
+ if (!window._interVis[secId]) window._interVis[secId] = {};
+ if (!window._interVis[secId][idx]) window._interVis[secId][idx] = {};
+ window._interVis[secId][idx][key] = false;
ced_syncDOMToData(objectData);
- if (objectData.use_interactions && objectData.use_interactions[idx]) {
- objectData.use_interactions[idx][key] = '';
+ if (objectData[secId] && objectData[secId][idx]) {
+ objectData[secId][idx][key] = '';
}
renderObjectEditor(objectID, objectData);
}
@@ -1061,20 +1032,12 @@ function hideGuardMob() {
renderObjectEditor(objectID, objectData);
}
-function toggleUseInterHelp() {
- if (window._useInterHelpCollapsed === undefined) {
- window._useInterHelpCollapsed = false;
- } else {
- window._useInterHelpCollapsed = !window._useInterHelpCollapsed;
- }
- renderCurrent();
-}
-
-function toggleOnLookHelp() {
- if (window._onLookHelpCollapsed === undefined) {
- window._onLookHelpCollapsed = false;
+function toggleInterHelp(secId) {
+ var key = secId + '_helpCollapsed';
+ if (window[key] === undefined) {
+ window[key] = false;
} else {
- window._onLookHelpCollapsed = !window._onLookHelpCollapsed;
+ window[key] = !window[key];
}
renderCurrent();
}
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index d31a0a5..69b91cd 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -1,5 +1,7 @@
package behavior
+import "gopkg.in/yaml.v3"
+
type GatherConfig struct {
Skill string `yaml:"skill"`
Tools []string `yaml:"tools"`
@@ -43,16 +45,88 @@ type TalkOption struct {
Action *NodeAction `yaml:"action,omitempty"`
}
+// NodeAction is the inline-only effects payload — used by talk nodes,
+// talk options, and any callers that need a simple "do these effects now"
+// primitive (use/look/kill interactions, exits). It is the embedded
+// subset of StepAction (the universal superset used by on_enter and triggers).
type NodeAction struct {
- SetGlobalFlags map[string]any `yaml:"set_global_flags"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags"`
- GiveItem string `yaml:"give_item"`
- TakeItem string `yaml:"take_item"`
- Teleport int `yaml:"teleport"`
- Heal int `yaml:"heal"`
- Credits int `yaml:"credits"`
- ReputationCost int `yaml:"reputation_cost"`
- ApsNode bool `yaml:"aps_node"`
+ SetGlobalFlags map[string]any `yaml:"set_global_flags,omitempty"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"`
+ GiveItem string `yaml:"give_item,omitempty"`
+ TakeItem string `yaml:"take_item,omitempty"`
+ Teleport int `yaml:"teleport,omitempty"`
+ Heal int `yaml:"heal,omitempty"`
+ Credits int `yaml:"credits,omitempty"`
+ ApsNode bool `yaml:"aps_node,omitempty"`
+}
+
+// Interaction is one conditional trigger entry shared by on_use, on_look
+// (objects) and on_kill (mobs). Item filters by the appropriate held/weapon
+// item depending on the interaction kind (see itemMatch on applyInteraction).
+// The first entry whose item filter and Condition pass wins and fires exactly
+// once.
+//
+// Action is a full StepAction so on_kill may broadcast a kill announcement or
+// spawn a follow-up mob; inline callers (use/look) simply leave the
+// sequence-only fields empty.
+type Interaction struct {
+ Item string `yaml:"item_id,omitempty"`
+ Condition *Condition `yaml:"condition,omitempty"`
+ Message string `yaml:"message,omitempty"`
+ Action *StepAction `yaml:"action,omitempty"`
+}
+
+// StepAction is the universal effect primitive — one superset struct shared
+// across all effect executors (on_enter steps, room/global triggers, talk
+// nodes, use/look/kill interactions, exit traversal). The embedded
+// NodeAction holds the inline-only effects (flags/items/teleport/heal/credits/
+// aps_node); the additional fields are the sequence-only effects (message,
+// broadcasts, mob spawn/despawn, delay). Condition is evaluated per-step by
+// the on_enter / trigger schedulers; inline callers ignore it (the gating
+// happens at the parent Interaction.ExitDef level instead).
+type StepAction struct {
+ NodeAction `yaml:",inline"`
+ Condition *Condition `yaml:"condition,omitempty"`
+ Message string `yaml:"message,omitempty"`
+ Broadcast string `yaml:"broadcast,omitempty"`
+ BroadcastGlobal string `yaml:"broadcast_global,omitempty"`
+ SpawnMob *SpawnMobConfig `yaml:"spawn_mob,omitempty"`
+ DespawnMob string `yaml:"despawn_mob,omitempty"`
+ Delay int `yaml:"delay,omitempty"`
+}
+
+// IsTimed reports whether the step carries sequence semantics (any non-zero
+// field means it needs per-tick scheduling rather than synchronous printing).
+func (s StepAction) IsTimed() bool {
+ return s.Delay > 0 || s.Message != "" || s.Broadcast != "" || s.BroadcastGlobal != "" ||
+ s.SpawnMob != nil || s.DespawnMob != "" ||
+ len(s.SetGlobalFlags) > 0 || len(s.SetPlayerFlags) > 0 ||
+ s.GiveItem != "" || s.TakeItem != "" || s.Teleport != 0 || s.Heal != 0 ||
+ s.Credits != 0 || s.ApsNode
+}
+
+// SpawnMobConfig configures a transient mob spawned by a trigger or on_enter
+// step. It accepts either a bare string (the mob id) or a full map at
+// unmarshal time.
+type SpawnMobConfig struct {
+ ID string `yaml:"id"`
+ OwnerOnly bool `yaml:"owner_only"`
+ DespawnOnLeave bool `yaml:"despawn_on_leave"`
+ DespawnRooms []int `yaml:"despawn_rooms"`
+ DespawnTicks float64 `yaml:"despawn_ticks"`
+}
+
+func (s *SpawnMobConfig) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode {
+ var id string
+ if err := value.Decode(&id); err != nil {
+ return err
+ }
+ s.ID = id
+ return nil
+ }
+ type raw SpawnMobConfig
+ return value.Decode((*raw)(s))
}
// ShopConfig is a root-level mob property (mob YAML `shop:`). Buy prices are
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index 18d6363..521867a 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -119,7 +119,7 @@ type TalkData struct {
type UseInteractionData struct {
Message string
- Action *NodeAction
+ Action *StepAction
ObjName string
}
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
new file mode 100644
index 0000000..82d5022
--- /dev/null
+++ b/internal/game/act_effects.go
@@ -0,0 +1,268 @@
+package game
+
+import (
+ "fmt"
+ "strconv"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+// effectScope controls which subset of a StepAction's effects applyStepAction
+// will fire. The three scopes cover the three callers of the unified executor:
+//
+// - scopePlayer: inline triggers (talk nodes, on_use, on_look,
+// on_kill, exit traversal). Receives all
+// player-targeted effects (flags, items, heal, credits,
+// teleport, aps_node). Broadcast is allowed (so on_kill
+// can announce to the room); broadcast_global is not
+// (there's no per-tick sequence owner to author it).
+// - scopePlayerSeq: on_enter steps + trigger player sequences. Same as
+// scopePlayer plus broadcast_global (a sequence can
+// shout to the whole world) and spawn_mob/despawn_mob
+// (player-owned transient mobs).
+// - scopeGlobal: trigger global sequences. No player is involved; only
+// broadcasts, global flag mutations, and world-owned
+// spawn/despawn fire.
+type effectScope int
+
+const (
+ scopePlayer effectScope = iota
+ scopePlayerSeq
+ scopeGlobal
+)
+
+// applyStepAction is the single effect executor used by every conditional
+// interaction and timed step in the game: talk nodes, talk options,
+// on_use/on_look/on_kill interactions, exit traversal, on_enter steps, and
+// room/global trigger sequences. The previous fireEnterStep /
+// executeTriggerStep / executeGlobalTriggerStep / applyNodeAction executors
+// are all thin wrappers over this one.
+//
+// flagValue (may be nil) is substituted into %v in any message/broadcast
+// template; playerName (derived from p when non-nil) is substituted into %p.
+// scopeGlobal ignores p entirely.
+//
+// The fixed effect order (matching the original executors, normalized):
+// 1. message (player/seq scopes — skipped for scopeGlobal)
+// 2. broadcast → all in room (seq/global scopes)
+// 3. broadcast_global → all online (seq/global scopes)
+// 4. set_global_flags (scopeGlobal only; player scopes handle globals in step 5
+// via applyFlagMutations so they aren't set twice)
+// 5. set_player_flags (player/seq scopes; cascades via setPlayerFlag)
+// 6. take_item (player/seq scopes)
+// 7. give_item (player/seq scopes; honors 28-slot inventory limit)
+// 8. heal (player/seq scopes; clamps to MaxHP)
+// 9. credits (player/seq scopes; negative is gated by affordability)
+// 10. spawn_mob (seq/global scopes; player-owned when seq)
+// 11. despawn_mob (seq/global scopes; filtered by owner when seq)
+// 12. teleport (player/seq scopes; re-runs look + on_enter of target)
+// 13. aps_node (player/seq scopes; marks "aps_node_<roomID>" player flag)
+
+// writePlayerMessage expands the message template (player name, flag value),
+// colorizes inline {spec}text{/} tags under the caller's color mode, and writes
+// it to sess. It is the single path used by every per-player message emission
+// in the interaction system (applyStepAction step 1, applyInteraction's
+// Interaction.Message, completeMove's on_traverse Message, and
+// advanceUseInteraction's d.Message) so color tags render consistently.
+func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName string, flagValue any) {
+ if sess == nil || msg == "" {
+ return
+ }
+ rendered := expandTemplate(msg, playerName, flagValue)
+ seqSpec := g.resolveColor(sess, "sequence")
+ mode := g.colorMode(sess)
+ rendered = color.ExpandTagsDefault(mode, seqSpec, rendered)
+ sess.WriteLine(rendered)
+}
+
+func (g *Game) applyStepAction(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, sc effectScope, flagValue any) {
+ if step == nil {
+ return
+ }
+
+ var playerName string
+ if p != nil {
+ playerName = p.Name
+ }
+
+ // 1. message — direct to the triggering session. Skipped for scopeGlobal
+ // (no per-player session is the target).
+ if sc != scopeGlobal {
+ g.writePlayerMessage(sess, step.Message, playerName, flagValue)
+ }
+
+ // 2 & 3. broadcast / broadcast_global — re-render color tags per recipient
+ // so each player sees them in their own color mode.
+ broadcastSpec := g.resolveColor(nil, "broadcast")
+ if (sc == scopePlayerSeq || sc == scopeGlobal) && g.Hub != nil {
+ if step.Broadcast != "" {
+ raw := expandTemplate(step.Broadcast, playerName, flagValue)
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ otherMode := g.colorMode(other)
+ rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
+ }
+ }
+ if step.BroadcastGlobal != "" {
+ raw := expandTemplate(step.BroadcastGlobal, playerName, flagValue)
+ for _, other := range g.Hub.AllSessions() {
+ if other.Player == nil {
+ continue
+ }
+ otherMode := g.colorMode(other)
+ rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
+ }
+ }
+ }
+
+ // 4. set_global_flags — scopeGlobal has no player, so it sets globals here
+ // (cascades via OnChange). Player scopes handle globals in step 5 alongside
+ // player flags (applyFlagMutations sets both atomically per-scope), avoiding
+ // a redundant second SetAll.
+ if sc == scopeGlobal && len(step.SetGlobalFlags) > 0 {
+ g.GlobalFlags.SetAll(step.SetGlobalFlags)
+ }
+
+ // Global triggers have no player — stop after the world-scoped effects.
+ if sc == scopeGlobal {
+ if step.SpawnMob != nil {
+ g.spawnWorldTriggerMob(step.SpawnMob, roomID)
+ }
+ if step.DespawnMob != "" {
+ g.despawnTriggerMobs(step.DespawnMob, "")
+ }
+ return
+ }
+
+ // Everything below needs a player.
+ if p == nil {
+ return
+ }
+
+ // 5. set_player_flags — cascade via setPlayerFlag (may synchronously re-fire
+ // other player-flag triggers).
+ if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 {
+ g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags)
+ g.AccountStore.SaveCharacter(p)
+ }
+
+ // 6. take_item
+ if step.TakeItem != "" {
+ if p.HasItem(step.TakeItem) {
+ p.RemoveItem(step.TakeItem, 1)
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
+ // 7. give_item
+ if step.GiveItem != "" {
+ slot := p.FirstFreeSlot()
+ if slot == -1 {
+ if sess != nil {
+ sess.WriteLine("Your inventory is too full to receive that.")
+ }
+ } else {
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
+ // 8. heal
+ if step.Heal > 0 {
+ p.HP += step.Heal
+ if maxHP := p.MaxHP(); p.HP > maxHP {
+ p.HP = maxHP
+ }
+ g.AccountStore.SaveCharacter(p)
+ if sess != nil {
+ sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
+ }
+ }
+
+ // 9. credits (signed; negative is gated)
+ if step.Credits != 0 {
+ if step.Credits < 0 {
+ if p.Credits >= -step.Credits {
+ p.Credits += step.Credits
+ g.AccountStore.SaveCharacter(p)
+ } else if sess != nil {
+ sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits))
+ }
+ } else {
+ p.Credits += step.Credits
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
+ // 10/11. spawn_mob / despawn_mob — sequence-only effects. Inline callers
+ // (scopePlayer) leave these empty; nothing fires.
+ if sc == scopePlayerSeq && step.SpawnMob != nil {
+ g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
+ }
+ if sc == scopePlayerSeq && step.DespawnMob != "" {
+ g.despawnTriggerMobs(step.DespawnMob, p.Name)
+ }
+
+ // 12. teleport
+ if step.Teleport > 0 && sess != nil {
+ g.teleportPlayer(sess, p, step.Teleport)
+ }
+
+ // 13. aps_node — marks this current room as a discovered APS node on the
+ // player's datapad. Kept as a dedicated effect because the room-id
+ // substitution is dynamic (the player's current room), not expressible
+ // via a static set_player_flags entry.
+ if step.ApsNode {
+ g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
+ g.AccountStore.SaveCharacter(p)
+ }
+}
+
+// applyNodeAction is the inline-only convenience wrapper used by talk nodes,
+// talk options, and any historical caller that holds a *NodeAction rather
+// than a *StepAction. It wraps the NodeAction in a StepAction (sequence-only
+// fields empty) and dispatches to applyStepAction with scopePlayer scoped
+// to the player's current room.
+func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
+ p := sess.Player
+ if p == nil || na == nil {
+ return
+ }
+ g.applyStepAction(sess, p, &behavior.StepAction{NodeAction: *na}, p.RoomID, scopePlayer, nil)
+}
+
+// applyInteraction fires the first matching entry in a list of conditional
+// interactions (on_use, on_look, on_kill, exit traversal). Returns true when
+// an entry matched and fired. The action runs at scopePlayer (or scopePlayerSeq
+// if allowSeq is set, for on_kill which may broadcast/spawn). The
+// interaction's Message is written to the player first (if non-empty), then
+// the Action fires via applyStepAction.
+//
+// itemMatch (optional) gates entries that carry an item_id: for on_look it
+// requires the player to have the item in inventory; for on_kill it requires
+// the player to be wielding it. nil means the item_id field is ignored (used
+// by callers without an item filter, e.g. exit traversal). Entries are walked
+// top-to-bottom; the first whose item filter, Condition, and the first-match-
+// wins rule all pass fires.
+func (g *Game) applyInteraction(sess *net.Session, p *player.Player, list []behavior.Interaction, roomID int, allowSeq bool, itemMatch func(string) bool) bool {
+ sc := scopePlayer
+ if allowSeq {
+ sc = scopePlayerSeq
+ }
+ for _, it := range list {
+ if it.Item != "" && itemMatch != nil && !itemMatch(it.Item) {
+ continue
+ }
+ if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
+ continue
+ }
+ g.writePlayerMessage(sess, it.Message, p.Name, nil)
+ g.applyStepAction(sess, p, it.Action, roomID, sc, nil)
+ return true
+ }
+ return false
+} \ No newline at end of file
diff --git a/internal/game/act_room.go b/internal/game/act_room.go
index da74d6e..2b08bfa 100644
--- a/internal/game/act_room.go
+++ b/internal/game/act_room.go
@@ -4,10 +4,9 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
- "thehouseoficarus/internal/world"
)
// enterSeq is an in-flight on_enter stepped sequence for a single player. It is
@@ -16,13 +15,13 @@ type enterSeq struct {
sess *net.Session
playerName string
roomID int
- steps []world.EnterStep
+ steps []behavior.StepAction
wait int
}
// runEnterSteps evaluates a room's on_enter script for the player. Steps whose
// condition fails are dropped. If none of the surviving steps are "timed"
-// (no delay, no flag mutation) the messages are printed synchronously.
+// (any non-zero effect field) the messages are printed synchronously.
// Otherwise the surviving steps become a scheduled enter sequence driven
// by EnterSeqTick.
func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
@@ -35,7 +34,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
return
}
- var steps []world.EnterStep
+ var steps []behavior.StepAction
sequenced := false
for _, step := range room.OnEnter {
if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
@@ -51,6 +50,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
}
if !sequenced {
+ // Pure message-only steps — print synchronously, no side effects.
for _, step := range steps {
if step.Message != "" {
sess.WriteLine(step.Message)
@@ -65,7 +65,7 @@ func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
// startEnterSeq registers a scheduled on_enter sequence and persists a marker
// on the player so the sequence can be resumed on reconnect if interrupted
// (disconnect, server restart).
-func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) {
+func (g *Game) startEnterSeq(sess *net.Session, p *player.Player, roomID int, steps []behavior.StepAction) {
g.cancelEnterSeq(p.Name)
if p.EnterSeqRoom != roomID {
p.EnterSeqRoom = roomID
@@ -98,7 +98,7 @@ func (g *Game) EnterSeqTick() {
continue
}
- var jobs []world.EnterStep
+ var jobs []behavior.StepAction
done := false
g.enterMu.Lock()
@@ -123,8 +123,8 @@ func (g *Game) EnterSeqTick() {
}
g.enterMu.Unlock()
- for _, step := range jobs {
- g.fireEnterStep(seq, step)
+ for i := range jobs {
+ g.fireEnterStep(seq, &jobs[i])
}
if done {
g.completeEnterSeq(seq)
@@ -132,71 +132,16 @@ func (g *Game) EnterSeqTick() {
}
}
-func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) {
+// fireEnterStep runs one on_enter step's effects via the unified
+// applyStepAction executor. The room-lock check (player must still be in the
+// room that the sequence is being played for) lives here, before dispatch —
+// applyStepAction itself doesn't re-check it.
+func (g *Game) fireEnterStep(seq *enterSeq, step *behavior.StepAction) {
p := seq.sess.Player
if p == nil || p.RoomID != seq.roomID {
return
}
- seqSpec := g.resolveColor(seq.sess, "sequence")
- broadcastSpec := g.resolveColor(seq.sess, "broadcast")
- mode := g.colorMode(seq.sess)
- if step.Message != "" {
- msg := expandTemplate(step.Message, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, seqSpec, msg)
- seq.sess.WriteLine(msg)
- }
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, broadcastSpec, msg)
- for _, other := range g.Hub.PlayersInRoom(seq.roomID) {
- other.WriteLine(g.colorize(other, "broadcast", msg))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, p.Name, nil)
- msg = color.ExpandTagsDefault(mode, broadcastSpec, msg)
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- other.WriteLine(g.colorize(other, "broadcast", msg))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 || len(step.SetPlayerFlags) > 0 {
- g.applyFlagMutations(p, step.SetGlobalFlags, step.SetPlayerFlags)
- g.AccountStore.SaveCharacter(p)
- }
- if step.SpawnMob != nil {
- g.spawnTriggerMob(seq.sess, p, step.SpawnMob, seq.roomID)
- }
- if step.DespawnMob != "" {
- g.despawnTriggerMobs(step.DespawnMob, p.Name)
- }
- if step.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- seq.sess.WriteLine("Your inventory is too full to receive that.")
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
- g.AccountStore.SaveCharacter(p)
- }
- }
- if step.TakeItem != "" {
- if p.HasItem(step.TakeItem) {
- p.RemoveItem(step.TakeItem, 1)
- g.AccountStore.SaveCharacter(p)
- }
- }
- if step.Heal > 0 {
- p.HP += step.Heal
- if maxHP := p.MaxHP(); p.HP > maxHP {
- p.HP = maxHP
- }
- g.AccountStore.SaveCharacter(p)
- seq.sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
- }
- if step.Teleport > 0 {
- g.teleportPlayer(seq.sess, p, step.Teleport)
- }
+ g.applyStepAction(seq.sess, p, step, seq.roomID, scopePlayerSeq, nil)
}
func (g *Game) completeEnterSeq(seq *enterSeq) {
@@ -229,8 +174,11 @@ func (g *Game) isCharLive(name string, sess *net.Session) bool {
return g.loggedInChars[name] == sess
}
-// applyFlagMutations sets global and player flags, shared by on_enter steps and
-// exit traversal.
+// applyFlagMutations sets global and player flags. It is the player-scoped
+// (scopePlayer / scopePlayerSeq) flag step of applyStepAction, called once
+// per non-global step that carries set_global_flags and/or set_player_flags.
+// scopeGlobal sets globals directly in applyStepAction step 4 instead (so
+// world-scoped cascades don't reach back into this helper).
func (g *Game) applyFlagMutations(p *player.Player, setGlobalFlags, setPlayerFlags map[string]any) {
g.GlobalFlags.SetAll(setGlobalFlags)
if len(setPlayerFlags) > 0 {
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index ca6054c..e08fbd2 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -2,7 +2,6 @@ package game
import (
"fmt"
- "strconv"
"strings"
"thehouseoficarus/internal/behavior"
@@ -331,78 +330,3 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
return
}
}
-
-func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
- p := sess.Player
- if p == nil {
- return
- }
-
- if na.ReputationCost > 0 {
- rep := getPlayerFlagInt(p, "assassin_reputation")
- if rep < na.ReputationCost {
- sess.WriteLine(fmt.Sprintf("You don't have enough Reputation. (Need %d, have %d)", na.ReputationCost, rep))
- return
- }
- g.setPlayerFlag(p, "assassin_reputation", rep-na.ReputationCost)
- g.AccountStore.SaveCharacter(p)
- }
-
- for k, v := range na.SetGlobalFlags {
- g.GlobalFlags.Set(k, v)
- }
- for k, v := range na.SetPlayerFlags {
- g.setPlayerFlag(p, k, v)
- }
- if na.TakeItem != "" {
- if p.HasItem(na.TakeItem) {
- p.RemoveItem(na.TakeItem, 1)
- }
- }
- if na.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- sess.WriteLine("Your inventory is too full to receive that.")
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1})
- g.AccountStore.SaveCharacter(p)
- }
- }
- if na.Teleport > 0 {
- p.RoomID = na.Teleport
- p.Stats.RecordRoomVisit(na.Teleport)
- g.AccountStore.SaveCharacter(p)
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.seedRoomObjects(p.RoomID)
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
- g.doLook(sess)
- g.runEnterSteps(sess, p.RoomID)
- }
- if na.Heal > 0 {
- p.HP += na.Heal
- if maxHP := p.MaxHP(); p.HP > maxHP {
- p.HP = maxHP
- }
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal))
- }
- if na.Credits != 0 {
- if na.Credits < 0 {
- if p.Credits >= -na.Credits {
- p.Credits += na.Credits
- g.AccountStore.SaveCharacter(p)
- } else {
- sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -na.Credits, p.Credits))
- }
- } else {
- p.Credits += na.Credits
- g.AccountStore.SaveCharacter(p)
- }
- }
- if na.ApsNode {
- g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
- }
-}
diff --git a/internal/game/act_use_interaction.go b/internal/game/act_use_interaction.go
index b96aaa3..9c64b56 100644
--- a/internal/game/act_use_interaction.go
+++ b/internal/game/act_use_interaction.go
@@ -12,12 +12,8 @@ func (g *Game) advanceUseInteraction(sess *net.Session, p *player.Player) {
g.cancelAction(p)
return
}
- if d.Message != "" {
- sess.WriteLine(d.Message)
- }
- if d.Action != nil {
- g.applyNodeAction(sess, d.Action)
- }
+ g.writePlayerMessage(sess, d.Message, p.Name, nil)
+ g.applyStepAction(sess, p, d.Action, p.RoomID, scopePlayer, nil)
g.broadcastAction(sess, "%s uses the %s.", p.Name, d.ObjName)
g.cancelAction(p)
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 2f27eed..d05146a 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -4,11 +4,13 @@ import (
"fmt"
"strings"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
)
func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
@@ -67,8 +69,11 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
}
}
- p.MovePendingGlobalFlags = exitDef.SetGlobalFlags
- p.MovePendingPlayerFlags = exitDef.SetPlayerFlags
+ // Stash the first on_traverse interaction whose condition passes — it will
+ // fire in completeMove once the player arrives. ExitDef.Condition (above,
+ // evaluated by exitDisplayState) gates whether the exit is passable at
+ // all; each on_traverse entry has its own additional Condition gate.
+ p.MovePendingInteraction = g.matchExitInteraction(sess, p, exitDef)
inCombat := g.Combat.Get(p.Name) != nil
@@ -96,6 +101,21 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
}
}
+// matchExitInteraction returns the first on_traverse Interaction on exitDef
+// whose Condition (if any) passes for this player, or nil if the exit has no
+// on_traverse entries. The matched interaction's Action fires (with its optional
+// Message) once the player arrives in the target room.
+func (g *Game) matchExitInteraction(sess *net.Session, p *player.Player, exitDef world.ExitDef) *behavior.Interaction {
+ for i := range exitDef.OnTraverse {
+ it := &exitDef.OnTraverse[i]
+ if it.Condition != nil && !g.checkCondition(sess, it.Condition) {
+ continue
+ }
+ return it
+ }
+ return nil
+}
+
var gracefulItems = map[string]bool{
"graceful_hat": true,
"graceful_torso": true,
@@ -132,8 +152,7 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int {
func (g *Game) completeMove(sess *net.Session, p *player.Player) {
exitDir := p.MoveDirection
targetID := p.MoveTarget
- pendingGlobalFlags := p.MovePendingGlobalFlags
- pendingPlayerFlags := p.MovePendingPlayerFlags
+ pendingInteraction := p.MovePendingInteraction
p.ClearMoveState()
@@ -163,8 +182,12 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
if p.EnterSeqRoom != 0 && p.EnterSeqRoom == oldRoom {
p.EnterSeqRoom = 0
}
- if len(pendingGlobalFlags) > 0 || len(pendingPlayerFlags) > 0 {
- g.applyFlagMutations(p, pendingGlobalFlags, pendingPlayerFlags)
+ // Fire the on_traverse interaction (if any matched at classification
+ // time) AFTER p.RoomID is set to the new room, so effect fields like
+ // aps_node / teleport operate on the destination as their reference frame.
+ if pendingInteraction != nil {
+ g.writePlayerMessage(sess, pendingInteraction.Message, p.Name, nil)
+ g.applyStepAction(sess, p, pendingInteraction.Action, p.RoomID, scopePlayer, nil)
}
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index b248e49..15b05ef 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -138,8 +138,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
Room: newID,
Condition: exitDef.Condition,
BlockedMessage: exitDef.BlockedMessage,
- SetGlobalFlags: exitDef.SetGlobalFlags,
- SetPlayerFlags: exitDef.SetPlayerFlags,
+ OnTraverse: exitDef.OnTraverse,
Hidden: exitDef.Hidden,
AlwaysBlocked: exitDef.AlwaysBlocked,
}
@@ -158,8 +157,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
Room: newID,
Condition: targetExit.Condition,
BlockedMessage: targetExit.BlockedMessage,
- SetGlobalFlags: targetExit.SetGlobalFlags,
- SetPlayerFlags: targetExit.SetPlayerFlags,
+ OnTraverse: targetExit.OnTraverse,
Hidden: targetExit.Hidden,
AlwaysBlocked: targetExit.AlwaysBlocked,
}
diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go
index 28433ff..187ec34 100644
--- a/internal/game/cmd_room_remove.go
+++ b/internal/game/cmd_room_remove.go
@@ -171,8 +171,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
Room: cID,
Condition: aExit.Condition,
BlockedMessage: aExit.BlockedMessage,
- SetGlobalFlags: aExit.SetGlobalFlags,
- SetPlayerFlags: aExit.SetPlayerFlags,
+ OnTraverse: aExit.OnTraverse,
Hidden: aExit.Hidden,
AlwaysBlocked: aExit.AlwaysBlocked,
}
@@ -191,8 +190,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) {
Room: aID,
Condition: cOppExit.Condition,
BlockedMessage: cOppExit.BlockedMessage,
- SetGlobalFlags: cOppExit.SetGlobalFlags,
- SetPlayerFlags: cOppExit.SetPlayerFlags,
+ OnTraverse: cOppExit.OnTraverse,
Hidden: cOppExit.Hidden,
AlwaysBlocked: cOppExit.AlwaysBlocked,
}
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index 22fc169..322e5fc 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -91,7 +91,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
g.startAction(sess, bt, def.Name)
return true
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item != "" {
continue
}
@@ -111,7 +111,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
}
return true
}
- if len(def.UseInteractions) > 0 {
+ if len(def.OnUse) > 0 {
sess.WriteLine(fmt.Sprintf("Use what on the %s?", def.Name))
return true
}
@@ -284,7 +284,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
return
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item != itemAID {
continue
}
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index e32910a..6c20004 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -75,7 +75,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
addUnique(&sectionObjs, &seen, "talk "+name)
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item == "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
addUnique(&sectionObjs, &seen, "use "+name)
break
@@ -106,7 +106,7 @@ func (g *Game) executeVerbs(sess *net.Session, args []string, rawInput string) {
}
}
- for _, ui := range def.UseInteractions {
+ for _, ui := range def.OnUse {
if ui.Item != "" && (ui.Condition == nil || g.checkCondition(sess, ui.Condition)) {
if itemDef, err := g.ItemStore.Load(ui.Item); err == nil {
addUnique(&sectionObjs, &seen, "use "+itemDef.Name+" on "+name)
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index a3fc040..0395049 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -217,6 +217,11 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
g.announceKill(sess, p, mob, isTask)
g.awardKillDrops(sess, p, mob, isTask)
+ // On Kill fires for both combat kills and task-mob completion (draining a
+ // task mob's HP to zero is the "kill" that completes the work). itemMatch
+ // is the "wielding" gate — on_kill entries with an item_id only fire if
+ // the player currently has that item equipped in a weapon-hand slot.
+ g.applyInteraction(sess, p, mob.OnKill, p.RoomID, true, p.IsWielding)
g.scheduleMobRespawn(mob)
g.writePrompt(sess)
}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 8b0a57c..24a1df5 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -154,8 +154,8 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText))
}
- if def.OnLook != nil {
- g.applyNodeAction(sess, def.OnLook)
+ if len(def.OnLook) > 0 {
+ g.applyInteraction(sess, p, def.OnLook, p.RoomID, false, p.HasItem)
}
if def.Safespot != nil {
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index b542fdd..2e8b877 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
@@ -82,7 +82,7 @@ func (g *Game) processPlayerTriggerSequences() {
continue
}
- var jobs []world.TriggerStep
+ var jobs []behavior.StepAction
for len(seq.Steps) > 0 && seq.Wait <= 0 {
step := seq.Steps[0]
seq.Steps = seq.Steps[1:]
@@ -92,9 +92,8 @@ func (g *Game) processPlayerTriggerSequences() {
}
}
- for _, step := range jobs {
- s := step
- g.executeTriggerStep(sess, p, &s, seq.RoomID, seq.FlagValue)
+ for i := range jobs {
+ g.executeTriggerStep(sess, p, &jobs[i], seq.RoomID, seq.FlagValue)
}
if len(seq.Steps) == 0 {
@@ -114,7 +113,7 @@ func (g *Game) processGlobalTriggerSequences() {
continue
}
- var jobs []world.TriggerStep
+ var jobs []behavior.StepAction
for len(seq.Steps) > 0 && seq.Wait <= 0 {
step := seq.Steps[0]
seq.Steps = seq.Steps[1:]
@@ -124,8 +123,8 @@ func (g *Game) processGlobalTriggerSequences() {
}
}
- for _, step := range jobs {
- g.executeGlobalTriggerStep(&step, seq.RoomID, seq.FlagValue)
+ for i := range jobs {
+ g.executeGlobalTriggerStep(&jobs[i], seq.RoomID, seq.FlagValue)
}
if len(seq.Steps) == 0 {
@@ -134,106 +133,21 @@ func (g *Game) processGlobalTriggerSequences() {
}
}
-func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *world.TriggerStep, roomID int, flagValue any) {
- seqSpec := g.resolveColor(sess, "sequence")
- broadcastSpec := g.resolveColor(sess, "broadcast")
- playerMode := g.colorMode(sess)
- if step.Message != "" {
- msg := expandTemplate(step.Message, p.Name, flagValue)
- msg = color.ExpandTagsDefault(playerMode, seqSpec, msg)
- sess.WriteLine(msg)
- }
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, p.Name, flagValue)
- for _, other := range g.Hub.PlayersInRoom(roomID) {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, p.Name, flagValue)
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 {
- g.GlobalFlags.SetAll(step.SetGlobalFlags)
- }
- if len(step.SetPlayerFlags) > 0 {
- for k, v := range step.SetPlayerFlags {
- g.setPlayerFlag(p, k, v)
- }
- g.AccountStore.SaveCharacter(p)
- }
- if step.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- sess.WriteLine("Your inventory is too full to receive that.")
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
- g.AccountStore.SaveCharacter(p)
- }
- }
- if step.TakeItem != "" {
- if p.HasItem(step.TakeItem) {
- p.RemoveItem(step.TakeItem, 1)
- g.AccountStore.SaveCharacter(p)
- }
- }
- if step.Heal > 0 {
- p.HP += step.Heal
- if maxHP := p.MaxHP(); p.HP > maxHP {
- p.HP = maxHP
- }
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", step.Heal))
- }
- if step.SpawnMob != nil {
- g.spawnTriggerMob(sess, p, step.SpawnMob, roomID)
- }
- if step.DespawnMob != "" {
- g.despawnTriggerMobs(step.DespawnMob, p.Name)
- }
- if step.Teleport > 0 {
- g.teleportPlayer(sess, p, step.Teleport)
+// executeTriggerStep is a thin wrapper over applyStepAction for trigger player
+// sequences (full player-targeted effects + sequence/broadcast/spawn fields).
+func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *behavior.StepAction, roomID int, flagValue any) {
+ // On every step, evaluate the per-step condition (a trigger can now gate
+ // individual steps via the Condition field inherited from StepAction).
+ if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
+ return
}
+ g.applyStepAction(sess, p, step, roomID, scopePlayerSeq, flagValue)
}
-func (g *Game) executeGlobalTriggerStep(step *world.TriggerStep, roomID int, flagValue any) {
- if step.Broadcast != "" && g.Hub != nil {
- msg := expandTemplate(step.Broadcast, "", flagValue)
- broadcastSpec := g.resolveColor(nil, "broadcast")
- for _, other := range g.Hub.PlayersInRoom(roomID) {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- if step.BroadcastGlobal != "" && g.Hub != nil {
- msg := expandTemplate(step.BroadcastGlobal, "", flagValue)
- broadcastSpec := g.resolveColor(nil, "broadcast")
- for _, other := range g.Hub.AllSessions() {
- if other.Player != nil {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", rendered))
- }
- }
- }
- if len(step.SetGlobalFlags) > 0 {
- g.GlobalFlags.SetAll(step.SetGlobalFlags)
- }
- if step.SpawnMob != nil {
- g.spawnWorldTriggerMob(step.SpawnMob, roomID)
- }
- if step.DespawnMob != "" {
- g.despawnTriggerMobs(step.DespawnMob, "")
- }
+// executeGlobalTriggerStep is a thin wrapper over applyStepAction for global
+// trigger sequences (no player; only broadcasts/global-flags/spawn/despawn).
+func (g *Game) executeGlobalTriggerStep(step *behavior.StepAction, roomID int, flagValue any) {
+ g.applyStepAction(nil, nil, step, roomID, scopeGlobal, flagValue)
}
func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.SpawnMobConfig, roomID int) {
diff --git a/internal/object/object.go b/internal/object/object.go
index 47243ad..fb93b98 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -2,37 +2,30 @@ package object
import "thehouseoficarus/internal/behavior"
-type UseInteraction struct {
- Item string `yaml:"item_id"`
- Condition *behavior.Condition `yaml:"condition"`
- Message string `yaml:"message"`
- Action *behavior.NodeAction `yaml:"action"`
-}
-
type ObjectSteal struct {
Drops []behavior.DropEntry `yaml:"drops,omitempty"`
Level int `yaml:"level"`
XP int `yaml:"xp"`
- Speed float64 `yaml:"speed"`
- GuardMob string `yaml:"guard_mob,omitempty"`
+ Speed float64 `yaml:"speed"`
+ GuardMob string `yaml:"guard_mob,omitempty"`
}
type ObjectDef struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- Aliases []string `yaml:"aliases"`
- Color string `yaml:"color"`
- Hidden bool `yaml:"hidden"`
- InRoomDescription string `yaml:"inroom_description"`
- RemovalItem string `yaml:"removal_item"`
- Description behavior.DescList `yaml:"description"`
- UseInteractions []UseInteraction `yaml:"use_interactions"`
- Steal *ObjectSteal `yaml:"steal,omitempty"`
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Aliases []string `yaml:"aliases"`
+ Color string `yaml:"color"`
+ Hidden bool `yaml:"hidden"`
+ InRoomDescription string `yaml:"inroom_description"`
+ RemovalItem string `yaml:"removal_item"`
+ Description behavior.DescList `yaml:"description"`
+ OnUse []behavior.Interaction `yaml:"on_use,omitempty"`
+ Steal *ObjectSteal `yaml:"steal,omitempty"`
Gather *behavior.GatherConfig `yaml:"gather,omitempty"`
Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
Safespot *SafespotConfig `yaml:"safespot,omitempty"`
- OnLook *behavior.NodeAction `yaml:"on_look,omitempty"`
+ OnLook []behavior.Interaction `yaml:"on_look,omitempty"`
}
type SafespotConfig struct {
diff --git a/internal/player/player.go b/internal/player/player.go
index fcf274b..f56521e 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -180,14 +180,13 @@ type Player struct {
WalkSequence []string `yaml:"-"`
ConsumeCooldown int `yaml:"-"`
HomeTransportCooldown int `yaml:"-"`
- MoveTicks int `yaml:"-"`
+ MoveTicks int `yaml:"-"`
MoveDirection string `yaml:"-"`
- MoveTarget int `yaml:"-"`
- MovePendingGlobalFlags map[string]any `yaml:"-"`
- MovePendingPlayerFlags map[string]any `yaml:"-"`
- VisualTickCurrent int `yaml:"-"`
- AutotriggerMod string `yaml:"-"`
- QueuedTrigger string `yaml:"-"`
+ MoveTarget int `yaml:"-"`
+ MovePendingInteraction *behavior.Interaction `yaml:"-"`
+ VisualTickCurrent int `yaml:"-"`
+ AutotriggerMod string `yaml:"-"`
+ QueuedTrigger string `yaml:"-"`
AttackTimer int `yaml:"-"`
HazardTimer int `yaml:"-"`
PendingDangerDir string `yaml:"-"`
@@ -219,8 +218,7 @@ func (p *Player) ClearMoveState() {
p.MoveTicks = 0
p.MoveDirection = ""
p.MoveTarget = 0
- p.MovePendingGlobalFlags = nil
- p.MovePendingPlayerFlags = nil
+ p.MovePendingInteraction = nil
}
func (p *Player) OptionBool(name string) bool {
@@ -402,6 +400,19 @@ func (p *Player) HasItem(itemID string) bool {
return false
}
+// IsWielding reports whether the given item is currently held in one of the
+// player's weapon-hand equipment slots (main_hand or off_hand). It is the
+// "wielding" predicate used to gate on_kill interactions whose item_id names
+// a specific weapon — two-handed weapons occupy main_hand alone, while dual
+// wield / weapon + shield uses both hands. Armor, ammo, ring, and tool slots
+// do NOT count as "wielding".
+func (p *Player) IsWielding(itemID string) bool {
+ if itemID == "" {
+ return false
+ }
+ return p.Equipment[item.SlotMainHand] == itemID || p.Equipment[item.SlotOffHand] == itemID
+}
+
func (p *Player) CountItem(itemID string) int {
count := 0
for _, slot := range p.Inventory {
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 64b1df4..47bbd1a 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -57,16 +57,20 @@ func validateRooms(s Source) []Issue {
})
continue
}
- if exit.Room <= 0 {
- continue
+ if exit.Room > 0 {
+ if _, ok := roomIndex[exit.Room]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: exit %q targets nonexistent room %d",
+ id, dir, exit.Room),
+ })
+ }
}
- if _, ok := roomIndex[exit.Room]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("Room %d: exit %q targets nonexistent room %d",
- id, dir, exit.Room),
- })
+ for i, it := range exit.OnTraverse {
+ issues = append(issues, validateStepAction(
+ fmt.Sprintf("Room %d: exit %q on_traverse[%d]", id, dir, i),
+ it.Action, itemIDs, roomIndex, mobIDs)...)
}
}
@@ -114,7 +118,7 @@ func validateRooms(s Source) []Issue {
displayName: world.NormalizeObjectName(robj.Local.Name),
effDefID: robj.ID,
})
- issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex)...)
+ issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex, mobIDs)...)
} else if robj.ID != "" && !objIDs[robj.ID] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -162,7 +166,7 @@ func validateRooms(s Source) []Issue {
// 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 validateLocalObject(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, mobIDs map[string]bool) []Issue {
var issues []Issue
def := robj.Local
prefix := fmt.Sprintf("Room %d: local object %q", roomID, robj.ID)
@@ -192,8 +196,8 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b
if def.Safespot != nil {
bad = append(bad, "safespot")
}
- if len(def.UseInteractions) > 0 {
- bad = append(bad, "use_interactions")
+ if len(def.OnUse) > 0 {
+ bad = append(bad, "on_use")
}
if def.Steal != nil {
bad = append(bad, "steal")
@@ -218,8 +222,8 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b
})
}
- if def.OnLook != nil {
- issues = append(issues, validateNodeAction(prefix+": on_look", def.OnLook, itemIDs, roomIndex)...)
+ if len(def.OnLook) > 0 {
+ issues = append(issues, validateOnLook(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs)...)
}
return issues
@@ -343,6 +347,7 @@ func validateMobs(s Source) []Issue {
itemIDs := s.Items.IDSet()
dropIDs := dropTableIDSet(s.DataDir)
mobIDs := s.Mobs.AllDefIDs()
+ roomIndex := s.World.RoomIndex()
for id := range mobIDs {
def, err := s.Mobs.LoadDef(id)
@@ -481,6 +486,11 @@ func validateMobs(s Source) []Issue {
}
}
}
+
+ for i, it := range def.OnKill {
+ issues = append(issues, validateStepAction(
+ fmt.Sprintf("Mob %q: on_kill[%d]", id, i), it.Action, itemIDs, roomIndex, mobIDs)...)
+ }
}
return issues
@@ -584,17 +594,17 @@ func validateObjects(s Source) []Issue {
}
}
- for _, ui := range obj.UseInteractions {
+ for _, ui := range obj.OnUse {
if ui.Item != "" && !itemIDs[ui.Item] {
issues = append(issues, Issue{
Level: "ERROR",
Type: "reference",
- Message: fmt.Sprintf("Object %q: use_interaction item %q does not exist",
+ Message: fmt.Sprintf("Object %q: on_use item %q does not exist",
id, ui.Item),
})
}
if ui.Action != nil {
- issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: use_interaction", id), ui.Action, itemIDs, roomIndex)...)
+ issues = append(issues, validateStepAction(fmt.Sprintf("Object %q: on_use", id), ui.Action, itemIDs, roomIndex, mobIDs)...)
}
}
@@ -606,9 +616,8 @@ func validateObjects(s Source) []Issue {
issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex)...)
}
-
- if obj.OnLook != nil {
- issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex)...)
+ if len(obj.OnLook) > 0 {
+ issues = append(issues, validateOnLook(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex, mobIDs)...)
}
}
@@ -804,49 +813,9 @@ func validateRoomEnterSteps(s Source) []Issue {
if err != nil || len(room.OnEnter) == 0 {
continue
}
- for si, step := range room.OnEnter {
+ for si := range room.OnEnter {
stepPrefix := fmt.Sprintf("Room %d: on_enter[%d]", id, si)
- if step.SpawnMob != nil && step.SpawnMob.ID != "" {
- if _, ok := mobIDs[step.SpawnMob.ID]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: spawn_mob %q does not exist", stepPrefix, step.SpawnMob.ID),
- })
- }
- for _, wr := range step.SpawnMob.DespawnRooms {
- if _, ok := roomIndex[wr]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d", stepPrefix, wr),
- })
- }
- }
- }
- if step.GiveItem != "" && !itemIDs[step.GiveItem] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: give_item %q does not exist", stepPrefix, step.GiveItem),
- })
- }
- if step.TakeItem != "" && !itemIDs[step.TakeItem] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: take_item %q does not exist", stepPrefix, step.TakeItem),
- })
- }
- if step.Teleport > 0 {
- if _, ok := roomIndex[step.Teleport]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: teleport to nonexistent room %d", stepPrefix, step.Teleport),
- })
- }
- }
+ issues = append(issues, validateStepAction(stepPrefix, &room.OnEnter[si], itemIDs, roomIndex, mobIDs)...)
}
}
@@ -866,54 +835,9 @@ func validateRoomTriggers(s Source) []Issue {
}
for ti, trigger := range room.Triggers {
prefix := fmt.Sprintf("Room %d: trigger[%d]", id, ti)
- for si, step := range trigger.Steps {
+ for si := range trigger.Steps {
stepPrefix := fmt.Sprintf("%s: step[%d]", prefix, si)
- if step.SpawnMob != nil && step.SpawnMob.ID != "" {
- if _, ok := mobIDs[step.SpawnMob.ID]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: spawn_mob %q does not exist",
- stepPrefix, step.SpawnMob.ID),
- })
- }
- for _, wr := range step.SpawnMob.DespawnRooms {
- if _, ok := roomIndex[wr]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d",
- stepPrefix, wr),
- })
- }
- }
- }
- if step.GiveItem != "" && !itemIDs[step.GiveItem] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: give_item %q does not exist",
- stepPrefix, step.GiveItem),
- })
- }
- if step.TakeItem != "" && !itemIDs[step.TakeItem] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: take_item %q does not exist",
- stepPrefix, step.TakeItem),
- })
- }
- if step.Teleport > 0 {
- if _, ok := roomIndex[step.Teleport]; !ok {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: teleport to nonexistent room %d",
- stepPrefix, step.Teleport),
- })
- }
- }
+ issues = append(issues, validateStepAction(stepPrefix, &trigger.Steps[si], itemIDs, roomIndex, mobIDs)...)
}
}
}
@@ -1309,3 +1233,98 @@ func validateNodeAction(prefix string, na *behavior.NodeAction, itemIDs map[stri
return issues
}
+
+// validateStepAction validates the universal effect superset used by on_use,
+// on_look, on_kill, on_enter steps, trigger steps, and exit
+// traversal. It covers the inline NodeAction fields plus spawn_mob/despawn_mob
+// (when mobIDs is non-nil). Returns the list of issues found.
+func validateStepAction(prefix string, step *behavior.StepAction, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue {
+ var issues []Issue
+ if step == nil {
+ return issues
+ }
+
+ // Validate the embedded NodeAction subset.
+ if step.GiveItem != "" && !itemIDs[step.GiveItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: give_item %q does not exist",
+ prefix, step.GiveItem),
+ })
+ }
+ if step.TakeItem != "" && !itemIDs[step.TakeItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: take_item %q does not exist",
+ prefix, step.TakeItem),
+ })
+ }
+ if step.Teleport > 0 {
+ if _, ok := roomIndex[step.Teleport]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: teleport to nonexistent room %d",
+ prefix, step.Teleport),
+ })
+ }
+ }
+
+ // Spawn mob: validate mob def + despawn_rooms (only if mobIDs populated —
+ // a nil map means the caller doesn't track mob ids, e.g. local objects).
+ if step.SpawnMob != nil && step.SpawnMob.ID != "" {
+ if mobIDs != nil && !mobIDs[step.SpawnMob.ID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: spawn_mob %q does not exist",
+ prefix, step.SpawnMob.ID),
+ })
+ }
+ for _, wr := range step.SpawnMob.DespawnRooms {
+ if _, ok := roomIndex[wr]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: spawn_mob despawn_rooms references nonexistent room %d",
+ prefix, wr),
+ })
+ }
+ }
+ }
+
+ // Despawn mob.
+ if step.DespawnMob != "" && mobIDs != nil && !mobIDs[step.DespawnMob] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: despawn_mob %q does not exist",
+ prefix, step.DespawnMob),
+ })
+ }
+
+ return issues
+}
+
+// validateOnLook validates a list of on_look interactions (now a list, not a
+// single NodeAction). For each entry, validates the optional item_id (unused
+// on on_look but permitted), the action's effects, and skips (the entry's
+// condition is a runtime gate, not a static referential ref).
+func validateOnLook(prefix string, list []behavior.Interaction, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue {
+ var issues []Issue
+ for i, it := range list {
+ entryPrefix := fmt.Sprintf("%s[%d]", prefix, i)
+ if it.Item != "" && !itemIDs[it.Item] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: item_id %q does not exist",
+ entryPrefix, it.Item),
+ })
+ }
+ issues = append(issues, validateStepAction(entryPrefix, it.Action, itemIDs, roomIndex, mobIDs)...)
+ }
+ return issues
+}
diff --git a/internal/validate/local_test.go b/internal/validate/local_test.go
index d37838e..337f3d2 100644
--- a/internal/validate/local_test.go
+++ b/internal/validate/local_test.go
@@ -17,7 +17,7 @@ func TestValidateLocalObjectPassiveOK(t *testing.T) {
Hidden: true,
Description: behavior.DescList{{Text: "A small window."}},
}}
- issues := validateLocalObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil)
if len(issues) != 0 {
t.Errorf("expected no issues for passive local object, got: %+v", issues)
}
@@ -28,7 +28,7 @@ func TestValidateLocalObjectRejectsInteractable(t *testing.T) {
Name: "rock",
Gather: &behavior.GatherConfig{},
}}
- issues := validateLocalObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil)
if !containsMsg(issues, "interactable behavior") {
t.Errorf("expected interactable-behavior error, got: %+v", issues)
}
@@ -38,7 +38,7 @@ func TestValidateLocalObjectRequiresName(t *testing.T) {
robj := world.RoomObject{ID: "x", Local: &object.ObjectDef{
Description: behavior.DescList{{Text: "no name"}},
}}
- issues := validateLocalObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil)
if !containsMsg(issues, "has no name") {
t.Errorf("expected has-no-name error, got: %+v", issues)
}
@@ -46,7 +46,7 @@ func TestValidateLocalObjectRequiresName(t *testing.T) {
func TestValidateLocalObjectStrayIDWarns(t *testing.T) {
robj := world.RoomObject{ID: "anvil", Local: &object.ObjectDef{Name: "anvil", ID: "anvil"}}
- issues := validateLocalObject(1001, robj, nil, nil)
+ issues := validateLocalObject(1001, robj, nil, nil, nil)
if !containsMsg(issues, "ignored on local objects") {
t.Errorf("expected stray-id warning, got: %+v", issues)
}
diff --git a/internal/world/grid_test.go b/internal/world/grid_test.go
index 9c2c7c6..c777f90 100644
--- a/internal/world/grid_test.go
+++ b/internal/world/grid_test.go
@@ -117,11 +117,11 @@ func TestBuildGridConflictsHypotheticalInsert(t *testing.T) {
copy.Exits = make(map[ExitDir]ExitDef, len(r.Exits))
for k, v := range r.Exits {
if id == 1 && k == East {
- copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, SetGlobalFlags: v.SetGlobalFlags, SetPlayerFlags: v.SetPlayerFlags, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
+ copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, OnTraverse: v.OnTraverse, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
continue
}
if id == 2 && k == West {
- copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, SetGlobalFlags: v.SetGlobalFlags, SetPlayerFlags: v.SetPlayerFlags, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
+ copy.Exits[k] = ExitDef{Room: 5, Condition: v.Condition, BlockedMessage: v.BlockedMessage, OnTraverse: v.OnTraverse, Hidden: v.Hidden, AlwaysBlocked: v.AlwaysBlocked}
continue
}
copy.Exits[k] = v
diff --git a/internal/world/insert_remove.go b/internal/world/insert_remove.go
index c55427c..05bfa22 100644
--- a/internal/world/insert_remove.go
+++ b/internal/world/insert_remove.go
@@ -20,8 +20,7 @@ func RewirePreserving(src ExitDef, newTarget int) ExitDef {
Room: newTarget,
Condition: src.Condition,
BlockedMessage: src.BlockedMessage,
- SetGlobalFlags: src.SetGlobalFlags,
- SetPlayerFlags: src.SetPlayerFlags,
+ OnTraverse: src.OnTraverse,
Hidden: src.Hidden,
AlwaysBlocked: src.AlwaysBlocked,
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 6780c45..a9e2f7e 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -78,13 +78,13 @@ type MobCombat struct {
}
type MobDef struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- Description string `yaml:"description"`
- IdleDescriptions []string `yaml:"idle_descriptions"`
- Protected bool `yaml:"protected"`
- Unique bool `yaml:"unique"`
- Drops MobDropTable `yaml:"drops"`
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ IdleDescriptions []string `yaml:"idle_descriptions"`
+ Protected bool `yaml:"protected"`
+ Unique bool `yaml:"unique"`
+ Drops MobDropTable `yaml:"drops"`
Steal *MobSteal `yaml:"steal,omitempty"`
Task *MobTask `yaml:"task,omitempty"`
@@ -93,6 +93,8 @@ type MobDef struct {
Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
Shop *behavior.ShopConfig `yaml:"shop,omitempty"`
+
+ OnKill []behavior.Interaction `yaml:"on_kill,omitempty"`
}
func (d *MobDef) IsTalkable() bool { return d.Talk != nil }
@@ -165,6 +167,13 @@ type MobInstance struct {
TalkConfig *behavior.TalkConfig
+ // OnKill is copied from the def at spawn time and fires from endCombat
+ // when this mob is defeated — either by a combat kill or by a task mob's
+ // HP draining to zero (the "completion" of the work). The first entry
+ // whose item filter, Condition, and the first-match-wins rule all pass
+ // fires.
+ OnKill []behavior.Interaction
+
// Shop is the (shared, read-only) shop config from the def. Per-instance
// live stock is tracked in ShopStock; ShopRestock holds per-item countdown
// timers. All three are only mutated under MobStore.mu.
@@ -332,6 +341,7 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
CompleteMessage: completeMessage,
CombatDescriptions: combatDescriptions,
TalkConfig: def.Talk,
+ OnKill: def.OnKill,
}
if def.Shop != nil {
inst.Shop = def.Shop
diff --git a/internal/world/room.go b/internal/world/room.go
index 2da40fb..08e18a4 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -75,13 +75,12 @@ type SpawnDef struct {
}
type ExitDef struct {
- Room int `yaml:"room"`
- Condition *behavior.Condition `yaml:"condition,omitempty"`
- BlockedMessage string `yaml:"blocked_message,omitempty"`
- SetGlobalFlags map[string]any `yaml:"set_global_flags,omitempty"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags,omitempty"`
- Hidden bool `yaml:"hidden,omitempty"`
- AlwaysBlocked bool `yaml:"always_blocked,omitempty"`
+ Room int `yaml:"room"`
+ Condition *behavior.Condition `yaml:"condition,omitempty"`
+ BlockedMessage string `yaml:"blocked_message,omitempty"`
+ OnTraverse []behavior.Interaction `yaml:"on_traverse,omitempty"`
+ Hidden bool `yaml:"hidden,omitempty"`
+ AlwaysBlocked bool `yaml:"always_blocked,omitempty"`
}
func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
@@ -106,7 +105,7 @@ type Room struct {
Objects []RoomObject `yaml:"objects"`
ItemSpawns []SpawnDef `yaml:"item_spawns"`
Mobs []RoomMob `yaml:"mobs"`
- OnEnter []EnterStep `yaml:"on_enter"`
+ OnEnter []behavior.StepAction `yaml:"on_enter"`
Hazard string `yaml:"hazard"`
BlockTransport bool `yaml:"block_transport"`
Triggers []TriggerDef `yaml:"triggers"`
@@ -131,32 +130,6 @@ func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
return value.Decode((*raw)(rm))
}
-type EnterStep struct {
- Message string `yaml:"message"`
- Condition *behavior.Condition `yaml:"condition"`
- Delay int `yaml:"delay"`
- SetGlobalFlags map[string]any `yaml:"set_global_flags"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags"`
- Broadcast string `yaml:"broadcast"`
- BroadcastGlobal string `yaml:"broadcast_global"`
- SpawnMob *SpawnMobConfig `yaml:"spawn_mob"`
- DespawnMob string `yaml:"despawn_mob"`
- GiveItem string `yaml:"give_item"`
- TakeItem string `yaml:"take_item"`
- Teleport int `yaml:"teleport"`
- Heal int `yaml:"heal"`
-}
-
-// IsTimed reports whether the step carries enter-sequence semantics (any
-// non-zero field means it needs per-tick scheduling rather than synchronous
-// printing).
-func (e EnterStep) IsTimed() bool {
- return e.Delay > 0 || len(e.SetGlobalFlags) > 0 || len(e.SetPlayerFlags) > 0 ||
- e.Message != "" || e.Broadcast != "" || e.BroadcastGlobal != "" ||
- e.SpawnMob != nil || e.DespawnMob != "" || e.GiveItem != "" ||
- e.TakeItem != "" || e.Teleport != 0 || e.Heal != 0
-}
-
// RoomObject is either a reference to a file-backed object definition (only
// `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
@@ -190,7 +163,7 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
return err
}
isLocal := def.Name != "" || len(def.Description) > 0 || def.InRoomDescription != "" ||
- len(def.Aliases) > 0 || def.Color != "" || def.OnLook != nil
+ len(def.Aliases) > 0 || def.Color != "" || len(def.OnLook) > 0
if isLocal {
ro.ID = NormalizeObjectName(def.Name)
ro.Local = &def
@@ -204,19 +177,19 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
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"`
- Steal *object.ObjectSteal `yaml:"steal,omitempty"`
- Gather *behavior.GatherConfig `yaml:"gather,omitempty"`
- Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
- Safespot *object.SafespotConfig `yaml:"safespot,omitempty"`
- OnLook *behavior.NodeAction `yaml:"on_look,omitempty"`
+ 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"`
+ OnUse []behavior.Interaction `yaml:"on_use,omitempty"`
+ Steal *object.ObjectSteal `yaml:"steal,omitempty"`
+ Gather *behavior.GatherConfig `yaml:"gather,omitempty"`
+ Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
+ Safespot *object.SafespotConfig `yaml:"safespot,omitempty"`
+ OnLook []behavior.Interaction `yaml:"on_look,omitempty"`
}
def := ro.Local
return inlineObj{
@@ -227,7 +200,7 @@ func (ro RoomObject) MarshalYAML() (interface{}, error) {
InRoomDescription: def.InRoomDescription,
RemovalItem: def.RemovalItem,
Description: def.Description,
- UseInteractions: def.UseInteractions,
+ OnUse: def.OnUse,
Steal: def.Steal,
Gather: def.Gather,
Talk: def.Talk,
diff --git a/internal/world/room_migrate.go b/internal/world/room_migrate.go
index 6d79499..946417f 100644
--- a/internal/world/room_migrate.go
+++ b/internal/world/room_migrate.go
@@ -16,7 +16,22 @@ func (r *Room) RewriteRoomIDs(idMap map[int]int) bool {
}
changed := false
for dir, exit := range r.Exits {
+ var exitChanged bool
if remapInt(idMap, &exit.Room) {
+ exitChanged = true
+ }
+ for i := range exit.OnTraverse {
+ if exit.OnTraverse[i].Action != nil {
+ if remapInt(idMap, &exit.OnTraverse[i].Action.Teleport) {
+ exitChanged = true
+ }
+ if exit.OnTraverse[i].Action.SpawnMob != nil &&
+ remapIntSlice(idMap, exit.OnTraverse[i].Action.SpawnMob.DespawnRooms) {
+ exitChanged = true
+ }
+ }
+ }
+ if exitChanged {
r.Exits[dir] = exit
changed = true
}
diff --git a/internal/world/room_migrate_test.go b/internal/world/room_migrate_test.go
index 5696a36..c54adf2 100644
--- a/internal/world/room_migrate_test.go
+++ b/internal/world/room_migrate_test.go
@@ -1,23 +1,29 @@
package world
-import "testing"
+import (
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+)
func TestRoomRewriteRoomIDs(t *testing.T) {
r := &Room{
Exits: map[ExitDir]ExitDef{
- North: {Room: 10, SetGlobalFlags: map[string]any{"last": 10}},
+ North: {Room: 10, OnTraverse: []behavior.Interaction{{Action: &behavior.StepAction{
+ NodeAction: behavior.NodeAction{SetGlobalFlags: map[string]any{"last": 10}, Teleport: 110},
+ }}}},
South: {Room: 99},
},
Triggers: []TriggerDef{
{
Room: 30,
- Steps: []TriggerStep{
- {Teleport: 40, SpawnMob: &SpawnMobConfig{DespawnRooms: []int{50, 60}}},
+ Steps: []behavior.StepAction{
+ {NodeAction: behavior.NodeAction{Teleport: 40}, SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{50, 60}}},
},
},
},
- OnEnter: []EnterStep{
- {Teleport: 70, SpawnMob: &SpawnMobConfig{DespawnRooms: []int{80}}},
+ OnEnter: []behavior.StepAction{
+ {NodeAction: behavior.NodeAction{Teleport: 70}, SpawnMob: &behavior.SpawnMobConfig{DespawnRooms: []int{80}}},
},
Mobs: []RoomMob{
{ID: "guard", WanderRooms: []int{90, 100}},
@@ -26,7 +32,7 @@ func TestRoomRewriteRoomIDs(t *testing.T) {
idMap := map[int]int{
10: 11, 20: 21, 30: 31, 40: 41, 50: 51, 60: 61,
- 70: 71, 80: 81, 90: 91, 100: 101,
+ 70: 71, 80: 81, 90: 91, 100: 101, 110: 111,
}
if !r.RewriteRoomIDs(idMap) {
t.Fatal("expected changed=true")
@@ -43,9 +49,13 @@ func TestRoomRewriteRoomIDs(t *testing.T) {
// Author set_flags values are NOT structural room references and must not
// be remapped (the old swapid regex wrongly rewrote these).
- if v := r.Exits[North].SetGlobalFlags["last"]; v != 10 {
+ if v := r.Exits[North].OnTraverse[0].Action.SetGlobalFlags["last"]; v != 10 {
t.Errorf("set_global_flags value remapped: got %v, want 10", v)
}
+ // Exit on_traverse teleport IS a structural room reference and must remap.
+ if r.Exits[North].OnTraverse[0].Action.Teleport != 111 {
+ t.Errorf("exit on_traverse teleport = %d, want 111", r.Exits[North].OnTraverse[0].Action.Teleport)
+ }
if r.Triggers[0].Room != 31 {
t.Errorf("trigger room = %d, want 31", r.Triggers[0].Room)
diff --git a/internal/world/trigger.go b/internal/world/trigger.go
index e31a5f9..e7f3b75 100644
--- a/internal/world/trigger.go
+++ b/internal/world/trigger.go
@@ -1,61 +1,20 @@
package world
-import (
- "gopkg.in/yaml.v3"
-)
+import "thehouseoficarus/internal/behavior"
+
+// SpawnMobConfig is an alias for the behavior.SpawnMobConfig so callers that
+// historically referenced world.SpawnMobConfig continue to compile.
+type SpawnMobConfig = behavior.SpawnMobConfig
type TriggerDef struct {
- ID string `yaml:"id"`
- OnPlayerFlag string `yaml:"on_player_flag"`
- OnGlobalFlag string `yaml:"on_global_flag"`
- Value any `yaml:"value"`
- Room int `yaml:"room"`
- Steps []TriggerStep `yaml:"steps"`
+ ID string `yaml:"id"`
+ OnPlayerFlag string `yaml:"on_player_flag"`
+ OnGlobalFlag string `yaml:"on_global_flag"`
+ Value any `yaml:"value"`
+ Room int `yaml:"room"`
+ Steps []behavior.StepAction `yaml:"steps"`
}
func (t *TriggerDef) IsPlayerFlagTrigger() bool { return t.OnPlayerFlag != "" }
-func (t *TriggerDef) IsGlobalFlagTrigger() bool { return t.OnGlobalFlag != "" }
-
-type TriggerStep struct {
- Delay int `yaml:"delay"`
- Message string `yaml:"message"`
- Broadcast string `yaml:"broadcast"`
- BroadcastGlobal string `yaml:"broadcast_global"`
- SetGlobalFlags map[string]any `yaml:"set_global_flags"`
- SetPlayerFlags map[string]any `yaml:"set_player_flags"`
- SpawnMob *SpawnMobConfig `yaml:"spawn_mob"`
- GiveItem string `yaml:"give_item"`
- TakeItem string `yaml:"take_item"`
- Teleport int `yaml:"teleport"`
- Heal int `yaml:"heal"`
- DespawnMob string `yaml:"despawn_mob"`
-}
-
-type SpawnMobConfig struct {
- ID string `yaml:"id"`
- OwnerOnly bool `yaml:"owner_only"`
- DespawnOnLeave bool `yaml:"despawn_on_leave"`
- DespawnRooms []int `yaml:"despawn_rooms"`
- DespawnTicks float64 `yaml:"despawn_ticks"`
-}
-
-func (s *SpawnMobConfig) UnmarshalYAML(value *yaml.Node) error {
- if value.Kind == yaml.ScalarNode {
- var id string
- if err := value.Decode(&id); err != nil {
- return err
- }
- s.ID = id
- return nil
- }
- type raw SpawnMobConfig
- return value.Decode((*raw)(s))
-}
-
-func (t TriggerStep) IsTimed() bool {
- return t.Delay > 0 || len(t.SetGlobalFlags) > 0 || len(t.SetPlayerFlags) > 0 ||
- t.Message != "" || t.Broadcast != "" || t.BroadcastGlobal != "" ||
- t.SpawnMob != nil || t.GiveItem != "" || t.TakeItem != "" ||
- t.Teleport != 0 || t.Heal != 0 || t.DespawnMob != ""
-}
+func (t *TriggerDef) IsGlobalFlagTrigger() bool { return t.OnGlobalFlag != "" } \ No newline at end of file
diff --git a/internal/world/trigger_store.go b/internal/world/trigger_store.go
index e18ae80..04d77f4 100644
--- a/internal/world/trigger_store.go
+++ b/internal/world/trigger_store.go
@@ -8,6 +8,7 @@ import (
"sync"
"gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
)
type TriggerStore struct {
@@ -26,7 +27,7 @@ type PlayerTriggerSeq struct {
PlayerName string
TriggerID string
RoomID int
- Steps []TriggerStep
+ Steps []behavior.StepAction
Wait int
Started bool
FlagValue any
@@ -35,7 +36,7 @@ type PlayerTriggerSeq struct {
type GlobalTriggerSeq struct {
TriggerID string
RoomID int
- Steps []TriggerStep
+ Steps []behavior.StepAction
Wait int
FlagValue any
}
@@ -227,7 +228,7 @@ func (ts *TriggerStore) startPlayerSeqLocked(playerName string, t *TriggerDef, r
PlayerName: playerName,
TriggerID: t.ID,
RoomID: roomID,
- Steps: make([]TriggerStep, len(t.Steps)),
+ Steps: make([]behavior.StepAction, len(t.Steps)),
FlagValue: flagValue,
}
copy(seq.Steps, t.Steps)
@@ -246,7 +247,7 @@ func (ts *TriggerStore) startGlobalSeqLocked(t *TriggerDef, flagValue any) *Glob
seq := &GlobalTriggerSeq{
TriggerID: t.ID,
RoomID: t.Room,
- Steps: make([]TriggerStep, len(t.Steps)),
+ Steps: make([]behavior.StepAction, len(t.Steps)),
FlagValue: flagValue,
}
copy(seq.Steps, t.Steps)