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 ®ion_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(), ®ion.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(), ®ion.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;