Skip to main content

delvewright_dsl/quest/
effect.rs

1//! An effect: a verb with its guards, audience and happening, and the bonfire
2//! dialog's canonical labels.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    ActorId, AnchorId, CameraShot, CameraSubject, Carrier, CutsceneParty,
9    DEFAULT_COLLAPSE_FALLING_BLOCK, DEFAULT_VOLLEY_INTERVAL, DEFAULT_VOLLEY_PROJECTILE,
10    DEFAULT_VOLLEY_SALVOS, FlagId, Happening, HappeningSubject, Mark, NarrateStyle, NpcId,
11    ParticleAt, PrefabId, SoundAt, StateCompare, StateId, StateWrite, StationKind, StealthZone,
12    Verb, WaveId, WorldTime, WorldWeather,
13};
14
15#[cfg(doc)]
16use crate::BodyRef;
17
18/// The canonical English title of the bonfire rest dialog. Baked at emit time
19/// when the campaign authors no `prompt`, in the
20/// `world.boundary.message` tradition: a compiler default is not inventoried, an
21/// authored line is — so a delve that wants this sentence in `zh-cn` authors it.
22pub const BONFIRE_PROMPT_EN: &str = "Bonfire";
23/// The canonical English label of the **rest and save** option.
24pub const BONFIRE_REST_LABEL_EN: &str = "Rest and save";
25/// The canonical English label of the **save only** option.
26pub const BONFIRE_SAVE_LABEL_EN: &str = "Save only";
27
28/// A `bonfire`'s authored rest-dialog strings, each `None` when the campaign
29/// leaves the compiler's canonical English in place
30/// ([`QuestEffect::bonfire_labels`]).
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct BonfireLabels<'a> {
33    /// Dialog title; `None` → [`BONFIRE_PROMPT_EN`].
34    pub prompt: Option<&'a str>,
35    /// **Rest and save** button label; `None` → [`BONFIRE_REST_LABEL_EN`].
36    pub rest_label: Option<&'a str>,
37    /// **Save only** button label; `None` → [`BONFIRE_SAVE_LABEL_EN`].
38    pub save_label: Option<&'a str>,
39    /// **Rest and save** button hover tooltip (spec-0078); `None` → none emitted.
40    pub rest_tooltip: Option<&'a str>,
41    /// **Save only** button hover tooltip (spec-0078); `None` → none emitted.
42    pub save_tooltip: Option<&'a str>,
43}
44
45impl BonfireLabels<'_> {
46    /// The dialog title actually emitted.
47    pub fn prompt_or_default(&self) -> &str {
48        self.prompt.unwrap_or(BONFIRE_PROMPT_EN)
49    }
50    /// The **rest and save** label actually emitted.
51    pub fn rest_or_default(&self) -> &str {
52        self.rest_label.unwrap_or(BONFIRE_REST_LABEL_EN)
53    }
54    /// The **save only** label actually emitted.
55    pub fn save_or_default(&self) -> &str {
56        self.save_label.unwrap_or(BONFIRE_SAVE_LABEL_EN)
57    }
58}
59
60/// The condition under which an effect fires, as one object.
61///
62/// The three axes of [`crate::gate::Gate`] in their declared form: the flags that
63/// must be set, the flags that must not be, and the numeric comparisons that must
64/// hold. Declared once and carried by [`QuestEffect::when`], so every verb is
65/// gatable on exactly the same terms and a fourth axis is one field here.
66#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
67#[serde(deny_unknown_fields)]
68pub struct Guard {
69    /// Flags that must ALL be set (per party) for the effect to fire. Emission
70    /// wraps the effect's commands in `execute if score #party dw.f_<flag> matches 1`.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub requires_flags: Vec<FlagId>,
73    /// Flags whose being set SUPPRESSES the effect — the dual of `requires_flags`,
74    /// emitted as `execute unless score #party dw.f_<flag> matches 1`.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub forbids_flags: Vec<FlagId>,
77    /// Numeric comparisons that must ALL hold (spec-0031), emitted as
78    /// `execute if score <holder> dw.s_<state> matches <range>`.
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub requires_state: Vec<StateCompare>,
81}
82
83/// An effect fired by quest progress: **one guard, one story note, one verb.**
84///
85/// The guard is a property of an effect, not of the verb that first wanted one, so
86/// it is declared once in [`Guard`] and every verb carries it — including the
87/// staging and souls vocabulary (`spawn-actor`, `move-actor`, `set-checkpoint`,
88/// `bonfire`, `begin-stealth`, `sequence`, …) that could not be branch-gated while
89/// the fields lived on the variants. A gate that can never open on a
90/// `campaign-complete` is caught where it belongs, by the completability proof.
91///
92/// `verb` is `#[serde(flatten)]`, so the JSON is unchanged in shape apart from the
93/// guard moving under `when`: `{"type": "open-gate", "anchor": "…", "when":
94/// {"requires_flags": ["…"]}}`. [`Verb`] keeps `deny_unknown_fields`, which is what
95/// the flattened deserializer applies to everything the outer struct did not claim
96/// — an author's typo is still `DW0100`.
97#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
98pub struct QuestEffect {
99    /// When this effect fires. `None` is the always-open gate.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub when: Option<Guard>,
102    /// What this beat does to the story (spec-0025) — validation metadata with no
103    /// emission of its own.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub happening: Option<Happening>,
106    /// **Who a player-facing effect addresses** (spec-0085 §3.2): `party` or
107    /// `actor`, the one player whose act fired the root. Absent = the root's own
108    /// answer — a quest completion addresses the party, a death, a respawn, a
109    /// purchase, a credited kill and a `presser` trigger address their actor, a
110    /// polled trigger, a trap and a shortcut address the party — so a campaign
111    /// that never writes the field emits exactly what it emitted before it
112    /// existed.
113    ///
114    /// A property of the envelope rather than of any verb: it reaches every
115    /// verb the emitter addresses to players ([`Verb::addresses_players`]), and
116    /// on any other verb it is refused (`DW0942`). `actor` where emission has no
117    /// acting player is `DW0503`.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub audience: Option<EffectAudience>,
120    /// **Narrows the audience to players inside an anchor-centred box**
121    /// (`anchor ± extent`, spec-0085 §3.2) at the moment the effect fires — the
122    /// same [`StealthZone`] a `begin-stealth` zone and a `lethal_volumes[]`
123    /// region take, resolved through the one `Plan::zone_box`. Composes with
124    /// [`Self::audience`]: `actor` + `in` is the actor, if they stand in the box.
125    ///
126    /// One field on the envelope, reaching every player-facing verb; refused on
127    /// any other (`DW0942`) — a box narrows an audience, and a world fact has
128    /// none.
129    #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
130    pub within: Option<StealthZone>,
131    /// What the effect does.
132    #[serde(flatten)]
133    pub verb: Verb,
134}
135
136/// **Who a player-facing effect addresses** (spec-0085 §3.2).
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138#[serde(rename_all = "kebab-case")]
139pub enum EffectAudience {
140    /// Every player — `@a`.
141    Party,
142    /// The one player whose act fired the root — the completing player, the
143    /// presser, the dying or respawning player, the buyer, the credited killer.
144    /// Emitted `@s`, so it exists only where emission has an acting player
145    /// (`DW0503`).
146    Actor,
147}
148
149impl EffectAudience {
150    /// The token the document spells.
151    pub fn token(self) -> &'static str {
152        match self {
153            EffectAudience::Party => "party",
154            EffectAudience::Actor => "actor",
155        }
156    }
157}
158
159impl From<Verb> for QuestEffect {
160    /// An unguarded effect with no story note and the root's own audience — the
161    /// shape a compiler-synthesized beat and most tests want.
162    fn from(verb: Verb) -> Self {
163        QuestEffect {
164            when: None,
165            happening: None,
166            audience: None,
167            within: None,
168            verb,
169        }
170    }
171}
172
173/// **How a nested effect list is dispatched, relative to the bundle it sits in**
174/// (spec-0085 §3.2) — the one statement of which command source each nesting
175/// site runs under, read by `DW0357`/`DW0503` and by the emitter's timeline
176/// keying alike.
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub enum NestedDispatch {
179    /// Under the parent's own source: a `sequence` step. A timeline started
180    /// where there is an acting player carries that player across its
181    /// `schedule`s by a compiler-owned tag, and a timeline started from the
182    /// server source has nobody to carry.
183    Inherit,
184    /// As one player, whatever the parent was: a `set-checkpoint`'s
185    /// `on_respawn` (the respawning player) and a `begin-stealth`'s `on_caught`
186    /// (the spotted player).
187    Player,
188    /// From the server command source, whatever the parent was: a
189    /// `move-npc`/`move-actor` `on_arrive` (the driver's scheduled tick) and a
190    /// `bonfire`'s `on_rest` (the party-wide rest, dispatched from the tick).
191    Server,
192}
193
194impl NestedDispatch {
195    /// Whether a list dispatched this way, inside a bundle that does (or does
196    /// not) have an acting player, has one.
197    pub fn has_actor(self, parent_has_actor: bool) -> bool {
198        match self {
199            NestedDispatch::Inherit => parent_has_actor,
200            NestedDispatch::Player => true,
201            NestedDispatch::Server => false,
202        }
203    }
204}
205
206impl QuestEffect {
207    /// Each nested effect list with how it is dispatched ([`NestedDispatch`]) —
208    /// the same lists, in the same order, as [`Self::nested_effect_lists`].
209    pub fn nested_effect_dispatch(&self) -> Vec<(&[QuestEffect], NestedDispatch)> {
210        match &self.verb {
211            Verb::Sequence { steps } => steps
212                .iter()
213                .map(|s| (s.effects.as_slice(), NestedDispatch::Inherit))
214                .collect(),
215            Verb::SetCheckpoint { on_respawn, .. } => {
216                vec![(on_respawn.as_slice(), NestedDispatch::Player)]
217            }
218            Verb::Bonfire { on_rest, .. } => vec![(on_rest.as_slice(), NestedDispatch::Server)],
219            Verb::BeginStealth { on_caught, .. } => {
220                vec![(on_caught.as_slice(), NestedDispatch::Player)]
221            }
222            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
223                vec![(on_arrive.as_slice(), NestedDispatch::Server)]
224            }
225            _ => Vec::new(),
226        }
227    }
228
229    /// Whether this effect addresses players at all — [`Verb::addresses_players`].
230    pub fn addresses_players(&self) -> bool {
231        self.verb.addresses_players()
232    }
233    /// The gate anchor if this is `open-gate`.
234    pub fn open_gate_anchor(&self) -> Option<&AnchorId> {
235        match &self.verb {
236            Verb::OpenGate { anchor, .. } => Some(anchor),
237            _ => None,
238        }
239    }
240
241    /// The gate anchor if this is `close-gate` (DSL v0.6).
242    pub fn close_gate_anchor(&self) -> Option<&AnchorId> {
243        match &self.verb {
244            Verb::CloseGate { anchor, .. } => Some(anchor),
245            _ => None,
246        }
247    }
248
249    /// The authored `sealed_hint` if this is a `close-gate` that declares one (DSL
250    /// v0.8). `None` for every other effect **and** for a `close-gate` that takes
251    /// the compiler's canonical English seal line.
252    pub fn close_gate_sealed_hint(&self) -> Option<&str> {
253        match &self.verb {
254            Verb::CloseGate { sealed_hint, .. } => sealed_hint.as_deref(),
255            _ => None,
256        }
257    }
258
259    /// The wave id if this is `spawn-wave` (v0.3).
260    pub fn spawn_wave(&self) -> Option<&WaveId> {
261        match &self.verb {
262            Verb::SpawnWave { wave, .. } => Some(wave),
263            _ => None,
264        }
265    }
266
267    /// The flag id if this is `set-flag` (v0.3).
268    pub fn set_flag(&self) -> Option<&FlagId> {
269        match &self.verb {
270            Verb::SetFlag { flag, .. } => Some(flag),
271            _ => None,
272        }
273    }
274
275    /// The item id if this is `give-item` (v0.3).
276    pub fn give_item(&self) -> Option<&str> {
277        match &self.verb {
278            Verb::GiveItem { item, .. } => Some(item),
279            _ => None,
280        }
281    }
282
283    /// `true` if this is a `give-item` carrying a v0.4 display `name`.
284    pub fn give_item_named(&self) -> bool {
285        matches!(self.verb, Verb::GiveItem { name: Some(_), .. })
286    }
287
288    /// The declared `carrier` if this is a `give-item` that states one (v0.6,
289    /// spec-0018). `None` for any other effect **and** for a `give-item` that
290    /// leaves it absent — absent reads as [`Carrier::All`], and the distinction
291    /// matters only for the pre-0.6 reserved-field gate.
292    pub fn give_carrier(&self) -> Option<Carrier> {
293        match &self.verb {
294            Verb::GiveItem { carrier, .. } => *carrier,
295            _ => None,
296        }
297    }
298
299    /// Does this `give-item` hand a single copy to the acting player (v0.6,
300    /// spec-0018)? `false` for every other effect and for the party-wide default.
301    pub fn gives_to_one(&self) -> bool {
302        matches!(self.give_carrier(), Some(Carrier::One))
303    }
304
305    /// The `set-block` block id if this is a v0.4 `set-block` effect.
306    pub fn set_block(&self) -> Option<(&AnchorId, &str)> {
307        match &self.verb {
308            Verb::SetBlock { anchor, block, .. } => Some((anchor, block.as_str())),
309            _ => None,
310        }
311    }
312
313    /// The NPC id if this is a v0.4 `despawn-npc` effect.
314    pub fn despawn_npc(&self) -> Option<&NpcId> {
315        match &self.verb {
316            Verb::DespawnNpc { npc, .. } => Some(npc),
317            _ => None,
318        }
319    }
320
321    /// `(npc, to)` if this is a v0.4 `move-npc` effect.
322    pub fn move_npc(&self) -> Option<(&NpcId, &Mark)> {
323        match &self.verb {
324            Verb::MoveNpc { npc, to, .. } => Some((npc, to)),
325            _ => None,
326        }
327    }
328
329    /// The v0.3 effect name if this effect is one introduced in DSL v0.3
330    /// (`give-item`/`set-flag`/`spawn-wave`). These validate in v0.3 campaigns
331    ///.
332    pub fn v03_effect(&self) -> Option<&'static str> {
333        match &self.verb {
334            Verb::GiveItem { .. } => Some("give-item"),
335            Verb::SetFlag { .. } => Some("set-flag"),
336            Verb::SpawnWave { .. } => Some("spawn-wave"),
337            Verb::OpenGate { .. }
338            | Verb::CloseGate { .. }
339            | Verb::CampaignComplete { .. } => None,
340            // v0.4 effects report via `v04_effect`; v0.5 via `v05_effect`; they
341            // are not v0.3 verbs.
342            Verb::Narrate { .. }
343            | Verb::SetBlock { .. }
344            | Verb::DespawnNpc { .. }
345            | Verb::MoveNpc { .. }
346            | Verb::Cutscene { .. }
347            | Verb::SetTime { .. }
348            | Verb::SetWeather { .. }
349            | Verb::PlaySound { .. }
350            | Verb::DamagePlayers { .. }
351            | Verb::SetCheckpoint { .. }
352            | Verb::Bonfire { .. }
353            | Verb::BeginStealth { .. }
354            | Verb::EndStealth
355            | Verb::SpawnActor { .. }
356            | Verb::DespawnActor { .. }
357            | Verb::MoveActor { .. }
358            | Verb::UnleashActor { .. }
359            | Verb::SpawnNpc { .. }
360            | Verb::Sequence { .. }
361            // spec-0022 trap-payload verbs are v0.6 — they report via `v06_effect`.
362            | Verb::Volley { .. }
363            | Verb::Collapse { .. }
364            // spec-0031's state verbs, region writes, status effects and teleport,
365            // and spec-0032's `drop-stake`, are all v0.10 — they report via
366            // `v10_effect`.
367            | Verb::SetState { .. }
368            | Verb::AddState { .. }
369            | Verb::ClearState { .. }
370            | Verb::FillRegion { .. }
371            | Verb::ClearRegion { .. }
372            | Verb::SetAtmosphere { .. }
373            // spec-0042's `open-way` is v0.12 — it reports via `v12_effect`.
374            | Verb::OpenWay { .. }
375            | Verb::GiveEffect { .. }
376            | Verb::ClearEffect { .. }
377            | Verb::Teleport { .. }
378            // spec-0068's `firework` is v0.29.
379            | Verb::Firework { .. }
380            // spec-0082's assembly verbs.
381            | Verb::SpawnAssembly { .. }
382            | Verb::DespawnAssembly { .. }
383            | Verb::PlayClip { .. }
384            // spec-0094's `arm-strikes`.
385            | Verb::ArmStrikes { .. }
386            // spec-0085's `particle`.
387            | Verb::Particle { .. }
388            // spec-0092's `lightning`.
389            | Verb::Lightning { .. }
390            | Verb::DropStake { .. } => None,
391        }
392    }
393
394    /// The v0.4 effect name if this effect is one introduced in DSL v0.4
395    /// (`narrate`/`set-block`/`despawn-npc`/`move-npc`/`cutscene`). These validate
396    /// in v0.4 campaigns.
397    pub fn v04_effect(&self) -> Option<&'static str> {
398        match &self.verb {
399            Verb::Narrate { .. } => Some("narrate"),
400            Verb::SetBlock { .. } => Some("set-block"),
401            Verb::DespawnNpc { .. } => Some("despawn-npc"),
402            Verb::MoveNpc { .. } => Some("move-npc"),
403            Verb::Cutscene { .. } => Some("cutscene"),
404            _ => None,
405        }
406    }
407
408    /// The v0.5 effect name if this effect is one introduced in DSL v0.5
409    /// (`set-time`/`set-weather`, spec-0010).
410    pub fn v05_effect(&self) -> Option<&'static str> {
411        match &self.verb {
412            Verb::SetTime { .. } => Some("set-time"),
413            Verb::SetWeather { .. } => Some("set-weather"),
414            _ => None,
415        }
416    }
417
418    /// The v0.6 effect name if this effect is one introduced in DSL v0.6
419    /// (`set-checkpoint`, spec-0012; `begin-stealth`/`end-stealth`, spec-0014;
420    /// `play-sound`, spec-0014; the scripted-actor staging verbs
421    /// `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`/`sequence`,
422    /// spec-0014). These validate in v0.6 campaigns
423    /// earlier. (The `narrate` `art` style is a v0.6 addition to an existing verb
424    /// — see [`QuestEffect::narrate_art`] — not a new effect.)
425    pub fn v06_effect(&self) -> Option<&'static str> {
426        match &self.verb {
427            Verb::CloseGate { .. } => Some("close-gate"),
428            Verb::SetCheckpoint { .. } => Some("set-checkpoint"),
429            Verb::Bonfire { .. } => Some("bonfire"),
430            Verb::BeginStealth { .. } => Some("begin-stealth"),
431            Verb::EndStealth => Some("end-stealth"),
432            Verb::PlaySound { .. } => Some("play-sound"),
433            Verb::DamagePlayers { .. } => Some("damage-players"),
434            Verb::SpawnActor { .. } => Some("spawn-actor"),
435            Verb::DespawnActor { .. } => Some("despawn-actor"),
436            Verb::MoveActor { .. } => Some("move-actor"),
437            Verb::UnleashActor { .. } => Some("unleash-actor"),
438            Verb::Sequence { .. } => Some("sequence"),
439            Verb::SpawnNpc { .. } => Some("spawn-npc"),
440            // spec-0022 trap-payload verbs — v0.6 surface, reserved earlier.
441            Verb::Volley { .. } => Some("volley"),
442            Verb::Collapse { .. } => Some("collapse"),
443            _ => None,
444        }
445    }
446
447    /// The v0.10 effect name if this effect is one introduced in DSL v0.10
448    /// (`set-state`/`add-state`/`clear-state` and the region writes
449    /// `fill-region`/`clear-region`, spec-0031). These validate in v0.10
450    /// campaigns.
451    pub fn v10_effect(&self) -> Option<&'static str> {
452        match &self.verb {
453            Verb::SetState { .. } => Some("set-state"),
454            Verb::AddState { .. } => Some("add-state"),
455            Verb::ClearState { .. } => Some("clear-state"),
456            Verb::FillRegion { .. } => Some("fill-region"),
457            Verb::ClearRegion { .. } => Some("clear-region"),
458            Verb::GiveEffect { .. } => Some("give-effect"),
459            Verb::ClearEffect { .. } => Some("clear-effect"),
460            Verb::Teleport { .. } => Some("teleport"),
461            Verb::DropStake { .. } => Some("drop-stake"),
462            _ => None,
463        }
464    }
465
466    /// The v0.12 effect name if this effect is one introduced in DSL v0.12
467    /// (`open-way`, spec-0042).
468    pub fn v12_effect(&self) -> Option<&'static str> {
469        match &self.verb {
470            Verb::OpenWay { .. } => Some("open-way"),
471            _ => None,
472        }
473    }
474
475    /// **The way this effect opens** (DSL v0.12, spec-0042): the placed piece and
476    /// the name of one way that piece's spatial contract exports.
477    ///
478    /// The third spelling of the one region write, beside
479    /// [`QuestEffect::region_write`] (the author's own box) and
480    /// [`QuestEffect::gate_region_write`] (a gate anchor's box). It answers with a
481    /// *reference* and never with geometry, because the geometry is not the
482    /// campaign's to state: the cells, the block and the direction all live in the
483    /// piece's metadata, and the compiler resolves them there
484    /// (`compiler::ways`). A region-shaped accessor here would be the second
485    /// authority this surface exists to avoid.
486    pub fn way_write(&self) -> Option<(&PrefabId, &str)> {
487        match &self.verb {
488            Verb::OpenWay { piece, way, .. } => Some((piece, way.as_str())),
489            _ => None,
490        }
491    }
492
493    /// **The one region write**, whichever verb spelled it (DSL v0.10,
494    /// spec-0031): the box to write and the block to write it with — `None` for
495    /// the block meaning *clear to air*.
496    ///
497    /// This is the accessor the capability belongs to. It answers for
498    /// `fill-region` / `clear-region`, which name their own box; `open-gate` /
499    /// `close-gate` are the same operation over a box a prefab gate anchor
500    /// declares, so they answer through
501    /// [`QuestEffect::gate_region_write`](Self::gate_region_write) — the anchor
502    /// is theirs, the *operation* is shared.
503    pub fn region_write(&self) -> Option<(&StealthZone, Option<&str>)> {
504        match &self.verb {
505            Verb::FillRegion { region, block, .. } => Some((region, Some(block.as_str()))),
506            Verb::ClearRegion { region, .. } => Some((region, None)),
507            _ => None,
508        }
509    }
510
511    /// The gate anchor this effect writes, and whether the write **fills** it:
512    /// `Some((anchor, true))` for `close-gate`, `Some((anchor, false))` for
513    /// `open-gate`, `None` for everything else.
514    ///
515    /// The gate half of [`QuestEffect::region_write`]: same operation, but the box
516    /// and the fill block come from the prefab's gate anchor rather than from the
517    /// author. Everything that reasons about runtime region writes reads both
518    /// accessors and nothing else.
519    pub fn gate_region_write(&self) -> Option<(&AnchorId, bool)> {
520        match &self.verb {
521            Verb::CloseGate { anchor, .. } => Some((anchor, true)),
522            Verb::OpenGate { anchor, .. } => Some((anchor, false)),
523            _ => None,
524        }
525    }
526
527    /// `(projectile, from_anchor, kill_zone, salvos, interval)` if this is a
528    /// `volley` (spec-0022), with the documented defaults already applied.
529    pub fn volley(&self) -> Option<(&str, &AnchorId, &StealthZone, u32, u32)> {
530        match &self.verb {
531            Verb::Volley {
532                projectile,
533                from_anchor,
534                kill_zone,
535                salvos,
536                interval,
537                ..
538            } => Some((
539                projectile.as_deref().unwrap_or(DEFAULT_VOLLEY_PROJECTILE),
540                from_anchor,
541                kill_zone,
542                salvos.unwrap_or(DEFAULT_VOLLEY_SALVOS),
543                interval.unwrap_or(DEFAULT_VOLLEY_INTERVAL),
544            )),
545            _ => None,
546        }
547    }
548
549    /// `(region_anchor, falling_block, then_floor)` if this is a `collapse`
550    /// (spec-0022), with the documented default already applied.
551    pub fn collapse(&self) -> Option<(&StealthZone, &str, Option<&str>)> {
552        match &self.verb {
553            Verb::Collapse {
554                region_anchor,
555                falling_block,
556                then_floor,
557                ..
558            } => Some((
559                region_anchor,
560                falling_block
561                    .as_deref()
562                    .unwrap_or(DEFAULT_COLLAPSE_FALLING_BLOCK),
563                then_floor.as_deref(),
564            )),
565            _ => None,
566        }
567    }
568
569    /// The NPC id if this is a v0.6 `spawn-npc` effect (the dual of
570    /// [`QuestEffect::despawn_npc`]).
571    pub fn spawn_npc(&self) -> Option<&NpcId> {
572        match &self.verb {
573            Verb::SpawnNpc { npc, .. } => Some(npc),
574            _ => None,
575        }
576    }
577
578    /// **The body this effect puts into the world**, by id, for every body class
579    /// alike ([`BodyRef`]).
580    ///
581    /// `spawn-npc` and `spawn-actor` are the two, and they are answered in one
582    /// place so a rule about a body's lifetime quantifies over bodies rather
583    /// than over the verb that first needed it. A body's OTHER entry — standing
584    /// on its mark from world init — is not an effect at all and is
585    /// [`BodyRef::at_world_init`].
586    ///
587    /// `unleash-actor` is deliberately not an entry: it puts no new body on the
588    /// mark, it replaces the one already standing there (see [`Self::body_exit`]).
589    pub fn body_entry(&self) -> Option<&str> {
590        match &self.verb {
591            Verb::SpawnNpc { npc, .. } => Some(npc.as_str()),
592            Verb::SpawnActor { actor, .. } => Some(actor.as_str()),
593            _ => None,
594        }
595    }
596
597    /// **The body this effect takes out of the world**, by id, for every body
598    /// class alike.
599    ///
600    /// `despawn-npc` and `despawn-actor` remove the body outright.
601    /// `unleash-actor` is the third: it kills the staged puppet and stands a
602    /// real-AI twin in its place, and from that moment the compiler makes no
603    /// claim about where that body is or whether it is still alive — the twin
604    /// walks, fights and dies under vanilla AI. Answering all three here is what
605    /// keeps "can this body still be standing?" from being decided one verb at a
606    /// time.
607    ///
608    /// Deliberately NOT an exit: `move-npc` / `move-actor`. A walked body is
609    /// still in the world, and its declared mark is still the cell the engine
610    /// summoned it onto — a mark two live bodies share is shared whether or not
611    /// one of them has since walked off it.
612    pub fn body_exit(&self) -> Option<&str> {
613        match &self.verb {
614            Verb::DespawnNpc { npc, .. } => Some(npc.as_str()),
615            Verb::DespawnActor { actor, .. } | Verb::UnleashActor { actor, .. } => {
616                Some(actor.as_str())
617            }
618            _ => None,
619        }
620    }
621
622    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` effect.
623    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
624        match &self.verb {
625            Verb::SetCheckpoint { anchor, on_respawn } => Some((anchor, on_respawn.as_slice())),
626            _ => None,
627        }
628    }
629
630    /// `(anchor, on_rest)` if this is a `bonfire` effect (spec-0016 §1).
631    pub fn bonfire(&self) -> Option<(&AnchorId, &[QuestEffect])> {
632        match &self.verb {
633            Verb::Bonfire {
634                anchor, on_rest, ..
635            } => Some((anchor, on_rest.as_slice())),
636            _ => None,
637        }
638    }
639
640    /// The bonfire's authored rest-dialog strings, each `None` when unauthored
641    /// (the compiler then bakes its canonical English). `None` for every other
642    /// effect (spec-0016 §1).
643    pub fn bonfire_labels(&self) -> Option<BonfireLabels<'_>> {
644        match &self.verb {
645            Verb::Bonfire {
646                prompt,
647                rest_label,
648                save_label,
649                rest_tooltip,
650                save_tooltip,
651                ..
652            } => Some(BonfireLabels {
653                prompt: prompt.as_deref(),
654                rest_label: rest_label.as_deref(),
655                save_label: save_label.as_deref(),
656                rest_tooltip: rest_tooltip.as_deref(),
657                save_tooltip: save_tooltip.as_deref(),
658            }),
659            _ => None,
660        }
661    }
662
663    /// The `within` filter zone if this is a v0.6 `damage-players` effect that
664    /// declares one (the `in` spatial scope). `None` for an unscoped
665    /// `damage-players` and for every other effect.
666    pub fn damage_within(&self) -> Option<&StealthZone> {
667        match &self.verb {
668            Verb::DamagePlayers { .. } => self.within.as_ref(),
669            _ => None,
670        }
671    }
672
673    /// `(zones, on_caught, grace_ticks)` if this is a v0.6 `begin-stealth` effect.
674    pub fn begin_stealth(&self) -> Option<(&[StealthZone], &[QuestEffect], u32)> {
675        match &self.verb {
676            Verb::BeginStealth {
677                zones,
678                on_caught,
679                grace_ticks,
680            } => Some((zones.as_slice(), on_caught.as_slice(), *grace_ticks)),
681            _ => None,
682        }
683    }
684
685    /// The target time if this is a v0.5 `set-time` effect.
686    pub fn set_time(&self) -> Option<WorldTime> {
687        match &self.verb {
688            Verb::SetTime { time, .. } => Some(*time),
689            _ => None,
690        }
691    }
692
693    /// The target weather if this is a v0.5 `set-weather` effect.
694    pub fn set_weather(&self) -> Option<WorldWeather> {
695        match &self.verb {
696            Verb::SetWeather { weather, .. } => Some(*weather),
697            _ => None,
698        }
699    }
700
701    /// The effect lists nested one level inside this effect (DSL v0.6): a
702    /// `sequence`'s per-step effects (in step order), a `set-checkpoint`'s
703    /// `on_respawn`, a `begin-stealth`'s `on_caught`, and a `move-actor`'s /
704    /// `move-npc`'s `on_arrive`. Empty for a leaf effect.
705    ///
706    /// This is the **single authority** on effect nesting. Every deep traversal —
707    /// the flag/wave producer scans, the checkpoint/stealth collector, the l10n
708    /// string inventory, and emission — walks the tree through it (see
709    /// [`Self::visit_deep`]), so a new nesting site is picked up everywhere at
710    /// once and no walker can silently miss a list (the class of bug where a
711    /// `set-flag`/`set-checkpoint` nested in a `sequence` was skipped).
712    pub fn nested_effect_lists(&self) -> Vec<&[QuestEffect]> {
713        match &self.verb {
714            Verb::Sequence { steps } => steps.iter().map(|s| s.effects.as_slice()).collect(),
715            Verb::SetCheckpoint { on_respawn, .. } => vec![on_respawn.as_slice()],
716            Verb::Bonfire { on_rest, .. } => vec![on_rest.as_slice()],
717            Verb::BeginStealth { on_caught, .. } => vec![on_caught.as_slice()],
718            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
719                vec![on_arrive.as_slice()]
720            }
721            _ => Vec::new(),
722        }
723    }
724
725    /// Visit `self` and every transitively nested effect (depth-first, pre-order),
726    /// descending through [`Self::nested_effect_lists`].
727    pub fn visit_deep<'a>(&'a self, f: &mut dyn FnMut(&'a QuestEffect)) {
728        f(self);
729        for list in self.nested_effect_lists() {
730            for e in list {
731                e.visit_deep(f);
732            }
733        }
734    }
735
736    /// Each nested effect list ([`Self::nested_effect_lists`]) paired with the
737    /// **stable key segment** used to derive child l10n keys / diagnostic paths, and
738    /// exposed mutably so the localization pass can rewrite nested player-visible
739    /// strings in place. Segments: `seq.<step>` for each sequence step (step index),
740    /// `respawn` for `set-checkpoint.on_respawn`, `caught` for
741    /// `begin-stealth.on_caught`, `arrive` for `move-actor.on_arrive`. Kept in
742    /// lockstep with `nested_effect_lists` (same lists, same order) — the position-
743    /// derived segments make every derived key deterministic and stable across
744    /// builds (ADR-0006 byte-identity).
745    pub fn nested_effect_lists_keyed_mut(&mut self) -> Vec<(String, &mut [QuestEffect])> {
746        match &mut self.verb {
747            Verb::Sequence { steps } => steps
748                .iter_mut()
749                .enumerate()
750                .map(|(s, st)| (format!("seq.{s}"), st.effects.as_mut_slice()))
751                .collect(),
752            Verb::SetCheckpoint { on_respawn, .. } => {
753                vec![("respawn".to_string(), on_respawn.as_mut_slice())]
754            }
755            Verb::Bonfire { on_rest, .. } => {
756                vec![("rest".to_string(), on_rest.as_mut_slice())]
757            }
758            Verb::BeginStealth { on_caught, .. } => {
759                vec![("caught".to_string(), on_caught.as_mut_slice())]
760            }
761            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
762                vec![("arrive".to_string(), on_arrive.as_mut_slice())]
763            }
764            _ => Vec::new(),
765        }
766    }
767
768    /// Immutable sibling of [`Self::nested_effect_lists_keyed_mut`] that additionally
769    /// exposes the **JSON-pointer path segment** for each nested list, so a deep
770    /// consumer scan (sound/art/give/wave refs) can report a precise diagnostic path
771    /// *and* the matching l10n key. Each entry is `(path_seg, key_seg, list)`; the
772    /// caller appends the per-effect index — `/{j}` to the path, `.{j}` to the key.
773    /// Kept in lockstep with `nested_effect_lists` / `nested_effect_lists_keyed_mut`
774    /// (same lists, same order): the l10n key segments match
775    /// `nested_effect_lists_keyed_mut` exactly (`seq.<step>`/`respawn`/`caught`/
776    /// `arrive`), and the path segments name the real fields
777    /// (`steps/<step>/effects`, `on_respawn`, `on_caught`, `on_arrive`).
778    pub fn nested_effect_lists_labeled(&self) -> Vec<(String, String, &[QuestEffect])> {
779        match &self.verb {
780            Verb::Sequence { steps } => steps
781                .iter()
782                .enumerate()
783                .map(|(s, st)| {
784                    (
785                        format!("steps/{s}/effects"),
786                        format!("seq.{s}"),
787                        st.effects.as_slice(),
788                    )
789                })
790                .collect(),
791            Verb::SetCheckpoint { on_respawn, .. } => vec![(
792                "on_respawn".to_string(),
793                "respawn".to_string(),
794                on_respawn.as_slice(),
795            )],
796            Verb::Bonfire { on_rest, .. } => vec![(
797                "on_rest".to_string(),
798                "rest".to_string(),
799                on_rest.as_slice(),
800            )],
801            Verb::BeginStealth { on_caught, .. } => vec![(
802                "on_caught".to_string(),
803                "caught".to_string(),
804                on_caught.as_slice(),
805            )],
806            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
807                vec![(
808                    "on_arrive".to_string(),
809                    "arrive".to_string(),
810                    on_arrive.as_slice(),
811                )]
812            }
813            _ => Vec::new(),
814        }
815    }
816
817    /// Every world anchor this effect names **at this node** — the single
818    /// authority on the anchor-bearing effect surface, the referential sibling of
819    /// [`Self::nested_effect_lists`]. Each entry is `(json_path_suffix, anchor)`,
820    /// where the suffix is appended to the effect's own JSON pointer
821    /// (`anchor`, `to/anchor`, `in/anchor`, `zones/<i>/anchor`, `at/anchor`,
822    /// `shots/<i>/path/<j>/anchor`, …).
823    ///
824    /// Not recursive: pair it with [`Self::visit_deep`] to sweep a whole effect
825    /// tree. Every consumer that must resolve an anchor — the DSL's `DW0142`
826    /// reference scan, the compiler's build-time resolution seal (`DW0360`) —
827    /// goes through here, so a new anchor-bearing variant (or a new anchor field
828    /// on an existing one) is picked up by all of them at once. That closes the
829    /// silent-drop class of bug: an anchor-bearing effect whose anchor is typo'd
830    /// used to emit *nothing* (the emitter's `for … if name == anchor` loop simply
831    /// found no match) while every shallow validator looked straight past it.
832    ///
833    /// # The third element is the SHAPE the site demands
834    ///
835    /// A reference is not only a name: `close-gate` fills and clears a region and
836    /// `bonfire` seats a body, and until spec-0052 those two sat in one match arm
837    /// with nothing recording the difference. The demand rides with the reference
838    /// so that a new anchor-bearing variant cannot be added without stating what
839    /// it does with the anchor — the same reason this is one authority and not
840    /// three walks.
841    ///
842    /// **A demand is stated only where the shape is structurally required**, and
843    /// the line is drawn where this engine's own `DW0845` already draws it — *a
844    /// region is not a place to stand*:
845    ///
846    /// * [`StationKind::Gate`] where the verb cannot function without a region
847    ///   that seals and clears: the two gate verbs. A point is not a gate, and
848    ///   `gate_region_block_any` finds nothing for one.
849    /// * [`StationKind::Point`] where a **body is put**: a checkpoint seat, a
850    ///   bonfire, a `move-npc`/`move-actor` destination, a `teleport`
851    ///   destination. A body cannot stand inside bars.
852    /// * `None` wherever a reference merely names a **location** — every
853    ///   anchor-centred volume's centre, every camera field, a `set-block`, a
854    ///   `play-sound`. A gate region's own corner is a perfectly good answer
855    ///   there, and refusing it would refuse correct content.
856    ///
857    /// That last case is not a gap left for later. An earlier draft of this rule
858    /// demanded a point at every non-gate site, and the gallery refused to
859    /// compile: `trigger/east-door-wrong-side` is a `use` trigger sitting on the
860    /// very `anchor/seam-…` of a barred door so that it can say "the east door
861    /// does not open from this side". A check that resolves against a smaller
862    /// world than the campaign has refuses CONTENT, which is the lesson `DW0343`
863    /// carries three files away.
864    pub fn anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
865        // The envelope's `in` box (spec-0085 §3.2) is one capability of every
866        // player-facing verb, so it registers once, here, before the verb's own.
867        let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = Vec::new();
868        if let Some(zone) = &self.within {
869            out.push(("in/anchor".to_string(), &zone.anchor, None));
870        }
871        out.extend(self.verb_anchor_refs());
872        out
873    }
874
875    /// The anchors the VERB names at this node — [`Self::anchor_refs`] without the
876    /// envelope.
877    fn verb_anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
878        // Every camera field is a point: a shot flies through cells and looks at
879        // one.
880        /// `(suffix, anchor, kind)` for a shot's own anchor-bearing fields, under `base`.
881        fn shot_refs<'a>(
882            base: &str,
883            shot: &'a CameraShot,
884        ) -> Vec<(String, &'a AnchorId, Option<StationKind>)> {
885            let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shot
886                .path
887                .iter()
888                .enumerate()
889                .map(|(j, w)| (format!("{base}path/{j}/anchor"), &w.anchor, None))
890                .collect();
891            if let Some(t) = &shot.look_at {
892                out.push((format!("{base}look_at/anchor"), &t.anchor, None));
893            }
894            for (field, subject) in [("subject", &shot.subject), ("subject_b", &shot.subject_b)] {
895                if let Some(CameraSubject::Anchor(s)) = subject {
896                    out.push((format!("{base}{field}/anchor"), &s.anchor, None));
897                }
898            }
899            out
900        }
901        match &self.verb {
902            // The two gate verbs address a REGION that seals and clears; the
903            // three below them seat or write at a cell. They shared an arm until
904            // the demand had somewhere to be written down.
905            Verb::OpenGate { anchor, .. } | Verb::CloseGate { anchor, .. } => {
906                vec![("anchor".to_string(), anchor, Some(StationKind::Gate))]
907            }
908            // A checkpoint and a bonfire are **respawn seats** — a body is put
909            // there, so a region is not one. `set-block` writes a block at a
910            // cell, which names a location and seats nothing.
911            Verb::SetCheckpoint { anchor, .. } | Verb::Bonfire { anchor, .. } => {
912                vec![("anchor".to_string(), anchor, Some(StationKind::Point))]
913            }
914            Verb::SetBlock { anchor, .. } => {
915                vec![("anchor".to_string(), anchor, None)]
916            }
917            Verb::MoveNpc { to, .. } | Verb::MoveActor { to, .. } => {
918                vec![(
919                    "to/anchor".to_string(),
920                    &to.anchor,
921                    Some(StationKind::Point),
922                )]
923            }
924            // Both of a `teleport`'s anchors are load-bearing — the source volume
925            // decides WHAT moves and the destination decides WHERE — so a typo in
926            // either is a dangling reference (`DW0142`), never a silently
927            // zero-cell volume or a dropped command.
928            Verb::Teleport { from, to, .. } => vec![
929                ("from/anchor".to_string(), &from.anchor, None),
930                (
931                    "to/anchor".to_string(),
932                    &to.anchor,
933                    Some(StationKind::Point),
934                ),
935            ],
936            Verb::BeginStealth { zones, .. } => zones
937                .iter()
938                .enumerate()
939                .map(|(i, z)| (format!("zones/{i}/anchor"), &z.anchor, None))
940                .collect(),
941            Verb::PlaySound {
942                at: Some(SoundAt::Anchor { anchor, .. }),
943                ..
944            } => vec![("at/anchor".to_string(), anchor, None)],
945            // A firework is launched from a point and seats nothing, so it names
946            // a location in the same shape `play-sound` does.
947            Verb::Firework { at, .. } => vec![("at/anchor".to_string(), &at.anchor, None)],
948            // A bolt strikes a point and seats nothing, the same shape.
949            Verb::Lightning { at } => vec![("at/anchor".to_string(), &at.anchor, None)],
950            // A particle at a mark names a location the same way; at `players`
951            // it names none.
952            Verb::Particle {
953                at: ParticleAt::Mark(m),
954                ..
955            } => vec![("at/anchor".to_string(), &m.anchor, None)],
956            // spec-0022 trap-payload verbs. Both anchors of a `volley` are
957            // load-bearing for the coverage proof, so both register here — a
958            // typo'd `kill_zone` must be a dangling-reference error, never a
959            // silently zero-cell (and therefore vacuously "covered") volley.
960            Verb::Volley {
961                from_anchor,
962                kill_zone,
963                ..
964            } => vec![
965                ("from_anchor".to_string(), from_anchor, None),
966                ("kill_zone/anchor".to_string(), &kill_zone.anchor, None),
967            ],
968            Verb::Collapse { region_anchor, .. } => {
969                vec![(
970                    "region_anchor/anchor".to_string(),
971                    &region_anchor.anchor,
972                    None,
973                )]
974            }
975            // The v0.10 region writes: the box's anchor is load-bearing for both
976            // the emission and the completability model, so a typo'd one must be a
977            // dangling-reference error (`DW0142`/`DW0360`), never a silently
978            // unwritten — and therefore vacuously proven — region.
979            Verb::FillRegion { region, .. } | Verb::ClearRegion { region, .. } => {
980                vec![("region/anchor".to_string(), &region.anchor, None)]
981            }
982            // A repaint's box centre names a location, exactly as a region
983            // write's does.
984            Verb::SetAtmosphere {
985                region: Some(region),
986                ..
987            } => vec![("region/anchor".to_string(), &region.anchor, None)],
988            // Both cutscene spellings (`DW0199` polices mixing them): the v0.6
989            // multi-shot list, or the v0.4 single-shot fields flattened at the
990            // effect's own level.
991            Verb::Cutscene {
992                shots,
993                path,
994                look_at,
995                ..
996            } => {
997                let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shots
998                    .iter()
999                    .enumerate()
1000                    .flat_map(|(i, s)| shot_refs(&format!("shots/{i}/"), s))
1001                    .collect();
1002                out.extend(
1003                    path.iter()
1004                        .enumerate()
1005                        .map(|(j, w)| (format!("path/{j}/anchor"), &w.anchor, None)),
1006                );
1007                if let Some(t) = look_at {
1008                    out.push(("look_at/anchor".to_string(), &t.anchor, None));
1009                }
1010                out
1011            }
1012            _ => Vec::new(),
1013        }
1014    }
1015
1016    /// **Every object of a subject kind this effect names at this node** — the
1017    /// `npc/`, `actor/`, `wave/` and `anchor/` ids [`Happening::subject`] may
1018    /// name, in a fixed order, deduplicated (spec-0071 §3).
1019    ///
1020    /// The bodies come from [`Self::body_entry`] / [`Self::body_exit`] and the
1021    /// move verbs; the anchors come from [`Self::anchor_refs`], the single
1022    /// authority on the anchor-bearing surface, so a verb that gains an anchor
1023    /// gains it here too. Not recursive: a `sequence`'s steps each answer for
1024    /// themselves.
1025    ///
1026    /// This is a question about the **object class** a beat can be about, not
1027    /// about a list of verbs somebody maintains — which is why `unleash-actor`
1028    /// (one actor), `set-block` (one anchor) and `fill-region` (one anchor)
1029    /// answer it without being named anywhere, and why `move-actor` (an actor
1030    /// AND a destination anchor) and `teleport` (two anchors) answer with two.
1031    pub fn subject_objects(&self) -> Vec<&str> {
1032        fn add<'a>(id: &'a str, out: &mut Vec<&'a str>) {
1033            if !out.contains(&id) {
1034                out.push(id);
1035            }
1036        }
1037        let mut out: Vec<&str> = Vec::new();
1038        match &self.verb {
1039            Verb::SpawnNpc { npc, .. }
1040            | Verb::DespawnNpc { npc, .. }
1041            | Verb::MoveNpc { npc, .. } => add(npc.as_str(), &mut out),
1042            Verb::SpawnWave { wave, .. } => add(wave.as_str(), &mut out),
1043            _ => {}
1044        }
1045        if let Some(actor) = self.actor_ref() {
1046            add(actor.as_str(), &mut out);
1047        }
1048        for (_, anchor, _) in self.anchor_refs() {
1049            add(anchor.as_str(), &mut out);
1050        }
1051        out
1052    }
1053
1054    /// **What this effect's `happening` is about**: the subject the branch
1055    /// chronicle records and the contradiction proof (`DW0485`) reasons over
1056    /// (spec-0071 §3).
1057    ///
1058    /// A stated [`Happening::subject`] always wins — the caller knows more. An
1059    /// absent one resolves to the effect's own object when it has exactly one
1060    /// ([`Self::subject_objects`]), because the beat that opens a gate is about
1061    /// that gate and the id is otherwise typed twice, two keys apart. An effect
1062    /// with several objects, or none, resolves nothing: naming one of them would
1063    /// be the compiler guessing which.
1064    ///
1065    /// **The one derivation.** The namespace check (`dsl::validate`) and the
1066    /// chronicle writer (`delvec::compiler::branch`) both read the subject
1067    /// through here, so a beat cannot be about one thing for the proof and
1068    /// another for the account a reader is handed.
1069    pub fn happening_subject(&self) -> Option<HappeningSubject<'_>> {
1070        let h = self.happening.as_ref()?;
1071        if let Some(stated) = h.subject.as_deref() {
1072            return Some(HappeningSubject {
1073                id: stated,
1074                derived: false,
1075            });
1076        }
1077        let objects = self.subject_objects();
1078        match objects.as_slice() {
1079            [only] => Some(HappeningSubject {
1080                id: only,
1081                derived: true,
1082            }),
1083            _ => None,
1084        }
1085    }
1086
1087    /// The `cutscene` camera subject if this is a single-shot `cutscene` carrying
1088    /// the v0.6 `look_at` field.
1089    pub fn cutscene_look_at(&self) -> Option<&Mark> {
1090        match &self.verb {
1091            Verb::Cutscene { look_at, .. } => look_at.as_ref(),
1092            _ => None,
1093        }
1094    }
1095
1096    /// Whether this `cutscene` shows the party's bodies (spec-0095): the stated
1097    /// `party`, or `present` when none is stated. `None` for any other effect.
1098    pub fn cutscene_party(&self) -> Option<CutsceneParty> {
1099        match &self.verb {
1100            Verb::Cutscene { party, .. } => Some(party.unwrap_or_default()),
1101            _ => None,
1102        }
1103    }
1104
1105    /// `true` if this is a `cutscene` written in the v0.6 multi-shot form
1106    ///.
1107    pub fn cutscene_multi_shot(&self) -> bool {
1108        matches!(&self.verb, Verb::Cutscene { shots, .. } if !shots.is_empty())
1109    }
1110
1111    /// The normalized shot list of a `cutscene`, whichever spelling was used: the
1112    /// v0.6 `shots` list as-is, or the v0.4 `path`/`seconds`/`look_at` fields as a
1113    /// single shot. `None` for a non-cutscene effect; an empty list for a cutscene
1114    /// whose shape is invalid (`DW0199` reports that).
1115    pub fn cutscene_shots(&self) -> Option<Vec<CameraShot>> {
1116        match &self.verb {
1117            Verb::Cutscene {
1118                shots,
1119                path,
1120                seconds,
1121                look_at,
1122                ..
1123            } => {
1124                if !shots.is_empty() {
1125                    return Some(shots.clone());
1126                }
1127                match seconds {
1128                    Some(secs) => Some(vec![CameraShot {
1129                        path: path.clone(),
1130                        seconds: Some(*secs),
1131                        look_at: look_at.clone(),
1132                        shot_style: None,
1133                        subject: None,
1134                        subject_b: None,
1135                        dist: None,
1136                        degrees: None,
1137                        bearing: None,
1138                    }]),
1139                    None => Some(Vec::new()),
1140                }
1141            }
1142            _ => None,
1143        }
1144    }
1145
1146    /// `true` if this is a `narrate` carrying the `art` style (glyph-checked
1147    /// `DW0328`).
1148    pub fn narrate_art(&self) -> bool {
1149        matches!(
1150            &self.verb,
1151            Verb::Narrate {
1152                style: Some(NarrateStyle::Art),
1153                ..
1154            }
1155        )
1156    }
1157
1158    /// The `narrate` line's text if this is a `narrate` with the `art` style.
1159    pub fn narrate_art_text(&self) -> Option<&str> {
1160        match &self.verb {
1161            Verb::Narrate {
1162                text,
1163                style: Some(NarrateStyle::Art),
1164                ..
1165            } => Some(text.as_str()),
1166            _ => None,
1167        }
1168    }
1169
1170    /// The `narrate` line's style and text if this is a `narrate` rendered **on
1171    /// screen** — `title`, `subtitle` or `art` — rather than in chat. These are the
1172    /// styles vanilla draws centred and unwrapped, so their rendered width is
1173    /// length-checked against the screen (`DW0330`); `chat` scrolls and wraps, so it
1174    /// is exempt.
1175    pub fn narrate_on_screen(&self) -> Option<(NarrateStyle, &str)> {
1176        match &self.verb {
1177            Verb::Narrate {
1178                text,
1179                style: Some(s),
1180                ..
1181            } if matches!(
1182                s,
1183                NarrateStyle::Title | NarrateStyle::Subtitle | NarrateStyle::Art
1184            ) =>
1185            {
1186                Some((*s, text.as_str()))
1187            }
1188            _ => None,
1189        }
1190    }
1191
1192    /// Every vanilla sound-event id this effect references, for registry
1193    /// validation (`DW0326`): a `play-sound`'s `sound`, and a `narrate`'s optional
1194    /// `sound`. Returns `(subpath, id)` pairs where `subpath` locates the field
1195    /// within the effect (e.g. `sound`).
1196    pub fn sound_refs(&self) -> Vec<(&'static str, &str)> {
1197        match &self.verb {
1198            Verb::PlaySound { sound, .. } => vec![("sound", sound.as_str())],
1199            Verb::Narrate { sound: Some(s), .. } => vec![("sound", s.as_str())],
1200            _ => Vec::new(),
1201        }
1202    }
1203
1204    /// The `play-sound` `at: actor` id, if this effect is a `play-sound`
1205    /// targeting an actor (rejected `DW0335`).
1206    pub fn play_sound_actor(&self) -> Option<&str> {
1207        match &self.verb {
1208            Verb::PlaySound {
1209                at: Some(SoundAt::Actor { actor }),
1210                ..
1211            } => Some(actor.as_str()),
1212            _ => None,
1213        }
1214    }
1215
1216    /// The per-effect flag gate: flags that must ALL be set (per party) for this
1217    /// effect to fire. Empty for an ungated effect. Read off the one [`Guard`], so
1218    /// **every** verb answers it — the staging and souls vocabulary included.
1219    pub fn requires_flags(&self) -> &[FlagId] {
1220        self.when.as_ref().map_or(&[][..], |g| &g.requires_flags)
1221    }
1222
1223    /// The per-effect **negative** flag gate: flags whose being set suppresses this
1224    /// effect — the dual of [`QuestEffect::requires_flags`], on the same guard.
1225    pub fn forbids_flags(&self) -> &[FlagId] {
1226        self.when.as_ref().map_or(&[][..], |g| &g.forbids_flags)
1227    }
1228
1229    /// The numeric gate terms (spec-0031) — the third axis of the same guard, so
1230    /// "which verbs are gatable" has exactly one answer.
1231    pub fn requires_state(&self) -> &[StateCompare] {
1232        self.when.as_ref().map_or(&[][..], |g| &g.requires_state)
1233    }
1234
1235    /// The datum this effect writes and how, if it is one of the DSL v0.10 state
1236    /// verbs (`set-state` / `add-state` / `clear-state`).
1237    pub fn writes_state(&self) -> Option<(&StateId, StateWrite)> {
1238        match &self.verb {
1239            Verb::SetState { state, value, .. } => Some((state, StateWrite::Set(*value))),
1240            Verb::AddState { state, amount, .. } => Some((state, StateWrite::Add(*amount))),
1241            Verb::ClearState { state, .. } => Some((state, StateWrite::Clear)),
1242            _ => None,
1243        }
1244    }
1245
1246    /// The `on_arrive` bundle if this is a `move-npc` carrying one (DSL v0.6;
1247    /// parity with `move-actor`). `None` for a bare `move-npc` and every other
1248    /// effect.
1249    pub fn move_npc_on_arrive(&self) -> Option<&[QuestEffect]> {
1250        match &self.verb {
1251            Verb::MoveNpc { on_arrive, .. } if !on_arrive.is_empty() => Some(on_arrive.as_slice()),
1252            _ => None,
1253        }
1254    }
1255
1256    /// The actor id this effect targets, if it is one of the actor staging effects
1257    /// (`spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`). `sequence` has
1258    /// no single actor (its nested effects each carry their own).
1259    pub fn actor_ref(&self) -> Option<&ActorId> {
1260        match &self.verb {
1261            Verb::SpawnActor { actor, .. }
1262            | Verb::DespawnActor { actor, .. }
1263            | Verb::MoveActor { actor, .. }
1264            | Verb::UnleashActor { actor, .. } => Some(actor),
1265            _ => None,
1266        }
1267    }
1268
1269    /// Every vanilla **status-effect** id this effect names, for registry
1270    /// validation (`DW0192`) — the sibling of [`Self::sound_refs`] and the single
1271    /// authority on the status-effect-bearing verb surface. `(subpath, id)`
1272    /// pairs; empty for a `clear-effect` that names none (which clears all).
1273    pub fn status_effect_refs(&self) -> Vec<(&'static str, &str)> {
1274        match &self.verb {
1275            Verb::GiveEffect { effect, .. } => vec![("effect", effect.as_str())],
1276            Verb::ClearEffect {
1277                effect: Some(e), ..
1278            } => vec![("effect", e.as_str())],
1279            _ => Vec::new(),
1280        }
1281    }
1282
1283    /// `(effect, seconds, amplifier, hide_particles, in)` if this is a
1284    /// `give-effect` (DSL v0.10), with the documented defaults already applied.
1285    pub fn give_effect(&self) -> Option<(&str, u32, u32, bool, Option<&StealthZone>)> {
1286        match &self.verb {
1287            Verb::GiveEffect {
1288                effect,
1289                seconds,
1290                amplifier,
1291                hide_particles,
1292                ..
1293            } => Some((
1294                effect.as_str(),
1295                *seconds,
1296                amplifier.unwrap_or(0),
1297                hide_particles.unwrap_or(false),
1298                self.within.as_ref(),
1299            )),
1300            _ => None,
1301        }
1302    }
1303
1304    /// `(effect, in)` if this is a `clear-effect` (DSL v0.10). The effect is
1305    /// `None` for the clear-everything form, exactly as vanilla spells it.
1306    pub fn clear_effect(&self) -> Option<(Option<&str>, Option<&StealthZone>)> {
1307        match &self.verb {
1308            Verb::ClearEffect { effect, .. } => Some((effect.as_deref(), self.within.as_ref())),
1309            _ => None,
1310        }
1311    }
1312
1313    /// `(from, to)` if this is a `teleport` (DSL v0.10): the source volume and
1314    /// the destination mark.
1315    pub fn teleport(&self) -> Option<(&StealthZone, &Mark)> {
1316        match &self.verb {
1317            Verb::Teleport { from, to, .. } => Some((from, to)),
1318            _ => None,
1319        }
1320    }
1321}
1322
1323#[cfg(test)]
1324mod happening_subject_tests;