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::{Assembly, EnvTrigger, Loop, 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    /// An assembly's `strikes.pattern[].on_land` bundle (spec-0082) — what a
129    /// blow does, run on the tick the strike clip's last frame is applied. A
130    /// root rather than sugar because it hangs off an object with runtime
131    /// machinery of its own (the per-assembly strike state machine). Polled on
132    /// the tick with no executor, like a trigger's bundle. Visited only where a
133    /// step declares one, so `unbound_roots` tells the truth about a campaign
134    /// with none.
135    AssemblyLand,
136    /// A `loops[].on_cross` bundle (spec-0086) — the dungeon's answer to a body
137    /// the loop just moved, run from the server source after the move and the
138    /// count. A root rather than sugar because it hangs off an object with
139    /// runtime machinery of its own (the loop's tick poll and its move
140    /// function). Visited only where declared.
141    LoopCross,
142}
143
144impl EffectRootKind {
145    /// Every root, in enumeration order. Not the *visit* order — see
146    /// [`for_each_effect_root`], which interleaves R1/R2 per quest.
147    pub const ALL: [EffectRootKind; 11] = [
148        EffectRootKind::ObjectiveComplete,
149        EffectRootKind::QuestComplete,
150        EffectRootKind::Trigger,
151        EffectRootKind::TrapPayload,
152        EffectRootKind::DialogueRespawn,
153        EffectRootKind::ShortcutUnlock,
154        EffectRootKind::OnDeath,
155        EffectRootKind::ShopOffer,
156        EffectRootKind::OnKill,
157        EffectRootKind::AssemblyLand,
158        EffectRootKind::LoopCross,
159    ];
160
161    /// How many roots there are. The binding ledger reports coverage against this.
162    pub const COUNT: usize = Self::ALL.len();
163
164    /// The stage document this root lives in (`quests` or `dialogue`).
165    pub fn stage(self) -> &'static str {
166        match self {
167            EffectRootKind::ObjectiveComplete
168            | EffectRootKind::QuestComplete
169            | EffectRootKind::Trigger
170            | EffectRootKind::TrapPayload
171            | EffectRootKind::ShortcutUnlock
172            | EffectRootKind::OnDeath
173            | EffectRootKind::ShopOffer
174            | EffectRootKind::OnKill
175            | EffectRootKind::AssemblyLand
176            | EffectRootKind::LoopCross => "quests",
177            EffectRootKind::DialogueRespawn => "dialogue",
178        }
179    }
180
181    /// Whether emission runs this root's bundle **with an acting player**
182    /// (`@s`), or from the server command source.
183    ///
184    /// This is a fact about the ROOT, not about any verb inside it, and it is
185    /// stated here — on the object class — rather than in the one diagnostic that
186    /// first needed it. Four roots have a player: `on_objective_complete` and
187    /// `on_complete` are dispatched `as @a` from the tick
188    /// (`Audience::Party`), and `on_death` and a dialogue `on_respawn` are the
189    /// dying/respawning player's own (`Audience::Solo`). Three do not: a
190    /// trigger's effects, a trap's payload and a shortcut's `on_unlock` are all
191    /// polled on the tick with no executor (`Audience::Scheduled`) — their own
192    /// doc comments in `emit` say so.
193    ///
194    /// **This is the class default, and one root now overrides it per
195    /// declaration.** A trigger declaring `audience: presser` (DSL v0.11) is
196    /// dispatched by a `player_interacted_with_entity` advancement and DOES run
197    /// as the clicking player; every other trigger is polled with no executor.
198    /// A consumer that must be right about a *particular* bundle therefore asks
199    /// [`EffectRootSite::runs_with_acting_player`], which answers per site;
200    /// this stays the answer for the kind.
201    ///
202    /// It is exhaustive, so an eighth root cannot be added without answering it,
203    /// and `emit::root_audience` is bound to it in both directions by
204    /// `emit`'s own test — the emitter and this answer cannot drift.
205    ///
206    /// The consumer that needs it today is `DW0503`: a `player`-scoped runtime
207    /// datum (spec-0031) read or written inside a bundle with no acting player
208    /// would emit `@s` into a sourceless function, which fails silently at
209    /// runtime.
210    pub fn runs_with_acting_player(self) -> bool {
211        match self {
212            EffectRootKind::ObjectiveComplete
213            | EffectRootKind::QuestComplete
214            | EffectRootKind::DialogueRespawn
215            | EffectRootKind::OnDeath
216            // A shop offer's handler is dispatched `as @a[scores={…}]`, so the
217            // choosing player IS the acting player — which is what makes a
218            // `player`-scoped purse debitable from a purchase.
219            | EffectRootKind::ShopOffer
220            // A kill's reward runs as the player the kill was credited to.
221            | EffectRootKind::OnKill => true,
222            EffectRootKind::Trigger
223            | EffectRootKind::TrapPayload
224            | EffectRootKind::ShortcutUnlock
225            // A blow lands from the strike machine on the tick, with no executor.
226            | EffectRootKind::AssemblyLand
227            // A loop's answer is the dungeon acting, as a trap's payload is: it
228            // runs from the server source, so `@s` has no body.
229            | EffectRootKind::LoopCross => false,
230        }
231    }
232
233    /// A short human label, used by the binding ledger and by diagnostics that
234    /// report which roots a proof examined.
235    pub fn label(self) -> &'static str {
236        match self {
237            EffectRootKind::ObjectiveComplete => "quest on_objective_complete",
238            EffectRootKind::QuestComplete => "quest on_complete",
239            EffectRootKind::Trigger => "trigger effects",
240            EffectRootKind::TrapPayload => "trap payload",
241            EffectRootKind::DialogueRespawn => "dialogue set-checkpoint on_respawn",
242            EffectRootKind::ShortcutUnlock => "shortcut on_unlock",
243            EffectRootKind::OnDeath => "campaign on_death",
244            EffectRootKind::ShopOffer => "shop offer effects",
245            EffectRootKind::OnKill => "fight on_kill",
246            EffectRootKind::AssemblyLand => "assembly strike on_land",
247            EffectRootKind::LoopCross => "loop on_cross",
248        }
249    }
250}
251
252/// What a root hangs off, with the owning object attached.
253///
254/// This is what a consumer matches on when it needs to reason about *when* a
255/// bundle fires (the completability model) or *who* gates it (a trigger's or
256/// trap's `requires_flags`). Because the match is exhaustive at every such site,
257/// an eighth root is a compile error everywhere the answer would have to change.
258#[derive(Clone, Copy)]
259pub enum EffectRootOwner<'a> {
260    /// A quest's `on_objective_complete[<objective>]` — fires at that objective's
261    /// `critical_path` step. Forced: completing the objective is the mainline.
262    ObjectiveComplete {
263        /// The owning quest.
264        quest: &'a Quest,
265        /// The objective whose completion fires the bundle.
266        objective: &'a str,
267    },
268    /// A quest's `on_complete` — fires at the quest's completion step. Forced.
269    QuestComplete {
270        /// The owning quest.
271        quest: &'a Quest,
272    },
273    /// An environment `triggers[].effects` — proximity/interaction-fired, so it
274    /// has no step of its own. Carries the trigger, whose `requires_flags` gate
275    /// the whole bundle.
276    Trigger(&'a EnvTrigger),
277    /// A `traps[].payload` (spec-0022) — proximity/interaction-fired exactly like
278    /// a trigger, and **optional**: the party may never trip it. Carries the trap,
279    /// whose `requires_flags` gate the whole payload.
280    TrapPayload(&'a Trap),
281    /// A dialogue option's `set-checkpoint` `on_respawn` bundle — re-run on death
282    /// while that checkpoint is active, so it is optional too (nobody is forced to
283    /// die). Carries no owning object: the npc, node and option are all named in
284    /// the site's `path`, and no consumer needs to reach the tree itself.
285    DialogueRespawn,
286    /// A `shortcuts[].on_unlock` (spec-0016 §2) — fired once, by the far-side
287    /// interaction, and **optional**: `Plan::build` registers every shortcut gate
288    /// as sealed at step 0 precisely so the delve is proven completable with no
289    /// shortcut ever taken. Carries the shortcut; it declares no flag gate of its
290    /// own, so the whole bundle is ungated.
291    ShortcutUnlock(&'a Shortcut),
292    /// The campaign's `on_death` (spec-0031) — fired at the moment a player dies,
293    /// so it has no step of its own and is **optional** in the strongest sense:
294    /// nobody is forced to die. Carries no owning object; there is exactly one
295    /// per campaign and its path is `/content/on_death`.
296    OnDeath,
297    /// A `shops[].offers[].effects` (spec-0032) — fired by the player pressing a
298    /// button, so it has no step of its own and is **optional**: nobody is forced
299    /// to buy anything. Carries the shop; the offer index is in the site's `path`.
300    ShopOffer(&'a Shop),
301    /// A wave's or an actor's `on_kill` (spec-0074) — fired by a player being
302    /// credited with a kill, so it has no step of its own and is **optional**:
303    /// nobody is forced to be credited with a kill (a body may fall, burn or be
304    /// cut down by another mob). Carries the fight.
305    OnKill(Fight<'a>),
306    /// An assembly's strike step `on_land` (spec-0082) — fired by the strike
307    /// machine while a player stands in the arming region, so it has no step
308    /// of its own and is **optional**: nobody is forced to stand where a blow
309    /// lands. Carries the assembly; the step index is in the site's `path`.
310    AssemblyLand(&'a Assembly),
311    /// A `loops[].on_cross` (spec-0086) — fired by a body crossing a holding
312    /// slab, so it has no objective step of its own; on the forced route it is
313    /// performed by the path's exercise step, elsewhere it is **optional**.
314    /// Carries the loop.
315    LoopCross(&'a Loop),
316}
317
318impl<'a> EffectRootOwner<'a> {
319    /// Which root this is. The one place a root's owner is mapped to its kind.
320    pub fn kind(&self) -> EffectRootKind {
321        match self {
322            EffectRootOwner::ObjectiveComplete { .. } => EffectRootKind::ObjectiveComplete,
323            EffectRootOwner::QuestComplete { .. } => EffectRootKind::QuestComplete,
324            EffectRootOwner::Trigger(_) => EffectRootKind::Trigger,
325            EffectRootOwner::TrapPayload(_) => EffectRootKind::TrapPayload,
326            EffectRootOwner::DialogueRespawn => EffectRootKind::DialogueRespawn,
327            EffectRootOwner::ShortcutUnlock(_) => EffectRootKind::ShortcutUnlock,
328            EffectRootOwner::OnDeath => EffectRootKind::OnDeath,
329            EffectRootOwner::ShopOffer(_) => EffectRootKind::ShopOffer,
330            EffectRootOwner::OnKill(_) => EffectRootKind::OnKill,
331            EffectRootOwner::AssemblyLand(_) => EffectRootKind::AssemblyLand,
332            EffectRootOwner::LoopCross(_) => EffectRootKind::LoopCross,
333        }
334    }
335
336    /// Whether emission runs **this site's** bundle with an acting player (`@s`).
337    ///
338    /// [`EffectRootKind::runs_with_acting_player`] answers for the root *class*,
339    /// which is the right answer for six of the eight and was the right answer for
340    /// all of them until DSL v0.11. A trigger is now the exception: an
341    /// `audience: presser` click is dispatched by a
342    /// `minecraft:player_interacted_with_entity` advancement and therefore runs as
343    /// the player who pressed, while every other trigger is polled on the tick
344    /// with no executor. The distinction is per-declaration, so it is answered
345    /// where the declaration is reachable — here — and the kind-level answer stays
346    /// the class default that `emit::root_audience` is bound to.
347    pub fn runs_with_acting_player(&self) -> bool {
348        match self {
349            EffectRootOwner::Trigger(t) => t.addresses_presser(),
350            other => other.kind().runs_with_acting_player(),
351        }
352    }
353
354    /// The quest this root belongs to, if it has a DAG position at all.
355    pub fn quest(&self) -> Option<&'a Quest> {
356        match self {
357            EffectRootOwner::ObjectiveComplete { quest, .. }
358            | EffectRootOwner::QuestComplete { quest } => Some(quest),
359            EffectRootOwner::Trigger(_)
360            | EffectRootOwner::TrapPayload(_)
361            | EffectRootOwner::DialogueRespawn
362            | EffectRootOwner::ShortcutUnlock(_)
363            | EffectRootOwner::OnDeath
364            | EffectRootOwner::ShopOffer(_)
365            | EffectRootOwner::OnKill(_)
366            | EffectRootOwner::AssemblyLand(_)
367            | EffectRootOwner::LoopCross(_) => None,
368        }
369    }
370}
371
372/// One effect root: which it is, where it is, and what its l10n keys hang off.
373///
374/// `path` points at the **list**; an element's pointer is `path` + `/<index>`.
375/// `key` is likewise the list's keybase; an element's key is `key` + `.<index>`.
376pub struct EffectRootSite<'a> {
377    /// What this root hangs off, with the owning object.
378    pub owner: EffectRootOwner<'a>,
379    /// The stage document the list lives in (`quests` or `dialogue`).
380    pub stage: &'static str,
381    /// JSON pointer to the list within that document.
382    pub path: String,
383    /// The list's l10n key prefix.
384    pub key: String,
385}
386
387impl EffectRootSite<'_> {
388    /// Which root this site is.
389    pub fn kind(&self) -> EffectRootKind {
390        self.owner.kind()
391    }
392
393    /// Whether emission runs this site's bundle with an acting player — the
394    /// per-declaration answer (see [`EffectRootOwner::runs_with_acting_player`]),
395    /// which is what `DW0357`/`DW0503` must ask.
396    pub fn runs_with_acting_player(&self) -> bool {
397        self.owner.runs_with_acting_player()
398    }
399}
400
401/// What a walk over the effect roots actually examined.
402///
403/// CLAUDE.md: *a green gate that binds to nothing is vacuous, not a pass*. A proof
404/// over "every effect" is only as good as the roots it reached and the bundles it
405/// found there, and neither number is visible from the proof's own output. This
406/// is that ledger, filled in by [`for_each_effect_root`] on every call.
407#[derive(Clone, Debug, PartialEq, Eq)]
408pub struct RootBinding {
409    /// How many of [`EffectRootKind::COUNT`] roots the walk enumerated. Always
410    /// `COUNT` for a walk that ran — a smaller number means a root stopped being
411    /// enumerated, which the walk itself asserts against.
412    pub roots_enumerated: usize,
413    /// Per-root: how many bundles the campaign actually has there. A zero is not
414    /// a failure — a campaign with no traps has no `traps[].payload` — but it is
415    /// the reason a proof over that root binds to nothing, and it is reported
416    /// rather than left for a reader to infer.
417    pub sites: [(EffectRootKind, usize); EffectRootKind::COUNT],
418    /// Total top-level effects across every root.
419    pub effects: usize,
420}
421
422impl RootBinding {
423    /// The roots this campaign has no bundles at — where any proof over the
424    /// effect surface is necessarily unbound.
425    pub fn unbound_roots(&self) -> Vec<EffectRootKind> {
426        self.sites
427            .iter()
428            .filter(|(_, n)| *n == 0)
429            .map(|(k, _)| *k)
430            .collect()
431    }
432
433    /// The ledger as a JSON object, for `<out>/validation/effect-roots.json`.
434    ///
435    /// [`Self::summary`] renders the same numbers for a human reading stderr,
436    /// and stderr is where they stayed: a build's stated binding was a *string*
437    /// nothing downstream could read, so a gate that wants to assert "this
438    /// campaign's effect walk bound to something" had to scrape prose or go
439    /// without. Every other proof in this compiler already publishes its binding
440    /// as a `validation/*.json` ledger; this is the one that did not, and
441    /// spec-0039 criterion 6 needs it machine-readable — "printed somewhere" is
442    /// explicitly not enough.
443    ///
444    /// `unbound_roots` is listed rather than left to be derived: a zero at a
445    /// root is not a failure (a campaign with no traps has no trap payloads),
446    /// but it is the reason any proof over that root binds to nothing, and the
447    /// point of a ledger is that a reader does not have to infer it.
448    pub fn to_json(&self) -> serde_json::Value {
449        let mut sites = serde_json::Map::new();
450        for (kind, n) in &self.sites {
451            sites.insert(kind.label().to_string(), serde_json::json!(n));
452        }
453        serde_json::json!({
454            "roots_enumerated": self.roots_enumerated,
455            "roots_total": EffectRootKind::COUNT,
456            "bundles": self.sites.iter().map(|(_, n)| n).sum::<usize>(),
457            "effects": self.effects,
458            "sites": serde_json::Value::Object(sites),
459            "unbound_roots": self
460                .unbound_roots()
461                .iter()
462                .map(|k| k.label())
463                .collect::<Vec<_>>(),
464        })
465    }
466
467    /// A one-line, deterministic rendering for a report or a `--json` field.
468    pub fn summary(&self) -> String {
469        let per: Vec<String> = self
470            .sites
471            .iter()
472            .map(|(k, n)| format!("{}={n}", k.label()))
473            .collect();
474        format!(
475            "roots {}/{}, bundles {}, effects {} [{}]",
476            self.roots_enumerated,
477            EffectRootKind::COUNT,
478            self.sites.iter().map(|(_, n)| n).sum::<usize>(),
479            self.effects,
480            per.join(", ")
481        )
482    }
483}
484
485/// **The root list, written once, as tokens.**
486///
487/// Expanded twice — by [`for_each_effect_root`] with `iter`/`as_slice` and by
488/// [`for_each_effect_root_mut`] with `iter_mut`/`as_mut_slice`. There is no second
489/// copy of "which lists are roots" anywhere in the workspace, so adding a root is
490/// one edit here and every consumer of either walk inherits it (roots 6 and 7 were
491/// added by spec-0031 and this claim is what made it a small change). That is
492/// the whole point of this module: the previous arrangement had the root list
493/// written out four times (twice in `l10n`, once in `plan`, once in `stages`) and
494/// approximated a further thirteen times by walkers that enumerated three or four
495/// of the five.
496///
497/// `$visit` is called as `$visit((kind, owner, objective), path, key, list)`. The
498/// per-root owner expressions are parameters because the mutable expansion cannot
499/// produce them: it cannot hand out `&Quest` while holding `&mut [QuestEffect]`
500/// from the same quest. That asymmetry is confined to what is *attached* to a
501/// visit — never to which roots are visited, which is what this body fixes.
502macro_rules! effect_root_walk {
503    (
504        campaign: $c:expr,
505        iter: $iter:ident,
506        slice: $slice:ident,
507        respawn: $respawn:ident,
508        note: $note:expr,
509        visit: $visit:expr,
510        quest_owner: |$q:ident| $ownq:expr,
511        trigger_owner: |$t:ident| $ownt:expr,
512        trap_owner: |$p:ident| $ownp:expr,
513        dialogue_owner: $ownd:expr,
514        shortcut_owner: |$s:ident| $owns:expr,
515        death_owner: $ownx:expr,
516        shop_owner: |$h:ident| $ownh:expr,
517        opt: $opt:ident,
518        wave_owner: |$w:ident| $ownw:expr,
519        actor_owner: |$a:ident| $owna:expr,
520        assembly_owner: |$m:ident| $ownm:expr,
521        loop_owner: |$l:ident| $ownl:expr,
522    ) => {{
523        #[allow(unused_mut)]
524        let mut visit = $visit;
525        // Fired once per root, before its loop, whether or not this campaign has a
526        // single bundle there. That is the distinction the binding ledger exists to
527        // make: "this walk enumerated the root" and "this campaign uses the root"
528        // are different facts, and a proof that conflates them reports a vacuous
529        // green as a pass (CLAUDE.md).
530        #[allow(unused_mut)]
531        let mut note = $note;
532        // R1 `on_objective_complete` and R2 `on_complete`, interleaved per quest.
533        // This order is contractual: it is the order emission writes bundles in and
534        // the order the l10n inventory keys them in, so a campaign that predates a
535        // later root produces byte-identical output.
536        note(EffectRootKind::ObjectiveComplete);
537        note(EffectRootKind::QuestComplete);
538        for (qi, $q) in $c.quests.content.quests.$iter().enumerate() {
539            let ql = local($q.id.as_str()).to_string();
540            let owner = $ownq;
541            for (oid, effs) in $q.on_objective_complete.$iter() {
542                let ol = local(oid.as_str()).to_string();
543                visit(
544                    (EffectRootKind::ObjectiveComplete, owner, Some(oid.as_str())),
545                    format!(
546                        "/content/quests/{qi}/on_objective_complete/{}",
547                        oid.as_str()
548                    ),
549                    format!("fx.{ql}.oc.{ol}"),
550                    effs.$slice(),
551                );
552            }
553            visit(
554                (EffectRootKind::QuestComplete, owner, None),
555                format!("/content/quests/{qi}/on_complete"),
556                format!("fx.{ql}.done"),
557                $q.on_complete.$slice(),
558            );
559        }
560        // R3 `triggers[].effects`.
561        note(EffectRootKind::Trigger);
562        for (ti, $t) in $c.quests.content.triggers.$iter().enumerate() {
563            let tl = local($t.id.as_str()).to_string();
564            let owner = $ownt;
565            visit(
566                (EffectRootKind::Trigger, owner, None),
567                format!("/content/triggers/{ti}/effects"),
568                format!("fx.trig.{tl}"),
569                $t.effects.$slice(),
570            );
571        }
572        // R4 `traps[].payload` (spec-0022 — a payload is an effect root).
573        note(EffectRootKind::TrapPayload);
574        for (pi, $p) in $c.quests.content.traps.$iter().enumerate() {
575            let pl = local($p.id.as_str()).to_string();
576            let owner = $ownp;
577            visit(
578                (EffectRootKind::TrapPayload, owner, None),
579                format!("/content/traps/{pi}/payload"),
580                format!("fx.trap.{pl}"),
581                $p.payload.$slice(),
582            );
583        }
584        // R5 a dialogue option's `set-checkpoint` `on_respawn` bundle — the root
585        // that hangs off a different stage document, and the one every walk written
586        // from "effects live in the quests stage" missed.
587        note(EffectRootKind::DialogueRespawn);
588        for (di, tree) in $c.dialogue.content.dialogues.$iter().enumerate() {
589            let np = local(tree.npc.as_str()).to_string();
590            let owner = $ownd;
591            for (ni, node) in tree.nodes.$iter().enumerate() {
592                let nd = local(node.id.as_str()).to_string();
593                for (oi, opt) in node.options.$iter().enumerate() {
594                    for (ei, de) in opt.effects.$iter().enumerate() {
595                        let Some(on_respawn) = de.$respawn() else {
596                            continue;
597                        };
598                        visit(
599                            (EffectRootKind::DialogueRespawn, owner, None),
600                            format!(
601                                "/content/dialogues/{di}/nodes/{ni}/options/{oi}/effects/{ei}/on_respawn"
602                            ),
603                            format!("fx.dlg.{np}.{nd}.{oi}.{ei}.respawn"),
604                            on_respawn,
605                        );
606                    }
607                }
608            }
609        }
610        // R6 `shortcuts[].on_unlock` (spec-0016 §2) — an effect bundle emission
611        // has always lowered and no enumeration knew about, closed by spec-0031.
612        note(EffectRootKind::ShortcutUnlock);
613        for (si, $s) in $c.quests.content.shortcuts.$iter().enumerate() {
614            let sl = local($s.id.as_str()).to_string();
615            let owner = $owns;
616            visit(
617                (EffectRootKind::ShortcutUnlock, owner, None),
618                format!("/content/shortcuts/{si}/on_unlock"),
619                format!("fx.sc.{sl}"),
620                $s.on_unlock.$slice(),
621            );
622        }
623        // R7 the campaign's `on_death` (DSL v0.10, spec-0031) — one bundle, no
624        // owning object, visited only when the campaign declares one. An empty
625        // list is NOT visited: `RootBinding` must be able to say "this campaign
626        // has no death beat", which a site count that is always 1 could not.
627        note(EffectRootKind::OnDeath);
628        {
629            let owner = $ownx;
630            let on_death = $c.quests.content.on_death.$slice();
631            if !on_death.is_empty() {
632                visit(
633                    (EffectRootKind::OnDeath, owner, None),
634                    "/content/on_death".to_string(),
635                    "fx.death".to_string(),
636                    on_death,
637                );
638            }
639        }
640        // R8 `shops[].offers[].effects` (DSL v0.10, spec-0032) — appended after
641        // R7 for the same reason every root is appended: a campaign that predates
642        // it keys and emits byte-identically.
643        note(EffectRootKind::ShopOffer);
644        for (hi, $h) in $c.quests.content.shops.$iter().enumerate() {
645            let hl = local($h.id.as_str()).to_string();
646            let owner = $ownh;
647            for (oi, off) in $h.offers.$iter().enumerate() {
648                visit(
649                    (EffectRootKind::ShopOffer, owner, None),
650                    format!("/content/shops/{hi}/offers/{oi}/effects"),
651                    format!("fx.shop.{hl}.{oi}"),
652                    off.effects.$slice(),
653                );
654            }
655        }
656        // R9 `waves[].on_kill` then `actors[].on_kill` (spec-0074) — appended after
657        // R8, and visited only where a fight declares a bundle, so a campaign
658        // with none keys and emits byte-identically and `RootBinding` can say it
659        // has none. The key is the fight's own (`wave.<id>.on_kill`,
660        // `actor.<id>.on_kill`): the bundle belongs to the body, not to a beat.
661        note(EffectRootKind::OnKill);
662        for (wi, $w) in $c.quests.content.waves.$iter().enumerate() {
663            let wl = local($w.id.as_str()).to_string();
664            let owner = $ownw;
665            if let Some(ok) = $w.on_kill.$opt() {
666                visit(
667                    (EffectRootKind::OnKill, owner, None),
668                    format!("/content/waves/{wi}/on_kill/effects"),
669                    format!("wave.{wl}.on_kill"),
670                    ok.effects.$slice(),
671                );
672            }
673        }
674        for (ai, $a) in $c.quests.content.actors.$iter().enumerate() {
675            let al = local($a.id.as_str()).to_string();
676            let owner = $owna;
677            if let Some(ok) = $a.on_kill.$opt() {
678                visit(
679                    (EffectRootKind::OnKill, owner, None),
680                    format!("/content/actors/{ai}/on_kill/effects"),
681                    format!("actor.{al}.on_kill"),
682                    ok.effects.$slice(),
683                );
684            }
685        }
686        // R10 `assemblies[].strikes.pattern[].on_land` (spec-0082) — appended
687        // after R9, visited only where a step declares a bundle, keyed by the
688        // assembly and the step (`assembly.<id>.strike.<step>`).
689        note(EffectRootKind::AssemblyLand);
690        for (mi, $m) in $c.quests.content.assemblies.$iter().enumerate() {
691            let ml = local($m.id.as_str()).to_string();
692            let owner = $ownm;
693            if let Some(strikes) = $m.strikes.$opt() {
694                for (si, step) in strikes.pattern.$iter().enumerate() {
695                    if step.on_land.is_empty() {
696                        continue;
697                    }
698                    visit(
699                        (EffectRootKind::AssemblyLand, owner, None),
700                        format!("/content/assemblies/{mi}/strikes/pattern/{si}/on_land"),
701                        format!("assembly.{ml}.strike.{si}"),
702                        step.on_land.$slice(),
703                    );
704                }
705            }
706        }
707        // R11 `loops[].on_cross` (spec-0086) — appended after R10, visited only
708        // where a loop declares a bundle, so a campaign with none keys and emits
709        // byte-identically and `RootBinding` can say it has none.
710        note(EffectRootKind::LoopCross);
711        for (li, $l) in $c.quests.content.loops.$iter().enumerate() {
712            let ll = local($l.id.as_str()).to_string();
713            let owner = $ownl;
714            if !$l.on_cross.is_empty() {
715                visit(
716                    (EffectRootKind::LoopCross, owner, None),
717                    format!("/content/loops/{li}/on_cross"),
718                    format!("fx.loop.{ll}"),
719                    $l.on_cross.$slice(),
720                );
721            }
722        }
723    }};
724}
725
726/// The owning object as the macro yields it, before it is paired with the
727/// objective id that only R1 has. Internal to [`for_each_effect_root`].
728#[derive(Clone, Copy)]
729enum RawOwner<'a> {
730    Quest(&'a Quest),
731    Trigger(&'a EnvTrigger),
732    Trap(&'a Trap),
733    Dialogue,
734    Shortcut(&'a Shortcut),
735    Death,
736    Shop(&'a Shop),
737    Fight(Fight<'a>),
738    Assembly(&'a Assembly),
739    Loop(&'a Loop),
740}
741
742impl<'a> RawOwner<'a> {
743    fn attach(self, kind: EffectRootKind, objective: Option<&'a str>) -> EffectRootOwner<'a> {
744        match (self, kind) {
745            (RawOwner::Quest(quest), EffectRootKind::ObjectiveComplete) => {
746                EffectRootOwner::ObjectiveComplete {
747                    quest,
748                    objective: objective
749                        .expect("an on_objective_complete root always names its objective"),
750                }
751            }
752            (RawOwner::Quest(quest), EffectRootKind::QuestComplete) => {
753                EffectRootOwner::QuestComplete { quest }
754            }
755            (RawOwner::Trigger(t), EffectRootKind::Trigger) => EffectRootOwner::Trigger(t),
756            (RawOwner::Trap(p), EffectRootKind::TrapPayload) => EffectRootOwner::TrapPayload(p),
757            (RawOwner::Dialogue, EffectRootKind::DialogueRespawn) => {
758                EffectRootOwner::DialogueRespawn
759            }
760            (RawOwner::Shortcut(s), EffectRootKind::ShortcutUnlock) => {
761                EffectRootOwner::ShortcutUnlock(s)
762            }
763            (RawOwner::Death, EffectRootKind::OnDeath) => EffectRootOwner::OnDeath,
764            (RawOwner::Shop(h), EffectRootKind::ShopOffer) => EffectRootOwner::ShopOffer(h),
765            (RawOwner::Fight(f), EffectRootKind::OnKill) => EffectRootOwner::OnKill(f),
766            (RawOwner::Assembly(m), EffectRootKind::AssemblyLand) => {
767                EffectRootOwner::AssemblyLand(m)
768            }
769            (RawOwner::Loop(l), EffectRootKind::LoopCross) => EffectRootOwner::LoopCross(l),
770            (owner, kind) => unreachable!(
771                "effect root {kind:?} was handed an owner of the wrong shape ({})",
772                match owner {
773                    RawOwner::Quest(_) => "quest",
774                    RawOwner::Trigger(_) => "trigger",
775                    RawOwner::Trap(_) => "trap",
776                    RawOwner::Dialogue => "dialogue",
777                    RawOwner::Shortcut(_) => "shortcut",
778                    RawOwner::Death => "on_death",
779                    RawOwner::Shop(_) => "shop",
780                    RawOwner::Fight(_) => "fight",
781                    RawOwner::Assembly(_) => "assembly",
782                    RawOwner::Loop(_) => "loop",
783                }
784            ),
785        }
786    }
787}
788
789/// Visit **every effect root the compiler can lower**, in one fixed deterministic
790/// order, as `f(&site, list)`.
791///
792/// The order is contractual, because emission and the l10n key scheme are defined
793/// by it: per quest, `on_objective_complete` (a `BTreeMap`, so key-ordered) then
794/// `on_complete`; then every trigger; then every trap payload; then every dialogue
795/// `on_respawn` bundle; then every shortcut's `on_unlock`; then the campaign's
796/// `on_death`. **Each new root is appended, never inserted**, so a campaign that
797/// predates it produces byte-identical output — that is why R6 hangs off the
798/// quests stage but comes after the dialogue-stage R5.
799///
800/// A list is a root if `emit::emit_quest_effect` can reach it, **not** if the
801/// quests stage happens to own it. That distinction is the entire defect class:
802/// six of the seven roots are reachable from `campaign.quests.content` and R5 is
803/// not, so every walk reasoned from "effects live in the quests stage" was
804/// correct-looking, green, and wrong. R6 is the mirror-image reading error —
805/// `shortcuts[].on_unlock` *is* in `campaign.quests.content` and was still missed,
806/// because the walks were written against a remembered list rather than against
807/// what emission reaches.
808///
809/// Returns the [`RootBinding`] ledger — how many roots were enumerated and how
810/// many bundles each actually bound to on this campaign. A proof that states what
811/// it examined reports it; a caller that does not need it may drop it.
812///
813/// # Panics
814///
815/// If the walk failed to enumerate all [`EffectRootKind::COUNT`] roots. Unreachable
816/// by construction — the macro emits one block per root — and asserted anyway, in
817/// release builds too, because the failure it guards has no other symptom: a walk
818/// that quietly stops visiting a root just answers a narrower question and stays
819/// green over every campaign that does not use it. That is how this defect class
820/// survived six independent fixes.
821pub fn for_each_effect_root<'a>(
822    c: &'a Campaign,
823    f: &mut dyn FnMut(&EffectRootSite<'a>, &'a [QuestEffect]),
824) -> RootBinding {
825    let mut sites = [
826        (EffectRootKind::ObjectiveComplete, 0usize),
827        (EffectRootKind::QuestComplete, 0usize),
828        (EffectRootKind::Trigger, 0usize),
829        (EffectRootKind::TrapPayload, 0usize),
830        (EffectRootKind::DialogueRespawn, 0usize),
831        (EffectRootKind::ShortcutUnlock, 0usize),
832        (EffectRootKind::OnDeath, 0usize),
833        (EffectRootKind::ShopOffer, 0usize),
834        (EffectRootKind::OnKill, 0usize),
835        (EffectRootKind::AssemblyLand, 0usize),
836        (EffectRootKind::LoopCross, 0usize),
837    ];
838    debug_assert_eq!(
839        sites.map(|(k, _)| k),
840        EffectRootKind::ALL,
841        "the binding ledger's slots are EffectRootKind::ALL, in order"
842    );
843    let mut effects = 0usize;
844    // Which roots the walk reached at all — set by `note`, independently of whether
845    // this campaign has a bundle there.
846    let mut enumerated = [false; EffectRootKind::COUNT];
847    fn slot_of(kind: EffectRootKind) -> usize {
848        EffectRootKind::ALL
849            .iter()
850            .position(|k| *k == kind)
851            .expect("every root kind is a member of EffectRootKind::ALL")
852    }
853
854    effect_root_walk!(
855        campaign: c,
856        iter: iter,
857        slice: as_slice,
858        respawn: set_checkpoint_on_respawn,
859        note: |kind: EffectRootKind| {
860            enumerated[slot_of(kind)] = true;
861        },
862        visit: |(kind, owner, objective): (EffectRootKind, RawOwner<'a>, Option<&'a str>),
863                path: String,
864                key: String,
865                list: &'a [QuestEffect]| {
866            let owner = owner.attach(kind, objective);
867            debug_assert_eq!(owner.kind(), kind, "a site's owner and kind must agree");
868            let slot = slot_of(kind);
869            sites[slot].1 += 1;
870            effects += list.len();
871            f(
872                &EffectRootSite {
873                    owner,
874                    stage: kind.stage(),
875                    path,
876                    key,
877                },
878                list,
879            );
880        },
881        quest_owner: |q| RawOwner::Quest(q),
882        trigger_owner: |t| RawOwner::Trigger(t),
883        trap_owner: |p| RawOwner::Trap(p),
884        dialogue_owner: RawOwner::Dialogue,
885        shortcut_owner: |s| RawOwner::Shortcut(s),
886        death_owner: RawOwner::Death,
887        shop_owner: |h| RawOwner::Shop(h),
888        opt: as_ref,
889        wave_owner: |w| RawOwner::Fight(Fight::Wave(w)),
890        actor_owner: |a| RawOwner::Fight(Fight::Actor(a)),
891        assembly_owner: |m| RawOwner::Assembly(m),
892        loop_owner: |l| RawOwner::Loop(l),
893    );
894
895    let missed: Vec<&str> = EffectRootKind::ALL
896        .iter()
897        .zip(enumerated)
898        .filter(|(_, s)| !*s)
899        .map(|(k, _)| k.label())
900        .collect();
901    assert!(
902        missed.is_empty(),
903        "for_each_effect_root enumerated {} of {} effect roots — missing: {}. A root that \
904         stops being enumerated has no other symptom.",
905        EffectRootKind::COUNT - missed.len(),
906        EffectRootKind::COUNT,
907        missed.join(", ")
908    );
909
910    RootBinding {
911        roots_enumerated: EffectRootKind::COUNT,
912        sites,
913        effects,
914    }
915}
916
917/// The callback [`for_each_effect_root_mut`] hands each root to:
918/// `(kind, json_pointer_to_the_list, l10n_keybase, list)`. `'a` ties the effects
919/// to the campaign borrow, so a consumer may collect them; `'f` is the callback's
920/// own borrow.
921pub type RootVisitorMut<'a, 'f> = dyn FnMut(EffectRootKind, &str, &str, &'a mut [QuestEffect]) + 'f;
922
923/// The **mutable mirror** of [`for_each_effect_root`]: the identical roots, in the
924/// identical order, with the same `(stage, path, key)` descriptors, exposed mutably
925/// so the localization pass can rewrite player-visible strings in place.
926///
927/// Generated from the same [`effect_root_walk`] body, so "which lists are roots" is
928/// not written twice and the pair cannot drift the way two hand-written mirrors
929/// can. `&mut Campaign` cannot yield the owning `&Quest` alongside
930/// `&mut [QuestEffect]` from the same quest, so this walk carries the
931/// [`EffectRootKind`] rather than an [`EffectRootOwner`]. That is the only
932/// difference between the two, and it is a difference in what is attached to a
933/// visit — never in which roots are visited.
934pub fn for_each_effect_root_mut<'a>(c: &'a mut Campaign, f: &mut RootVisitorMut<'a, '_>) {
935    effect_root_walk!(
936        campaign: c,
937        iter: iter_mut,
938        slice: as_mut_slice,
939        respawn: set_checkpoint_on_respawn_mut,
940        note: |_kind: EffectRootKind| {},
941        visit: |(kind, _owner, _objective): (EffectRootKind, (), Option<&str>),
942                path: String,
943                key: String,
944                list: &'a mut [QuestEffect]| {
945            f(kind, &path, &key, list);
946        },
947        quest_owner: |_q| (),
948        trigger_owner: |_t| (),
949        trap_owner: |_p| (),
950        dialogue_owner: (),
951        shortcut_owner: |_s| (),
952        death_owner: (),
953        shop_owner: |_h| (),
954        opt: as_mut,
955        wave_owner: |_w| (),
956        actor_owner: |_a| (),
957        assembly_owner: |_m| (),
958        loop_owner: |_l| (),
959    );
960}
961
962/// Where in the campaign one effect sits — the attribution every per-branch
963/// proof and the branch chronicle need (spec-0025).
964#[derive(Clone, Debug, PartialEq, Eq)]
965pub enum EffectSite {
966    /// A quest's `on_objective_complete[<objective>]` bundle.
967    Objective {
968        /// The owning quest.
969        quest: String,
970        /// The objective whose completion fires the bundle.
971        objective: String,
972    },
973    /// A quest's `on_complete` bundle.
974    QuestComplete {
975        /// The owning quest.
976        quest: String,
977    },
978    /// An environment trigger's `effects` bundle — ambient, no DAG position.
979    Trigger {
980        /// The trigger id.
981        trigger: String,
982    },
983    /// A trap's spec-0022 `payload` bundle — ambient, no DAG position.
984    Trap {
985        /// The trap id.
986        trap: String,
987    },
988    /// A **dialogue option's** `set-checkpoint` `on_respawn` bundle — ambient, no
989    /// DAG position, and the only site that does not live in the quests stage.
990    ///
991    /// This variant did not exist until the effect-root sweep, and its absence was
992    /// load-bearing: `EffectSite` had no way to *represent* a dialogue-hosted
993    /// bundle, so the four proofs that walk [`for_each_campaign_effect`]
994    /// (`combat::actor_beats`, `wave::difficulty_checks`,
995    /// `daylight::fightable_actor`, `nav::actor_fights`) could not have seen root 5
996    /// even if their authors had thought of it. Widening the type is what let the
997    /// walk widen.
998    DialogueRespawn {
999        /// The NPC whose dialogue tree hosts the option.
1000        npc: String,
1001        /// The node the option sits under.
1002        node: String,
1003    },
1004    /// A `shortcuts[].on_unlock` bundle (spec-0016 §2) — ambient, no DAG
1005    /// position, and the sixth root: representable here only since spec-0031, for
1006    /// exactly the reason [`EffectSite::DialogueRespawn`] records above.
1007    ShortcutUnlock {
1008        /// The shortcut id.
1009        shortcut: String,
1010    },
1011    /// A `shops[].offers[].effects` bundle (DSL v0.10, spec-0032) — ambient, no
1012    /// DAG position: nobody is forced to buy anything.
1013    ShopOffer {
1014        /// The shop id.
1015        shop: String,
1016        /// The offer's index within that shop, which is also its button order.
1017        offer: usize,
1018    },
1019    /// The campaign's `on_death` bundle (spec-0031) — ambient, no DAG position,
1020    /// and no owning object: there is one per campaign.
1021    OnDeath,
1022    /// A wave's or an actor's `on_kill` bundle (spec-0074) — ambient, no DAG
1023    /// position: nobody is forced to be credited with a kill.
1024    OnKill {
1025        /// The fight's id (`wave/<kebab>` or `actor/<kebab>`).
1026        fight: String,
1027    },
1028    /// An assembly strike step's `on_land` bundle (spec-0082) — ambient, no
1029    /// DAG position: nobody is forced to stand where a blow lands.
1030    AssemblyLand {
1031        /// The assembly id.
1032        assembly: String,
1033        /// The step's index within the pattern.
1034        step: usize,
1035    },
1036    /// A loop's `on_cross` bundle (spec-0086) — no DAG position of its own: it
1037    /// runs when a body crosses the holding slab.
1038    LoopCross {
1039        /// The loop id (`loop/<kebab>`).
1040        r#loop: String,
1041    },
1042}
1043
1044impl EffectSite {
1045    /// The quest this site belongs to, if it has a DAG position at all.
1046    ///
1047    /// **This `Option` is the capability, and it is on the enum rather than on the
1048    /// variants that happen to have a quest.** Only two of the eight sites name a
1049    /// quest, because only two of the eight roots HAVE a DAG position: an ambient
1050    /// root — a trigger, a trap payload, a dialogue `on_respawn`, a shortcut's
1051    /// `on_unlock`, the campaign's `on_death`, a shop offer — fires at a moment no
1052    /// static model can order, and inventing a quest for one would be exactly the
1053    /// over-attribution the completability model must not make. Asking the question
1054    /// of every variant and getting an honest `None` is the lift;
1055    /// `tools/ci/check-capability-ownership.py` check D asked for it while the field
1056    /// was still cross-cutting, and spec-0032's eighth site took it below that
1057    /// threshold, so the reasoning lives here now rather than in an exemption.
1058    pub fn quest(&self) -> Option<&str> {
1059        match self {
1060            EffectSite::Objective { quest, .. } | EffectSite::QuestComplete { quest } => {
1061                Some(quest)
1062            }
1063            EffectSite::Trigger { .. }
1064            | EffectSite::Trap { .. }
1065            | EffectSite::DialogueRespawn { .. }
1066            | EffectSite::ShortcutUnlock { .. }
1067            | EffectSite::ShopOffer { .. }
1068            | EffectSite::OnDeath
1069            | EffectSite::OnKill { .. }
1070            | EffectSite::AssemblyLand { .. }
1071            | EffectSite::LoopCross { .. } => None,
1072        }
1073    }
1074}
1075
1076/// Visit **every** effect the compiler can lower — at every one of the five
1077/// effect roots, top-level and transitively nested — in a fixed deterministic
1078/// order, invoking `f(json_pointer, site, effect)`.
1079///
1080/// The roots come from [`crate::effects::for_each_effect_root`], the single
1081/// enumeration; nesting is descended through the single
1082/// [`QuestEffect::nested_effect_lists_labeled`] authority. Neither axis is
1083/// enumerated here, which is the point: this walk used to hand-list four of the
1084/// five roots (it had no `EffectSite` variant for the fifth), so every proof
1085/// defined in terms of it inherited that blind spot.
1086pub fn for_each_campaign_effect<'a>(
1087    c: &'a crate::envelope::Campaign,
1088    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
1089) {
1090    crate::effects::for_each_effect_root(c, &mut |root, list| {
1091        let site = match root.owner {
1092            crate::effects::EffectRootOwner::ObjectiveComplete { quest, objective } => {
1093                EffectSite::Objective {
1094                    quest: quest.id.as_str().to_string(),
1095                    objective: objective.to_string(),
1096                }
1097            }
1098            crate::effects::EffectRootOwner::QuestComplete { quest } => EffectSite::QuestComplete {
1099                quest: quest.id.as_str().to_string(),
1100            },
1101            crate::effects::EffectRootOwner::Trigger(t) => EffectSite::Trigger {
1102                trigger: t.id.as_str().to_string(),
1103            },
1104            crate::effects::EffectRootOwner::TrapPayload(t) => EffectSite::Trap {
1105                trap: t.id.as_str().to_string(),
1106            },
1107            crate::effects::EffectRootOwner::DialogueRespawn => {
1108                // The npc and node are in the root's path; parse them back rather
1109                // than widening the root walk's owner for one consumer.
1110                let seg = |n: usize| -> String {
1111                    root.path.split('/').nth(n).unwrap_or_default().to_string()
1112                };
1113                EffectSite::DialogueRespawn {
1114                    npc: seg(3),
1115                    node: seg(5),
1116                }
1117            }
1118            crate::effects::EffectRootOwner::ShortcutUnlock(s) => EffectSite::ShortcutUnlock {
1119                shortcut: s.id.as_str().to_string(),
1120            },
1121            crate::effects::EffectRootOwner::OnDeath => EffectSite::OnDeath,
1122            crate::effects::EffectRootOwner::ShopOffer(h) => EffectSite::ShopOffer {
1123                shop: h.id.as_str().to_string(),
1124                // The offer index is in the root's path
1125                // (`/content/shops/<h>/offers/<i>/effects`), parsed back rather
1126                // than widening the owner for one consumer — the same call the
1127                // dialogue arm above makes. Segment 5 is the index: segment 4 is
1128                // the literal `offers`, which parses as nothing and reported
1129                // every offer as the shop's first.
1130                offer: root
1131                    .path
1132                    .split('/')
1133                    .nth(5)
1134                    .and_then(|n| n.parse().ok())
1135                    .unwrap_or(0),
1136            },
1137            crate::effects::EffectRootOwner::OnKill(f) => EffectSite::OnKill {
1138                fight: f.id().to_string(),
1139            },
1140            crate::effects::EffectRootOwner::AssemblyLand(m) => EffectSite::AssemblyLand {
1141                assembly: m.id.as_str().to_string(),
1142                // `/content/assemblies/<m>/strikes/pattern/<s>/on_land`: segment
1143                // 6 is the step index, parsed back as the shop arm does.
1144                step: root
1145                    .path
1146                    .split('/')
1147                    .nth(6)
1148                    .and_then(|n| n.parse().ok())
1149                    .unwrap_or(0),
1150            },
1151            crate::effects::EffectRootOwner::LoopCross(l) => EffectSite::LoopCross {
1152                r#loop: l.id.as_str().to_string(),
1153            },
1154        };
1155        for (i, eff) in list.iter().enumerate() {
1156            campaign_effect_deep(eff, &format!("{}/{i}", root.path), &site, f);
1157        }
1158    });
1159}
1160
1161fn campaign_effect_deep<'a>(
1162    eff: &'a QuestEffect,
1163    path: &str,
1164    site: &EffectSite,
1165    f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
1166) {
1167    f(path, site, eff);
1168    for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
1169        for (j, inner) in list.iter().enumerate() {
1170            campaign_effect_deep(inner, &format!("{path}/{pseg}/{j}"), site, f);
1171        }
1172    }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::*;
1178
1179    /// The binding ledger's slots are `EffectRootKind::ALL`, in order — the
1180    /// property `summary()` and `unbound_roots()` both read off positionally.
1181    #[test]
1182    fn binding_slots_are_all_the_roots_in_order() {
1183        let b = RootBinding {
1184            roots_enumerated: EffectRootKind::COUNT,
1185            sites: [
1186                (EffectRootKind::ObjectiveComplete, 0),
1187                (EffectRootKind::QuestComplete, 0),
1188                (EffectRootKind::Trigger, 0),
1189                (EffectRootKind::TrapPayload, 0),
1190                (EffectRootKind::DialogueRespawn, 0),
1191                (EffectRootKind::ShortcutUnlock, 0),
1192                (EffectRootKind::OnDeath, 0),
1193                (EffectRootKind::ShopOffer, 0),
1194                (EffectRootKind::OnKill, 0),
1195                (EffectRootKind::AssemblyLand, 0),
1196                (EffectRootKind::LoopCross, 0),
1197            ],
1198            effects: 0,
1199        };
1200        assert_eq!(b.sites.map(|(k, _)| k), EffectRootKind::ALL);
1201        assert_eq!(b.unbound_roots().len(), EffectRootKind::COUNT);
1202    }
1203
1204    /// Every root kind names a stage and a label, and the stages are exactly the
1205    /// two stage documents effect roots live in.
1206    #[test]
1207    fn every_root_names_its_stage() {
1208        for k in EffectRootKind::ALL {
1209            assert!(matches!(k.stage(), "quests" | "dialogue"), "{k:?}");
1210            assert!(!k.label().is_empty(), "{k:?}");
1211        }
1212    }
1213}