Skip to main content

delvewright_dsl/
quest_plan.rs

1//! Stage 4 — the quest plan: the planned quests, their dependencies, branch
2//! points and happenings, and the spine they form.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::{AreaId, BranchId, BranchPointId, EndingId, FlagId, NpcId, QuestId};
10
11#[cfg(doc)]
12use crate::QuestEffect;
13
14/// Stage 4 payload: the quest dependency plan.
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct QuestPlanContent {
18    /// Planned quests (expanded in stage 5).
19    pub quests: Vec<PlannedQuest>,
20    /// The quest whose completion ends the campaign.
21    pub finale: QuestId,
22    /// The campaign's declared **story forks** (DSL v0.8, spec-0025). Empty/absent = a campaign that claims to have no
23    /// branch — which the compiler then *verifies* rather than assumes: any flag
24    /// that gates casts, staging or structure and is set on some playthroughs and
25    /// not others belongs to no declared point and is `DW0480`.
26    ///
27    /// Enumerated branches are the **product of the declared points**, so the
28    /// branch set is authored and small — never a combinatorial sweep of every
29    /// flag in the campaign.
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub branch_points: Vec<BranchPoint>,
32}
33
34impl QuestPlanContent {
35    /// **The ONE authority on which quests are the spine**: the finale and every
36    /// quest its `depends_on` chain transitively demands — the quests a body
37    /// cannot reach the finale without.
38    ///
39    /// The capability belongs here, on the stage-4 document, because the spine is
40    /// a fact about the quest plan and about nothing else. It had grown two
41    /// derivations of the same closure in two files — one inline in
42    /// [`crate::validate`]'s `DW0132` convergence check, one a private
43    /// `mandatory_quests` in [`crate::layout`] read by the layout binding and by
44    /// the critical-path spine obligation. Both were correct and neither said it
45    /// was the authority, which is exactly the shape a later clean merge turns
46    /// into two rules that disagree.
47    ///
48    /// **Why the closure is taken over the raw `depends_on` edges, unfiltered.**
49    /// The `validate` copy first dropped every dep naming a quest the plan does
50    /// not declare. That filtering is not this function's question: a dangling
51    /// `depends_on` is `DW0112`'s finding, and silently pruning it here would
52    /// make the set disagree with the document it is derived from. So an id the
53    /// plan does not declare is reported in the spine and expands no further —
54    /// and the one reader that could care, `DW0132`, only ever asks whether a
55    /// **declared** quest is a member, so an undeclared member cannot change its
56    /// verdict.
57    ///
58    /// Cycle-safe by construction (a quest already in the set is not expanded
59    /// again), so a plan `DW0130` will refuse still yields a set rather than
60    /// hanging.
61    ///
62    /// **Not the same question as the `mandatory` field**, and the name says so
63    /// deliberately. Today the two sets always coincide, because `DW0132` demands
64    /// every declared quest be a transitive dependency of the finale and `DW0866`
65    /// demands every quest set `mandatory: true`. If `mandatory: false` ever
66    /// becomes legal those coincide no longer, and this function keeps answering
67    /// the graph question it has always answered.
68    #[must_use]
69    pub fn spine(&self) -> BTreeSet<&str> {
70        let deps: BTreeMap<&str, &[QuestId]> = self
71            .quests
72            .iter()
73            .map(|q| (q.id.as_str(), q.depends_on.as_slice()))
74            .collect();
75        let mut spine: BTreeSet<&str> = BTreeSet::new();
76        let mut stack = vec![self.finale.as_str()];
77        while let Some(q) = stack.pop() {
78            if !spine.insert(q) {
79                continue;
80            }
81            for dep in deps.get(q).copied().unwrap_or(&[]) {
82                stack.push(dep.as_str());
83            }
84        }
85        spine
86    }
87
88    /// **The ONE authority on which quests are elective** (spec-0051): the
89    /// quests declaring `mandatory: false`.
90    ///
91    /// The counterpart to [`Self::spine`], and deliberately a *different*
92    /// question. `spine` asks what the graph demands; this asks what the author
93    /// claims. `DW0866`/`DW0867` are exactly the rules that keep the two
94    /// answers honest about each other, and they can only do that while each
95    /// side has one derivation — which is the defect the spine function was
96    /// created to end, and which a second private `!q.mandatory` filter in the
97    /// compiler would re-introduce on the other half.
98    #[must_use]
99    pub fn optional(&self) -> BTreeSet<&str> {
100        self.quests
101            .iter()
102            .filter(|q| !q.mandatory)
103            .map(|q| q.id.as_str())
104            .collect()
105    }
106}
107
108/// One declared story fork (DSL v0.8, spec-0025).
109///
110/// A branch point names the flag set the story forks on, the quest at which the
111/// fork opens, and every branch it offers. Each branch pins the point's whole
112/// flag set: the flags it lists are **set**, and every other flag of `forks_on`
113/// is **not** — which is what makes exclusive-content leakage (`DW0484`) and
114/// per-branch cast resolution (`DW0483`) decidable instead of hopeful.
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
116#[serde(deny_unknown_fields)]
117pub struct BranchPoint {
118    /// Unique branch-point id.
119    pub id: BranchPointId,
120    /// The quest at which the fork opens — every branch's divergent content is at
121    /// or after it in the stage-4 DAG.
122    pub opens_at: QuestId,
123    /// The flag set the story forks on. Every branch's `flags` is a subset of
124    /// this, and the flags it does not list are pinned **unset** on that branch.
125    pub forks_on: Vec<FlagId>,
126    /// The alternatives (≥ 2).
127    pub branches: Vec<BranchDecl>,
128}
129
130/// One alternative of a [`BranchPoint`] (DSL v0.8, spec-0025).
131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
132#[serde(deny_unknown_fields)]
133pub struct BranchDecl {
134    /// Unique branch id (campaign-wide — it names the emitted chronicle file).
135    pub id: BranchId,
136    /// The subset of the point's `forks_on` that is SET on this branch. The rest
137    /// of `forks_on` is pinned unset. An empty list is legal — the "took neither
138    /// option" branch — as long as it is genuinely reachable.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub flags: Vec<FlagId>,
141    /// Where this branch goes: either the `quest/<kebab>` the branches converge
142    /// at, or the `ending/<kebab>` this branch runs to.
143    ///
144    /// One field, not two mutually exclusive ones, because the **id prefix
145    /// already says which it is** — the same convention every cross-stage
146    /// reference in the DSL uses. That is what makes "exactly one of them" an
147    /// unrepresentable state rather than a rule some diagnostic has to police: a
148    /// value that is neither a syntactically valid `quest/…` nor `ending/…` is
149    /// the ordinary malformed-id `DW0110`, and one that names nothing is the
150    /// ordinary dangling-reference `DW0112`.
151    pub leads_to: String,
152}
153
154impl BranchDecl {
155    /// The convergence quest, if [`Self::leads_to`] names one.
156    pub fn converges_at(&self) -> Option<QuestId> {
157        let q = QuestId(self.leads_to.clone());
158        q.is_valid_syntax().then_some(q)
159    }
160
161    /// The ending, if [`Self::leads_to`] names one.
162    pub fn ending(&self) -> Option<EndingId> {
163        let e = EndingId(self.leads_to.clone());
164        e.is_valid_syntax().then_some(e)
165    }
166}
167
168/// What a story node does to the story (DSL v0.8, spec-0025).
169///
170/// The generalization of spec-0020's `doing` from NPC presence to event flow: a
171/// design that never got written down node by node cannot compile. It is
172/// **node-local on purpose** — there is no parallel per-branch script document
173/// that could itself drift from the graph.
174///
175/// `text` is authoring/validation metadata, never shown to a player, so it is
176/// deliberately **excluded from the l10n inventory** exactly like `doing`. The
177/// compiler reads only `verb` and `subject`; `text` is the flesh the per-branch
178/// chronicle is assembled from.
179#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
180#[serde(deny_unknown_fields)]
181pub struct Happening {
182    /// The structured event verb — the machine-decidable part.
183    pub verb: HappeningVerb,
184    /// One line of prose stating what this node does to the story.
185    pub text: String,
186    /// What the event happens TO: an `npc/`, `actor/`, `wave/` or `anchor/` id
187    /// (validated — a dangling one is `DW0112`), or an `item/<kebab>` label for a
188    /// story token the campaign tracks by hand. Optional, because not every beat
189    /// is about somebody; the hard-contradiction proof (`DW0485`) reasons only
190    /// over the beats that name one.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub subject: Option<String>,
193}
194
195/// The subject a beat is about, and whether the document said so (spec-0071 §3).
196///
197/// Answered by [`QuestEffect::happening_subject`], the one derivation. `derived`
198/// is carried rather than dropped because the two readers want different things
199/// from it: the namespace check reports on what an author **wrote**, and the
200/// chronicle reasons over what the beat **is about**.
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub struct HappeningSubject<'a> {
203    /// The subject id (`npc/`, `actor/`, `wave/`, `anchor/` or an `item/` label).
204    pub id: &'a str,
205    /// `true` when the effect's own single object supplied it, `false` when the
206    /// `happening` states it.
207    pub derived: bool,
208}
209
210/// The structured event vocabulary (DSL v0.8, spec-0025).
211///
212/// Deliberately small and closed. These ten verbs are what make a subset of
213/// narrative errors machine-decidable per branch (`DW0485`); everything else a
214/// beat means lives in [`Happening::text`], which the compiler never interprets.
215/// Extend only when a real campaign cannot state its beat with what is here.
216#[derive(
217    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
218)]
219#[serde(rename_all = "kebab-case")]
220pub enum HappeningVerb {
221    /// The subject is killed / destroyed. Terminal: nothing the subject does may
222    /// follow it on the same branch.
223    Dies,
224    /// The subject comes through alive — the explicit counterpart of `dies`,
225    /// which is what lets a branch state that somebody *did not* die.
226    Survives,
227    /// The subject leaves the stage (offstage, not dead). Cleared by `arrives`.
228    Departs,
229    /// The subject enters the stage.
230    Arrives,
231    /// Somebody learns a fact — the true-information beat.
232    Learns,
233    /// Somebody comes to believe something (whether or not it is true) — the beat
234    /// that carries a wrong belief forward, which is where branch drift shows.
235    Believes,
236    /// The party (or the subject) gains a thing.
237    Gains,
238    /// The party (or the subject) loses a thing. A second `loses` with no
239    /// intervening `gains` is spending what is already spent (`DW0485`).
240    Loses,
241    /// A way is opened.
242    Opens,
243    /// A way is sealed. Cleared by `opens`.
244    Seals,
245}
246
247/// One planned quest (dependency-graph node).
248#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
249#[serde(deny_unknown_fields)]
250pub struct PlannedQuest {
251    /// Unique quest id.
252    pub id: QuestId,
253    /// Human-readable goal.
254    pub goal: String,
255    /// Area this quest takes place in (stage-1 ref).
256    pub area: AreaId,
257    /// NPCs involved (stage-2 refs).
258    pub npcs: Vec<NpcId>,
259    /// Prerequisite quests; edges must form a DAG.
260    pub depends_on: Vec<QuestId>,
261    /// `false` declares an optional quest (spec-0051).
262    pub mandatory: bool,
263    /// Act number (informational).
264    pub act: u32,
265}
266
267// ---------------------------------------------------------------------------
268// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
269// ---------------------------------------------------------------------------
270
271use crate::Verb;
272use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
273use crate::envelope::Campaign;
274use crate::validate::{graph_has_cycle, produced_flags};
275
276crate::dw_code! {
277    /// Quest dependency cycle.
278    pub const PLAN_CYCLE: DwCode = DwCode::new("DW0130", ExitTier::Build);
279}
280
281crate::dw_code! {
282    /// `finale` is not a declared quest.
283    pub const FINALE_UNKNOWN: DwCode = DwCode::new("DW0131", ExitTier::Build);
284}
285
286crate::dw_code! {
287    /// `finale` is not the convergent sink of the plan: some declared quest is
288    /// not a transitive dependency of it.
289    ///
290    /// **The name deliberately does not contain `FINALE_UNREACHABLE`, which
291    /// belongs to `DW0201`.** That code says the finale can never complete; this
292    /// one says nothing at all about the finale being reachable — in the fixture
293    /// that raises it the finale completes perfectly well and a side trip hangs
294    /// off the plan. Both are `DwCode`, so nothing but the name distinguishes
295    /// them at a call site, and `tools/ci/check-dw-codes.py` credits a bare
296    /// constant name mentioned in a crate's tests to **that crate's** code — so
297    /// one shared name would buy coverage for whichever rule the file happens to
298    /// sit next to.
299    pub const PLAN_NOT_CONVERGENT: DwCode = DwCode::new("DW0132", ExitTier::Build);
300}
301
302crate::dw_code! {
303    /// An optional quest inside the finale's dependency closure (spec-0051
304    /// §8.1) — including a finale that declares itself optional.
305    pub const OPTIONAL_ON_SPINE: DwCode = DwCode::new("DW0866", ExitTier::Build);
306}
307
308crate::dw_code! {
309    /// A mandatory quest whose `depends_on` edge or stage-5 `quest-complete`
310    /// trigger names an optional quest (spec-0051 §8.2).
311    pub const MANDATORY_ON_OPTIONAL: DwCode = DwCode::new("DW0867", ExitTier::Build);
312}
313
314crate::dw_code! {
315    /// A mandatory objective gated on a flag only an optional quest produces
316    /// (spec-0051 §8.3) — the mainline key behind participation.
317    ///
318    /// The participation-minimal replay (`DW0204`) is the compensating stronger
319    /// check behind it; this one refuses at the edge so the message can name
320    /// the strand.
321    pub const MAINLINE_KEY_OPTIONAL: DwCode = DwCode::new("DW0868", ExitTier::Build);
322}
323
324pub(crate) fn plan_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
325    let plan = &c.quest_plan.content;
326    let planned_ids: BTreeSet<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
327
328    // The partition (spec-0051). `spine()` is the ONE authority on which quests
329    // the finale cannot fire without; `mandatory` is the author's claim about
330    // the same set, and `optional()` is the ONE authority on the other half.
331    let optional: BTreeSet<&str> = plan.optional();
332
333    // Dependency edges (only to existing quests; dangling handled elsewhere).
334    let edges: BTreeMap<&str, Vec<&str>> = plan
335        .quests
336        .iter()
337        .map(|q| {
338            let deps = q
339                .depends_on
340                .iter()
341                .map(|x| x.as_str())
342                .filter(|x| planned_ids.contains(x))
343                .collect();
344            (q.id.as_str(), deps)
345        })
346        .collect();
347    let nodes: Vec<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
348
349    if graph_has_cycle(&nodes, &edges) {
350        d.push(Diagnostic::error(
351            PLAN_CYCLE,
352            "quest-plan",
353            "/content/quests",
354            "stage-4 quest `depends_on` graph contains a cycle — the plan must be a DAG; remove a \
355             `depends_on` edge so the quests form an acyclic order",
356        ));
357        return; // reachability is meaningless with a cycle
358    }
359
360    // Finale must be declared.
361    if !planned_ids.contains(plan.finale.as_str()) {
362        d.push(Diagnostic::error(
363            FINALE_UNKNOWN,
364            "quest-plan",
365            "/content/finale",
366            format!(
367                "stage-4 `finale` `{}` is not a declared quest — set `finale` to the id of an \
368                 existing planned quest (the one that ends the delve)",
369                plan.finale
370            ),
371        ));
372        return;
373    }
374
375    // Finale convergence: every quest must be a transitive dependency of the
376    // finale (the plan converges on the finale). See README (spec ambiguity).
377    //
378    // The spine is asked of [`QuestPlanContent::spine`], which is the ONE
379    // authority on it — the same function the layout binding and the
380    // critical-path spine obligation read. This check used to derive the closure
381    // itself, over `edges` (deps pruned to declared quests) rather than over the
382    // raw `depends_on`; both derivations were correct and neither was named, so
383    // nothing would have caught them drifting apart. The two sets differ only by
384    // ids the plan does not declare, which is `DW0112`'s finding and not this
385    // one's, and which cannot move this verdict because the membership below is
386    // only ever asked about a DECLARED quest.
387    let reach = plan.spine();
388    for (i, q) in plan.quests.iter().enumerate() {
389        // Below the fence `optional` is empty, so this is every quest and the
390        // message is the one it has always been. At and above it, the rule is
391        // the MANDATORY half of spec-0051 §2.4's mismatch pair: a quest that
392        // claims to be on the critical path and is not reachable from the
393        // finale is still the wiring mistake it always was — it does not
394        // silently become optional content. The other half (an optional quest
395        // the closure does reach) is `DW0866`, because those are opposite
396        // errors and a shared message could prescribe neither.
397        if optional.contains(q.id.as_str()) {
398            continue;
399        }
400        if !reach.contains(q.id.as_str()) {
401            d.push(Diagnostic::error(
402                PLAN_NOT_CONVERGENT,
403                "quest-plan",
404                format!("/content/quests/{i}"),
405                format!(
406                    "quest `{}` is not a (transitive) dependency of finale `{}`, so the plan does \
407                     not converge on the finale — add a `depends_on` chain so `{}` eventually \
408                     depends on `{}` (or drop `{}` if it is not part of this delve)",
409                    q.id, plan.finale, plan.finale, q.id, q.id
410                ),
411            ));
412        }
413    }
414
415    partition(c, &optional, &reach, d);
416}
417
418/// The partition refusals of spec-0051 §8.1–2: the two ways a declared-optional
419/// quest can be a lie about the completion proof.
420///
421/// Both are edge-shaped and both are refused where the author can see the edge.
422/// They are separate codes because they prescribe opposite repairs — one says
423/// *this is not really optional*, the other says *this dependency is not really
424/// mandatory* — and a campaign can trip either without the other.
425///
426/// Inert on a campaign that declares no optional quest: `optional` is empty, so
427/// every loop below ranges over nothing.
428fn partition(
429    c: &Campaign,
430    optional: &BTreeSet<&str>,
431    spine: &BTreeSet<&str>,
432    d: &mut Vec<Diagnostic>,
433) {
434    if optional.is_empty() {
435        return;
436    }
437    let plan = &c.quest_plan.content;
438
439    // §8.1 — the finale leans on it. An optional quest the finale cannot fire
440    // without is not optional; the declaration would be a lie the proof then
441    // rests on. Covers the finale itself: `spine()` contains it, so a finale
442    // declared `mandatory: false` lands here rather than needing its own rule.
443    for (i, q) in plan.quests.iter().enumerate() {
444        if !optional.contains(q.id.as_str()) || !spine.contains(q.id.as_str()) {
445            continue;
446        }
447        let how = if q.id.as_str() == plan.finale.as_str() {
448            "it IS the finale".to_string()
449        } else {
450            format!("finale `{}` transitively depends on it", plan.finale)
451        };
452        d.push(Diagnostic::error(
453            OPTIONAL_ON_SPINE,
454            "quest-plan",
455            format!("/content/quests/{i}/mandatory"),
456            format!(
457                "quest `{}` declares `mandatory: false`, but {} — so the delve cannot be \
458                 completed without it and calling it optional would be a claim the \
459                 completability proof then rests on. Set `mandatory: true`, or cut the \
460                 `depends_on` chain that puts it in the finale's closure. Do not leave it for \
461                 the proof to sort out: the skip world is exactly the world in which this \
462                 quest is never played, and the finale never fires there",
463                q.id, how
464            ),
465        ));
466    }
467
468    // §8.2 — the mainline hangs off it. Refused at the EDGE, naming the edge,
469    // for both edge kinds a quest has: the stage-4 `depends_on` graph and the
470    // stage-5 `quest-complete` trigger. One rule ("a mandatory quest may not
471    // wait on elective content"), so one code; the message names which edge.
472    for (i, q) in plan.quests.iter().enumerate() {
473        if optional.contains(q.id.as_str()) {
474            continue; // optional-on-optional and optional-on-mandatory are legal (§4)
475        }
476        for (j, dep) in q.depends_on.iter().enumerate() {
477            if !optional.contains(dep.as_str()) {
478                continue;
479            }
480            d.push(Diagnostic::error(
481                MANDATORY_ON_OPTIONAL,
482                "quest-plan",
483                format!("/content/quests/{i}/depends_on/{j}"),
484                format!(
485                    "mandatory quest `{}` declares `depends_on` `{}`, which is optional — a \
486                     quest on the critical path cannot wait on content the party may never \
487                     play, so this edge makes the mainline unreachable in the skip world. \
488                     Either mark `{}` mandatory, or drop the edge and attach `{}` to the \
489                     spine some other way",
490                    q.id, dep, dep, dep
491                ),
492            ));
493        }
494    }
495
496    // The same rule over the stage-5 activation edge. `depends_on` orders the
497    // plan; the trigger is what actually arms the quest at runtime, and nothing
498    // ties the two together (a `quest-complete` trigger is resolved against the
499    // stage-5 quest set, never against stage 4). So a campaign can spell this
500    // edge with the trigger alone, and the `depends_on` loop above would not
501    // see it.
502    let declared: BTreeSet<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
503    for (i, q) in c.quests.content.quests.iter().enumerate() {
504        if optional.contains(q.id.as_str()) || !declared.contains(q.id.as_str()) {
505            continue;
506        }
507        let crate::Trigger::QuestComplete { quest } = &q.trigger else {
508            continue;
509        };
510        if !optional.contains(quest.as_str()) {
511            continue;
512        }
513        d.push(Diagnostic::error(
514            MANDATORY_ON_OPTIONAL,
515            "quests",
516            format!("/content/quests/{i}/trigger/quest"),
517            format!(
518                "mandatory quest `{}` is triggered by the completion of `{}`, which is \
519                 optional — the party may never complete `{}`, so `{}` would never activate \
520                 and the mainline would stop there. Trigger `{}` from a mandatory quest, or \
521                 mark `{}` mandatory",
522                q.id, quest, quest, q.id, q.id, quest
523            ),
524        ));
525    }
526
527    mainline_key(c, optional, d);
528}
529
530/// spec-0051 §8.3 — **a mainline key behind participation**: a mandatory
531/// objective gated on a flag every producer of which is rooted in an optional
532/// quest.
533///
534/// Refused **at the edge**, naming the objective, the flag and the optional-only
535/// producers, because that is where an author can act. The
536/// participation-minimal replay (`DW0204`) remains the compensating stronger
537/// check behind it, exactly as it already backstops the negative-gate fixpoint:
538/// the replay credits only the exported path's own producers, so this shape
539/// fails there too. What the edge buys is a message that names the strand
540/// instead of a walk that stops.
541///
542/// **The producer partition is conservative in the safe direction.** A flag is
543/// optional-only when EVERY root that sets it is an optional quest's bundle;
544/// a single producer anywhere else — a mandatory quest, an environment trigger,
545/// a trap disarm, a dialogue option, `on_death` — takes the flag out of the set.
546/// Dialogue is counted as non-optional deliberately: whether an option is
547/// reachable only inside an optional quest's scene is a cast-ladder question
548/// this rule cannot answer, and answering it wrongly here would refuse a
549/// correct campaign. `DW0204` can answer it, and does.
550///
551/// **Not yet covered, and named rather than implied**: the `requires_state` and
552/// `dropped_by` chains of §8.3. Both are real shapes — an item that drops only
553/// from a wave an optional quest spawns is the example the spec gives — and
554/// both are still caught by `DW0204`, one step later and with a worse message.
555fn mainline_key(c: &Campaign, optional: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) {
556    // flag -> the optional quests that set it, while nothing else does.
557    let mut only_optional: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
558    let mut disqualified: BTreeSet<&str> = BTreeSet::new();
559
560    crate::for_each_campaign_effect(c, &mut |_path, site, eff| {
561        let Verb::SetFlag { flag, .. } = &eff.verb else {
562            return;
563        };
564        let flag = flag.as_str();
565        let owner = match site {
566            crate::EffectSite::Objective { quest, .. }
567            | crate::EffectSite::QuestComplete { quest } => quest.as_str(),
568            // Every other root is ambient or dialogue-hosted: not a quest, so
569            // not "optional participation" in this rule's sense.
570            _ => {
571                disqualified.insert(flag);
572                return;
573            }
574        };
575        match optional.get(owner) {
576            Some(q) => only_optional.entry(flag).or_default().insert(*q),
577            None => disqualified.insert(flag),
578        };
579    });
580    for t in &c.dialogue.content.dialogues {
581        for n in &t.nodes {
582            for o in &n.options {
583                for e in &o.effects {
584                    if let crate::DialogueEffect::SetFlag { flag } = e {
585                        disqualified.insert(flag.as_str());
586                    }
587                }
588            }
589        }
590    }
591    for trap in &c.quests.content.traps {
592        if let Some(dis) = &trap.disarm {
593            disqualified.insert(dis.sets_flag.as_str());
594        }
595    }
596
597    // A mandatory quest's objective gated on such a flag.
598    for (i, q) in c.quests.content.quests.iter().enumerate() {
599        if optional.contains(q.id.as_str()) {
600            continue;
601        }
602        for (j, o) in q.objectives.iter().enumerate() {
603            for (m, f) in o.requires_flags().iter().enumerate() {
604                let flag = f.as_str();
605                if disqualified.contains(flag) {
606                    continue;
607                }
608                let Some(producers) = only_optional.get(flag) else {
609                    continue; // never produced at all: `DW0172`'s finding, not this one's
610                };
611                let names = producers.iter().copied().collect::<Vec<_>>().join("`, `");
612                d.push(Diagnostic::error(
613                    MAINLINE_KEY_OPTIONAL,
614                    "quests",
615                    format!("/content/quests/{i}/objectives/{j}/requires_flags/{m}"),
616                    format!(
617                        "objective `{}` of mandatory quest `{}` requires flag `{}`, and the \
618                         only effect that ever sets `{}` is rooted in optional quest(s) \
619                         `{}` — so a party that plays only the mainline can never open \
620                         this beat, and the delve is not completable with zero optional \
621                         participation. Move the `set-flag` onto a mandatory quest, mark \
622                         the producing quest mandatory, or drop the gate",
623                        o.id(),
624                        q.id,
625                        flag,
626                        flag,
627                        names
628                    ),
629                ));
630            }
631        }
632    }
633}
634
635/// Structural validation of the stage-4 `branch_points` declaration (spec-0025).
636///
637/// Everything here reuses the DSL's existing structural codes on purpose — a
638/// branch point is an ordinary declaration with ordinary ids, so a malformed id
639/// is `DW0110`, a repeated one `DW0111`, and a reference to something that does
640/// not exist `DW0112`. The `DW048x` block is reserved for what is genuinely new:
641/// proofs *about* branches.
642pub(crate) fn branch_point_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
643    let quests: BTreeSet<&str> = c
644        .quest_plan
645        .content
646        .quests
647        .iter()
648        .map(|q| q.id.as_str())
649        .collect();
650    let endings: BTreeSet<String> = declared_endings(c);
651    let flags: BTreeSet<String> = produced_flags(c);
652    let mut seen_points: BTreeSet<&str> = BTreeSet::new();
653    let mut seen_branches: BTreeSet<&str> = BTreeSet::new();
654
655    for (i, bp) in c.quest_plan.content.branch_points.iter().enumerate() {
656        let base = format!("/content/branch_points/{i}");
657        if !bp.id.is_valid_syntax() {
658            d.push(Diagnostic::error(
659                codes::ID_SYNTAX,
660                "quest-plan",
661                format!("{base}/id"),
662                format!(
663                    "`{}` is not a valid branch-point id — use `branch-point/<kebab-case>`",
664                    bp.id.as_str()
665                ),
666            ));
667        } else if !seen_points.insert(bp.id.as_str()) {
668            d.push(Diagnostic::error(
669                codes::ID_DUPLICATE,
670                "quest-plan",
671                format!("{base}/id"),
672                format!("duplicate branch-point id `{}`", bp.id.as_str()),
673            ));
674        }
675        if !quests.contains(bp.opens_at.as_str()) {
676            d.push(Diagnostic::error(
677                codes::DANGLING_REF,
678                "quest-plan",
679                format!("{base}/opens_at"),
680                format!(
681                    "branch point `{}` opens at `{}`, which is not a planned quest — name the \
682                     quest at which the story actually forks",
683                    bp.id.as_str(),
684                    bp.opens_at.as_str()
685                ),
686            ));
687        }
688        for (j, f) in bp.forks_on.iter().enumerate() {
689            if !flags.contains(f.as_str()) {
690                d.push(Diagnostic::error(
691                    codes::FLAG_UNKNOWN,
692                    "quest-plan",
693                    format!("{base}/forks_on/{j}"),
694                    format!(
695                        "branch point `{}` forks on `{}`, which no `set-flag` effect produces — a \
696                         fork nothing can set is not a fork",
697                        bp.id.as_str(),
698                        f.as_str()
699                    ),
700                ));
701            }
702        }
703        let fork_set: BTreeSet<&str> = bp.forks_on.iter().map(|f| f.as_str()).collect();
704        for (j, b) in bp.branches.iter().enumerate() {
705            let bpath = format!("{base}/branches/{j}");
706            if !b.id.is_valid_syntax() {
707                d.push(Diagnostic::error(
708                    codes::ID_SYNTAX,
709                    "quest-plan",
710                    format!("{bpath}/id"),
711                    format!(
712                        "`{}` is not a valid branch id — use `branch/<kebab-case>`",
713                        b.id.as_str()
714                    ),
715                ));
716            } else if !seen_branches.insert(b.id.as_str()) {
717                d.push(Diagnostic::error(
718                    codes::ID_DUPLICATE,
719                    "quest-plan",
720                    format!("{bpath}/id"),
721                    format!(
722                        "duplicate branch id `{}` — branch ids are campaign-wide unique because \
723                         each one names an emitted `validation/branch-chronicle-<id>.md`",
724                        b.id.as_str()
725                    ),
726                ));
727            }
728            for (k, f) in b.flags.iter().enumerate() {
729                if !fork_set.contains(f.as_str()) {
730                    d.push(Diagnostic::error(
731                        codes::DANGLING_REF,
732                        "quest-plan",
733                        format!("{bpath}/flags/{k}"),
734                        format!(
735                            "branch `{}` holds `{}`, which its branch point does not list in \
736                             `forks_on` — a branch may only pin flags its own fork owns",
737                            b.id.as_str(),
738                            f.as_str()
739                        ),
740                    ));
741                }
742            }
743            match (b.converges_at(), b.ending()) {
744                (Some(q), _) => {
745                    if !quests.contains(q.as_str()) {
746                        d.push(Diagnostic::error(
747                            codes::DANGLING_REF,
748                            "quest-plan",
749                            format!("{bpath}/leads_to"),
750                            format!(
751                                "branch `{}` converges at `{}`, which is not a planned quest",
752                                b.id.as_str(),
753                                q.as_str()
754                            ),
755                        ));
756                    }
757                }
758                (None, Some(e)) => {
759                    if !endings.contains(e.as_str()) {
760                        d.push(Diagnostic::error(
761                            codes::DANGLING_REF,
762                            "quest-plan",
763                            format!("{bpath}/leads_to"),
764                            format!(
765                                "branch `{}` runs to `{}`, which no `campaign-complete` effect \
766                                 declares — name the ending on the `campaign-complete` that ends \
767                                 this branch",
768                                b.id.as_str(),
769                                e.as_str()
770                            ),
771                        ));
772                    }
773                }
774                (None, None) => d.push(Diagnostic::error(
775                    codes::ID_SYNTAX,
776                    "quest-plan",
777                    format!("{bpath}/leads_to"),
778                    format!(
779                        "`{}` is neither a `quest/<kebab>` (the branches converge there) nor an \
780                         `ending/<kebab>` (this branch runs to it) — the prefix is what says which \
781                         one a branch leads to",
782                        b.leads_to
783                    ),
784                )),
785            }
786        }
787    }
788}
789
790/// Dangling-subject check for every `happening` (spec-0025). A subject naming an
791/// `npc/`, `actor/`, `wave/` or `anchor/` id must resolve; an `item/<kebab>`
792/// label is a free namespace for a story token the campaign tracks by hand, and
793/// anything else is a malformed id.
794pub(crate) fn happening_subject_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
795    let npcs: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
796    let actors: BTreeSet<&str> = c
797        .quests
798        .content
799        .actors
800        .iter()
801        .map(|a| a.id.as_str())
802        .collect();
803    let waves: BTreeSet<&str> = c
804        .quests
805        .content
806        .waves
807        .iter()
808        .map(|w| w.id.as_str())
809        .collect();
810    let check = |subject: &str, stage: &str, path: String, d: &mut Vec<Diagnostic>| {
811        let known = match subject.split_once('/') {
812            Some(("npc", _)) => npcs.contains(subject),
813            Some(("actor", _)) => actors.contains(subject),
814            Some(("wave", _)) => waves.contains(subject),
815            // Anchors resolve against prefab metadata far downstream (pool areas
816            // are drawn at build time), so the DSL only polices the namespace.
817            Some(("anchor", _)) | Some(("item", _)) => true,
818            _ => false,
819        };
820        if !known {
821            d.push(Diagnostic::error(
822                codes::DANGLING_REF,
823                stage,
824                path,
825                format!(
826                    "`happening.subject` names `{subject}`, which is not a declared `npc/`, \
827                     `actor/` or `wave/` id (`anchor/` and `item/` labels are also accepted). A \
828                     subject the compiler cannot resolve cannot be reasoned about, so the \
829                     contradiction proof would silently skip this beat"
830                ),
831            ));
832        }
833    };
834    for (i, q) in c.quests.content.quests.iter().enumerate() {
835        if let Some(h) = &q.happening
836            && let Some(s) = &h.subject
837        {
838            check(
839                s,
840                "quests",
841                format!("/content/quests/{i}/happening/subject"),
842                d,
843            );
844        }
845        for (j, o) in q.objectives.iter().enumerate() {
846            if let Some(h) = o.happening()
847                && let Some(s) = &h.subject
848            {
849                check(
850                    s,
851                    "quests",
852                    format!("/content/quests/{i}/objectives/{j}/happening/subject"),
853                    d,
854                );
855            }
856        }
857    }
858    let mut effect_subjects: Vec<(String, String)> = Vec::new();
859    crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
860        // The one derivation (spec-0071 §3), read here exactly as the chronicle
861        // reads it. Only a **stated** subject is policed: a derived one is the
862        // effect's own `anchor`/`npc`/`actor`/`wave` reference, already refused
863        // by kind where it is written, and a second report would point the
864        // author at a `happening/subject` the document does not have.
865        if let Some(s) = eff.happening_subject().filter(|s| !s.derived) {
866            effect_subjects.push((format!("{path}/happening/subject"), s.id.to_string()));
867        }
868    });
869    for (path, s) in effect_subjects {
870        check(&s, "quests", path, d);
871    }
872    for (i, t) in c.dialogue.content.dialogues.iter().enumerate() {
873        for (j, n) in t.nodes.iter().enumerate() {
874            for (k, o) in n.options.iter().enumerate() {
875                if let Some(h) = &o.happening
876                    && let Some(s) = &h.subject
877                {
878                    check(
879                        s,
880                        "dialogue",
881                        format!("/content/dialogues/{i}/nodes/{j}/options/{k}/happening/subject"),
882                        d,
883                    );
884                }
885            }
886        }
887    }
888}
889
890// ---------------------------------------------------------------------------
891// Validation
892// ---------------------------------------------------------------------------
893
894/// Every ending id some `campaign-complete` declares. There is no separate
895/// declaration list — the same rule flags follow.
896pub fn declared_endings(c: &Campaign) -> BTreeSet<String> {
897    let mut out = BTreeSet::new();
898    crate::for_each_campaign_effect(c, &mut |_p, _site, eff| {
899        if let Verb::CampaignComplete {
900            ending: Some(e), ..
901        } = &eff.verb
902        {
903            out.insert(e.as_str().to_string());
904        }
905    });
906    out
907}
908
909/// `DW0110` over the planned quests' ids.
910pub(crate) fn plan_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
911    for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
912        crate::ids::id_syntax!(d, q.id, "quest-plan", format!("/content/quests/{i}/id"));
913    }
914}
915
916/// `DW0111` over the planned quests' ids.
917pub(crate) fn plan_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
918    crate::ids::dup_check(
919        c.quest_plan
920            .content
921            .quests
922            .iter()
923            .enumerate()
924            .map(|(i, q)| (q.id.as_str(), format!("/content/quests/{i}/id"))),
925        "quest-plan",
926        "quest",
927        d,
928    );
929}
930
931/// `DW0112` over what a planned quest names: its area, its NPCs, and the
932/// quests it `depends_on`.
933pub(crate) fn plan_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
934    use crate::ids::dangling;
935    let area_ids = crate::world::declared_area_ids(c);
936    let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
937    let planned_ids: BTreeSet<&str> = c
938        .quest_plan
939        .content
940        .quests
941        .iter()
942        .map(|q| q.id.as_str())
943        .collect();
944    for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
945        dangling(
946            d,
947            area_ids.contains(q.area.as_str()),
948            "quest-plan",
949            format!("/content/quests/{i}/area"),
950            format!(
951                "quest references unknown area `{}` — {}",
952                q.area,
953                crate::placement::Placement::of(c).area_remedy(),
954            ),
955        );
956        for (k, npc) in q.npcs.iter().enumerate() {
957            dangling(
958                d,
959                npc_ids.contains(npc.as_str()),
960                "quest-plan",
961                format!("/content/quests/{i}/npcs/{k}"),
962                format!(
963                    "quest references unknown npc `{npc}` — declare it in stage 2 or correct the \
964                     reference"
965                ),
966            );
967        }
968        for (k, dep) in q.depends_on.iter().enumerate() {
969            dangling(
970                d,
971                planned_ids.contains(dep.as_str()),
972                "quest-plan",
973                format!("/content/quests/{i}/depends_on/{k}"),
974                format!(
975                    "quest depends on unknown quest `{dep}` — declare it in the stage-4 quest \
976                     plan or correct the `depends_on` entry"
977                ),
978            );
979        }
980    }
981}
982
983#[cfg(test)]
984mod spine_tests {
985    use super::QuestPlanContent;
986
987    /// Build a plan from `(id, deps)` pairs plus a finale. JSON rather than a
988    /// struct literal on purpose: a field added to `PlannedQuest` later must not
989    /// red these tests for a reason that has nothing to do with the spine.
990    fn plan(finale: &str, quests: &[(&str, &[&str])]) -> QuestPlanContent {
991        let quests: Vec<serde_json::Value> = quests
992            .iter()
993            .map(|(id, deps)| {
994                serde_json::json!({
995                    "id": id,
996                    "goal": "g",
997                    "area": "area/keep",
998                    "npcs": [],
999                    "depends_on": deps,
1000                    "mandatory": true,
1001                    "act": 1,
1002                })
1003            })
1004            .collect();
1005        serde_json::from_value(serde_json::json!({
1006            "finale": finale,
1007            "quests": quests,
1008        }))
1009        .expect("plan fixture parses")
1010    }
1011
1012    fn sorted(p: &QuestPlanContent) -> Vec<String> {
1013        p.spine().into_iter().map(str::to_owned).collect()
1014    }
1015
1016    #[test]
1017    fn a_chain_is_wholly_spine() {
1018        let p = plan(
1019            "quest/c",
1020            &[
1021                ("quest/a", &[]),
1022                ("quest/b", &["quest/a"]),
1023                ("quest/c", &["quest/b"]),
1024            ],
1025        );
1026        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c"]);
1027    }
1028
1029    #[test]
1030    fn a_quest_the_finale_does_not_depend_on_is_off_the_spine() {
1031        // Exactly the `DW0132` shape: the plan does not converge, and the spine
1032        // is the half that does. The authority answers, it does not refuse — the
1033        // refusal is `validate`'s, built on this answer.
1034        let p = plan("quest/end", &[("quest/end", &[]), ("quest/side-trip", &[])]);
1035        assert_eq!(sorted(&p), ["quest/end"]);
1036    }
1037
1038    #[test]
1039    fn a_diamond_counts_the_join_once() {
1040        let p = plan(
1041            "quest/d",
1042            &[
1043                ("quest/a", &[]),
1044                ("quest/b", &["quest/a"]),
1045                ("quest/c", &["quest/a"]),
1046                ("quest/d", &["quest/b", "quest/c"]),
1047            ],
1048        );
1049        assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c", "quest/d"]);
1050    }
1051
1052    #[test]
1053    fn a_cycle_terminates_and_yields_a_set() {
1054        // `DW0130` refuses this plan, but the authority is asked before that
1055        // verdict is known (the layout binding prints on an erroring campaign),
1056        // so it must terminate rather than hang.
1057        let p = plan(
1058            "quest/b",
1059            &[("quest/a", &["quest/b"]), ("quest/b", &["quest/a"])],
1060        );
1061        assert_eq!(sorted(&p), ["quest/a", "quest/b"]);
1062    }
1063
1064    #[test]
1065    fn a_dangling_dependency_is_reported_and_expands_no_further() {
1066        // The deliberate difference between the two derivations this function
1067        // replaced. `validate` pruned undeclared ids before walking; the
1068        // authority does not, because pruning them would make the set disagree
1069        // with the document, and naming an id the plan does not declare is
1070        // `DW0112`'s finding rather than the spine's.
1071        let p = plan("quest/end", &[("quest/end", &["quest/ghost"])]);
1072        assert_eq!(sorted(&p), ["quest/end", "quest/ghost"]);
1073    }
1074
1075    #[test]
1076    fn an_undeclared_finale_is_the_whole_spine() {
1077        // `DW0131`'s shape. The answer is honest about the document: nothing the
1078        // plan declares is on the spine of a finale it never declared.
1079        let p = plan(
1080            "quest/ghost",
1081            &[("quest/a", &[]), ("quest/b", &["quest/a"])],
1082        );
1083        assert_eq!(sorted(&p), ["quest/ghost"]);
1084    }
1085}