Skip to main content

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