Skip to main content

delvewright_dsl/quest/
check.rs

1//! The quest object's validation (ADR-0031): what `dsl::validate` checks over a
2//! stage-5 quest — its objectives' ordering, references, anchors and props — and
3//! over the verbs its effects carry at every effect root (status effects, the
4//! `sequence` nesting rule, a `carrier: "one"` give, `give-item` enchantments).
5
6use crate::cutscene::check_cutscene_shape;
7use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
8use crate::envelope::Campaign;
9use crate::loot::{MIN_CONTAINER_SLOTS, check_stack_count};
10use crate::registry::{AnchorRegistry, BlockRegistry, EffectRegistry, ItemRegistry};
11use crate::validate::{
12    AnchorProviders, check_block_field, for_each_effect_deep, graph_has_cycle, produced_flags,
13    station_kind_diag,
14};
15use crate::wave::check_enchantments;
16use crate::{Objective, PlannedQuest, QuestEffect, Verb};
17use std::collections::{BTreeMap, BTreeSet};
18
19crate::dw_code! {
20    /// Objective `after` cycle.
21    pub const AFTER_CYCLE: DwCode = DwCode::new("DW0140", ExitTier::Build);
22}
23
24crate::dw_code! {
25    /// An `interact` declares `missing_item_hint` without a `requires_item`: the
26    /// hint answers a gate that does not exist, so it could never narrate.
27    pub const MISSING_ITEM_HINT_WITHOUT_ITEM: DwCode = DwCode::new("DW0437", ExitTier::Build);
28}
29
30crate::dw_code! {
31    /// Planned quest (stage 4) has no expansion in stage 5.
32    pub const QUEST_NOT_EXPANDED: DwCode = DwCode::new("DW0150", ExitTier::Build);
33}
34
35crate::dw_code! {
36    /// Stage-5 quest is not planned in stage 4.
37    pub const QUEST_NOT_PLANNED: DwCode = DwCode::new("DW0151", ExitTier::Build);
38}
39
40crate::dw_code! {
41    /// (v0.3) A declared wave is referenced by a `kill` objective but is never
42    /// spawned by any `spawn-wave` effect (referenced-but-never-spawned). A wave
43    /// must be spawned by some effect before its kill objective is reachable.
44    pub const WAVE_NEVER_SPAWNED: DwCode = DwCode::new("DW0171", ExitTier::Build);
45}
46
47crate::dw_code! {
48    /// (spec-0031, DSL v0.10) **A grant whose removal is a later effect, not its
49    /// own duration.** A `give-effect` is still live at the moment a
50    /// `clear-effect` for the same effect fires in the same bundle, so the clear
51    /// — not the duration — is what ends it.
52    ///
53    /// A bundle that does not reach its end leaves the effect on the player: a
54    /// logout, a crash, a death mid-chain, a `sequence` whose remaining
55    /// `schedule` never runs. A duration expires with no cooperation from
56    /// anything, which is why `seconds` is mandatory and why vanilla's `infinite`
57    /// is absent from this surface — this diagnostic is what stops the same
58    /// hazard being rebuilt out of two effects that are individually fine.
59    pub const EFFECT_CLEARED_LIVE: DwCode = DwCode::new("DW0540", ExitTier::Build);
60}
61
62crate::dw_code! {
63    /// (spec-0031, DSL v0.10) A `give-effect`'s `seconds` is zero or beyond
64    /// [`crate::MAX_EFFECT_SECONDS`], or its `amplifier` is beyond
65    /// [`crate::MAX_POTION_AMPLIFIER`].
66    ///
67    /// Zero is the grant that never happens — the unbound-vacuity class as a
68    /// number. The ceilings are vanilla's own field widths, so a duration typed
69    /// in ticks or milliseconds is caught instead of silently overflowing.
70    pub const EFFECT_GRANT_BOUNDS: DwCode = DwCode::new("DW0541", ExitTier::Build);
71}
72
73crate::dw_code! {
74    /// An `interact` objective's `prop` is a block a step fires — a pressure
75    /// plate or the tripwire string ([`crate::stepped_blocks`]). The
76    /// objective completes on a right-click, so the block invites an act that
77    /// does nothing. A step is a `trigger` with `on: step`.
78    pub const INTERACT_PROP_STEPPED: DwCode = DwCode::new("DW0957", ExitTier::Build);
79}
80
81crate::dw_code! {
82    /// (v0.6) A `sequence` effect is nested inside another `sequence` (directly, or
83    /// reachable via a nested `move-actor` `on_arrive`) — timelines do not recurse
84    /// (spec-0014). Flatten the inner steps into the outer timeline.
85    pub const NESTED_SEQUENCE: DwCode = DwCode::new("DW0329", ExitTier::Build);
86}
87
88crate::dw_code! {
89    /// (v0.6, spec-0018) A `carrier: "one"` `give-item` sits in a bundle that is
90    /// only ever reached from the **scheduler** (`move-npc`/`move-actor`
91    /// `on_arrive`, a `sequence` step). `carrier: "one"` means "hand this single
92    /// quest prop to the player whose action earned it"; a scheduled bundle runs
93    /// with the server command source and has no acting player, so there is no
94    /// defensible recipient. Give it to the whole party (drop `carrier`), or move
95    /// the hand-off onto the beat that a player completes. Validation-tier (exit 1).
96    pub const PARTY_CARRIER_SCHEDULED: DwCode = DwCode::new("DW0357", ExitTier::Build);
97}
98
99pub(crate) fn after_ordering(c: &Campaign, d: &mut Vec<Diagnostic>) {
100    for (i, q) in c.quests.content.quests.iter().enumerate() {
101        let local: BTreeSet<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
102        let edges: BTreeMap<&str, Vec<&str>> = q
103            .objectives
104            .iter()
105            .map(|o| {
106                let deps = o
107                    .after()
108                    .iter()
109                    .map(|x| x.as_str())
110                    .filter(|x| local.contains(x))
111                    .collect();
112                (o.id().as_str(), deps)
113            })
114            .collect();
115        let nodes: Vec<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
116        if graph_has_cycle(&nodes, &edges) {
117            d.push(Diagnostic::error(
118                AFTER_CYCLE,
119                "quests",
120                format!("/content/quests/{i}/objectives"),
121                format!(
122                    "objective `after` ordering in quest `{}` contains a cycle — the `after` \
123                     edges must form a DAG; remove one `after` entry to break the cycle",
124                    q.id
125                ),
126            ));
127        }
128    }
129}
130
131/// DSL v0.10 status-effect checks (spec-0031): a real effect id, a duration that
132/// is actually a duration, and the one *pattern* that reintroduces the hazard the
133/// mandatory duration exists to remove.
134///
135/// ## Why the duration is not enough on its own
136///
137/// `give-effect` has no infinite form: `seconds` is required and bounded, so a
138/// grant always ends by itself. That is the whole design, and it can still be
139/// defeated by two effects that are individually fine — grant blindness for an
140/// hour, clear it four ticks later. The clear is then the real removal, and a
141/// bundle that does not reach it (a logout, a crash, a death mid-chain, a
142/// `sequence` whose remaining `schedule` never runs) leaves the player blind for
143/// the rest of the hour. `DW0540` is that pattern, and it fires on exactly the
144/// grants that are still LIVE when their clear arrives: where the duration
145/// expires first, the duration is the removal and there is nothing to say.
146///
147/// ## What "the same sequence" means, mechanically
148///
149/// A bundle's own timeline: every effect it fires, at the tick offset it fires
150/// on. A plain member runs at offset 0; a directly-nested `sequence`'s members
151/// run at their step's `at_ticks` (nested sequences are `DW0329`, so the
152/// expansion terminates). Conditional continuations — `on_arrive`, `on_caught`,
153/// `on_respawn`, `on_rest` — are NOT folded in: they are separate bundles with
154/// their own timelines, and each is walked as one. A clear that hangs off an
155/// arrival is strictly more fragile than one on a fixed tick, and it is the
156/// mandatory duration, not this rule, that keeps that case survivable.
157pub(crate) fn status_effect_checks(
158    c: &Campaign,
159    effects: &dyn EffectRegistry,
160    d: &mut Vec<Diagnostic>,
161) {
162    crate::effects::for_each_effect_root(c, &mut |site, list| {
163        status_effect_bundle(site.stage, &site.path, list, effects, d);
164    });
165}
166
167/// One effect bundle: its own timeline is checked for the grant/clear pattern,
168/// then each member is checked individually and its nested bundles are walked.
169fn status_effect_bundle(
170    stage: &'static str,
171    path: &str,
172    list: &[QuestEffect],
173    effects: &dyn EffectRegistry,
174    d: &mut Vec<Diagnostic>,
175) {
176    let tl = effect_timeline(path, list);
177    check_grant_removal(stage, &tl, d);
178    for (_, p, eff) in &tl {
179        check_one_status_effect(stage, p, eff, effects, d);
180        for (pseg, _key, inner) in eff.nested_effect_lists_labeled() {
181            status_effect_bundle(stage, &format!("{p}/{pseg}"), inner, effects, d);
182        }
183    }
184}
185
186/// A bundle's timeline: `(tick offset, json pointer, effect)` for every effect
187/// that runs on it, in declaration order.
188///
189/// A `sequence` is REPLACED by its steps' effects at their `at_ticks`, so the
190/// timeline is what actually happens rather than what is written — which is the
191/// only view in which "the grant is still live when the clear fires" is a
192/// question with an answer.
193fn effect_timeline<'a>(path: &str, list: &'a [QuestEffect]) -> Vec<(u32, String, &'a QuestEffect)> {
194    let mut out = Vec::new();
195    for (i, eff) in list.iter().enumerate() {
196        match &eff.verb {
197            Verb::Sequence { steps } => {
198                for (s, step) in steps.iter().enumerate() {
199                    for (k, inner) in step.effects.iter().enumerate() {
200                        out.push((
201                            step.at_ticks,
202                            format!("{path}/{i}/steps/{s}/effects/{k}"),
203                            inner,
204                        ));
205                    }
206                }
207            }
208            _ => out.push((0, format!("{path}/{i}"), eff)),
209        }
210    }
211    out
212}
213
214/// `DW0540`: a grant that is still live when a clear in the same bundle removes
215/// it.
216///
217/// Each grant is paired with the EARLIEST clear that could remove it: the one
218/// smallest in `(tick, declaration position)` among those strictly after the
219/// grant, that either names the same effect or names none at all (vanilla's
220/// `effect clear <targets>` with no id clears everything, and so does this verb).
221///
222/// **Ordered by the key, never by the loop index.** A bundle's steps are declared
223/// in whatever order the author wrote them, not in tick order, so "the next clear
224/// in the list" and "the clear that actually fires first" are different effects
225/// the moment a `sequence` declares `at_ticks: 40` above `at_ticks: 5`. Taking
226/// the first in declaration order made the rule miss exactly the live removal it
227/// exists to find — and, symmetrically, miss a clear declared *above* a grant but
228/// scheduled after it.
229fn check_grant_removal(
230    stage: &'static str,
231    timeline: &[(u32, String, &QuestEffect)],
232    d: &mut Vec<Diagnostic>,
233) {
234    for (gi, (g_tick, g_path, g_eff)) in timeline.iter().enumerate() {
235        let Some((id, seconds, _, _, _)) = g_eff.give_effect() else {
236            continue;
237        };
238        // `blindness` and `minecraft:blindness` are one effect to vanilla and to
239        // the registry, so they are one effect here (`namespaced_effect_id`) —
240        // an exact string compare would have made the rule silently miss the
241        // pair whenever the two sites spelled the id differently.
242        let granted = crate::registry::namespaced_effect_id(id);
243        let removal = timeline
244            .iter()
245            .enumerate()
246            .filter(|(ci, (t, _, _))| (*t, *ci) > (*g_tick, gi))
247            .filter_map(|(ci, (t, p, e))| match e.clear_effect() {
248                Some((None, _)) => Some(((*t, ci), t, p, "clears every effect")),
249                Some((Some(c), _)) if crate::registry::namespaced_effect_id(c) == granted => {
250                    Some(((*t, ci), t, p, "clears it"))
251                }
252                _ => None,
253            })
254            .min_by_key(|(key, _, _, _)| *key)
255            .map(|(_, t, p, what)| (*t, p, what));
256        let Some((c_tick, c_path, what)) = removal else {
257            continue;
258        };
259        let live_ticks = seconds.saturating_mul(20);
260        let elapsed = c_tick - g_tick;
261        if elapsed >= live_ticks {
262            // The duration ended first, so the duration IS the removal and the
263            // clear is inert. That is a different (and much milder) finding than
264            // this rule makes, so this rule says nothing about it.
265            continue;
266        }
267        d.push(Diagnostic::error(
268            EFFECT_CLEARED_LIVE,
269            stage,
270            g_path.clone(),
271            format!(
272                "this `give-effect` grants `{id}` for {seconds}s ({live_ticks} ticks) and the \
273                 `clear-effect` at `{c_path}`, {elapsed} tick(s) later in the same bundle, {what} \
274                 while it is still live — so the CLEAR is what ends this grant, not its duration. \
275                 Every path that does not reach that clear (a logout, a crash, a death mid-chain, \
276                 a `sequence` whose remaining schedule never runs) leaves the player holding \
277                 `{id}` for the rest of the {seconds}s. Set `seconds` to how long the effect \
278                 should actually last — {elapsed} tick(s) here, so {} — and drop the \
279                 `clear-effect`: a duration expires with no cooperation from anything. \
280                 `clear-effect` is for effects this campaign did not grant.",
281                if elapsed == 0 {
282                    "the grant is doing nothing at all".to_string()
283                } else {
284                    format!("`seconds`: {}", elapsed.div_ceil(20).max(1))
285                }
286            ),
287        ));
288    }
289}
290
291/// The per-effect half: a real status-effect id (`DW0192`, the same registry
292/// wave-mob effects and kit potions validate against) and a duration/amplifier
293/// inside vanilla's own field widths (`DW0541`).
294fn check_one_status_effect(
295    stage: &'static str,
296    path: &str,
297    eff: &QuestEffect,
298    effects: &dyn EffectRegistry,
299    d: &mut Vec<Diagnostic>,
300) {
301    for (sub, id) in eff.status_effect_refs() {
302        if !effects.contains(id) {
303            d.push(Diagnostic::error(
304                codes::EFFECT_UNKNOWN,
305                stage,
306                format!("{path}/{sub}"),
307                format!(
308                    "`{}` names status effect `{id}`, which is not in the pinned 1.21.11 \
309                     `mob_effect` registry — use a valid namespaced effect id (e.g. \
310                     `minecraft:blindness`)",
311                    eff.verb.tag()
312                ),
313            ));
314        }
315    }
316    let Some((_, seconds, amplifier, _, _)) = eff.give_effect() else {
317        return;
318    };
319    if seconds == 0 || seconds > crate::MAX_EFFECT_SECONDS {
320        d.push(Diagnostic::error(
321            EFFECT_GRANT_BOUNDS,
322            stage,
323            format!("{path}/seconds"),
324            format!(
325                "`give-effect` duration {seconds}s is out of range — it must be between 1 and {} \
326                 seconds. {}",
327                crate::MAX_EFFECT_SECONDS,
328                if seconds == 0 {
329                    "Zero grants nothing at all: the effect is applied and gone before the next \
330                     tick, so the beat reports green and the player sees nothing."
331                } else {
332                    "The ceiling is vanilla's own field width (past the 10-hour delve ceiling), \
333                     so a value above it is a duration typed in ticks or milliseconds."
334                }
335            ),
336        ));
337    }
338    if amplifier > crate::MAX_POTION_AMPLIFIER {
339        d.push(Diagnostic::error(
340            EFFECT_GRANT_BOUNDS,
341            stage,
342            format!("{path}/amplifier"),
343            format!(
344                "`give-effect` amplifier {amplifier} is out of range — vanilla stores it in an \
345                 unsigned byte, so {} is the end of the field, not a policy",
346                crate::MAX_POTION_AMPLIFIER
347            ),
348        ));
349    }
350}
351
352/// True if `e` is (or transitively reaches, via a `move-actor` / `move-npc`
353/// `on_arrive`) a `sequence` — the recursion `DW0329` forbids inside another
354/// sequence's steps.
355fn reaches_sequence(e: &QuestEffect) -> bool {
356    match &e.verb {
357        Verb::Sequence { .. } => true,
358        Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
359            on_arrive.iter().any(reaches_sequence)
360        }
361        _ => false,
362    }
363}
364
365/// Reject a `sequence` nested inside another `sequence` (`DW0329`). Recurses into a
366/// `move-actor` / `move-npc` `on_arrive` (a sequence there is legal — not yet
367/// inside a sequence) but not into an already-flagged sequence's steps (avoids
368/// double-reporting).
369pub(crate) fn check_no_nested_sequence(effs: &[QuestEffect], path: &str, d: &mut Vec<Diagnostic>) {
370    for e in effs {
371        match &e.verb {
372            Verb::Sequence { steps } => {
373                for s in steps {
374                    for inner in &s.effects {
375                        if reaches_sequence(inner) {
376                            d.push(Diagnostic::error(
377                                NESTED_SEQUENCE,
378                                "quests",
379                                path.to_string(),
380                                "a `sequence` effect is nested inside another `sequence` — \
381                                 timelines do not recurse (spec-0014). Flatten the inner steps \
382                                 into the outer timeline (shift their `at_ticks` by the inner \
383                                 sequence's start), or drive the second beat from a flag/objective"
384                                    .to_string(),
385                            ));
386                        }
387                    }
388                }
389            }
390            Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
391                check_no_nested_sequence(on_arrive, path, d);
392            }
393            _ => {}
394        }
395    }
396}
397
398/// Reject a `carrier: "one"` `give-item` inside a **scheduler-only** bundle
399/// (`DW0357`, spec-0018).
400///
401/// `carrier: "one"` means "hand this one quest prop to the player whose action
402/// earned it". A bundle the vanilla scheduler re-invokes with the **server**
403/// command source has no acting player, so the effect has no defensible
404/// recipient. The party-wide default (absent `carrier`) is always fine — it
405/// addresses `@a`.
406///
407/// Which nested bundle has an acting player is the DSL's one statement of it,
408/// [`QuestEffect::nested_effect_dispatch`]: a `move-npc`/`move-actor`
409/// `on_arrive` and a `bonfire`'s `on_rest` never do; a `set-checkpoint`'s
410/// `on_respawn` and a `begin-stealth`'s `on_caught` always do; a `sequence`
411/// step has one exactly where the timeline was started with one, because the
412/// timeline carries its actor across its `schedule`s (spec-0085 §3.2). The
413/// root's own top level is not refused here — a polled root lowers a
414/// `carrier: "one"` give to the party, which is what an absent carrier says.
415fn check_carrier_one_not_scheduled(
416    effs: &[QuestEffect],
417    path: &str,
418    has_actor: bool,
419    top: bool,
420    d: &mut Vec<Diagnostic>,
421) {
422    for e in effs {
423        if !has_actor && !top && e.gives_to_one() {
424            d.push(Diagnostic::error(
425                PARTY_CARRIER_SCHEDULED,
426                "quests",
427                path.to_string(),
428                "a `give-item` with `carrier: \"one\"` sits in a bundle only the scheduler ever \
429                 runs with no acting player (a `move-npc`/`move-actor` `on_arrive`, a `bonfire`'s \
430                 `on_rest`, or a `sequence` step of a timeline started where nobody acted — a \
431                 trigger's effects, a trap's payload, a shortcut's `on_unlock`). Those run with \
432                 the server command source — there is no acting player to hand the prop to, so \
433                 the give would silently reach nobody. Drop `carrier` to arm the whole party, or \
434                 move the hand-off onto the beat a player completes"
435                    .to_string(),
436            ));
437        }
438        for (list, how) in e.nested_effect_dispatch() {
439            check_carrier_one_not_scheduled(list, path, how.has_actor(has_actor), false, d);
440        }
441    }
442}
443
444/// `give-item` enchantments (`DW0433`/`DW0434`) at **every effect root**, at
445/// any nesting depth: the same checks a `loot` stack and an equipped piece get,
446/// because it is the same field on the same object — an item stack.
447pub(crate) fn give_item_enchantment_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
448    let reg = crate::registry::VendoredEnchantmentRegistry::v1_21_11();
449    fn walk(
450        stage: &'static str,
451        path: &str,
452        list: &[QuestEffect],
453        reg: &dyn crate::registry::EnchantmentRegistry,
454        d: &mut Vec<Diagnostic>,
455    ) {
456        for (n, eff) in list.iter().enumerate() {
457            let p = format!("{path}/{n}");
458            if let Verb::GiveItem {
459                item, enchantments, ..
460            } = &eff.verb
461            {
462                check_enchantments(
463                    enchantments,
464                    &format!("`give-item` item `{item}`"),
465                    stage,
466                    &format!("{p}/enchantments"),
467                    reg,
468                    d,
469                );
470            }
471            for (seg, _key, inner) in eff.nested_effect_lists_labeled() {
472                walk(stage, &format!("{p}/{seg}"), inner, reg, d);
473            }
474        }
475    }
476    crate::effects::for_each_effect_root(c, &mut |site, list| {
477        walk(site.stage, &site.path, list, &reg, d);
478    });
479}
480
481/// Validate a v0.4-relevant [`QuestEffect`]'s refs: `set-block` block id
482/// (`DW0193`), `despawn-npc`/`move-npc` npc ids (`DW0112`), `move-npc` speed
483/// positivity, `cutscene` shape (`DW0199`). Item/wave/flag refs are covered by
484/// the shared v0.3 checks.
485pub(crate) fn check_effect_v04(
486    eff: &QuestEffect,
487    blocks: &dyn BlockRegistry,
488    _declared_waves: &BTreeSet<&str>,
489    base_path: &str,
490    npc_ids: &BTreeSet<&str>,
491    d: &mut Vec<Diagnostic>,
492) {
493    check_cutscene_shape(eff, base_path, d);
494    match &eff.verb {
495        Verb::SetBlock { block, .. } => {
496            check_block_field(
497                blocks,
498                block,
499                format!("{base_path}/block"),
500                "set-block",
501                "minecraft:air",
502                d,
503            );
504        }
505        // The v0.10 region write (spec-0031) places the same kind of block over a
506        // box instead of a cell, so it is the same `DW0193` check — the registry
507        // belongs to "a block id an author wrote", not to `set-block`.
508        Verb::FillRegion { block, .. } => {
509            check_block_field(
510                blocks,
511                block,
512                format!("{base_path}/block"),
513                "fill-region",
514                "minecraft:stone",
515                d,
516            );
517        }
518        // NPC-lifecycle refs. `spawn-npc` (v0.6) joins the v0.4 `despawn-npc`/
519        // `move-npc` family: an unknown npc id is the same `DW0112` dangling ref.
520        Verb::DespawnNpc { npc, .. } | Verb::MoveNpc { npc, .. } | Verb::SpawnNpc { npc, .. }
521            if !npc_ids.contains(npc.as_str()) =>
522        {
523            let verb = eff
524                .v04_effect()
525                .or_else(|| eff.v06_effect())
526                .unwrap_or("lifecycle");
527            d.push(Diagnostic::error(
528                codes::DANGLING_REF,
529                "quests",
530                format!("{base_path}/npc"),
531                format!(
532                    "`{verb}` references unknown npc `{npc}` — declare it in stage 2 or correct \
533                     the reference"
534                ),
535            ));
536        }
537        _ => {}
538    }
539}
540
541/// Push `DW0142` if `anchor` is not provided by the quest's (known single-prefab)
542/// area, or `DW0871` if it is provided and is the wrong SHAPE.
543///
544/// `None` set = pool area or unknown prefab → deferred to the compiler.
545///
546/// Every objective that names an anchor names a cell — `reach-anchor` walks a
547/// body to it, `interact` presses something at it, `collect` opens a container
548/// standing on it — so the demand is [`crate::layout::StationKind::Point`] for
549/// all three and is stated once here rather than at each caller.
550fn anchor_resolves(
551    providers: &AnchorProviders,
552    set: Option<&BTreeSet<String>>,
553    anchor: &crate::ids::AnchorId,
554    qi: usize,
555    oi: usize,
556    field: &str,
557    d: &mut Vec<Diagnostic>,
558) {
559    if let Some(f) = station_kind_diag(
560        providers,
561        anchor.as_str(),
562        crate::layout::StationKind::Point,
563        &format!("an objective's `{field}`"),
564        "quests",
565        format!("/content/quests/{qi}/objectives/{oi}/{field}"),
566    ) {
567        d.push(f);
568        return;
569    }
570    if let Some(set) = set
571        && !set.contains(anchor.as_str())
572    {
573        d.push(Diagnostic::error(
574            codes::ANCHOR_UNRESOLVED,
575            "quests",
576            format!("/content/quests/{qi}/objectives/{oi}/{field}"),
577            format!(
578                "objective `{field}` anchor `{anchor}` is not provided by the prefab bound to \
579                 this quest's area — {remedy}",
580                remedy = providers.anchor_remedy(
581                    "use an anchor the prefab exposes (anchor names come from prefab metadata; \
582                     do NOT invent one)"
583                ),
584            ),
585        ));
586    }
587}
588
589/// `DW0150` for the state a campaign is in when the stage-4 plan is written and
590/// stage 5 is not: **every** planned quest is unexpanded, because stage 5
591/// declares no quests at all.
592///
593/// **The state this names is the one the authoring page ends its story-document
594/// step in.** The plan is written before the quests that fill it — that is the
595/// order the stages are numbered in — so between the two there is necessarily a
596/// campaign that carries a plan and no expansion, and this code fires once for
597/// every quest in it. Per-quest, with the two ordinary remedies attached, that
598/// reads as one fault per planned quest with a repair the author is expected to
599/// perform now, and both of the repairs it names are wrong here: writing the
600/// expansions *is* the next authoring step and the plan is not a mistake to
601/// delete.
602///
603/// **There is no third, cheaper remedy, and the message says so rather than
604/// leaving the author to find out.** A stage-5 quest carrying only what the
605/// schema requires — a trigger and two empty arrays — is refused again by
606/// `DW0481`, which demands every quest say what it does to the story, and by
607/// `DW0460` once for every NPC live in it: measured on a five-quest plan,
608/// writing the five minimal expansions took the campaign from 5 errors to 15.
609/// A diagnostic that offered "stub it" here would be sending the author to a
610/// state three times worse than the one they are in.
611///
612/// So what changes is what the refusal SAYS, never whether it refuses. A plan
613/// with no expansion emits nothing and cannot build, so the error and the exit
614/// stand exactly as before; this is [`crate::diagnostic::QUEST_NOT_EXPANDED`]
615/// telling the truth about where the author is standing. It is grouped into one
616/// diagnostic for the same reason `DW0874` names all five missing documents in
617/// one run: N copies of a sentence about one state is a count the reader has to
618/// discount, not information.
619fn plan_awaiting_expansion(unexpanded: &[&PlannedQuest]) -> Diagnostic {
620    let names = unexpanded
621        .iter()
622        .map(|q| format!("`{}`", q.id))
623        .collect::<Vec<_>>()
624        .join(", ");
625    Diagnostic::error(
626        QUEST_NOT_EXPANDED,
627        "quest-plan",
628        "/content/quests",
629        format!(
630            "stage 5 declares no quests at all, so none of the {n} planned quest(s) has an \
631             expansion: {names}. That is an authoring state, not a fault — the plan is written \
632             before the quests that fill it, and a campaign is written a document at a time. The \
633             refusal still stands, because a planned quest with no expansion has no trigger, no \
634             objective and no completion, so nothing of it is emitted and no campaign in this \
635             state can build. What there is NO route to is a cheaper way out: a stage-5 quest \
636             carrying only what its schema requires — a trigger and two empty arrays — is refused \
637             again by `DW0481`, because every quest must say what it does to the story, and by \
638             `DW0460` once for every NPC live in it, so writing empty expansions raises the error \
639             count instead of lowering it. Do not try to clear this code from the plan, and do \
640             not re-run validation expecting it to move. The two things that clear it are \
641             writing stage 5, which clears it for every quest at once, and dropping the quests \
642             from the stage-4 plan. Until stage 5 is written this is the expected state of a \
643             plan-only campaign and this code is what marks it.",
644            n = unexpanded.len(),
645        ),
646    )
647}
648
649pub(crate) fn cross_stage(c: &Campaign, d: &mut Vec<Diagnostic>) {
650    let planned_ids: BTreeSet<&str> = c
651        .quest_plan
652        .content
653        .quests
654        .iter()
655        .map(|q| q.id.as_str())
656        .collect();
657    let expanded_ids: BTreeSet<&str> = c
658        .quests
659        .content
660        .quests
661        .iter()
662        .map(|q| q.id.as_str())
663        .collect();
664
665    // `DW0150` has two readings and only one of them is a mistake. When stage 5
666    // declares quests and this plan entry is not among them, the two remedies
667    // below are both available and one of them is what the author meant. When
668    // stage 5 declares NO quests at all, neither remedy is: the author is
669    // between writing the plan and writing the quests, every planned quest is
670    // unexpanded by construction, and the message has to say so — see
671    // [`plan_awaiting_expansion`].
672    let unexpanded: Vec<&PlannedQuest> = c
673        .quest_plan
674        .content
675        .quests
676        .iter()
677        .filter(|q| !expanded_ids.contains(q.id.as_str()))
678        .collect();
679    if !unexpanded.is_empty() && c.quests.content.quests.is_empty() {
680        d.push(plan_awaiting_expansion(&unexpanded));
681    } else {
682        for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
683            if !expanded_ids.contains(q.id.as_str()) {
684                d.push(Diagnostic::error(
685                    QUEST_NOT_EXPANDED,
686                    "quest-plan",
687                    format!("/content/quests/{i}"),
688                    format!(
689                        "planned quest `{}` has no stage-5 expansion — stage 5 declares {n} \
690                         quest(s) and none of them carries this id, so this is a mismatch rather \
691                         than an unwritten stage: add a stage-5 quest with id `{}` \
692                         (objectives/effects), or drop it from the stage-4 plan",
693                        q.id,
694                        q.id,
695                        n = c.quests.content.quests.len(),
696                    ),
697                ));
698            }
699        }
700    }
701    for (i, q) in c.quests.content.quests.iter().enumerate() {
702        if !planned_ids.contains(q.id.as_str()) {
703            d.push(Diagnostic::error(
704                QUEST_NOT_PLANNED,
705                "quests",
706                format!("/content/quests/{i}"),
707                format!(
708                    "stage-5 quest `{}` is not planned in stage 4 — add a stage-4 plan entry with \
709                     id `{}` (every quest must be planned), or remove this expansion",
710                    q.id, q.id
711                ),
712            ));
713        }
714    }
715}
716
717/// `DW0357` at every quests-stage effect root: a `carrier: "one"` give where the
718/// bundle has no acting player ([`check_carrier_one_not_scheduled`]).
719pub(crate) fn carrier_one_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
720    // `DW0357`, per root SITE: whether its bundle has an acting player is the
721    // site's own answer (a `presser` trigger does, every other trigger does not).
722    crate::effects::for_each_effect_root(c, &mut |site, effs| {
723        if site.stage == "quests" {
724            check_carrier_one_not_scheduled(
725                effs,
726                &site.path,
727                site.runs_with_acting_player(),
728                true,
729                d,
730            );
731        }
732    });
733}
734
735/// Every objective and effect anchor of a quest (`DW0142`/`DW0871`), resolved
736/// against the quest's planned area — a cutscene's camera against every area —
737/// at any nesting depth, through the single anchor authority
738/// ([`QuestEffect::anchor_refs`]).
739pub(crate) fn quest_anchor_checks(
740    c: &Campaign,
741    providers: &AnchorProviders,
742    d: &mut Vec<Diagnostic>,
743) {
744    // planned quest id -> its area.
745    let quest_area: BTreeMap<&str, &str> = c
746        .quest_plan
747        .content
748        .quests
749        .iter()
750        .map(|q| (q.id.as_str(), q.area.as_str()))
751        .collect();
752
753    // Objective / effect anchors, resolved against the quest's planned area.
754    for (i, q) in c.quests.content.quests.iter().enumerate() {
755        let set = quest_area
756            .get(q.id.as_str())
757            .and_then(|area| providers.for_area(area));
758        let Some(set) = set else { continue };
759
760        for (j, obj) in q.objectives.iter().enumerate() {
761            if let crate::Objective::ReachAnchor { anchor, .. } = obj
762                && let Some(f) = station_kind_diag(
763                    providers,
764                    anchor.as_str(),
765                    crate::layout::StationKind::Point,
766                    "a `reach-anchor` objective",
767                    "quests",
768                    format!("/content/quests/{i}/objectives/{j}/anchor"),
769                )
770            {
771                d.push(f);
772            } else if let crate::Objective::ReachAnchor { anchor, .. } = obj
773                && !set.contains(anchor.as_str())
774            {
775                d.push(Diagnostic::error(
776                    codes::ANCHOR_UNRESOLVED,
777                    "quests",
778                    format!("/content/quests/{i}/objectives/{j}/anchor"),
779                    format!(
780                        "objective anchor `{anchor}` is not provided by the prefab bound to \
781                         this quest's area — {}",
782                        providers.anchor_remedy(
783                            "use an anchor the prefab exposes (anchor names come from prefab \
784                             metadata; do NOT invent one)"
785                        ),
786                    ),
787                ));
788            }
789        }
790        // Effect anchors — **deep** and driven by the single anchor authority
791        // ([`QuestEffect::anchor_refs`]), so every anchor-bearing effect at any
792        // nesting depth resolves or is named. This scan used to be shallow
793        // ([`for_each_effect`]) and enumerate variants by hand: a typo'd
794        // `open-gate`/`set-block`/`set-checkpoint` anchor one level down (a
795        // `sequence` step, an `on_respawn`/`on_caught`/`on_arrive` bundle)
796        // validated clean and then emitted *nothing* — the silent-drop class of
797        // bug the compiler's build-time seal (`DW0360`) now backstops.
798        for_each_effect_deep(q, |path, eff| {
799            // A `cutscene`'s camera anchors are legitimately cross-area (the
800            // camera flies wherever the shot needs), so they resolve against the
801            // union of every known area's anchors; every other effect anchor names
802            // a world position the quest's own area must provide.
803            let cross_area = matches!(&eff.verb, Verb::Cutscene { .. });
804            for (suffix, anchor, demands) in eff.anchor_refs() {
805                if let Some(f) = station_kind_diag(
806                    providers,
807                    anchor.as_str(),
808                    demands,
809                    &format!("`{}`", eff.verb.tag()),
810                    "quests",
811                    format!("/content/quests/{i}/{path}/{suffix}"),
812                ) {
813                    d.push(f);
814                    continue;
815                }
816                let resolves = if cross_area {
817                    // An unknown/pool area makes the union incomplete — defer to
818                    // the compiler's build-time seal rather than guess.
819                    !providers.all_areas_known() || providers.union().contains(anchor.as_str())
820                } else {
821                    set.contains(anchor.as_str())
822                };
823                if resolves {
824                    continue;
825                }
826                let scope = if cross_area {
827                    "any area's prefab"
828                } else {
829                    "the prefab bound to this quest's area"
830                };
831                d.push(Diagnostic::error(
832                    codes::ANCHOR_UNRESOLVED,
833                    "quests",
834                    format!("/content/quests/{i}/{path}/{suffix}"),
835                    format!(
836                        "`{verb}` anchor `{anchor}` is not provided by {scope} — {}",
837                        providers.anchor_remedy(
838                            "use an anchor a prefab exposes (anchor names come from prefab \
839                             metadata; do NOT invent one)"
840                        ),
841                        verb = eff.verb.tag(),
842                    ),
843                ));
844            }
845        });
846    }
847}
848
849/// A quest's references (spec-0001 v0.3): `kill.wave` / `spawn-wave.wave` /
850/// `collect.dropped_by` name a declared wave (`DW0170`) and a killed wave is
851/// spawned (`DW0171`); `requires_flags` / `forbids_flags` on objectives and
852/// effects name a produced flag (`DW0172`); item ids on `collect` /
853/// `interact.requires_item` / `give-item` are in the registry (`DW0143`);
854/// `collect` / `interact` anchors resolve against the quest's area (`DW0142`).
855pub(crate) fn quest_reference_checks(
856    c: &Campaign,
857    items: &dyn ItemRegistry,
858    anchors: &dyn AnchorRegistry,
859    d: &mut Vec<Diagnostic>,
860) {
861    let quests = &c.quests.content;
862
863    let declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
864
865    // Flags declared by `set-flag`, from the ONE producer inventory
866    // ([`produced_flags`]); waves spawned by `spawn-wave`.
867    let declared_flags: BTreeSet<String> = produced_flags(c);
868    let mut spawned_waves: BTreeSet<&str> = BTreeSet::new();
869    crate::for_each_campaign_effect(c, &mut |_path, _site, e| {
870        if let Some(w) = e.spawn_wave() {
871            spawned_waves.insert(w.as_str());
872        }
873    });
874
875    // area id -> its single-prefab anchor set (pool areas deferred to compiler).
876    let providers = AnchorProviders::build(c, anchors);
877    let quest_area: BTreeMap<&str, &str> = c
878        .quest_plan
879        .content
880        .quests
881        .iter()
882        .map(|q| (q.id.as_str(), q.area.as_str()))
883        .collect();
884
885    // Reference checks.
886    for (i, q) in quests.quests.iter().enumerate() {
887        let set = quest_area
888            .get(q.id.as_str())
889            .and_then(|area| providers.for_area(area));
890
891        for (j, obj) in q.objectives.iter().enumerate() {
892            match obj {
893                Objective::Kill { wave, .. } => {
894                    if !declared_waves.contains(wave.as_str()) {
895                        d.push(Diagnostic::error(
896                            codes::WAVE_UNKNOWN,
897                            "quests",
898                            format!("/content/quests/{i}/objectives/{j}/wave"),
899                            format!(
900                                "`kill` objective references unknown wave `{wave}` — declare it \
901                                 in the stage-5 `waves` section or correct the reference"
902                            ),
903                        ));
904                    } else if !spawned_waves.contains(wave.as_str()) {
905                        d.push(Diagnostic::error(
906                            WAVE_NEVER_SPAWNED,
907                            "quests",
908                            format!("/content/quests/{i}/objectives/{j}/wave"),
909                            format!(
910                                "wave `{wave}` is killed but never spawned by any `spawn-wave` \
911                                 effect — a wave must be spawned before its `kill` objective is \
912                                 reachable; add a `spawn-wave` effect for `{wave}` on an earlier \
913                                 objective/quest"
914                            ),
915                        ));
916                    }
917                }
918                Objective::Collect {
919                    id: oid,
920                    item,
921                    count,
922                    anchor,
923                    container,
924                    dropped_by,
925                    fill_count,
926                    ..
927                } => {
928                    // v0.9: the wave a drop-gated collect names must
929                    // exist — the same dangling-reference rule a `kill` follows.
930                    if let Some(wave) = dropped_by
931                        && !declared_waves.contains(wave.as_str())
932                    {
933                        d.push(Diagnostic::error(
934                            codes::WAVE_UNKNOWN,
935                            "quests",
936                            format!("/content/quests/{i}/objectives/{j}/dropped_by"),
937                            format!(
938                                "`collect` `dropped_by` references unknown wave `{wave}` — \
939                                 declare it in the stage-5 `waves` section or correct the \
940                                 reference"
941                            ),
942                        ));
943                    }
944                    if !items.contains(item) {
945                        d.push(Diagnostic::error(
946                            codes::ITEM_UNKNOWN,
947                            "quests",
948                            format!("/content/quests/{i}/objectives/{j}/item"),
949                            format!(
950                                "collect item `{item}` is not in the pinned 1.21.11 item \
951                                 registry — use a valid namespaced item id (e.g. \
952                                 `minecraft:emerald`)"
953                            ),
954                        ));
955                    }
956                    // The objective's props are placed with a single-slot `item
957                    // replace … container.0` fill, so an over-cap count leaves the
958                    // chest empty and the objective uncompletable (`DW0436`).
959                    check_stack_count(
960                        item,
961                        *count,
962                        &format!("collect objective `{oid}`"),
963                        format!("/content/quests/{i}/objectives/{j}/count"),
964                        items,
965                        d,
966                    );
967                    anchor_resolves(&providers, set, anchor, i, j, "anchor", d);
968                    // v0.8: the adopted container's anchor must exist
969                    // too. Whether its CELL really holds a chest/barrel needs the
970                    // assembled world and is the build-tier `DW0438`.
971                    if let Some(cont) = container {
972                        anchor_resolves(&providers, set, cont, i, j, "container", d);
973                    }
974                    // v0.8: the fill is positional — the required stack
975                    // in `container.0` plus one padding stack per slot after it —
976                    // so it obeys the same 27-slot ceiling a `loot` declaration
977                    // does, and for the same reason: every stack past the last slot
978                    // is dropped without a word.
979                    let slots = 1usize + *fill_count as usize;
980                    if slots > MIN_CONTAINER_SLOTS {
981                        d.push(Diagnostic::error(
982                            codes::LOOT_TOO_MANY_ITEMS,
983                            "quests",
984                            format!("/content/quests/{i}/objectives/{j}/fill_count"),
985                            format!(
986                                "collect objective `{oid}` fills {slots} slots (its own stack \
987                                 plus `fill_count` {fill_count}), more than the \
988                                 {MIN_CONTAINER_SLOTS} slots a vanilla chest or barrel has. \
989                                 Slots are assigned positionally, so every stack past the \
990                                 {MIN_CONTAINER_SLOTS}th would be dropped silently. Lower \
991                                 `fill_count` to at most {max} — a container that reads full \
992                                 does not need to overflow.",
993                                max = MIN_CONTAINER_SLOTS - 1
994                            ),
995                        ));
996                    }
997                }
998                Objective::Interact {
999                    anchor,
1000                    requires_item,
1001                    missing_item_hint,
1002                    ..
1003                } => {
1004                    // v0.7: the empty-hand narration answers the held-item gate;
1005                    // without a gate there is no missing hand to narrate to, and
1006                    // the authored line would be dead content that never fires.
1007                    if missing_item_hint.is_some() && requires_item.is_none() {
1008                        d.push(Diagnostic::error(
1009                            MISSING_ITEM_HINT_WITHOUT_ITEM,
1010                            "quests",
1011                            format!("/content/quests/{i}/objectives/{j}/missing_item_hint"),
1012                            "`interact.missing_item_hint` narrates the click that arrives \
1013                             without the required item in hand, but this objective declares no \
1014                             `requires_item` — add the `requires_item` this hint is about, or \
1015                             drop the hint"
1016                                .to_string(),
1017                        ));
1018                    }
1019                    if let Some(it) = requires_item
1020                        && !items.contains(it)
1021                    {
1022                        d.push(Diagnostic::error(
1023                            codes::ITEM_UNKNOWN,
1024                            "quests",
1025                            format!("/content/quests/{i}/objectives/{j}/requires_item"),
1026                            format!(
1027                                "`interact.requires_item` `{it}` is not in the pinned 1.21.11 \
1028                                 item registry — use a valid namespaced item id (e.g. \
1029                                 `minecraft:tripwire_hook`)"
1030                            ),
1031                        ));
1032                    }
1033                    anchor_resolves(&providers, set, anchor, i, j, "anchor", d);
1034                }
1035                Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => {}
1036            }
1037
1038            for (m, f) in obj.requires_flags().iter().enumerate() {
1039                if !declared_flags.contains(f.as_str()) {
1040                    d.push(Diagnostic::error(
1041                        codes::FLAG_UNKNOWN,
1042                        "quests",
1043                        format!("/content/quests/{i}/objectives/{j}/requires_flags/{m}"),
1044                        format!(
1045                            "objective `requires_flags` references flag `{f}`, which no \
1046                             `set-flag` effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` \
1047                             effect on an earlier objective/quest, or correct the flag name"
1048                        ),
1049                    ));
1050                }
1051            }
1052            // v0.6: `forbids_flags` gets the same unknown-flag treatment as
1053            // `requires_flags` — a never-produced flag can never suppress anything,
1054            // so the reference is dead (a typo until proven otherwise).
1055            for (m, f) in obj.forbids_flags().iter().enumerate() {
1056                if !declared_flags.contains(f.as_str()) {
1057                    d.push(Diagnostic::error(
1058                        codes::FLAG_UNKNOWN,
1059                        "quests",
1060                        format!("/content/quests/{i}/objectives/{j}/forbids_flags/{m}"),
1061                        format!(
1062                            "objective `forbids_flags` references flag `{f}`, which no `set-flag` \
1063                             effect ever produces — the gate can never suppress anything; add the \
1064                             producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
1065                             name"
1066                        ),
1067                    ));
1068                }
1069            }
1070        }
1071
1072        for_each_effect_deep(q, |path, eff| {
1073            if let Some(w) = eff.spawn_wave()
1074                && !declared_waves.contains(w.as_str())
1075            {
1076                d.push(Diagnostic::error(
1077                    codes::WAVE_UNKNOWN,
1078                    "quests",
1079                    format!("/content/quests/{i}/{path}/wave"),
1080                    format!(
1081                        "`spawn-wave` effect references unknown wave `{w}` — declare it in the \
1082                         stage-5 `waves` section or correct the reference"
1083                    ),
1084                ));
1085            }
1086            if let Some(it) = eff.give_item()
1087                && !items.contains(it)
1088            {
1089                d.push(Diagnostic::error(
1090                    codes::ITEM_UNKNOWN,
1091                    "quests",
1092                    format!("/content/quests/{i}/{path}/item"),
1093                    format!(
1094                        "`give-item` item `{it}` is not in the pinned 1.21.11 item registry — use \
1095                         a valid namespaced item id (e.g. `minecraft:golden_apple`)"
1096                    ),
1097                ));
1098            }
1099            // v0.6: per-effect `requires_flags` must resolve to a produced flag,
1100            // mirroring the objective/trigger `requires_flags` check (DW0172).
1101            for (n, f) in eff.requires_flags().iter().enumerate() {
1102                if !declared_flags.contains(f.as_str()) {
1103                    d.push(Diagnostic::error(
1104                        codes::FLAG_UNKNOWN,
1105                        "quests",
1106                        format!("/content/quests/{i}/{path}/when/requires_flags/{n}"),
1107                        format!(
1108                            "effect `requires_flags` references flag `{f}`, which no `set-flag` \
1109                             effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
1110                             earlier, or correct the flag name"
1111                        ),
1112                    ));
1113                }
1114            }
1115            // v0.6: per-effect `forbids_flags` — same unknown-flag treatment.
1116            for (n, f) in eff.forbids_flags().iter().enumerate() {
1117                if !declared_flags.contains(f.as_str()) {
1118                    d.push(Diagnostic::error(
1119                        codes::FLAG_UNKNOWN,
1120                        "quests",
1121                        format!("/content/quests/{i}/{path}/when/forbids_flags/{n}"),
1122                        format!(
1123                            "effect `forbids_flags` references flag `{f}`, which no `set-flag` \
1124                             effect ever produces — the gate can never suppress anything; add the \
1125                             producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
1126                             name"
1127                        ),
1128                    ));
1129                }
1130            }
1131        });
1132    }
1133}
1134
1135/// An `interact` objective's `prop` (`DW0193`, `DW0957`) and every quest effect's
1136/// block, npc and cutscene references ([`check_effect_v04`]), at any nesting
1137/// depth.
1138pub(crate) fn quest_prop_checks(c: &Campaign, blocks: &dyn BlockRegistry, d: &mut Vec<Diagnostic>) {
1139    let quests = &c.quests.content;
1140    let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
1141    let declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
1142
1143    for (i, q) in quests.quests.iter().enumerate() {
1144        for (j, o) in q.objectives.iter().enumerate() {
1145            if let Some(prop) = o.prop() {
1146                check_block_field(
1147                    blocks,
1148                    &prop.block,
1149                    format!("/content/quests/{i}/objectives/{j}/prop/block"),
1150                    "interact.prop",
1151                    "minecraft:lever",
1152                    d,
1153                );
1154                if crate::fires_on_step(&prop.block) {
1155                    d.push(Diagnostic::error(
1156                        INTERACT_PROP_STEPPED,
1157                        "quests",
1158                        format!("/content/quests/{i}/objectives/{j}/prop/block"),
1159                        format!(
1160                            "`interact` objective `{}` uses `{}` as its prop, a block a player \
1161                             fires by stepping on it — but an `interact` completes on a \
1162                             right-click, so walking onto it does nothing. Prescription: give the \
1163                             objective a block a hand works (a lever, a button), or make the step \
1164                             the act: a `trigger` with `on: step` at an anchor whose cell holds the \
1165                             plate, whose effects do what completing the objective did",
1166                            o.id(),
1167                            prop.block
1168                        ),
1169                    ));
1170                }
1171            }
1172        }
1173        for_each_effect_deep(q, |path, eff| {
1174            check_effect_v04(
1175                eff,
1176                blocks,
1177                &declared_waves,
1178                &format!("/content/quests/{i}/{path}"),
1179                &npc_ids,
1180                d,
1181            );
1182        });
1183    }
1184}
1185
1186/// `DW0110` over the expanded quests' ids and their objectives' ids.
1187pub(crate) fn quest_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
1188    for (i, q) in c.quests.content.quests.iter().enumerate() {
1189        crate::ids::id_syntax!(d, q.id, "quests", format!("/content/quests/{i}/id"));
1190        for (j, obj) in q.objectives.iter().enumerate() {
1191            crate::ids::id_syntax!(
1192                d,
1193                obj.id(),
1194                "quests",
1195                format!("/content/quests/{i}/objectives/{j}/id")
1196            );
1197        }
1198    }
1199}
1200
1201/// `DW0111` over the expanded quests' ids, and over the objective ids.
1202pub(crate) fn quest_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
1203    crate::ids::dup_check(
1204        c.quests
1205            .content
1206            .quests
1207            .iter()
1208            .enumerate()
1209            .map(|(i, q)| (q.id.as_str(), format!("/content/quests/{i}/id"))),
1210        "quests",
1211        "quest",
1212        d,
1213    );
1214    // Objective ids: unique across all of stage 5 (so cross-stage dialogue refs
1215    // resolve unambiguously).
1216    crate::ids::dup_check(
1217        c.quests
1218            .content
1219            .quests
1220            .iter()
1221            .enumerate()
1222            .flat_map(|(i, q)| {
1223                q.objectives.iter().enumerate().map(move |(j, o)| {
1224                    (
1225                        o.id().as_str(),
1226                        format!("/content/quests/{i}/objectives/{j}/id"),
1227                    )
1228                })
1229            }),
1230        "quests",
1231        "objective",
1232        d,
1233    );
1234}
1235
1236/// `DW0112` over what an expanded quest names: the quest a `quest-complete`
1237/// trigger waits on, the NPC a `talk-to` objective names, the objectives an
1238/// `after` lists, and the objective each `on_objective_complete` key names.
1239pub(crate) fn quest_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
1240    use crate::ids::dangling;
1241    let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
1242    let expanded_ids: BTreeSet<&str> = c
1243        .quests
1244        .content
1245        .quests
1246        .iter()
1247        .map(|q| q.id.as_str())
1248        .collect();
1249    for (i, q) in c.quests.content.quests.iter().enumerate() {
1250        if let crate::Trigger::QuestComplete { quest } = &q.trigger {
1251            dangling(
1252                d,
1253                expanded_ids.contains(quest.as_str()),
1254                "quests",
1255                format!("/content/quests/{i}/trigger/quest"),
1256                format!(
1257                    "quest trigger `quest-complete` references unknown quest `{quest}` — declare \
1258                     that quest in stage 5 or correct the reference"
1259                ),
1260            );
1261        }
1262        let local_objs: BTreeSet<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
1263        for (j, obj) in q.objectives.iter().enumerate() {
1264            if let crate::Objective::TalkTo { npc, .. } = obj {
1265                dangling(
1266                    d,
1267                    npc_ids.contains(npc.as_str()),
1268                    "quests",
1269                    format!("/content/quests/{i}/objectives/{j}/npc"),
1270                    format!(
1271                        "`talk-to` objective references unknown npc `{npc}` — declare it in \
1272                         stage 2 or correct the reference"
1273                    ),
1274                );
1275            }
1276            for (m, aft) in obj.after().iter().enumerate() {
1277                dangling(
1278                    d,
1279                    local_objs.contains(aft.as_str()),
1280                    "quests",
1281                    format!("/content/quests/{i}/objectives/{j}/after/{m}"),
1282                    format!(
1283                        "objective `after` references unknown objective `{aft}` — `after` may \
1284                         only name another objective in the same quest; declare it or correct \
1285                         the reference"
1286                    ),
1287                );
1288            }
1289        }
1290        for key in q.on_objective_complete.keys() {
1291            dangling(
1292                d,
1293                local_objs.contains(key.as_str()),
1294                "quests",
1295                format!("/content/quests/{i}/on_objective_complete/{key}"),
1296                format!(
1297                    "`on_objective_complete` is keyed by unknown objective `{key}` — the key must \
1298                     name an objective declared in this quest; declare it or correct the key"
1299                ),
1300            );
1301        }
1302    }
1303}