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}