## Triggers A **Trigger** is the universal wrapper for all event-driven behavior — objects being used or looked at, rooms being entered or exited, mobs being killed, exits being walked through, and flags changing value. Every event block is a list of Triggers; the first whose filters pass runs its `steps` as a scripted sequence. ### Flags: Global vs Player State **Global flags** (`set_global_flags`, checked with `global_flag`) are shared by every player on the server. A door opened by one player is open for everyone. A lever pulled once changes the world for all. Stored in memory — lost on server restart. Numeric values are compared by coercion (int/int64/float64 are normalized), so a YAML-decoded `3` matches a code-set `int(3)`. **Player flags** (`set_player_flags`, checked with `player_flag`) are per-character. Quest progress, "has read the sign," "paid the toll" — these are different for each player. Saved to the character YAML and persist across logins. Any flag change — world or player, set from on_look, on_use, on_kill, talk nodes, on_enter/on_exit steps, on_traverse, flag-change triggers, or admin commands — can fire other triggers watching that flag. ### The Trigger shape Every event block on every entity is `[]Trigger`. A Trigger has filters and an ordered `steps` list. The first Trigger in a block whose `item_id` and/or `condition` pass fires; its steps run as a scripted sequence. ```yaml # event block: on_use, on_look, on_kill, on_enter, on_exit, on_traverse, # on_flag_change, on_global_flag_change : - lock: false # atomic + resumable (see Lock below) item_id: "" # on_use/on_look/on_kill only condition: # optional gate; see Condition vocabulary player_flag: quest_started steps: - wait: 5 # ticks before this step fires messages: # plain strings sent to the player - "The mechanism grinds..." broadcast: "" # to everyone in the room broadcast_global: "" # to everyone online set_player_flags: {} set_global_flags: {} give_item: "" take_item: "" teleport: 0 heal: 0 credits: 0 start_game: "" # joins a casino table for the current room spawn_mob: null # SpawnMobConfig (or bare string) despawn_mob: "" aps_node: false on_player_flag: "" # on_flag_change only on_global_flag: "" # on_global_flag_change only ``` Field meanings: | Field | Applies to | Description | |---|---|---| | `lock` | all | `true` = atomic + resumable (player can only `quit`; resumes on reconnect). Default `false` = interruptable by any verb, not persisted. | | `item_id` | on_use/on_look/on_kill | `on_use`: required item (`use on `); empty = bare `use `. `on_look`: only fires if carrying this item. `on_kill`: only fires if wielding this weapon. | | `condition` | all | Optional gate evaluated when the event fires. See Condition vocabulary. | | `steps` | all | Ordered list of Step entries run as a scripted sequence. | | `on_player_flag` | on_flag_change only | Player flag this trigger watches. | | `on_global_flag` | on_global_flag_change only | Global flag this trigger watches. | ### Step vocabulary Each step in a `steps:` list can carry a `wait` (ticks to pause before this step fires) and any combination of the effects below. All effects on a step fire simultaneously when the wait elapses. | Field | Description | |---|---| | `wait` | Ticks to wait before this step fires. `0` or omitted = next tick. | | `messages` | List of plain strings sent to the triggering player only. Supports `%p` (player name) and `%v` (flag value) templates. | | `broadcast` | Text sent to everyone in the room. Inline color tags work; `\n` prefix is added automatically. | | `broadcast_global` | Text sent to every online player. Useful for server-wide announcements. | | `set_global_flags` | Map of global flags to set (shared by all players, cascades other triggers). | | `set_player_flags` | Map of player flags to set (per-character, saved to YAML). | | `spawn_mob` | Spawns a **transient** mob from a mob definition. String or SpawnMobConfig — see below. | | `despawn_mob` | Removes all trigger-spawned mobs matching the given mob ID (and optionally owner). | | `give_item` | Gives an item to the player's inventory. | | `take_item` | Removes an item from the player's inventory. | | `teleport` | Moves the player to a room ID. | | `heal` | Restores hitpoints (clamped to MaxHP). | | `credits` | Positive = award, negative = deduct credits. | | `start_game` | Joins the named casino game configured in the player's current room. | | `aps_node` | Marks this room's APS node as unlocked. | | `condition` | Per-step gate (evaluated once when the sequence reaches this step). | Messages are **plain strings**, no longer `{message, delay}` maps. The `delay` field is gone — use `wait` instead. ### Condition vocabulary Conditions gate a Trigger (or an individual step). A bare `global_flag` / `player_flag` check passes when the flag is **set to a truthy value**. `not: true` inverts any check. | Field | Description | |---|---| | `global_flag` | Passes when the named global flag is truthy. | | `player_flag` | Passes when the named player flag is truthy. | | `value` | Match a specific (non-boolean) value. | | `not` | `true` inverts the entire condition. | | `has_item` | Passes when the player carries this item. | | `min_credits` | Passes when the player has at least this many credits. | | `room` | **New.** Passes when the triggering player is currently in this room. Use it to scope flag-change triggers to a location. | | `all_of` | List of sub-conditions; all must pass. | | `any_of` | List of sub-conditions; any one must pass. | ```yaml condition: all_of: - global_flag: gate_open - player_flag: paid_toll - has_item: pass_stub - not: true player_flag: finished_quest - room: 601 # only fires if the player is in room 601 ``` ### First-match-wins Entries in an event block are walked top-to-bottom. The first whose `item_id` filter and `condition` pass fires; its entire `steps` list runs and no further entries in that block are evaluated for that event. This gates puzzle interactions cleanly: ```yaml on_use: - item_id: crystal_key condition: global_flag: crystal_inserted steps: - messages: ["The crystal key is already in the slot."] - item_id: crystal_key steps: - messages: ["You insert the crystal key. It clicks into place."] take_item: crystal_key set_global_flags: crystal_inserted: true ``` For `on_enter` specifically, the old flat step list ran **every** matching step. Migration wraps the old steps into a single Trigger entry, so all the old steps still run as one sequence. New `on_enter` content should use multiple entries with distinct conditions when the steps are mutually exclusive. ### Lock ```yaml on_use: - lock: true condition: global_flag: ritual_started steps: - messages: ["You begin the lock sequence. Typing anything except 'quit' is blocked until it finishes."] - wait: 10 spawn_mob: ritual_guardian - wait: 10 broadcast: "The ritual completes." ``` - `lock: true` — the sequence is **atomic** (the player can only `quit` while it runs) and **resumable** (if they disconnect, the sequence resumes on reconnect). - `lock: false` (default) — the sequence is **interruptable** by any verb the player types and **not persisted** across disconnect. --- ### Event blocks Every entity carries its event blocks as `[]Trigger`. Available blocks depend on the entity: | Entity | Blocks | |---|---| | Object | `on_use`, `on_look` | | Mob | `on_kill` | | Exit (room YAML) | `on_traverse` | | Room | `on_enter`, `on_exit` (NEW), `on_flag_change`, `on_global_flag_change` | | Global file (`data/triggers/*.yaml`) | one Trigger per file (flag triggers) | - **on_use / on_look** — see `objects.md`. `item_id` filters which item triggers the entry. - **on_kill** — see `mobs.md`. `item_id` filters by wielded weapon. - **on_traverse** — on an exit definition; fires when the player moves through the exit (not when it's blocked). The exit's own `condition:` / `blocked_message:` belong to the exit gate, not the Trigger list. - **on_enter** — fires after the player enters the room. - **on_exit** (NEW) — fires when a player leaves the room, **before** `RoomID` is updated to the destination. Broadcasts/spawn resolve against the old (departed) room. Mirror of on_enter, useful for parting messages, closing spawns, or recording departures. - **on_flag_change** / **on_global_flag_change** — flag-watch triggers, replacing the old room `triggers:` block. See Flag triggers below. ### Quick Start: Landing Sequence Room 1001 has a local `framed sign` object whose `on_look` plays the landing sequence directly — there is no separate `triggers:` block. The object's trigger sets the initial flag, waits, and lands the shuttle: ```yaml # data/rooms/intro/1001.yaml objects: - name: framed sign hidden: true on_look: - steps: - set_player_flags: 1001_look_sign: true - wait: 5 - wait: 5 - set_player_flags: 1001_touchdown: true ``` (Run off-screen filler steps omitted here for brevity; see the file for the full sequence.) Setting `1001_touchdown` opens the `down` exit (gated by `player_flag: 1001_touchdown`). The `on_enter` block of room 1001 is a separate scripted welcome: ```yaml # data/rooms/intro/1001.yaml on_enter: - steps: - condition: not: true player_flag: 1001_welcome messages: - "{0B bold}Welcome to The House of Icarus{/}" wait: 5 - condition: not: true player_flag: 1001_welcome messages: - "{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}" set_player_flags: 1001_welcome: true wait: 7 - condition: not: true player_flag: 1001_welcome messages: - "{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide...{/}" wait: 7 ``` Both the sign's on_look and the room's on_enter are the same Trigger shape — a top-level `steps` list under the event block. Because each is wrapped in a single Trigger entry, the whole sequence runs. Any event that sets a player flag — `on_look`, `on_use`, `on_kill`, talk nodes, on_enter/on_exit steps, on_traverse, other trigger steps — can in turn fire flag-change triggers watching that flag. --- ### Flag triggers `on_flag_change` and `on_global_flag_change` replace the old room `triggers:` block. Each entry carries the flag it watches (`on_player_flag` or `on_global_flag`), an optional `condition` (use `player_flag`/`global_flag` + `value` to match a specific flag value), and a `steps` list. Key behaviors: - **Listen globally.** A flag-change trigger fires regardless of where the flag is set. The event block lives in a room (for broadcasts/spawns to use that room) or in a global file (see Global trigger files). - **Room scoping is opt-in.** Use `condition: { room: }` to restrict the trigger to players currently in that room. Migration injects `{ room: }` into migrated room triggers to preserve the old room-scoped behavior; authors can delete it to make the trigger fire globally. - **Fires on the non-existent → existent transition.** Setting a flag that didn't exist before counts as a change and fires the trigger. Setting a flag to the same value it already has does **not** re-fire. - **Triggers activate on becoming truthy.** Setting `true → false` does **not** fire. ```yaml # A room watches a player flag and plays a sequence for that player on_flag_change: - on_player_flag: lever_pulled steps: - broadcast: "The lever snaps back into place with a loud clunk." - set_player_flags: lever_pulled: false # reset so next pull re-fires ``` To migrate the old room `triggers:` block, replace `triggers:` with `on_flag_change:` (for `on_player_flag`) or `on_global_flag_change:` (for `on_global_flag`) and add `condition: { room: }` if you want the old room-scoped behavior. ### Value-matching flag triggers By default a flag trigger fires when the watched flag becomes truthy. Use a `condition` with a `player_flag`/`global_flag` + `value` to require a specific value — enabling multi-stage quests off one numeric flag: ```yaml on_flag_change: - on_player_flag: quest_stage condition: player_flag: quest_stage value: 1 steps: - messages: ["Quest started — find the crystal shard."] - on_player_flag: quest_stage condition: player_flag: quest_stage value: 2 steps: - messages: ["You found the shard — return to the elder."] - on_player_flag: quest_stage condition: player_flag: quest_stage value: 3 steps: - messages: ["The ritual begins..."] - wait: 10 broadcast: "The temple hums with ancient power!" - spawn_mob: crystal_guardian ``` ### Global flag trigger A room can watch a global flag and fire once globally (not per-player). Everyone in the room sees the broadcast: ```yaml on_global_flag_change: - on_global_flag: floodgate_open steps: - broadcast: "Ancient gears grind as the floodgate slowly opens..." - wait: 15 broadcast: "Water thunders through the opening!" - set_global_flags: valley_flooded: true ``` ### Global trigger files Global triggers live one-per-file in `data/triggers/`. They fire regardless of where the flag-setting player is. Use them for server-wide events. The filename stem is the trigger ID (must be globally unique). One Trigger per file — the block fields sit at the top level: ```yaml # data/triggers/announce_99.yaml on_player_flag: announce_99_skill steps: - broadcast_global: "%p has reached level 99 %v!" ``` The game code sets `announce_99_skill` to the skill name (e.g. `"attack"`) when a player hits level 99. `%p` expands to the player's name; `%v` expands to the flag value. Renders: ``` PlayerName has reached level 99 attack! ``` A world-first boss kill: ```yaml # data/triggers/world_boss_slain.yaml on_global_flag: world_boss_slain steps: - broadcast_global: "The Ancient One has been vanquished! The land stirs with new life." - set_global_flags: ancient_lands_access: true # opens a zone for everyone ``` --- ### Boss arena — puzzle unlocks a boss Player activates an altar (on a separate object) setting a player flag, and this room's `on_flag_change` triggers the boss spawn: ```yaml # data/rooms/dungeon/boss_chamber.yaml on_flag_change: - on_player_flag: activated_altar steps: - broadcast: "The altar glows with an eerie light..." - wait: 10 broadcast: "The ground trembles beneath your feet." - wait: 20 broadcast: "A massive guardian emerges from the shadows!" - spawn_mob: id: altar_guardian owner_only: true despawn_on_leave: true despawn_rooms: [450, 451, 452] ``` The boss is `owner_only` — only the player who triggered it can interact with it. It won't despawn as long as the owner stays in rooms 450, 451, or 452 (a 3-room boss arena). When the owner leaves those rooms, the boss begins despawning. ### Story beat — timed cutscene after NPC conversation An NPC conversation node sets `quest_ritual: started`. A room trigger plays a dramatic sequence for that player: ```yaml on_flag_change: - on_player_flag: quest_ritual steps: - wait: 8 messages: ["The elder begins to chant in a language you don't recognize."] - wait: 6 messages: ["Wisps of light swirl around the altar."] - wait: 4 messages: ["The ground beneath you shudders as the ritual reaches its peak."] - wait: 6 broadcast: "A blinding flash fills the chamber!" - teleport: 601 messages: ["You open your eyes. You're somewhere else entirely."] ``` The final step uses `teleport` and `messages` (the triggering player is moved and sees a personal message), while the previous step broadcasts to the ritual room. --- ### Cascading triggers When a step sets a flag, any triggers watching that flag fire immediately (on the next tick). This lets you chain sequences: ```yaml on_flag_change: - on_player_flag: phase_1_done steps: - broadcast: "The first seal cracks." - set_player_flags: phase_2_started: true # triggers the next trigger - on_player_flag: phase_2_started steps: - wait: 10 broadcast: "The second seal glows brighter..." - wait: 10 spawn_mob: phase_2_adds ``` Both sequences run concurrently — phase 2's wait countdown starts on the same tick phase 1 completes. **Self-re-triggering is prevented.** A trigger can't fire itself again while its sequence is already in progress (tracked per-player per-trigger-ID). Two different triggers watching the same flag both fire independently. --- ### Transient Mobs Mobs spawned via `spawn_mob` are **transient** — they exist until killed or despawned, but do **not** respawn. They behave exactly like regular mobs: they can be attacked, talked to, examined, and stolen from. #### SpawnMobConfig `spawn_mob` accepts either a bare string (mob ID) or a full config map: ```yaml spawn_mob: altar_guardian # Equivalent to: spawn_mob: id: altar_guardian ``` Full config: | Field | Default | Description | |---|---|---| | `id` | *(required)* | Mob definition ID from `data/mobs/`. | | `owner_only` | `false` | Only the triggering player can interact (attack, talk, steal). Non-owners see "They don't seem interested in you." Everyone can still `look`. | | `despawn_on_leave` | `false` | If `true`, the mob despawns when the owner leaves the allowed rooms. | | `despawn_rooms` | spawn room | Rooms the owner can be in without triggering despawn. Empty = the spawn room only. | | `despawn_ticks` | `0` | Tick countdown after owner leaves despawn_rooms. `0` = immediate removal. Owner returning resets the countdown. | #### Personal boss — despawns if you leave the arena ```yaml steps: - spawn_mob: id: arena_champion owner_only: true despawn_on_leave: true despawn_ticks: 60 # 60-tick grace period if you step out ``` #### Persistent NPC — stays until killed ```yaml spawn_mob: id: wandering_merchant # despawn_on_leave defaults to false — stays forever ``` #### Despawning via a step Use `despawn_mob` in a step to remove previously spawned mobs: ```yaml on_flag_change: - on_player_flag: puzzle_solved steps: - despawn_mob: puzzle_guardian # remove the puzzle mob - wait: 10 broadcast: "The guardian dissolves into mist." - spawn_mob: boss_guardian # spawn the real boss ``` `despawn_mob` with an owner only removes mobs spawned by that player. Without an owner (in global-flag triggers), it removes all matching mobs. #### Fixed-lifetime mob A mob that exists for exactly 2 minutes regardless of owner location: ```yaml steps: - spawn_mob: id: timed_challenge_mob owner_only: true - wait: 200 # 200 ticks = 2 minutes despawn_mob: timed_challenge_mob broadcast: "The challenge ends. The mob vanishes." ``` --- ### Template Variables Steps that send text support these variables: | Variable | Expands to | |---|---| | `%p` | Player name (only available for player-flag triggers) | | `%v` | Flag value at the time the trigger fired (e.g. the skill name for level-up announcements) | ```yaml steps: - broadcast_global: "%p has completed %v!" # Renders: "Alice has completed the Gauntlet!" ``` --- ### How Flags Get Set Triggers fire whenever a flag changes — it doesn't matter *how* the flag was set. All of these paths activate triggers: | Source | Example | |---|---| | 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 / on-exit steps | Room entry sets `1001_welcome: true` | | Exit `on_traverse` | Walking through an exit sets `boarded_shuttle: true` | | Other trigger steps | One trigger chain-sets a flag for another trigger | --- ### Trigger Firing Rules 1. **Flag triggers fire on actual value change.** Setting `true → true` is a no-op. Setting `1 → 2` fires if a trigger watches that flag. Setting `nil → true` counts as a change and fires. Triggers activate on becoming truthy, not on becoming falsy — setting `true → false` does **not** fire. 2. **First-match-wins for event blocks.** The first Trigger in a block whose `item_id` and `condition` pass fires; its entire `steps` sequence runs. Other entries are skipped for that event. 3. **Per-player per-trigger-ID.** The same player can't have two instances of the same trigger running simultaneously. Starting a new one replaces the old. 4. **Player must be online.** Player-flag triggers only fire for connected players. Global-flag triggers fire regardless. 5. **Flag triggers listen globally; scoping is opt-in.** `on_flag_change` / `on_global_flag_change` fire regardless of where the flag is set. Add `condition: { room: }` to restrict to players currently in a room. 6. **Cascading triggers run concurrently.** If trigger A sets a flag that activates trigger B, both sequences advance on each tick independently. --- ### Full Scenario: Multi-Room Investigation Quest A player investigates a murder scene across several rooms. Finding clues in order advances a numeric flag through stages. The last stage spawns a boss. **Step 1: The body — sets `investigation: 1`** ```yaml # data/objects/unique/5001_body.yaml name: body on_look: - steps: - set_player_flags: investigation: 1 ``` **Step 2: Blood trail in room 5002** — flag trigger fires on value 1 ```yaml # data/rooms/city/5002_alley.yaml on_flag_change: - on_player_flag: investigation condition: all_of: - player_flag: investigation value: 1 - room: 5002 steps: - wait: 3 messages: ["You notice a trail of blood leading east..."] - set_player_flags: investigation: 2 ``` **Step 3: Broken dagger in room 5003** — flag trigger fires on value 2 ```yaml # data/rooms/city/5003_warehouse.yaml on_flag_change: - on_player_flag: investigation condition: all_of: - player_flag: investigation value: 2 - room: 5003 steps: - wait: 3 messages: ["A glint of metal catches your eye under a crate."] ``` **Step 4: Confrontation** — flag trigger fires on value 3 (set by a separate talk node), spawns boss ```yaml # data/rooms/city/5004_docks.yaml on_flag_change: - on_player_flag: investigation condition: all_of: - player_flag: investigation value: 3 - room: 5004 steps: - wait: 5 broadcast: "A shadow detaches itself from the warehouse wall..." - wait: 5 broadcast: "The assassin steps into the light, blade drawn." - spawn_mob: id: assassin_boss owner_only: true despawn_on_leave: true despawn_rooms: [5004] ``` The player must stay in room 5004 to fight the boss. If they flee, the boss despawns and the flag stays at 3 — they can't re-trigger anything because the flag is already at that value. --- ### Full Scenario: Server-Wide World Event A server event progresses through stages using global triggers. Each global trigger chain-sets the next global flag. **Phase 1 trigger:** ```yaml # data/triggers/event_phase1.yaml on_global_flag: event_phase1_start steps: - broadcast_global: "The sky darkens as an eclipse begins..." - wait: 100 set_global_flags: event_phase2_start: true ``` **Phase 2 trigger:** ```yaml # data/triggers/event_phase2.yaml on_global_flag: event_phase2_start steps: - broadcast_global: "Monsters pour from the shadows across the land!" - wait: 300 set_global_flags: event_phase3_start: true ``` **Phase 3 trigger — event ends:** ```yaml # data/triggers/event_phase3.yaml on_global_flag: event_phase3_start steps: - broadcast_global: "The eclipse passes. The monsters retreat." - wait: 50 set_global_flags: event_active: false ``` GM commands or admin tools set `event_phase1_start: true` to kick things off. The cascade handles the rest. --- ### Validation Startup validation checks that trigger `spawn_mob.id` references an existing mob definition, `give_item`/`take_item` reference existing items, `teleport` and `despawn_rooms` reference existing rooms, and that global trigger IDs are unique. Validation does NOT check that the watched flags (`on_player_flag` / `on_global_flag`) are ever set — those are dynamic, set by runtime gameplay, and can't be statically verified.