Skip to main content

delvewright_dsl/
dialogue.rs

1//! Stage 6 — dialogue: each NPC's branching conversation, its options and effects.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    AnchorId, DialogueId, FlagId, Happening, NpcId, ObjectiveId, QuestEffect, StateCompare,
10    WorldTime, WorldWeather,
11};
12
13#[cfg(doc)]
14use crate::Verb;
15
16/// Stage 6 payload: one dialogue tree per stage-2 NPC (spec-0001 v0.2).
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
18#[serde(deny_unknown_fields)]
19pub struct DialogueContent {
20    /// One tree per NPC (1:1 with stage 2, both directions).
21    pub dialogues: Vec<NpcDialogue>,
22}
23
24impl DialogueContent {
25    /// The dialogue tree for an NPC id, if present.
26    pub fn tree_for(&self, npc: &str) -> Option<&NpcDialogue> {
27        self.dialogues.iter().find(|t| t.npc.as_str() == npc)
28    }
29}
30
31/// One NPC's dialogue tree: a root node plus a set of nodes.
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
33#[serde(deny_unknown_fields)]
34pub struct NpcDialogue {
35    /// The NPC this tree belongs to (stage-2 ref).
36    pub npc: NpcId,
37    /// The entry node id; every node must be reachable from it.
38    pub root: DialogueId,
39    /// The dialogue nodes.
40    pub nodes: Vec<DialogueNode>,
41}
42
43impl NpcDialogue {
44    /// The ids of the nodes reachable from `roots` by following option `next`
45    /// edges, ignoring every option gate.
46    ///
47    /// **The one authority for "what can this tree show, entered here".** A
48    /// dialogue tree has more than one entry point — the stage-6 `root`, and
49    /// every node a quest's `cast` ledger names as a scene — so "reachable" is
50    /// always relative to a root SET, and each consumer supplies the set its own
51    /// question is about. `DW0120`/`DW0123` ask about every entry point at once;
52    /// the cast ledger's `DW0858` asks about the scenes live during one
53    /// objective. Gates are ignored on purpose: an option's flag gate is
54    /// `DW0191`'s question, not this one's.
55    ///
56    /// Root ids that name no node of this tree contribute nothing (a dangling
57    /// scene root is `DW0464`, and a dangling stage-6 `root` is `DW0121`).
58    pub fn reachable_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
59        let by_id: BTreeMap<&'a str, &'a DialogueNode> =
60            self.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
61        let mut seen: BTreeSet<&'a str> = BTreeSet::new();
62        let mut stack: Vec<&'a str> = roots
63            .iter()
64            .filter_map(|r| by_id.get_key_value(*r).map(|(k, _)| *k))
65            .collect();
66        while let Some(cur) = stack.pop() {
67            if !seen.insert(cur) {
68                continue;
69            }
70            let Some(node) = by_id.get(cur) else { continue };
71            for opt in &node.options {
72                if let Some(next) = &opt.next
73                    && let Some((k, _)) = by_id.get_key_value(next.as_str())
74                {
75                    stack.push(k);
76                }
77            }
78        }
79        seen
80    }
81
82    /// The objective ids some option reachable from `roots` completes — what a
83    /// player entering this tree at those scenes can actually finish.
84    pub fn completes_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
85        let seen = self.reachable_from(roots);
86        let mut out: BTreeSet<&'a str> = BTreeSet::new();
87        for node in &self.nodes {
88            if !seen.contains(node.id.as_str()) {
89                continue;
90            }
91            for opt in &node.options {
92                for eff in &opt.effects {
93                    if let DialogueEffect::CompleteObjective { objective } = eff {
94                        out.insert(objective.as_str());
95                    }
96                }
97            }
98        }
99        out
100    }
101}
102
103/// One dialogue node: text plus branching options.
104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
105#[serde(deny_unknown_fields)]
106pub struct DialogueNode {
107    /// Node id (unique within this NPC's dialogue).
108    pub id: DialogueId,
109    /// The line the NPC speaks.
110    pub text: String,
111    /// Branching options; empty closes the dialog.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub options: Vec<DialogueOption>,
114}
115
116/// One selectable dialogue option.
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
118#[serde(deny_unknown_fields)]
119pub struct DialogueOption {
120    /// Button label.
121    pub label: String,
122    /// The full line this button is the caption of (DSL v0.8). Vanilla's dialog button codec
123    /// (`CommonButtonData`) carries an optional `tooltip` component beside
124    /// `label`, and the client hangs it on the button as a real hover tooltip —
125    /// so a caption on the button and the sentence the character actually says
126    /// can both exist. **Not** subject to `DW0331`: a tooltip is not drawn on the
127    /// 150 px button, it is wrapped at 170 px into its own hover box, so it never
128    /// scrolls. Player-visible, so it translates like the label
129    /// (`dlg.<npc>.<node>.opt.<i>.tooltip`).
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub tooltip: Option<String>,
132    /// Next node; omitted closes the dialog.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub next: Option<DialogueId>,
135    /// Flags that must be set for this option to be shown (DSL v0.4). Mirrors an
136    /// objective's `requires_flags`: an ungated option (empty) is unchanged; a
137    /// gated option is hidden until every referenced flag has been set by a
138    /// `set-flag` effect (quest or dialogue). Validation guarantees a flag-gated
139    /// option cannot make a critical-path node unreachable (`DW0191`).
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub requires_flags: Vec<FlagId>,
142    /// Negative flag gate (DSL v0.6): the option is
143    /// **hidden** (and its `/trigger` handler inert) while ANY listed flag is set
144    /// for the player — the dual of `requires_flags`. A `forbids_flags`-gated
145    /// option counts as *gated* for the `DW0191` deadlock guard: it can be
146    /// suppressed at any point, so it cannot be the only completing path.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub forbids_flags: Vec<FlagId>,
149    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
150    /// hold for this gate to be open. The third field of the one gate, carried by
151    /// every gate consumer — never by the verb that first wanted it. Default
152    /// empty, so a pre-0.10 campaign is byte-identical.
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub requires_state: Vec<StateCompare>,
155    /// Effects fired when this option is chosen.
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub effects: Vec<DialogueEffect>,
158    /// What choosing this option does to the story (spec-0025). Required for a
159    /// **story-weight** beat
160    /// — an option carrying a `set-flag` effect, which is how a player's choice
161    /// forks the world (`DW0481`). An option that only walks the tree or
162    /// completes an objective needs none: the objective already declares one.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub happening: Option<Happening>,
165}
166
167/// Effect fired by a dialogue option. `complete-objective` (v0.2) and, from DSL
168/// v0.4, `set-flag` (mirrors the quest effect — sets a campaign flag from a
169/// dialogue choice, enabling flag-gated options/objectives/triggers).
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
171#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
172pub enum DialogueEffect {
173    /// Marks a stage-5 objective complete.
174    CompleteObjective {
175        /// The objective to complete (resolved at the stage-5 boundary).
176        objective: ObjectiveId,
177    },
178    /// Sets a campaign flag (DSL v0.4), mirroring [`Verb::SetFlag`].
179    SetFlag {
180        /// The flag to set.
181        flag: FlagId,
182    },
183    /// Cuts the world time (DSL v0.5), mirroring [`Verb::SetTime`].
184    SetTime {
185        /// The time state to cut to.
186        time: WorldTime,
187    },
188    /// Cuts the weather (DSL v0.5), mirroring [`Verb::SetWeather`].
189    SetWeather {
190        /// The weather state to cut to.
191        weather: WorldWeather,
192    },
193    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012), mirroring
194    /// [`Verb::SetCheckpoint`] — usable from a dialogue outcome.
195    SetCheckpoint {
196        /// The prefab checkpoint anchor the party respawns at.
197        anchor: AnchorId,
198        /// Per-player effects re-run on respawn while this checkpoint is active
199        /// (scene reset). Empty = no hook.
200        #[serde(default, skip_serializing_if = "Vec::is_empty")]
201        on_respawn: Vec<QuestEffect>,
202    },
203    /// Summons a `deferred` stage-2 NPC (DSL v0.6), mirroring
204    /// [`Verb::SpawnNpc`] — a character who walks in mid-conversation.
205    SpawnNpc {
206        /// The NPC (stage-2 ref) to summon.
207        npc: NpcId,
208    },
209}
210
211impl DialogueEffect {
212    /// The v0.5 effect name if this dialogue effect is one introduced in DSL v0.5
213    /// (`set-time`/`set-weather`, spec-0010).
214    pub fn v05_effect(&self) -> Option<&'static str> {
215        match self {
216            DialogueEffect::SetTime { .. } => Some("set-time"),
217            DialogueEffect::SetWeather { .. } => Some("set-weather"),
218            _ => None,
219        }
220    }
221
222    /// The v0.6 effect name if this dialogue effect is one introduced in DSL v0.6
223    /// (`set-checkpoint`, spec-0012; `spawn-npc`).
224    pub fn v06_effect(&self) -> Option<&'static str> {
225        match self {
226            DialogueEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
227            DialogueEffect::SpawnNpc { .. } => Some("spawn-npc"),
228            _ => None,
229        }
230    }
231
232    /// The NPC id if this is a v0.6 `spawn-npc` dialogue effect.
233    pub fn spawn_npc(&self) -> Option<&NpcId> {
234        match self {
235            DialogueEffect::SpawnNpc { npc } => Some(npc),
236            _ => None,
237        }
238    }
239
240    /// **The body this dialogue effect puts into the world**, by id — the
241    /// dialogue half of [`QuestEffect::body_entry`].
242    ///
243    /// A body can enter the world from a conversation as well as from a quest
244    /// bundle, and a rule about what is standing where has to enumerate **every**
245    /// entry point or it is a gate with a door beside it. This enum carries no
246    /// exit at all: nothing a dialogue option does removes a body.
247    pub fn body_entry(&self) -> Option<&str> {
248        match self {
249            DialogueEffect::SpawnNpc { npc } => Some(npc.as_str()),
250            _ => None,
251        }
252    }
253
254    /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` dialogue effect.
255    pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
256        match self {
257            DialogueEffect::SetCheckpoint { anchor, on_respawn } => {
258                Some((anchor, on_respawn.as_slice()))
259            }
260            _ => None,
261        }
262    }
263
264    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect —
265    /// **effect root 5** ([`crate::effects`]). Named separately from
266    /// [`Self::set_checkpoint`] so the root walk and its mutable mirror name the
267    /// same accessor modulo mutability, which is what lets one macro body generate
268    /// both.
269    pub fn set_checkpoint_on_respawn(&self) -> Option<&[QuestEffect]> {
270        match self {
271            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_slice()),
272            _ => None,
273        }
274    }
275
276    /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect, exposed
277    /// mutably so the localization pass can rewrite the player-visible strings
278    /// nested inside it. Lockstep sibling of [`Self::set_checkpoint`] — the bundle
279    /// is a plain `Vec<QuestEffect>` that emission really lowers, so every scan
280    /// that reaches it read-only needs a way to reach it writable too.
281    pub fn set_checkpoint_on_respawn_mut(&mut self) -> Option<&mut [QuestEffect]> {
282        match self {
283            DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_mut_slice()),
284            _ => None,
285        }
286    }
287}
288
289impl DialogueEffect {
290    /// The `set-flag` flag id if this is a v0.4 `set-flag` dialogue effect.
291    pub fn set_flag(&self) -> Option<&FlagId> {
292        match self {
293            DialogueEffect::SetFlag { flag } => Some(flag),
294            _ => None,
295        }
296    }
297
298    /// The v0.4 effect name if this dialogue effect is one introduced in DSL v0.4
299    /// (`set-flag`).
300    pub fn v04_effect(&self) -> Option<&'static str> {
301        match self {
302            DialogueEffect::SetFlag { .. } => Some("set-flag"),
303            _ => None,
304        }
305    }
306
307    /// The target time if this is a v0.5 `set-time` dialogue effect.
308    pub fn set_time(&self) -> Option<WorldTime> {
309        match self {
310            DialogueEffect::SetTime { time } => Some(*time),
311            _ => None,
312        }
313    }
314
315    /// The target weather if this is a v0.5 `set-weather` dialogue effect.
316    pub fn set_weather(&self) -> Option<WorldWeather> {
317        match self {
318            DialogueEffect::SetWeather { weather } => Some(*weather),
319            _ => None,
320        }
321    }
322}
323
324// ---------------------------------------------------------------------------
325// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
326// ---------------------------------------------------------------------------
327
328use crate::Objective;
329use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
330use crate::envelope::Campaign;
331
332crate::dw_code! {
333    /// Stage-6 dialogue node unreachable from `root`.
334    pub const DIALOGUE_UNREACHABLE: DwCode = DwCode::new("DW0120", ExitTier::Build);
335}
336
337crate::dw_code! {
338    /// Stage-6 dialogue `root`/`next` references an unknown node.
339    pub const DIALOGUE_BAD_REF: DwCode = DwCode::new("DW0121", ExitTier::Build);
340}
341
342crate::dw_code! {
343    /// Stage-6 dialogue effect references an objective that is unknown, not a
344    /// `talk-to`, or a `talk-to` on a different NPC (foreign effect).
345    pub const DIALOGUE_BAD_OBJECTIVE: DwCode = DwCode::new("DW0122", ExitTier::Build);
346}
347
348crate::dw_code! {
349    /// A stage-5 `talk-to` objective has no reachable completing dialogue option
350    /// (the static half of the compiler's `DW0203` deadlock guarantee).
351    pub const DIALOGUE_UNCOVERED: DwCode = DwCode::new("DW0123", ExitTier::Build);
352}
353
354crate::dw_code! {
355    /// (v0.4) A `talk-to` objective has no **ungated** reachable completing
356    /// dialogue option — every completing option is `requires_flags`-gated, so
357    /// the objective can deadlock the moment it activates (spec-0008 §1). Keep at
358    /// least one ungated completing path.
359    pub const DIALOGUE_FLAG_DEADLOCK: DwCode = DwCode::new("DW0191", ExitTier::Build);
360}
361
362crate::dw_code! {
363    /// Stage-2 NPC has no stage-6 dialogue tree.
364    pub const NPC_WITHOUT_TREE: DwCode = DwCode::new("DW0152", ExitTier::Build);
365}
366
367crate::dw_code! {
368    /// Stage-6 dialogue tree references an NPC not declared in stage 2.
369    pub const TREE_WITHOUT_NPC: DwCode = DwCode::new("DW0153", ExitTier::Build);
370}
371
372pub(crate) fn dialogue_graph_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
373    use crate::{DialogueEffect, Objective};
374
375    // Stage-5 objective facts: which are `talk-to`, and (for those) their npc.
376    let mut all_objectives: BTreeSet<&str> = BTreeSet::new();
377    let mut talk_npc: BTreeMap<&str, &str> = BTreeMap::new();
378    for q in &c.quests.content.quests {
379        for o in &q.objectives {
380            all_objectives.insert(o.id().as_str());
381            if let Objective::TalkTo { id, npc, .. } = o {
382                talk_npc.insert(id.as_str(), npc.as_str());
383            }
384        }
385    }
386
387    // npc id -> objective ids completed by an option reachable from that tree's
388    // root (feeds the DW0123 coverage check).
389    let mut reachable_completes: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
390
391    for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
392        let node_ids: BTreeSet<&str> = tree.nodes.iter().map(|n| n.id.as_str()).collect();
393
394        // `next` / effect references.
395        for (j, node) in tree.nodes.iter().enumerate() {
396            for (k, opt) in node.options.iter().enumerate() {
397                if let Some(next) = &opt.next
398                    && !node_ids.contains(next.as_str())
399                {
400                    d.push(Diagnostic::error(
401                        DIALOGUE_BAD_REF,
402                        "dialogue",
403                        format!("/content/dialogues/{i}/nodes/{j}/options/{k}/next"),
404                        format!(
405                            "dialogue option `next` references unknown node `{next}` — add a node \
406                             with that id to this tree or correct the reference"
407                        ),
408                    ));
409                }
410                for (m, eff) in opt.effects.iter().enumerate() {
411                    let DialogueEffect::CompleteObjective { objective } = eff else {
412                        continue;
413                    };
414                    let oid = objective.as_str();
415                    let path = format!(
416                        "/content/dialogues/{i}/nodes/{j}/options/{k}/effects/{m}/objective"
417                    );
418                    let msg = if !all_objectives.contains(oid) {
419                        Some(format!(
420                            "dialogue `complete-objective` effect references unknown objective \
421                             `{objective}` — it must name a `talk-to` objective on this tree's \
422                             npc; declare it or correct the reference"
423                        ))
424                    } else if let Some(owner) = talk_npc.get(oid) {
425                        if *owner == tree.npc.as_str() {
426                            None
427                        } else {
428                            Some(format!(
429                                "dialogue effect completes `talk-to` objective `{objective}`, \
430                                 which belongs to npc `{owner}`, not this tree's npc `{}` — a tree \
431                                 may only complete its own npc's objectives; move the effect into \
432                                 `{owner}`'s tree",
433                                tree.npc
434                            ))
435                        }
436                    } else {
437                        Some(format!(
438                            "dialogue `complete-objective` effect targets objective `{objective}`, \
439                             which is not a `talk-to` objective — only `talk-to` objectives are \
440                             completed through dialogue; retarget it or change the objective's type"
441                        ))
442                    };
443                    if let Some(msg) = msg {
444                        d.push(Diagnostic::error(
445                            DIALOGUE_BAD_OBJECTIVE,
446                            "dialogue",
447                            path,
448                            msg,
449                        ));
450                    }
451                }
452            }
453        }
454
455        // Root existence.
456        if !node_ids.contains(tree.root.as_str()) {
457            d.push(Diagnostic::error(
458                DIALOGUE_BAD_REF,
459                "dialogue",
460                format!("/content/dialogues/{i}/root"),
461                format!(
462                    "dialogue tree `root` references unknown node `{}` — add a node with that id \
463                     or point `root` at an existing node",
464                    tree.root
465                ),
466            ));
467            continue; // reachability is undefined without a root
468        }
469
470        // Reachability from root.
471        //
472        // Entry points: the tree's own `root`, plus (DSL v0.7, spec-0020) every
473        // node some quest's `cast` ledger declares as this NPC's root. A ledger
474        // root IS an entry point — right-click opens it directly once that quest
475        // begins — so a node reached only that way is reachable, not orphaned.
476        // Without this, retiring a premise root by swapping to a later one would
477        // make the later one `DW0120`, and the ledger would be unusable for the
478        // exact thing it exists to do.
479        //
480        // The walk itself is `NpcDialogue::reachable_from` — the one authority,
481        // shared with the cast ledger's `DW0858`, which asks the same question
482        // over a different root set.
483        let mut roots = vec![tree.root.as_str()];
484        for q in &c.quests.content.quests {
485            for (npc, entry) in &q.cast {
486                if npc.as_str() != tree.npc.as_str() {
487                    continue;
488                }
489                for p in entry.placements() {
490                    if let Some(crate::CastDialogue::Root(r)) = &p.dialogue {
491                        roots.push(r.as_str());
492                    }
493                }
494            }
495        }
496        let seen = tree.reachable_from(&roots);
497        for (j, node) in tree.nodes.iter().enumerate() {
498            if !seen.contains(node.id.as_str()) {
499                d.push(Diagnostic::error(
500                    DIALOGUE_UNREACHABLE,
501                    "dialogue",
502                    format!("/content/dialogues/{i}/nodes/{j}"),
503                    format!(
504                        "dialogue node `{}` is unreachable from `root` — add an option whose \
505                         `next` leads here from a reachable node, or remove this node",
506                        node.id
507                    ),
508                ));
509            }
510        }
511
512        // Objectives completed by reachable options (for the coverage check).
513        let completes = reachable_completes.entry(tree.npc.as_str()).or_default();
514        for node in &tree.nodes {
515            if !seen.contains(node.id.as_str()) {
516                continue;
517            }
518            for opt in &node.options {
519                for eff in &opt.effects {
520                    if let DialogueEffect::CompleteObjective { objective } = eff {
521                        completes.insert(objective.as_str());
522                    }
523                }
524            }
525        }
526    }
527
528    // Every `talk-to` objective must have ≥ 1 reachable completing option in its
529    // own npc's tree (the static half of the compiler's DW0203 guarantee).
530    for (qi, q) in c.quests.content.quests.iter().enumerate() {
531        for (oi, o) in q.objectives.iter().enumerate() {
532            if let Objective::TalkTo { id, npc, .. } = o {
533                let covered = reachable_completes
534                    .get(npc.as_str())
535                    .is_some_and(|s| s.contains(id.as_str()));
536                if !covered {
537                    d.push(Diagnostic::error(
538                        DIALOGUE_UNCOVERED,
539                        "dialogue",
540                        format!("/content/quests/{qi}/objectives/{oi}"),
541                        format!(
542                            "`talk-to` objective `{id}` has no reachable dialogue option in npc \
543                             `{npc}`'s tree that completes it — add an option (reachable from \
544                             `root`) with a `complete-objective` effect for `{id}`, else the \
545                             objective can never finish"
546                        ),
547                    ));
548                }
549            }
550        }
551    }
552}
553
554/// Dialogue v0.4: option `requires_flags` resolve against declared flags
555/// (`DW0172`); a `talk-to` whose completing options are all flag-gated is a
556/// potential deadlock (`DW0191`, spec-0008 §1).
557pub(crate) fn dialogue_flag_checks(c: &Campaign, flags: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) {
558    use crate::DialogueEffect;
559    // Option requires_flags resolution.
560    for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
561        for (j, node) in tree.nodes.iter().enumerate() {
562            for (k, opt) in node.options.iter().enumerate() {
563                for (m, f) in opt.requires_flags.iter().enumerate() {
564                    if !flags.contains(f.as_str()) {
565                        d.push(Diagnostic::error(
566                            codes::FLAG_UNKNOWN,
567                            "dialogue",
568                            format!(
569                                "/content/dialogues/{i}/nodes/{j}/options/{k}/requires_flags/{m}"
570                            ),
571                            format!(
572                                "dialogue option `requires_flags` references flag `{f}`, which no \
573                                 `set-flag` effect ever produces — add a `set-flag {{ flag: \
574                                 \"{f}\" }}` effect somewhere, or correct the flag name"
575                            ),
576                        ));
577                    }
578                }
579            }
580        }
581    }
582    // v0.6: dialogue option `forbids_flags` — same unknown-flag treatment as
583    // `requires_flags` (DW0172).
584    for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
585        for (j, node) in tree.nodes.iter().enumerate() {
586            for (k, opt) in node.options.iter().enumerate() {
587                for (m, f) in opt.forbids_flags.iter().enumerate() {
588                    if !flags.contains(f.as_str()) {
589                        d.push(Diagnostic::error(
590                            codes::FLAG_UNKNOWN,
591                            "dialogue",
592                            format!(
593                                "/content/dialogues/{i}/nodes/{j}/options/{k}/forbids_flags/{m}"
594                            ),
595                            format!(
596                                "dialogue option `forbids_flags` references flag `{f}`, which no \
597                                 `set-flag` effect ever produces — the gate can never suppress \
598                                 anything; add the producing `set-flag {{ flag: \"{f}\" }}` \
599                                 effect, or correct the flag name"
600                            ),
601                        ));
602                    }
603                }
604            }
605        }
606    }
607    // Per-NPC: objectives completed by an UNGATED option in that npc's tree. An
608    // option gated either way — `requires_flags` (hidden until set) or, v0.6,
609    // `forbids_flags` (hidden once set) — counts as gated: the static analysis
610    // does no temporal reasoning about which flags end up set, so any
611    // conditionally-visible option may be unavailable exactly when needed.
612    let mut ungated_completes: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
613    let mut any_completes: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
614    for tree in &c.dialogue.content.dialogues {
615        let npc = tree.npc.as_str();
616        for node in &tree.nodes {
617            for opt in &node.options {
618                for eff in &opt.effects {
619                    if let DialogueEffect::CompleteObjective { objective } = eff {
620                        any_completes
621                            .entry(npc)
622                            .or_default()
623                            .insert(objective.as_str());
624                        if opt.requires_flags.is_empty() && opt.forbids_flags.is_empty() {
625                            ungated_completes
626                                .entry(npc)
627                                .or_default()
628                                .insert(objective.as_str());
629                        }
630                    }
631                }
632            }
633        }
634    }
635    for (qi, q) in c.quests.content.quests.iter().enumerate() {
636        for (oi, o) in q.objectives.iter().enumerate() {
637            if let Objective::TalkTo { id, npc, .. } = o {
638                let npc = npc.as_str();
639                let oid = id.as_str();
640                let completed = any_completes.get(npc).is_some_and(|s| s.contains(oid));
641                let ungated = ungated_completes.get(npc).is_some_and(|s| s.contains(oid));
642                // Only when it IS completed somewhere (else DW0123 fires) but every
643                // completing option is flag-gated.
644                if completed && !ungated {
645                    d.push(Diagnostic::error(
646                        DIALOGUE_FLAG_DEADLOCK,
647                        "quests",
648                        format!("/content/quests/{qi}/objectives/{oi}"),
649                        format!(
650                            "`talk-to` objective `{id}` has no ungated completing dialogue option \
651                             in npc `{npc}`'s tree — every completing option is `requires_flags`- \
652                             or `forbids_flags`-gated, so it can be unavailable the moment it is \
653                             needed; keep at least one completing option with no flag gate"
654                        ),
655                    ));
656                }
657            }
658        }
659    }
660}
661
662/// Stage-2 NPC ↔ stage-6 dialogue tree, 1:1 both directions (`DW0152`,
663/// `DW0153`).
664pub(crate) fn npc_tree_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
665    // Stage-2 NPC ↔ stage-6 dialogue tree, 1:1 both directions.
666    let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
667    let tree_npcs: BTreeSet<&str> = c
668        .dialogue
669        .content
670        .dialogues
671        .iter()
672        .map(|t| t.npc.as_str())
673        .collect();
674    for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
675        if !tree_npcs.contains(npc.id.as_str()) {
676            d.push(Diagnostic::error(
677                NPC_WITHOUT_TREE,
678                "dialogue",
679                format!("/content/npcs/{i}"),
680                format!(
681                    "npc `{}` has no stage-6 dialogue tree — every stage-2 npc needs exactly one \
682                     tree; add a dialogue tree for `{}`, or remove the npc",
683                    npc.id, npc.id
684                ),
685            ));
686        }
687    }
688    for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
689        if !npc_ids.contains(tree.npc.as_str()) {
690            d.push(Diagnostic::error(
691                TREE_WITHOUT_NPC,
692                "dialogue",
693                format!("/content/dialogues/{i}/npc"),
694                format!(
695                    "dialogue tree targets npc `{}`, which is not declared in stage 2 — declare \
696                     that npc in stage 2, or point this tree at an existing npc",
697                    tree.npc
698                ),
699            ));
700        }
701    }
702}
703
704/// `DW0110` over the dialogue node ids.
705pub(crate) fn dialogue_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
706    for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
707        for (j, node) in tree.nodes.iter().enumerate() {
708            crate::ids::id_syntax!(
709                d,
710                node.id,
711                "dialogue",
712                format!("/content/dialogues/{i}/nodes/{j}/id")
713            );
714        }
715    }
716}
717
718/// `DW0111` over the dialogue trees and their nodes.
719pub(crate) fn dialogue_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
720    // Dialogue trees: at most one per NPC (a duplicate tree is a duplicate npc
721    // binding within the stage-6 dialogue namespace).
722    crate::ids::dup_check(
723        c.dialogue
724            .content
725            .dialogues
726            .iter()
727            .enumerate()
728            .map(|(i, t)| (t.npc.as_str(), format!("/content/dialogues/{i}/npc"))),
729        "dialogue",
730        "dialogue tree for npc",
731        d,
732    );
733    // Dialogue node ids: unique within each tree.
734    for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
735        crate::ids::dup_check(
736            tree.nodes.iter().enumerate().map(|(j, node)| {
737                (
738                    node.id.as_str(),
739                    format!("/content/dialogues/{i}/nodes/{j}/id"),
740                )
741            }),
742            "dialogue",
743            "dialogue node",
744            d,
745        );
746    }
747}