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}