diff options
| author | historia <[not public]> | 2026-07-08 19:59:14 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-08 19:59:14 -0400 |
| commit | 09d325dcf5e779eab5550d3fd3377bde50101428 (patch) | |
| tree | a433be903aabbf1d2eadce2aacdac12a9eb8dde0 | |
| parent | 9184377301c2604e003f36426788c032fb0ca524 (diff) | |
| download | thehouseoficarus-09d325dcf5e779eab5550d3fd3377bde50101428.tar.gz | |
feat: unify on use, on look, and on kill. all support same conditions/actions now.
45 files changed, 1266 insertions, 913 deletions
@@ -19,7 +19,7 @@ File prefixes in `internal/game/`: `game.go/cmd_registry.go/tick.go` (core), `co ## Key Conventions -**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses under `data/`. Read from disk on every access — edit and it takes effect immediately. **The filename is the ID** for every data type: lookup by filename stem. **Do not put a top-level `id:` field** (ignored). A nested `- id:` under a room's `objects:`/`mobs:` is a *reference* and is still required. Subdirectories are organizational only. **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 ? '▶' : '▼') + '</span>'; + h += 'How Conditions & 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 ? '▶' : '▼') + '</span>'; - h += 'How Set Global Flags & 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> — Sets world flags shared by all players. JSON object mapping flag names to values:<br>'; - h += ' <code>{"gate_open":true,"boss_summoned":false}</code><br>'; - h += ' Flags persist until changed by another on_look, room script, or interact action.<br><br>'; - h += '<b>Set Player Flags</b> — Sets per-character flags saved to the player\'s YAML. JSON object mapping flag names to values:<br>'; - h += ' <code>{"has_seen_cave":true,"dialog_spoke":1}</code><br>'; - h += ' These flags can be used in conditions (<code>player_flag</code>) to track player progress.<br><br>'; - h += '<b>Other Fields</b> — <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 ? '▶' : '▼') + '</span>'; h += 'How Conditions & 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 <item> on <object></code>; omit for plain <code>use <object></code>.<br>'; - h += '<b>Condition</b>: JSON. When this interaction is available:<br>'; - h += ' <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 <item> on <object></code>; empty = bare <code>use <object></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 += ' <code>{"global_flag":"gate_open","not":true}</code> — world flag is NOT set<br>'; h += ' <code>{"player_flag":"has_key","value":1}</code> — player flag matches value<br>'; h += ' <code>{"has_item":"silver_bar"}</code> — player carries item<br>'; - h += ' <code>{"all_of":[{"flag":"a"},{"has_item":"b"}]}</code> — ALL sub-conditions<br>'; - h += ' <code>{"any_of":[...]}</code> — ANY sub-condition<br>'; + h += ' <code>{"min_credits":50}</code> — minimum credits<br>'; + h += ' <code>{"all_of":[{"global_flag":"a"},{"has_item":"b"}]}</code> — ALL<br>'; + h += ' <code>{"any_of":[...]}</code> — ANY<br>'; h += ' Omit for an unconditional interaction.<br>'; - h += '<b>Action</b>: JSON. What happens when the interaction fires:<br>'; - h += ' <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 += ' <code>{"set_global_flags":{"gate_open":true}}</code> — set world flags<br>'; h += ' <code>{"set_player_flags":{"spoke":true}}</code> — set player flags<br>'; - h += ' <code>{"give_item":"keycard","teleport":5}</code> — give item + teleport<br>'; - h += ' <code>{"take_item":"bomb","heal":10}</code> — take item + heal<br>'; - h += ' <code>{"credits":-50}</code> — deduct credits<br>'; - h += ' <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 += ' <code>{"give_item":"keycard","take_item":"bomb"}</code> — give/take item<br>'; + h += ' <code>{"teleport":5,"heal":10,"credits":100}</code> — move, heal, give credits<br>'; + h += ' <code>{"aps_node":true}</code> — mark current room as APS node<br>'; + h += ' <code>{"spawn_mob":"rat","despawn_mob":"rat"}</code> — spawn/despawn transient mob<br>'; + h += ' <code>{"broadcast":"%p activates it.","broadcast_global":"%p did it!"}</code> — announce (%p = player, %v = flag value)<br>'; + h += ' <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(§ionObjs, &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(§ionObjs, &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(§ionObjs, &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) |
