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}