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//! five, they hang off three 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_gate_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//! What this module deliberately does **not** try to be is a guard against a
37//! fourteenth hand-rolled walk being written tomorrow. Nothing in the type system
38//! can stop someone iterating `campaign.quests.content.quests` directly; that
39//! half of the obligation is `tools/check-effect-roots.py`, which fails CI when a
40//! source file reaches for two or more root fields outside this module.
41//!
42//! Determinism (ADR-0006): iteration is over `BTreeMap` keys and slices, in a
43//! fixed order that is part of this module's contract — see
44//! [`for_each_effect_root`].
45
46use crate::envelope::Campaign;
47use crate::stages::{EnvTrigger, Quest, QuestEffect, Trap};
48
49/// The local part of a type-prefixed id (`npc/keeper` → `keeper`), the segment
50/// every l10n key is built from. Duplicated from `l10n::local` deliberately: this
51/// module is below `l10n` and the key scheme is part of a root's identity.
52fn local(id: &str) -> &str {
53    id.split_once('/').map(|(_, r)| r).unwrap_or(id)
54}
55
56/// Which of the campaign's effect roots a bundle is.
57///
58/// `ALL` is the closed set. Adding a variant is a rustc error in
59/// [`EffectRootOwner::kind`] and in every consumer that matches on an owner, and
60/// makes `ALL`'s length wrong until it is listed — so a new root cannot be added
61/// without visiting the walk.
62#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
63pub enum EffectRootKind {
64    /// A quest's `on_objective_complete[<objective>]` bundle.
65    ObjectiveComplete,
66    /// A quest's `on_complete` bundle.
67    QuestComplete,
68    /// An environment trigger's `effects` bundle.
69    Trigger,
70    /// A trap's spec-0022 `payload` bundle.
71    TrapPayload,
72    /// A dialogue option's `set-checkpoint` `on_respawn` bundle — a plain
73    /// `Vec<QuestEffect>` hanging off the **dialogue** stage. `DialogueEffect`
74    /// carries no gate, movement or actor verb of its own, which is the reasoning
75    /// that made every older walk stop at the quests stage; the bundle nested
76    /// inside one is quest-effect vocabulary all the same, and it is lowered
77    /// (into `cp_on_respawn_<i>`).
78    DialogueRespawn,
79}
80
81impl EffectRootKind {
82    /// Every root, in enumeration order. Not the *visit* order — see
83    /// [`for_each_effect_root`], which interleaves R1/R2 per quest.
84    pub const ALL: [EffectRootKind; 5] = [
85        EffectRootKind::ObjectiveComplete,
86        EffectRootKind::QuestComplete,
87        EffectRootKind::Trigger,
88        EffectRootKind::TrapPayload,
89        EffectRootKind::DialogueRespawn,
90    ];
91
92    /// How many roots there are. The binding ledger reports coverage against this.
93    pub const COUNT: usize = Self::ALL.len();
94
95    /// The stage document this root lives in (`quests` or `dialogue`).
96    pub fn stage(self) -> &'static str {
97        match self {
98            EffectRootKind::ObjectiveComplete
99            | EffectRootKind::QuestComplete
100            | EffectRootKind::Trigger
101            | EffectRootKind::TrapPayload => "quests",
102            EffectRootKind::DialogueRespawn => "dialogue",
103        }
104    }
105
106    /// A short human label, used by the binding ledger and by diagnostics that
107    /// report which roots a proof examined.
108    pub fn label(self) -> &'static str {
109        match self {
110            EffectRootKind::ObjectiveComplete => "quest on_objective_complete",
111            EffectRootKind::QuestComplete => "quest on_complete",
112            EffectRootKind::Trigger => "trigger effects",
113            EffectRootKind::TrapPayload => "trap payload",
114            EffectRootKind::DialogueRespawn => "dialogue set-checkpoint on_respawn",
115        }
116    }
117}
118
119/// What a root hangs off, with the owning object attached.
120///
121/// This is what a consumer matches on when it needs to reason about *when* a
122/// bundle fires (the completability model) or *who* gates it (a trigger's or
123/// trap's `requires_flags`). Because the match is exhaustive at every such site,
124/// a sixth root is a compile error everywhere the answer would have to change.
125#[derive(Clone, Copy)]
126pub enum EffectRootOwner<'a> {
127    /// A quest's `on_objective_complete[<objective>]` — fires at that objective's
128    /// `critical_path` step. Forced: completing the objective is the mainline.
129    ObjectiveComplete {
130        /// The owning quest.
131        quest: &'a Quest,
132        /// The objective whose completion fires the bundle.
133        objective: &'a str,
134    },
135    /// A quest's `on_complete` — fires at the quest's completion step. Forced.
136    QuestComplete {
137        /// The owning quest.
138        quest: &'a Quest,
139    },
140    /// An environment `triggers[].effects` — proximity/interaction-fired, so it
141    /// has no step of its own. Carries the trigger, whose `requires_flags` gate
142    /// the whole bundle.
143    Trigger(&'a EnvTrigger),
144    /// A `traps[].payload` (spec-0022) — proximity/interaction-fired exactly like
145    /// a trigger, and **optional**: the party may never trip it. Carries the trap,
146    /// whose `requires_flags` gate the whole payload.
147    TrapPayload(&'a Trap),
148    /// A dialogue option's `set-checkpoint` `on_respawn` bundle — re-run on death
149    /// while that checkpoint is active, so it is optional too (nobody is forced to
150    /// die). Carries no owning object: the npc, node and option are all named in
151    /// the site's `path`, and no consumer needs to reach the tree itself.
152    DialogueRespawn,
153}
154
155impl<'a> EffectRootOwner<'a> {
156    /// Which root this is. The one place a root's owner is mapped to its kind.
157    pub fn kind(&self) -> EffectRootKind {
158        match self {
159            EffectRootOwner::ObjectiveComplete { .. } => EffectRootKind::ObjectiveComplete,
160            EffectRootOwner::QuestComplete { .. } => EffectRootKind::QuestComplete,
161            EffectRootOwner::Trigger(_) => EffectRootKind::Trigger,
162            EffectRootOwner::TrapPayload(_) => EffectRootKind::TrapPayload,
163            EffectRootOwner::DialogueRespawn => EffectRootKind::DialogueRespawn,
164        }
165    }
166
167    /// The quest this root belongs to, if it has a DAG position at all.
168    pub fn quest(&self) -> Option<&'a Quest> {
169        match self {
170            EffectRootOwner::ObjectiveComplete { quest, .. }
171            | EffectRootOwner::QuestComplete { quest } => Some(quest),
172            EffectRootOwner::Trigger(_)
173            | EffectRootOwner::TrapPayload(_)
174            | EffectRootOwner::DialogueRespawn => None,
175        }
176    }
177}
178
179/// One effect root: which it is, where it is, and what its l10n keys hang off.
180///
181/// `path` points at the **list**; an element's pointer is `path` + `/<index>`.
182/// `key` is likewise the list's keybase; an element's key is `key` + `.<index>`.
183pub struct EffectRootSite<'a> {
184    /// What this root hangs off, with the owning object.
185    pub owner: EffectRootOwner<'a>,
186    /// The stage document the list lives in (`quests` or `dialogue`).
187    pub stage: &'static str,
188    /// JSON pointer to the list within that document.
189    pub path: String,
190    /// The list's l10n key prefix.
191    pub key: String,
192}
193
194impl EffectRootSite<'_> {
195    /// Which root this site is.
196    pub fn kind(&self) -> EffectRootKind {
197        self.owner.kind()
198    }
199}
200
201/// What a walk over the effect roots actually examined.
202///
203/// CLAUDE.md: *a green gate that binds to nothing is vacuous, not a pass*. A proof
204/// over "every effect" is only as good as the roots it reached and the bundles it
205/// found there, and neither number is visible from the proof's own output. This
206/// is that ledger, filled in by [`for_each_effect_root`] on every call.
207#[derive(Clone, Debug, PartialEq, Eq)]
208pub struct RootBinding {
209    /// How many of [`EffectRootKind::COUNT`] roots the walk enumerated. Always
210    /// `COUNT` for a walk that ran — a smaller number means a root stopped being
211    /// enumerated, which the walk itself asserts against.
212    pub roots_enumerated: usize,
213    /// Per-root: how many bundles the campaign actually has there. A zero is not
214    /// a failure — a campaign with no traps has no `traps[].payload` — but it is
215    /// the reason a proof over that root binds to nothing, and it is reported
216    /// rather than left for a reader to infer.
217    pub sites: [(EffectRootKind, usize); EffectRootKind::COUNT],
218    /// Total top-level effects across every root.
219    pub effects: usize,
220}
221
222impl RootBinding {
223    /// The roots this campaign has no bundles at — where any proof over the
224    /// effect surface is necessarily unbound.
225    pub fn unbound_roots(&self) -> Vec<EffectRootKind> {
226        self.sites
227            .iter()
228            .filter(|(_, n)| *n == 0)
229            .map(|(k, _)| *k)
230            .collect()
231    }
232
233    /// A one-line, deterministic rendering for a report or a `--json` field.
234    pub fn summary(&self) -> String {
235        let per: Vec<String> = self
236            .sites
237            .iter()
238            .map(|(k, n)| format!("{}={n}", k.label()))
239            .collect();
240        format!(
241            "roots {}/{}, bundles {}, effects {} [{}]",
242            self.roots_enumerated,
243            EffectRootKind::COUNT,
244            self.sites.iter().map(|(_, n)| n).sum::<usize>(),
245            self.effects,
246            per.join(", ")
247        )
248    }
249}
250
251/// **The root list, written once, as tokens.**
252///
253/// Expanded twice — by [`for_each_effect_root`] with `iter`/`as_slice` and by
254/// [`for_each_effect_root_mut`] with `iter_mut`/`as_mut_slice`. There is no second
255/// copy of "which lists are roots" anywhere in the workspace, so adding a sixth
256/// root is one edit here and every consumer of either walk inherits it. That is
257/// the whole point of this module: the previous arrangement had the root list
258/// written out four times (twice in `l10n`, once in `plan`, once in `stages`) and
259/// approximated a further thirteen times by walkers that enumerated three or four
260/// of the five.
261///
262/// `$visit` is called as `$visit((kind, owner, objective), path, key, list)`. The
263/// per-root owner expressions are parameters because the mutable expansion cannot
264/// produce them: it cannot hand out `&Quest` while holding `&mut [QuestEffect]`
265/// from the same quest. That asymmetry is confined to what is *attached* to a
266/// visit — never to which roots are visited, which is what this body fixes.
267macro_rules! effect_root_walk {
268    (
269        campaign: $c:expr,
270        iter: $iter:ident,
271        slice: $slice:ident,
272        respawn: $respawn:ident,
273        note: $note:expr,
274        visit: $visit:expr,
275        quest_owner: |$q:ident| $ownq:expr,
276        trigger_owner: |$t:ident| $ownt:expr,
277        trap_owner: |$p:ident| $ownp:expr,
278        dialogue_owner: $ownd:expr,
279    ) => {{
280        #[allow(unused_mut)]
281        let mut visit = $visit;
282        // Fired once per root, before its loop, whether or not this campaign has a
283        // single bundle there. That is the distinction the binding ledger exists to
284        // make: "this walk enumerated the root" and "this campaign uses the root"
285        // are different facts, and a proof that conflates them reports a vacuous
286        // green as a pass (CLAUDE.md).
287        #[allow(unused_mut)]
288        let mut note = $note;
289        // R1 `on_objective_complete` and R2 `on_complete`, interleaved per quest.
290        // This order is contractual: it is the order emission writes bundles in and
291        // the order the l10n inventory keys them in, so a campaign that predates a
292        // later root produces byte-identical output.
293        note(EffectRootKind::ObjectiveComplete);
294        note(EffectRootKind::QuestComplete);
295        for (qi, $q) in $c.quests.content.quests.$iter().enumerate() {
296            let ql = local($q.id.as_str()).to_string();
297            let owner = $ownq;
298            for (oid, effs) in $q.on_objective_complete.$iter() {
299                let ol = local(oid.as_str()).to_string();
300                visit(
301                    (EffectRootKind::ObjectiveComplete, owner, Some(oid.as_str())),
302                    format!(
303                        "/content/quests/{qi}/on_objective_complete/{}",
304                        oid.as_str()
305                    ),
306                    format!("fx.{ql}.oc.{ol}"),
307                    effs.$slice(),
308                );
309            }
310            visit(
311                (EffectRootKind::QuestComplete, owner, None),
312                format!("/content/quests/{qi}/on_complete"),
313                format!("fx.{ql}.done"),
314                $q.on_complete.$slice(),
315            );
316        }
317        // R3 `triggers[].effects`.
318        note(EffectRootKind::Trigger);
319        for (ti, $t) in $c.quests.content.triggers.$iter().enumerate() {
320            let tl = local($t.id.as_str()).to_string();
321            let owner = $ownt;
322            visit(
323                (EffectRootKind::Trigger, owner, None),
324                format!("/content/triggers/{ti}/effects"),
325                format!("fx.trig.{tl}"),
326                $t.effects.$slice(),
327            );
328        }
329        // R4 `traps[].payload` (spec-0022 — a payload is an effect root).
330        note(EffectRootKind::TrapPayload);
331        for (pi, $p) in $c.quests.content.traps.$iter().enumerate() {
332            let pl = local($p.id.as_str()).to_string();
333            let owner = $ownp;
334            visit(
335                (EffectRootKind::TrapPayload, owner, None),
336                format!("/content/traps/{pi}/payload"),
337                format!("fx.trap.{pl}"),
338                $p.payload.$slice(),
339            );
340        }
341        // R5 a dialogue option's `set-checkpoint` `on_respawn` bundle — the root
342        // that hangs off a different stage document, and the one every walk written
343        // from "effects live in the quests stage" missed.
344        note(EffectRootKind::DialogueRespawn);
345        for (di, tree) in $c.dialogue.content.dialogues.$iter().enumerate() {
346            let np = local(tree.npc.as_str()).to_string();
347            let owner = $ownd;
348            for (ni, node) in tree.nodes.$iter().enumerate() {
349                let nd = local(node.id.as_str()).to_string();
350                for (oi, opt) in node.options.$iter().enumerate() {
351                    for (ei, de) in opt.effects.$iter().enumerate() {
352                        let Some(on_respawn) = de.$respawn() else {
353                            continue;
354                        };
355                        visit(
356                            (EffectRootKind::DialogueRespawn, owner, None),
357                            format!(
358                                "/content/dialogues/{di}/nodes/{ni}/options/{oi}/effects/{ei}/on_respawn"
359                            ),
360                            format!("fx.dlg.{np}.{nd}.{oi}.{ei}.respawn"),
361                            on_respawn,
362                        );
363                    }
364                }
365            }
366        }
367    }};
368}
369
370/// The owning object as the macro yields it, before it is paired with the
371/// objective id that only R1 has. Internal to [`for_each_effect_root`].
372#[derive(Clone, Copy)]
373enum RawOwner<'a> {
374    Quest(&'a Quest),
375    Trigger(&'a EnvTrigger),
376    Trap(&'a Trap),
377    Dialogue,
378}
379
380impl<'a> RawOwner<'a> {
381    fn attach(self, kind: EffectRootKind, objective: Option<&'a str>) -> EffectRootOwner<'a> {
382        match (self, kind) {
383            (RawOwner::Quest(quest), EffectRootKind::ObjectiveComplete) => {
384                EffectRootOwner::ObjectiveComplete {
385                    quest,
386                    objective: objective
387                        .expect("an on_objective_complete root always names its objective"),
388                }
389            }
390            (RawOwner::Quest(quest), EffectRootKind::QuestComplete) => {
391                EffectRootOwner::QuestComplete { quest }
392            }
393            (RawOwner::Trigger(t), EffectRootKind::Trigger) => EffectRootOwner::Trigger(t),
394            (RawOwner::Trap(p), EffectRootKind::TrapPayload) => EffectRootOwner::TrapPayload(p),
395            (RawOwner::Dialogue, EffectRootKind::DialogueRespawn) => {
396                EffectRootOwner::DialogueRespawn
397            }
398            (owner, kind) => unreachable!(
399                "effect root {kind:?} was handed an owner of the wrong shape ({})",
400                match owner {
401                    RawOwner::Quest(_) => "quest",
402                    RawOwner::Trigger(_) => "trigger",
403                    RawOwner::Trap(_) => "trap",
404                    RawOwner::Dialogue => "dialogue",
405                }
406            ),
407        }
408    }
409}
410
411/// Visit **every effect root the compiler can lower**, in one fixed deterministic
412/// order, as `f(&site, list)`.
413///
414/// The order is contractual, because emission and the l10n key scheme are defined
415/// by it: per quest, `on_objective_complete` (a `BTreeMap`, so key-ordered) then
416/// `on_complete`; then every trigger; then every trap payload; then every dialogue
417/// `on_respawn` bundle. Roots 1–3 keep the order they had before roots 4 and 5
418/// existed, so a campaign that uses only them produces byte-identical output.
419///
420/// A list is a root if `emit::emit_quest_effect` can reach it, **not** if the
421/// quests stage happens to own it. That distinction is the entire defect class:
422/// four of the five roots are reachable from `campaign.quests.content` and the
423/// fifth is not, so every walk reasoned from "effects live in the quests stage"
424/// was correct-looking, green, and wrong.
425///
426/// Returns the [`RootBinding`] ledger — how many roots were enumerated and how
427/// many bundles each actually bound to on this campaign. A proof that states what
428/// it examined reports it; a caller that does not need it may drop it.
429///
430/// # Panics
431///
432/// If the walk failed to enumerate all [`EffectRootKind::COUNT`] roots. Unreachable
433/// by construction — the macro emits one block per root — and asserted anyway, in
434/// release builds too, because the failure it guards has no other symptom: a walk
435/// that quietly stops visiting a root just answers a narrower question and stays
436/// green over every campaign that does not use it. That is how this defect class
437/// survived six independent fixes.
438pub fn for_each_effect_root<'a>(
439    c: &'a Campaign,
440    f: &mut dyn FnMut(&EffectRootSite<'a>, &'a [QuestEffect]),
441) -> RootBinding {
442    let mut sites = [
443        (EffectRootKind::ObjectiveComplete, 0usize),
444        (EffectRootKind::QuestComplete, 0usize),
445        (EffectRootKind::Trigger, 0usize),
446        (EffectRootKind::TrapPayload, 0usize),
447        (EffectRootKind::DialogueRespawn, 0usize),
448    ];
449    debug_assert_eq!(
450        sites.map(|(k, _)| k),
451        EffectRootKind::ALL,
452        "the binding ledger's slots are EffectRootKind::ALL, in order"
453    );
454    let mut effects = 0usize;
455    // Which roots the walk reached at all — set by `note`, independently of whether
456    // this campaign has a bundle there.
457    let mut enumerated = [false; EffectRootKind::COUNT];
458    fn slot_of(kind: EffectRootKind) -> usize {
459        EffectRootKind::ALL
460            .iter()
461            .position(|k| *k == kind)
462            .expect("every root kind is a member of EffectRootKind::ALL")
463    }
464
465    effect_root_walk!(
466        campaign: c,
467        iter: iter,
468        slice: as_slice,
469        respawn: set_checkpoint_on_respawn,
470        note: |kind: EffectRootKind| {
471            enumerated[slot_of(kind)] = true;
472        },
473        visit: |(kind, owner, objective): (EffectRootKind, RawOwner<'a>, Option<&'a str>),
474                path: String,
475                key: String,
476                list: &'a [QuestEffect]| {
477            let owner = owner.attach(kind, objective);
478            debug_assert_eq!(owner.kind(), kind, "a site's owner and kind must agree");
479            let slot = slot_of(kind);
480            sites[slot].1 += 1;
481            effects += list.len();
482            f(
483                &EffectRootSite {
484                    owner,
485                    stage: kind.stage(),
486                    path,
487                    key,
488                },
489                list,
490            );
491        },
492        quest_owner: |q| RawOwner::Quest(q),
493        trigger_owner: |t| RawOwner::Trigger(t),
494        trap_owner: |p| RawOwner::Trap(p),
495        dialogue_owner: RawOwner::Dialogue,
496    );
497
498    let missed: Vec<&str> = EffectRootKind::ALL
499        .iter()
500        .zip(enumerated)
501        .filter(|(_, s)| !*s)
502        .map(|(k, _)| k.label())
503        .collect();
504    assert!(
505        missed.is_empty(),
506        "for_each_effect_root enumerated {} of {} effect roots — missing: {}. A root that \
507         stops being enumerated has no other symptom.",
508        EffectRootKind::COUNT - missed.len(),
509        EffectRootKind::COUNT,
510        missed.join(", ")
511    );
512
513    RootBinding {
514        roots_enumerated: EffectRootKind::COUNT,
515        sites,
516        effects,
517    }
518}
519
520/// The callback [`for_each_effect_root_mut`] hands each root to:
521/// `(kind, json_pointer_to_the_list, l10n_keybase, list)`. `'a` ties the effects
522/// to the campaign borrow, so a consumer may collect them; `'f` is the callback's
523/// own borrow.
524pub type RootVisitorMut<'a, 'f> = dyn FnMut(EffectRootKind, &str, &str, &'a mut [QuestEffect]) + 'f;
525
526/// The **mutable mirror** of [`for_each_effect_root`]: the identical roots, in the
527/// identical order, with the same `(stage, path, key)` descriptors, exposed mutably
528/// so the localization pass can rewrite player-visible strings in place.
529///
530/// Generated from the same [`effect_root_walk`] body, so "which lists are roots" is
531/// not written twice and the pair cannot drift the way two hand-written mirrors
532/// can. `&mut Campaign` cannot yield the owning `&Quest` alongside
533/// `&mut [QuestEffect]` from the same quest, so this walk carries the
534/// [`EffectRootKind`] rather than an [`EffectRootOwner`]. That is the only
535/// difference between the two, and it is a difference in what is attached to a
536/// visit — never in which roots are visited.
537pub fn for_each_effect_root_mut<'a>(c: &'a mut Campaign, f: &mut RootVisitorMut<'a, '_>) {
538    effect_root_walk!(
539        campaign: c,
540        iter: iter_mut,
541        slice: as_mut_slice,
542        respawn: set_checkpoint_on_respawn_mut,
543        note: |_kind: EffectRootKind| {},
544        visit: |(kind, _owner, _objective): (EffectRootKind, (), Option<&str>),
545                path: String,
546                key: String,
547                list: &'a mut [QuestEffect]| {
548            f(kind, &path, &key, list);
549        },
550        quest_owner: |_q| (),
551        trigger_owner: |_t| (),
552        trap_owner: |_p| (),
553        dialogue_owner: (),
554    );
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    /// The binding ledger's slots are `EffectRootKind::ALL`, in order — the
562    /// property `summary()` and `unbound_roots()` both read off positionally.
563    #[test]
564    fn binding_slots_are_all_the_roots_in_order() {
565        let b = RootBinding {
566            roots_enumerated: EffectRootKind::COUNT,
567            sites: [
568                (EffectRootKind::ObjectiveComplete, 0),
569                (EffectRootKind::QuestComplete, 0),
570                (EffectRootKind::Trigger, 0),
571                (EffectRootKind::TrapPayload, 0),
572                (EffectRootKind::DialogueRespawn, 0),
573            ],
574            effects: 0,
575        };
576        assert_eq!(b.sites.map(|(k, _)| k), EffectRootKind::ALL);
577        assert_eq!(b.unbound_roots().len(), EffectRootKind::COUNT);
578    }
579
580    /// Every root kind names a stage and a label, and the stages are exactly the
581    /// two stage documents effect roots live in.
582    #[test]
583    fn every_root_names_its_stage() {
584        for k in EffectRootKind::ALL {
585            assert!(matches!(k.stage(), "quests" | "dialogue"), "{k:?}");
586            assert!(!k.label().is_empty(), "{k:?}");
587        }
588    }
589}