aboutsummaryrefslogtreecommitdiff
path: root/building_guide/triggers.md
blob: 7ef9b4c9e1fc014c4a0542cfe9ba6962b3adf466 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
## 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
<event>:
  - 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
        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
    value: null                    # value-match filter for flag triggers
```

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 <item> on <obj>`); empty = bare `use <obj>`. `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. |
| `value` | flag triggers | Optional value-match filter; trigger only fires when the flag changes to this value. |

### 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. |
| `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
`value` filter, an optional `condition`, 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: <id> }` to restrict the trigger to
  players currently in that room. Migration injects `{ room: <roomID> }` 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: <id> }` if you want the old room-scoped behavior.

### Value-matching flag triggers

By default a flag trigger fires when the watched flag becomes truthy. Add `value:` to
require a specific value — enabling multi-stage quests off one numeric flag:

```yaml
on_flag_change:
  - on_player_flag: quest_stage
    value: 1
    steps:
      - messages: ["Quest started — find the crystal shard."]
  - on_player_flag: quest_stage
    value: 2
    steps:
      - messages: ["You found the shard — return to the elder."]
  - on_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: <id> }` 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
    value: 1
    condition:
      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
    value: 2
    condition:
      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
    value: 3
    condition:
      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.