delvewright_dsl/effects.rs
1//! **The one enumeration of the campaign's effect roots.**
2//!
3//! An *effect root* is a `Vec<QuestEffect>` that emission can lower. There are
4//! seven, they hang off two different stage documents, and nothing about the
5//! shape of the DSL makes them findable by inspection — which is why every walk
6//! that needed "every effect" was, historically, written by someone enumerating
7//! the roots they happened to know about.
8//!
9//! Six separate investigations each found one such walk and fixed it in place
10//! (`plan::collect_region_events`, `l10n::each_string`, `timeline::walk_campaign` /
11//! `nav::all_effects`, `flow::read_flags`, `emit::check_effect_anchors`,
12//! `emit::declared_flags`). A sweep after the sixth found **thirteen more**
13//! walkers still enumerating three or four of the five. None of them was red:
14//! a walk that visits four of five roots produces correct-looking output over
15//! any campaign that happens not to use the fifth, and it stays green until a
16//! campaign uses it.
17//!
18//! Fixing thirteen walkers by hand fixes thirteen walkers. This module exists so
19//! that the *next* root — and there will be one; the fifth was added once
20//! already — is added in **one** place and every consumer inherits it:
21//!
22//! * [`for_each_effect_root`] is the single immutable enumeration. Every
23//! campaign-wide effect walk in the workspace is defined in terms of it or of
24//! [`for_each_campaign_effect`], which is defined in terms of it.
25//! * [`for_each_effect_root_mut`] is its mutable mirror, generated from the
26//! **same macro body** ([`effect_root_walk`]) rather than written out a second
27//! time, so the two cannot drift: the root list exists once as tokens.
28//! * [`EffectRootKind`] names the roots. `EffectRootKind::ALL` is the closed set;
29//! the walk asserts it visited every member on every call ([`RootBinding`]),
30//! so a root that stops being enumerated is a panic in every build rather than
31//! a quietly narrower answer.
32//! * Consumers that need to know *which* root a bundle is match on
33//! [`EffectRootOwner`]. Adding a variant there is a rustc error at every such
34//! site, so a new root cannot be silently mis-classified either.
35//!
36//! Roots 6 and 7 were added by spec-0031, and they are worth reading as a pair
37//! because they are the two ways this defect class recurs after the enumeration
38//! exists:
39//!
40//! * **R6 `shortcuts[].on_unlock` was already a root and nobody had noticed.**
41//! It is a `Vec<QuestEffect>` hanging off a stage-5 struct, structurally
42//! identical in kind to `traps[].payload` (which is R4), and emission really
43//! lowers it (`emit::emit_shortcut_functions`). It was simply never listed —
44//! so every proof, every l10n pass and every diagnostic written for "the
45//! general path" silently did not cover it: a `narrate` inside it was never
46//! inventoried, a `set-flag` inside it was invisible to the flag model, and a
47//! `sequence` inside it would have emitted a `function` call to a function
48//! nothing generated. Zero campaigns happened to use it, which is the only
49//! reason it never shipped as a bug. The sixth blind spot in the family that
50//! three earlier hand-rolled walks each closed one instance of.
51//! * **R7 `on_death` is new surface that starts inside the enumeration.** The
52//! whole point of adding it as a root, rather than as a hook on the checkpoint
53//! machinery that detects death, is that "the purse is dropped on death" then
54//! stops being an engine feature and becomes ordinary content in a general
55//! mechanism.
56//!
57//! What this module deliberately does **not** try to be is a guard against a
58//! fourteenth hand-rolled walk being written tomorrow. Nothing in the type system
59//! can stop someone iterating `campaign.quests.content.quests` directly; that
60//! half of the obligation is `tools/check-effect-roots.py`, which fails CI when a
61//! source file reaches for two or more root fields outside this module.
62//!
63//! Determinism (ADR-0006): iteration is over `BTreeMap` keys and slices, in a
64//! fixed order that is part of this module's contract — see
65//! [`for_each_effect_root`].
66
67use crate::envelope::Campaign;
68use crate::stages::{EnvTrigger, Quest, QuestEffect, Shop, Shortcut, Trap};
69
70/// The local part of a type-prefixed id (`npc/keeper` → `keeper`), the segment
71/// every l10n key is built from. Duplicated from `l10n::local` deliberately: this
72/// module is below `l10n` and the key scheme is part of a root's identity.
73fn local(id: &str) -> &str {
74 id.split_once('/').map(|(_, r)| r).unwrap_or(id)
75}
76
77/// Which of the campaign's effect roots a bundle is.
78///
79/// `ALL` is the closed set. Adding a variant is a rustc error in
80/// [`EffectRootOwner::kind`] and in every consumer that matches on an owner, and
81/// makes `ALL`'s length wrong until it is listed — so a new root cannot be added
82/// without visiting the walk.
83#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
84pub enum EffectRootKind {
85 /// A quest's `on_objective_complete[<objective>]` bundle.
86 ObjectiveComplete,
87 /// A quest's `on_complete` bundle.
88 QuestComplete,
89 /// An environment trigger's `effects` bundle.
90 Trigger,
91 /// A trap's spec-0022 `payload` bundle.
92 TrapPayload,
93 /// A dialogue option's `set-checkpoint` `on_respawn` bundle — a plain
94 /// `Vec<QuestEffect>` hanging off the **dialogue** stage. `DialogueEffect`
95 /// carries no gate, movement or actor verb of its own, which is the reasoning
96 /// that made every older walk stop at the quests stage; the bundle nested
97 /// inside one is quest-effect vocabulary all the same, and it is lowered
98 /// (into `cp_on_respawn_<i>`).
99 DialogueRespawn,
100 /// A `shortcuts[].on_unlock` bundle (spec-0016 §2) — the beat that plays as
101 /// the bar lifts. Lowered by `emit::emit_shortcut_functions` into
102 /// `shortcut_open_<id>`, and unenumerated until spec-0031: the sixth blind
103 /// spot, structurally the same shape as R4.
104 ShortcutUnlock,
105 /// The campaign's `on_death` bundle (DSL v0.10, spec-0031) — the effects that
106 /// run at the moment a player dies, for that player. One per campaign, and
107 /// visited only when non-empty, so `unbound_roots` tells the truth about a
108 /// campaign that declares no death beat.
109 OnDeath,
110 /// A `shops[].offers[].effects` bundle (DSL v0.10, spec-0032) — what choosing
111 /// a shop button does, for the player who chose it.
112 ///
113 /// It is a root rather than sugar for the reason spec-0031 states: *add a root
114 /// when the bundle hangs off an object that has runtime machinery of its own.*
115 /// An offer's machinery is a player-interaction advancement, a
116 /// `minecraft:multi_action` dialog, a `/trigger` objective and a tick
117 /// dispatch — the same hardware a bonfire rest runs on. Desugaring it into a
118 /// `use` trigger would put two independent detectors on one right-click.
119 ShopOffer,
120}
121
122impl EffectRootKind {
123 /// Every root, in enumeration order. Not the *visit* order — see
124 /// [`for_each_effect_root`], which interleaves R1/R2 per quest.
125 pub const ALL: [EffectRootKind; 8] = [
126 EffectRootKind::ObjectiveComplete,
127 EffectRootKind::QuestComplete,
128 EffectRootKind::Trigger,
129 EffectRootKind::TrapPayload,
130 EffectRootKind::DialogueRespawn,
131 EffectRootKind::ShortcutUnlock,
132 EffectRootKind::OnDeath,
133 EffectRootKind::ShopOffer,
134 ];
135
136 /// How many roots there are. The binding ledger reports coverage against this.
137 pub const COUNT: usize = Self::ALL.len();
138
139 /// The stage document this root lives in (`quests` or `dialogue`).
140 pub fn stage(self) -> &'static str {
141 match self {
142 EffectRootKind::ObjectiveComplete
143 | EffectRootKind::QuestComplete
144 | EffectRootKind::Trigger
145 | EffectRootKind::TrapPayload
146 | EffectRootKind::ShortcutUnlock
147 | EffectRootKind::OnDeath
148 | EffectRootKind::ShopOffer => "quests",
149 EffectRootKind::DialogueRespawn => "dialogue",
150 }
151 }
152
153 /// Whether emission runs this root's bundle **with an acting player**
154 /// (`@s`), or from the server command source.
155 ///
156 /// This is a fact about the ROOT, not about any verb inside it, and it is
157 /// stated here — on the object class — rather than in the one diagnostic that
158 /// first needed it. Four roots have a player: `on_objective_complete` and
159 /// `on_complete` are dispatched `as @a` from the tick
160 /// (`Audience::Party`), and `on_death` and a dialogue `on_respawn` are the
161 /// dying/respawning player's own (`Audience::Solo`). Three do not: a
162 /// trigger's effects, a trap's payload and a shortcut's `on_unlock` are all
163 /// polled on the tick with no executor (`Audience::Scheduled`) — their own
164 /// doc comments in `emit` say so.
165 ///
166 /// **This is the class default, and one root now overrides it per
167 /// declaration.** A trigger declaring `audience: presser` (DSL v0.11) is
168 /// dispatched by a `player_interacted_with_entity` advancement and DOES run
169 /// as the clicking player; every other trigger is polled with no executor.
170 /// A consumer that must be right about a *particular* bundle therefore asks
171 /// [`EffectRootSite::runs_with_acting_player`], which answers per site;
172 /// this stays the answer for the kind.
173 ///
174 /// It is exhaustive, so an eighth root cannot be added without answering it,
175 /// and `emit::root_audience` is bound to it in both directions by
176 /// `emit`'s own test — the emitter and this answer cannot drift.
177 ///
178 /// The consumer that needs it today is `DW0503`: a `player`-scoped runtime
179 /// datum (spec-0031) read or written inside a bundle with no acting player
180 /// would emit `@s` into a sourceless function, which fails silently at
181 /// runtime.
182 pub fn runs_with_acting_player(self) -> bool {
183 match self {
184 EffectRootKind::ObjectiveComplete
185 | EffectRootKind::QuestComplete
186 | EffectRootKind::DialogueRespawn
187 | EffectRootKind::OnDeath
188 // A shop offer's handler is dispatched `as @a[scores={…}]`, so the
189 // choosing player IS the acting player — which is what makes a
190 // `player`-scoped purse debitable from a purchase.
191 | EffectRootKind::ShopOffer => true,
192 EffectRootKind::Trigger
193 | EffectRootKind::TrapPayload
194 | EffectRootKind::ShortcutUnlock => false,
195 }
196 }
197
198 /// A short human label, used by the binding ledger and by diagnostics that
199 /// report which roots a proof examined.
200 pub fn label(self) -> &'static str {
201 match self {
202 EffectRootKind::ObjectiveComplete => "quest on_objective_complete",
203 EffectRootKind::QuestComplete => "quest on_complete",
204 EffectRootKind::Trigger => "trigger effects",
205 EffectRootKind::TrapPayload => "trap payload",
206 EffectRootKind::DialogueRespawn => "dialogue set-checkpoint on_respawn",
207 EffectRootKind::ShortcutUnlock => "shortcut on_unlock",
208 EffectRootKind::OnDeath => "campaign on_death",
209 EffectRootKind::ShopOffer => "shop offer effects",
210 }
211 }
212}
213
214/// What a root hangs off, with the owning object attached.
215///
216/// This is what a consumer matches on when it needs to reason about *when* a
217/// bundle fires (the completability model) or *who* gates it (a trigger's or
218/// trap's `requires_flags`). Because the match is exhaustive at every such site,
219/// an eighth root is a compile error everywhere the answer would have to change.
220#[derive(Clone, Copy)]
221pub enum EffectRootOwner<'a> {
222 /// A quest's `on_objective_complete[<objective>]` — fires at that objective's
223 /// `critical_path` step. Forced: completing the objective is the mainline.
224 ObjectiveComplete {
225 /// The owning quest.
226 quest: &'a Quest,
227 /// The objective whose completion fires the bundle.
228 objective: &'a str,
229 },
230 /// A quest's `on_complete` — fires at the quest's completion step. Forced.
231 QuestComplete {
232 /// The owning quest.
233 quest: &'a Quest,
234 },
235 /// An environment `triggers[].effects` — proximity/interaction-fired, so it
236 /// has no step of its own. Carries the trigger, whose `requires_flags` gate
237 /// the whole bundle.
238 Trigger(&'a EnvTrigger),
239 /// A `traps[].payload` (spec-0022) — proximity/interaction-fired exactly like
240 /// a trigger, and **optional**: the party may never trip it. Carries the trap,
241 /// whose `requires_flags` gate the whole payload.
242 TrapPayload(&'a Trap),
243 /// A dialogue option's `set-checkpoint` `on_respawn` bundle — re-run on death
244 /// while that checkpoint is active, so it is optional too (nobody is forced to
245 /// die). Carries no owning object: the npc, node and option are all named in
246 /// the site's `path`, and no consumer needs to reach the tree itself.
247 DialogueRespawn,
248 /// A `shortcuts[].on_unlock` (spec-0016 §2) — fired once, by the far-side
249 /// interaction, and **optional**: `Plan::build` registers every shortcut gate
250 /// as sealed at step 0 precisely so the delve is proven completable with no
251 /// shortcut ever taken. Carries the shortcut; it declares no flag gate of its
252 /// own, so the whole bundle is ungated.
253 ShortcutUnlock(&'a Shortcut),
254 /// The campaign's `on_death` (spec-0031) — fired at the moment a player dies,
255 /// so it has no step of its own and is **optional** in the strongest sense:
256 /// nobody is forced to die. Carries no owning object; there is exactly one
257 /// per campaign and its path is `/content/on_death`.
258 OnDeath,
259 /// A `shops[].offers[].effects` (spec-0032) — fired by the player pressing a
260 /// button, so it has no step of its own and is **optional**: nobody is forced
261 /// to buy anything. Carries the shop; the offer index is in the site's `path`.
262 ShopOffer(&'a Shop),
263}
264
265impl<'a> EffectRootOwner<'a> {
266 /// Which root this is. The one place a root's owner is mapped to its kind.
267 pub fn kind(&self) -> EffectRootKind {
268 match self {
269 EffectRootOwner::ObjectiveComplete { .. } => EffectRootKind::ObjectiveComplete,
270 EffectRootOwner::QuestComplete { .. } => EffectRootKind::QuestComplete,
271 EffectRootOwner::Trigger(_) => EffectRootKind::Trigger,
272 EffectRootOwner::TrapPayload(_) => EffectRootKind::TrapPayload,
273 EffectRootOwner::DialogueRespawn => EffectRootKind::DialogueRespawn,
274 EffectRootOwner::ShortcutUnlock(_) => EffectRootKind::ShortcutUnlock,
275 EffectRootOwner::OnDeath => EffectRootKind::OnDeath,
276 EffectRootOwner::ShopOffer(_) => EffectRootKind::ShopOffer,
277 }
278 }
279
280 /// Whether emission runs **this site's** bundle with an acting player (`@s`).
281 ///
282 /// [`EffectRootKind::runs_with_acting_player`] answers for the root *class*,
283 /// which is the right answer for six of the eight and was the right answer for
284 /// all of them until DSL v0.11. A trigger is now the exception: an
285 /// `audience: presser` click is dispatched by a
286 /// `minecraft:player_interacted_with_entity` advancement and therefore runs as
287 /// the player who pressed, while every other trigger is polled on the tick
288 /// with no executor. The distinction is per-declaration, so it is answered
289 /// where the declaration is reachable — here — and the kind-level answer stays
290 /// the class default that `emit::root_audience` is bound to.
291 pub fn runs_with_acting_player(&self) -> bool {
292 match self {
293 EffectRootOwner::Trigger(t) => t.addresses_presser(),
294 other => other.kind().runs_with_acting_player(),
295 }
296 }
297
298 /// The quest this root belongs to, if it has a DAG position at all.
299 pub fn quest(&self) -> Option<&'a Quest> {
300 match self {
301 EffectRootOwner::ObjectiveComplete { quest, .. }
302 | EffectRootOwner::QuestComplete { quest } => Some(quest),
303 EffectRootOwner::Trigger(_)
304 | EffectRootOwner::TrapPayload(_)
305 | EffectRootOwner::DialogueRespawn
306 | EffectRootOwner::ShortcutUnlock(_)
307 | EffectRootOwner::OnDeath
308 | EffectRootOwner::ShopOffer(_) => None,
309 }
310 }
311}
312
313/// One effect root: which it is, where it is, and what its l10n keys hang off.
314///
315/// `path` points at the **list**; an element's pointer is `path` + `/<index>`.
316/// `key` is likewise the list's keybase; an element's key is `key` + `.<index>`.
317pub struct EffectRootSite<'a> {
318 /// What this root hangs off, with the owning object.
319 pub owner: EffectRootOwner<'a>,
320 /// The stage document the list lives in (`quests` or `dialogue`).
321 pub stage: &'static str,
322 /// JSON pointer to the list within that document.
323 pub path: String,
324 /// The list's l10n key prefix.
325 pub key: String,
326}
327
328impl EffectRootSite<'_> {
329 /// Which root this site is.
330 pub fn kind(&self) -> EffectRootKind {
331 self.owner.kind()
332 }
333
334 /// Whether emission runs this site's bundle with an acting player — the
335 /// per-declaration answer (see [`EffectRootOwner::runs_with_acting_player`]),
336 /// which is what `DW0357`/`DW0503` must ask.
337 pub fn runs_with_acting_player(&self) -> bool {
338 self.owner.runs_with_acting_player()
339 }
340}
341
342/// What a walk over the effect roots actually examined.
343///
344/// CLAUDE.md: *a green gate that binds to nothing is vacuous, not a pass*. A proof
345/// over "every effect" is only as good as the roots it reached and the bundles it
346/// found there, and neither number is visible from the proof's own output. This
347/// is that ledger, filled in by [`for_each_effect_root`] on every call.
348#[derive(Clone, Debug, PartialEq, Eq)]
349pub struct RootBinding {
350 /// How many of [`EffectRootKind::COUNT`] roots the walk enumerated. Always
351 /// `COUNT` for a walk that ran — a smaller number means a root stopped being
352 /// enumerated, which the walk itself asserts against.
353 pub roots_enumerated: usize,
354 /// Per-root: how many bundles the campaign actually has there. A zero is not
355 /// a failure — a campaign with no traps has no `traps[].payload` — but it is
356 /// the reason a proof over that root binds to nothing, and it is reported
357 /// rather than left for a reader to infer.
358 pub sites: [(EffectRootKind, usize); EffectRootKind::COUNT],
359 /// Total top-level effects across every root.
360 pub effects: usize,
361}
362
363impl RootBinding {
364 /// The roots this campaign has no bundles at — where any proof over the
365 /// effect surface is necessarily unbound.
366 pub fn unbound_roots(&self) -> Vec<EffectRootKind> {
367 self.sites
368 .iter()
369 .filter(|(_, n)| *n == 0)
370 .map(|(k, _)| *k)
371 .collect()
372 }
373
374 /// The ledger as a JSON object, for `<out>/validation/effect-roots.json`.
375 ///
376 /// [`Self::summary`] renders the same numbers for a human reading stderr,
377 /// and stderr is where they stayed: a build's stated binding was a *string*
378 /// nothing downstream could read, so a gate that wants to assert "this
379 /// campaign's effect walk bound to something" had to scrape prose or go
380 /// without. Every other proof in this compiler already publishes its binding
381 /// as a `validation/*.json` ledger; this is the one that did not, and
382 /// spec-0039 criterion 6 needs it machine-readable — "printed somewhere" is
383 /// explicitly not enough.
384 ///
385 /// `unbound_roots` is listed rather than left to be derived: a zero at a
386 /// root is not a failure (a campaign with no traps has no trap payloads),
387 /// but it is the reason any proof over that root binds to nothing, and the
388 /// point of a ledger is that a reader does not have to infer it.
389 pub fn to_json(&self) -> serde_json::Value {
390 let mut sites = serde_json::Map::new();
391 for (kind, n) in &self.sites {
392 sites.insert(kind.label().to_string(), serde_json::json!(n));
393 }
394 serde_json::json!({
395 "roots_enumerated": self.roots_enumerated,
396 "roots_total": EffectRootKind::COUNT,
397 "bundles": self.sites.iter().map(|(_, n)| n).sum::<usize>(),
398 "effects": self.effects,
399 "sites": serde_json::Value::Object(sites),
400 "unbound_roots": self
401 .unbound_roots()
402 .iter()
403 .map(|k| k.label())
404 .collect::<Vec<_>>(),
405 })
406 }
407
408 /// A one-line, deterministic rendering for a report or a `--json` field.
409 pub fn summary(&self) -> String {
410 let per: Vec<String> = self
411 .sites
412 .iter()
413 .map(|(k, n)| format!("{}={n}", k.label()))
414 .collect();
415 format!(
416 "roots {}/{}, bundles {}, effects {} [{}]",
417 self.roots_enumerated,
418 EffectRootKind::COUNT,
419 self.sites.iter().map(|(_, n)| n).sum::<usize>(),
420 self.effects,
421 per.join(", ")
422 )
423 }
424}
425
426/// **The root list, written once, as tokens.**
427///
428/// Expanded twice — by [`for_each_effect_root`] with `iter`/`as_slice` and by
429/// [`for_each_effect_root_mut`] with `iter_mut`/`as_mut_slice`. There is no second
430/// copy of "which lists are roots" anywhere in the workspace, so adding a root is
431/// one edit here and every consumer of either walk inherits it (roots 6 and 7 were
432/// added by spec-0031 and this claim is what made it a small change). That is
433/// the whole point of this module: the previous arrangement had the root list
434/// written out four times (twice in `l10n`, once in `plan`, once in `stages`) and
435/// approximated a further thirteen times by walkers that enumerated three or four
436/// of the five.
437///
438/// `$visit` is called as `$visit((kind, owner, objective), path, key, list)`. The
439/// per-root owner expressions are parameters because the mutable expansion cannot
440/// produce them: it cannot hand out `&Quest` while holding `&mut [QuestEffect]`
441/// from the same quest. That asymmetry is confined to what is *attached* to a
442/// visit — never to which roots are visited, which is what this body fixes.
443macro_rules! effect_root_walk {
444 (
445 campaign: $c:expr,
446 iter: $iter:ident,
447 slice: $slice:ident,
448 respawn: $respawn:ident,
449 note: $note:expr,
450 visit: $visit:expr,
451 quest_owner: |$q:ident| $ownq:expr,
452 trigger_owner: |$t:ident| $ownt:expr,
453 trap_owner: |$p:ident| $ownp:expr,
454 dialogue_owner: $ownd:expr,
455 shortcut_owner: |$s:ident| $owns:expr,
456 death_owner: $ownx:expr,
457 shop_owner: |$h:ident| $ownh:expr,
458 ) => {{
459 #[allow(unused_mut)]
460 let mut visit = $visit;
461 // Fired once per root, before its loop, whether or not this campaign has a
462 // single bundle there. That is the distinction the binding ledger exists to
463 // make: "this walk enumerated the root" and "this campaign uses the root"
464 // are different facts, and a proof that conflates them reports a vacuous
465 // green as a pass (CLAUDE.md).
466 #[allow(unused_mut)]
467 let mut note = $note;
468 // R1 `on_objective_complete` and R2 `on_complete`, interleaved per quest.
469 // This order is contractual: it is the order emission writes bundles in and
470 // the order the l10n inventory keys them in, so a campaign that predates a
471 // later root produces byte-identical output.
472 note(EffectRootKind::ObjectiveComplete);
473 note(EffectRootKind::QuestComplete);
474 for (qi, $q) in $c.quests.content.quests.$iter().enumerate() {
475 let ql = local($q.id.as_str()).to_string();
476 let owner = $ownq;
477 for (oid, effs) in $q.on_objective_complete.$iter() {
478 let ol = local(oid.as_str()).to_string();
479 visit(
480 (EffectRootKind::ObjectiveComplete, owner, Some(oid.as_str())),
481 format!(
482 "/content/quests/{qi}/on_objective_complete/{}",
483 oid.as_str()
484 ),
485 format!("fx.{ql}.oc.{ol}"),
486 effs.$slice(),
487 );
488 }
489 visit(
490 (EffectRootKind::QuestComplete, owner, None),
491 format!("/content/quests/{qi}/on_complete"),
492 format!("fx.{ql}.done"),
493 $q.on_complete.$slice(),
494 );
495 }
496 // R3 `triggers[].effects`.
497 note(EffectRootKind::Trigger);
498 for (ti, $t) in $c.quests.content.triggers.$iter().enumerate() {
499 let tl = local($t.id.as_str()).to_string();
500 let owner = $ownt;
501 visit(
502 (EffectRootKind::Trigger, owner, None),
503 format!("/content/triggers/{ti}/effects"),
504 format!("fx.trig.{tl}"),
505 $t.effects.$slice(),
506 );
507 }
508 // R4 `traps[].payload` (spec-0022 — a payload is an effect root).
509 note(EffectRootKind::TrapPayload);
510 for (pi, $p) in $c.quests.content.traps.$iter().enumerate() {
511 let pl = local($p.id.as_str()).to_string();
512 let owner = $ownp;
513 visit(
514 (EffectRootKind::TrapPayload, owner, None),
515 format!("/content/traps/{pi}/payload"),
516 format!("fx.trap.{pl}"),
517 $p.payload.$slice(),
518 );
519 }
520 // R5 a dialogue option's `set-checkpoint` `on_respawn` bundle — the root
521 // that hangs off a different stage document, and the one every walk written
522 // from "effects live in the quests stage" missed.
523 note(EffectRootKind::DialogueRespawn);
524 for (di, tree) in $c.dialogue.content.dialogues.$iter().enumerate() {
525 let np = local(tree.npc.as_str()).to_string();
526 let owner = $ownd;
527 for (ni, node) in tree.nodes.$iter().enumerate() {
528 let nd = local(node.id.as_str()).to_string();
529 for (oi, opt) in node.options.$iter().enumerate() {
530 for (ei, de) in opt.effects.$iter().enumerate() {
531 let Some(on_respawn) = de.$respawn() else {
532 continue;
533 };
534 visit(
535 (EffectRootKind::DialogueRespawn, owner, None),
536 format!(
537 "/content/dialogues/{di}/nodes/{ni}/options/{oi}/effects/{ei}/on_respawn"
538 ),
539 format!("fx.dlg.{np}.{nd}.{oi}.{ei}.respawn"),
540 on_respawn,
541 );
542 }
543 }
544 }
545 }
546 // R6 `shortcuts[].on_unlock` (spec-0016 §2) — an effect bundle emission
547 // has always lowered and no enumeration knew about, closed by spec-0031.
548 note(EffectRootKind::ShortcutUnlock);
549 for (si, $s) in $c.quests.content.shortcuts.$iter().enumerate() {
550 let sl = local($s.id.as_str()).to_string();
551 let owner = $owns;
552 visit(
553 (EffectRootKind::ShortcutUnlock, owner, None),
554 format!("/content/shortcuts/{si}/on_unlock"),
555 format!("fx.sc.{sl}"),
556 $s.on_unlock.$slice(),
557 );
558 }
559 // R7 the campaign's `on_death` (DSL v0.10, spec-0031) — one bundle, no
560 // owning object, visited only when the campaign declares one. An empty
561 // list is NOT visited: `RootBinding` must be able to say "this campaign
562 // has no death beat", which a site count that is always 1 could not.
563 note(EffectRootKind::OnDeath);
564 {
565 let owner = $ownx;
566 let on_death = $c.quests.content.on_death.$slice();
567 if !on_death.is_empty() {
568 visit(
569 (EffectRootKind::OnDeath, owner, None),
570 "/content/on_death".to_string(),
571 "fx.death".to_string(),
572 on_death,
573 );
574 }
575 }
576 // R8 `shops[].offers[].effects` (DSL v0.10, spec-0032) — appended after
577 // R7 for the same reason every root is appended: a campaign that predates
578 // it keys and emits byte-identically.
579 note(EffectRootKind::ShopOffer);
580 for (hi, $h) in $c.quests.content.shops.$iter().enumerate() {
581 let hl = local($h.id.as_str()).to_string();
582 let owner = $ownh;
583 for (oi, off) in $h.offers.$iter().enumerate() {
584 visit(
585 (EffectRootKind::ShopOffer, owner, None),
586 format!("/content/shops/{hi}/offers/{oi}/effects"),
587 format!("fx.shop.{hl}.{oi}"),
588 off.effects.$slice(),
589 );
590 }
591 }
592 }};
593}
594
595/// The owning object as the macro yields it, before it is paired with the
596/// objective id that only R1 has. Internal to [`for_each_effect_root`].
597#[derive(Clone, Copy)]
598enum RawOwner<'a> {
599 Quest(&'a Quest),
600 Trigger(&'a EnvTrigger),
601 Trap(&'a Trap),
602 Dialogue,
603 Shortcut(&'a Shortcut),
604 Death,
605 Shop(&'a Shop),
606}
607
608impl<'a> RawOwner<'a> {
609 fn attach(self, kind: EffectRootKind, objective: Option<&'a str>) -> EffectRootOwner<'a> {
610 match (self, kind) {
611 (RawOwner::Quest(quest), EffectRootKind::ObjectiveComplete) => {
612 EffectRootOwner::ObjectiveComplete {
613 quest,
614 objective: objective
615 .expect("an on_objective_complete root always names its objective"),
616 }
617 }
618 (RawOwner::Quest(quest), EffectRootKind::QuestComplete) => {
619 EffectRootOwner::QuestComplete { quest }
620 }
621 (RawOwner::Trigger(t), EffectRootKind::Trigger) => EffectRootOwner::Trigger(t),
622 (RawOwner::Trap(p), EffectRootKind::TrapPayload) => EffectRootOwner::TrapPayload(p),
623 (RawOwner::Dialogue, EffectRootKind::DialogueRespawn) => {
624 EffectRootOwner::DialogueRespawn
625 }
626 (RawOwner::Shortcut(s), EffectRootKind::ShortcutUnlock) => {
627 EffectRootOwner::ShortcutUnlock(s)
628 }
629 (RawOwner::Death, EffectRootKind::OnDeath) => EffectRootOwner::OnDeath,
630 (RawOwner::Shop(h), EffectRootKind::ShopOffer) => EffectRootOwner::ShopOffer(h),
631 (owner, kind) => unreachable!(
632 "effect root {kind:?} was handed an owner of the wrong shape ({})",
633 match owner {
634 RawOwner::Quest(_) => "quest",
635 RawOwner::Trigger(_) => "trigger",
636 RawOwner::Trap(_) => "trap",
637 RawOwner::Dialogue => "dialogue",
638 RawOwner::Shortcut(_) => "shortcut",
639 RawOwner::Death => "on_death",
640 RawOwner::Shop(_) => "shop",
641 }
642 ),
643 }
644 }
645}
646
647/// Visit **every effect root the compiler can lower**, in one fixed deterministic
648/// order, as `f(&site, list)`.
649///
650/// The order is contractual, because emission and the l10n key scheme are defined
651/// by it: per quest, `on_objective_complete` (a `BTreeMap`, so key-ordered) then
652/// `on_complete`; then every trigger; then every trap payload; then every dialogue
653/// `on_respawn` bundle; then every shortcut's `on_unlock`; then the campaign's
654/// `on_death`. **Each new root is appended, never inserted**, so a campaign that
655/// predates it produces byte-identical output — that is why R6 hangs off the
656/// quests stage but comes after the dialogue-stage R5.
657///
658/// A list is a root if `emit::emit_quest_effect` can reach it, **not** if the
659/// quests stage happens to own it. That distinction is the entire defect class:
660/// six of the seven roots are reachable from `campaign.quests.content` and R5 is
661/// not, so every walk reasoned from "effects live in the quests stage" was
662/// correct-looking, green, and wrong. R6 is the mirror-image reading error —
663/// `shortcuts[].on_unlock` *is* in `campaign.quests.content` and was still missed,
664/// because the walks were written against a remembered list rather than against
665/// what emission reaches.
666///
667/// Returns the [`RootBinding`] ledger — how many roots were enumerated and how
668/// many bundles each actually bound to on this campaign. A proof that states what
669/// it examined reports it; a caller that does not need it may drop it.
670///
671/// # Panics
672///
673/// If the walk failed to enumerate all [`EffectRootKind::COUNT`] roots. Unreachable
674/// by construction — the macro emits one block per root — and asserted anyway, in
675/// release builds too, because the failure it guards has no other symptom: a walk
676/// that quietly stops visiting a root just answers a narrower question and stays
677/// green over every campaign that does not use it. That is how this defect class
678/// survived six independent fixes.
679pub fn for_each_effect_root<'a>(
680 c: &'a Campaign,
681 f: &mut dyn FnMut(&EffectRootSite<'a>, &'a [QuestEffect]),
682) -> RootBinding {
683 let mut sites = [
684 (EffectRootKind::ObjectiveComplete, 0usize),
685 (EffectRootKind::QuestComplete, 0usize),
686 (EffectRootKind::Trigger, 0usize),
687 (EffectRootKind::TrapPayload, 0usize),
688 (EffectRootKind::DialogueRespawn, 0usize),
689 (EffectRootKind::ShortcutUnlock, 0usize),
690 (EffectRootKind::OnDeath, 0usize),
691 (EffectRootKind::ShopOffer, 0usize),
692 ];
693 debug_assert_eq!(
694 sites.map(|(k, _)| k),
695 EffectRootKind::ALL,
696 "the binding ledger's slots are EffectRootKind::ALL, in order"
697 );
698 let mut effects = 0usize;
699 // Which roots the walk reached at all — set by `note`, independently of whether
700 // this campaign has a bundle there.
701 let mut enumerated = [false; EffectRootKind::COUNT];
702 fn slot_of(kind: EffectRootKind) -> usize {
703 EffectRootKind::ALL
704 .iter()
705 .position(|k| *k == kind)
706 .expect("every root kind is a member of EffectRootKind::ALL")
707 }
708
709 effect_root_walk!(
710 campaign: c,
711 iter: iter,
712 slice: as_slice,
713 respawn: set_checkpoint_on_respawn,
714 note: |kind: EffectRootKind| {
715 enumerated[slot_of(kind)] = true;
716 },
717 visit: |(kind, owner, objective): (EffectRootKind, RawOwner<'a>, Option<&'a str>),
718 path: String,
719 key: String,
720 list: &'a [QuestEffect]| {
721 let owner = owner.attach(kind, objective);
722 debug_assert_eq!(owner.kind(), kind, "a site's owner and kind must agree");
723 let slot = slot_of(kind);
724 sites[slot].1 += 1;
725 effects += list.len();
726 f(
727 &EffectRootSite {
728 owner,
729 stage: kind.stage(),
730 path,
731 key,
732 },
733 list,
734 );
735 },
736 quest_owner: |q| RawOwner::Quest(q),
737 trigger_owner: |t| RawOwner::Trigger(t),
738 trap_owner: |p| RawOwner::Trap(p),
739 dialogue_owner: RawOwner::Dialogue,
740 shortcut_owner: |s| RawOwner::Shortcut(s),
741 death_owner: RawOwner::Death,
742 shop_owner: |h| RawOwner::Shop(h),
743 );
744
745 let missed: Vec<&str> = EffectRootKind::ALL
746 .iter()
747 .zip(enumerated)
748 .filter(|(_, s)| !*s)
749 .map(|(k, _)| k.label())
750 .collect();
751 assert!(
752 missed.is_empty(),
753 "for_each_effect_root enumerated {} of {} effect roots — missing: {}. A root that \
754 stops being enumerated has no other symptom.",
755 EffectRootKind::COUNT - missed.len(),
756 EffectRootKind::COUNT,
757 missed.join(", ")
758 );
759
760 RootBinding {
761 roots_enumerated: EffectRootKind::COUNT,
762 sites,
763 effects,
764 }
765}
766
767/// The callback [`for_each_effect_root_mut`] hands each root to:
768/// `(kind, json_pointer_to_the_list, l10n_keybase, list)`. `'a` ties the effects
769/// to the campaign borrow, so a consumer may collect them; `'f` is the callback's
770/// own borrow.
771pub type RootVisitorMut<'a, 'f> = dyn FnMut(EffectRootKind, &str, &str, &'a mut [QuestEffect]) + 'f;
772
773/// The **mutable mirror** of [`for_each_effect_root`]: the identical roots, in the
774/// identical order, with the same `(stage, path, key)` descriptors, exposed mutably
775/// so the localization pass can rewrite player-visible strings in place.
776///
777/// Generated from the same [`effect_root_walk`] body, so "which lists are roots" is
778/// not written twice and the pair cannot drift the way two hand-written mirrors
779/// can. `&mut Campaign` cannot yield the owning `&Quest` alongside
780/// `&mut [QuestEffect]` from the same quest, so this walk carries the
781/// [`EffectRootKind`] rather than an [`EffectRootOwner`]. That is the only
782/// difference between the two, and it is a difference in what is attached to a
783/// visit — never in which roots are visited.
784pub fn for_each_effect_root_mut<'a>(c: &'a mut Campaign, f: &mut RootVisitorMut<'a, '_>) {
785 effect_root_walk!(
786 campaign: c,
787 iter: iter_mut,
788 slice: as_mut_slice,
789 respawn: set_checkpoint_on_respawn_mut,
790 note: |_kind: EffectRootKind| {},
791 visit: |(kind, _owner, _objective): (EffectRootKind, (), Option<&str>),
792 path: String,
793 key: String,
794 list: &'a mut [QuestEffect]| {
795 f(kind, &path, &key, list);
796 },
797 quest_owner: |_q| (),
798 trigger_owner: |_t| (),
799 trap_owner: |_p| (),
800 dialogue_owner: (),
801 shortcut_owner: |_s| (),
802 death_owner: (),
803 shop_owner: |_h| (),
804 );
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 /// The binding ledger's slots are `EffectRootKind::ALL`, in order — the
812 /// property `summary()` and `unbound_roots()` both read off positionally.
813 #[test]
814 fn binding_slots_are_all_the_roots_in_order() {
815 let b = RootBinding {
816 roots_enumerated: EffectRootKind::COUNT,
817 sites: [
818 (EffectRootKind::ObjectiveComplete, 0),
819 (EffectRootKind::QuestComplete, 0),
820 (EffectRootKind::Trigger, 0),
821 (EffectRootKind::TrapPayload, 0),
822 (EffectRootKind::DialogueRespawn, 0),
823 (EffectRootKind::ShortcutUnlock, 0),
824 (EffectRootKind::OnDeath, 0),
825 (EffectRootKind::ShopOffer, 0),
826 ],
827 effects: 0,
828 };
829 assert_eq!(b.sites.map(|(k, _)| k), EffectRootKind::ALL);
830 assert_eq!(b.unbound_roots().len(), EffectRootKind::COUNT);
831 }
832
833 /// Every root kind names a stage and a label, and the stages are exactly the
834 /// two stage documents effect roots live in.
835 #[test]
836 fn every_root_names_its_stage() {
837 for k in EffectRootKind::ALL {
838 assert!(matches!(k.stage(), "quests" | "dialogue"), "{k:?}");
839 assert!(!k.label().is_empty(), "{k:?}");
840 }
841 }
842}