Skip to main content

delvewright_dsl/
npc.rs

1//! Stage 2 — NPCs: who they are, what they look like and where they stand.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::serde_fields::{is_false, is_zero3};
7use crate::{AnchorId, AreaId, BodyTraversal, NpcId};
8
9#[cfg(doc)]
10use crate::{EncounterTier, Mark, Verb};
11
12/// Stage 2 payload: the campaign's NPCs (casting sheets).
13#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
14#[serde(deny_unknown_fields)]
15pub struct NpcsContent {
16    /// All NPCs in the campaign.
17    pub npcs: Vec<Npc>,
18}
19
20/// A stationary NPC bound to an area anchor (a casting sheet, spec-0001 v0.2).
21///
22/// Stage 2 carries **no dialogue** — the structured [`Persona`] is the character
23/// contract the stage-6 `dialogue` tree must honor.
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct Npc {
27    /// Unique NPC id.
28    pub id: NpcId,
29    /// Player-facing name.
30    pub name: String,
31    /// NPC role.
32    pub role: Role,
33    /// The area this NPC stands in (stage-1 ref).
34    pub area: AreaId,
35    /// The prefab anchor this NPC stands on.
36    pub anchor: AnchorId,
37    /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
38    /// `[0, 0, 0]`): the NPC stands at the [`Mark`] the two fields spell.
39    #[serde(default, skip_serializing_if = "is_zero3")]
40    pub offset: [i32; 3],
41    /// The vanilla entity to re-dress, e.g. `minecraft:villager`.
42    pub base_entity: String,
43    /// The structured persona (character contract for stage 6).
44    pub persona: Persona,
45    /// Optional player-model skin (DSL v0.4, spec-0008 §6 / spec-0009). When set,
46    /// the compiler emits a `minecraft:mannequin` body carrying this skin profile
47    /// instead of re-dressing `base_entity`; the interaction hitbox is unchanged.
48    /// Non-skinned NPCs are byte-identical to v0.3.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub skin: Option<NpcSkin>,
51    /// Deferred entrance (DSL v0.6): when `true` the NPC is **not** summoned at
52    /// world init — its body and interaction hitbox only appear when a
53    /// [`Verb::SpawnNpc`] fires, at this same `anchor`. The dual of
54    /// `despawn-npc`: a character with a scripted entrance must not stand at its
55    /// mark as a statue from minute one. A deferred NPC that no `spawn-npc` ever
56    /// spawns is unreachable content (`DW0197`). Default `false` = summoned at
57    /// init, byte-identical to pre-0.6.
58    #[serde(default, skip_serializing_if = "is_false")]
59    pub deferred: bool,
60    /// What this body can do when it moves (DSL v0.11, spec-0034). Absent = the
61    /// class the compiler derives from `base_entity` (or from `minecraft:mannequin`
62    /// when `skin` is set — the body that actually ships). See [`BodyTraversal`]:
63    /// the declaration must change a verdict or it is `DW0454`, and it can never
64    /// reach the error tier.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub traversal: Option<BodyTraversal>,
67}
68
69/// A mannequin NPC's player-model skin (DSL v0.4). The skin PNG is sourced from
70/// the campaign dir's `skins/<texture_id>.png` and ships in the per-delve resource
71/// pack at `assets/delvewright/textures/npc/<campaign_id>/<texture_id>.png`, which
72/// is what the mannequin's `profile.texture` resolves to. The delve's own
73/// directory is stamped on at emission ([`crate::l10n::namespace_skin_textures`])
74/// — a client merges every applied pack's textures into ONE space, so two delves
75/// that both cast a `keeper` would otherwise wear each other's faces. Nothing a
76/// creator writes or names on disk carries it.
77#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct NpcSkin {
80    /// Skin id: the PNG basename under `skins/`, and the last segment of the
81    /// resource-pack texture path (a bare kebab token; validated by `DW0190`).
82    pub texture_id: String,
83    /// Player model. **Required** (spec-0009): an omitted model renders slim, so
84    /// a wide skin on a slim model is distorted — the compiler always emits it.
85    pub model: SkinModel,
86    /// The overlay layers this mannequin does **not** draw (spec-0097 §5). A
87    /// mannequin draws all seven of the player model's second-layer parts unless
88    /// told otherwise, and the skin's paint on a hidden part is not shown. Absent
89    /// or empty draws every layer and emits nothing; otherwise the list is
90    /// emitted as the mannequin's own `hidden_layers` field, in this order. A
91    /// layer named twice is `DW0980`.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub hidden_layers: Vec<SkinLayer>,
94}
95
96/// One of the player model's second-layer parts, as the pinned client's
97/// `PlayerModelPart` names it (spec-0097 §2.4). `left` and `right` are the
98/// model's own, not the observer's. `crates/delvec/tests/skin_parts.rs` holds
99/// these tokens equal to the layers `crates/delvec/data/model-parts-1.21.11.json`
100/// read from the jar.
101#[derive(
102    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
103)]
104#[serde(rename_all = "snake_case")]
105pub enum SkinLayer {
106    /// The cape, where the profile carries one.
107    Cape,
108    /// The torso's overlay shell.
109    Jacket,
110    /// The left arm's overlay shell.
111    LeftSleeve,
112    /// The right arm's overlay shell.
113    RightSleeve,
114    /// The left leg's overlay shell.
115    LeftPantsLeg,
116    /// The right leg's overlay shell.
117    RightPantsLeg,
118    /// The head's overlay shell.
119    Hat,
120}
121
122impl SkinLayer {
123    /// Every layer, in the client's own order.
124    pub const ALL: [SkinLayer; 7] = [
125        SkinLayer::Cape,
126        SkinLayer::Jacket,
127        SkinLayer::LeftSleeve,
128        SkinLayer::RightSleeve,
129        SkinLayer::LeftPantsLeg,
130        SkinLayer::RightPantsLeg,
131        SkinLayer::Hat,
132    ];
133
134    /// The vanilla id a mannequin's `hidden_layers` list carries.
135    pub fn token(self) -> &'static str {
136        match self {
137            SkinLayer::Cape => "cape",
138            SkinLayer::Jacket => "jacket",
139            SkinLayer::LeftSleeve => "left_sleeve",
140            SkinLayer::RightSleeve => "right_sleeve",
141            SkinLayer::LeftPantsLeg => "left_pants_leg",
142            SkinLayer::RightPantsLeg => "right_pants_leg",
143            SkinLayer::Hat => "hat",
144        }
145    }
146}
147
148/// Player-model shape for a mannequin skin (`wide` = classic/Steve, `slim` =
149/// Alex). Emitted verbatim into the mannequin `profile.model`.
150#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151#[serde(rename_all = "kebab-case")]
152pub enum SkinModel {
153    /// Classic 4-pixel arms (Steve).
154    Wide,
155    /// Slim 3-pixel arms (Alex).
156    Slim,
157}
158
159impl SkinModel {
160    /// The vanilla `profile.model` token.
161    pub fn token(self) -> &'static str {
162        match self {
163            SkinModel::Wide => "wide",
164            SkinModel::Slim => "slim",
165        }
166    }
167}
168
169/// A structured casting sheet. Structure lives in the
170/// keys; every value is free text. `archetype`, `speech_style` and `motivation`
171/// are required; the rest are optional.
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
173#[serde(deny_unknown_fields)]
174pub struct Persona {
175    /// One-line character archetype (required).
176    pub archetype: String,
177    /// How the NPC speaks — register, tics, formality (required).
178    pub speech_style: String,
179    /// Emotional bearing toward the player (optional).
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub demeanor: Option<String>,
182    /// What the NPC wants (required).
183    pub motivation: String,
184    /// Something the NPC hides (optional).
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub secret: Option<String>,
187    /// Backstory colour (optional).
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub backstory: Option<String>,
190    /// Attitudes toward other same-stage NPCs (optional; refs validated).
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub relationships: Vec<Relationship>,
193}
194
195/// One persona relationship: an attitude toward another same-stage NPC.
196#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
197#[serde(deny_unknown_fields)]
198pub struct Relationship {
199    /// The other NPC (stage-2 ref, validated within stage 2).
200    pub npc: NpcId,
201    /// Free-text attitude toward that NPC.
202    pub attitude: String,
203}
204
205/// What a speaking part does. A schema enum offers what the engine accepts,
206/// so there are two of them: how hard a fight is billed is [`EncounterTier`] on
207/// the body that fights (a `waves[]` entry or a stage-5 actor), not a role here.
208#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
209#[serde(rename_all = "kebab-case")]
210pub enum Role {
211    /// Gives and advances quests.
212    QuestGiver,
213    /// Flavor only.
214    Flavor,
215}
216
217// ---------------------------------------------------------------------------
218// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
219// ---------------------------------------------------------------------------
220
221use std::collections::{BTreeMap, BTreeSet};
222
223use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
224use crate::envelope::Campaign;
225use crate::ids::is_kebab;
226use crate::{Objective, QuestEffect};
227
228crate::dw_code! {
229    /// (v0.4) A dialogue `talk-to` or `interact` objective targets an NPC after a
230    /// `despawn-npc` removes it on a reachable path (spec-0008 §5).
231    pub const NPC_DESPAWNED_REF: DwCode = DwCode::new("DW0195", ExitTier::Build);
232}
233
234crate::dw_code! {
235    /// (v0.6) A stage-2 NPC declares `deferred: true` but **no** `spawn-npc` effect
236    /// anywhere in the campaign ever summons it — the NPC never enters the world,
237    /// so its dialogue tree and any `talk-to` on it are unreachable content. The
238    /// NPC-lifecycle dual of [`NPC_DESPAWNED_REF`] / `DW0195`.
239    ///
240    /// (0197/0198 were *reserved* by spec-0011's draft and released when that spec
241    /// renumbered to `DW0340`/`DW0341`; they were never emitted by any code.)
242    pub const NPC_NEVER_SPAWNED: DwCode = DwCode::new("DW0197", ExitTier::Build);
243}
244
245crate::dw_code! {
246    /// (v0.6) A `talk-to` on a `deferred` NPC activates before the NPC can exist:
247    /// every `spawn-npc` for it sits in a quest that is a strict *descendant* of the
248    /// objective's quest on the stage-4 DAG (and none fires from a trigger or
249    /// dialogue), so the objective provably activates on an empty anchor.
250    pub const NPC_SPAWNED_LATE: DwCode = DwCode::new("DW0198", ExitTier::Build);
251}
252
253/// Collect every NPC `e` (or an effect nested inside it) despawns **on every
254/// playthrough that runs `e` at all** — the only despawns `DW0195` may reason
255/// about, because its model is quest-DAG order with no branch semantics.
256///
257/// Two things stop the descent, and each is a real branch rather than a
258/// convenience:
259///
260/// - **A flag gate.** An effect carrying `requires_flags`/`forbids_flags` fires
261///   only when campaign state says so. The island's Perimedes walks out through
262///   the cave mouth and despawns *only* on the flee branch (`flag/flee`); the
263///   `talk-to`s that follow live on the sealed-in branch. Counting that despawn
264///   would reject a perfectly playable delve. Branch-conditional reachability is
265///   the branch-coherent completability proof's job (`DW0204`), not this rule's.
266/// - **A lifecycle reaction bundle.** `set-checkpoint`'s `on_respawn` runs only if
267///   a player dies and `begin-stealth`'s `on_caught` only if one is caught, so
268///   neither is guaranteed. A `sequence` step and a `move-*` `on_arrive` *are*
269///   guaranteed once their parent runs, so the descent continues through them.
270fn unconditional_despawns<'a>(e: &'a QuestEffect, out: &mut Vec<&'a crate::ids::NpcId>) {
271    if !e.requires_flags().is_empty() || !e.forbids_flags().is_empty() {
272        return;
273    }
274    if let Some(npc) = e.despawn_npc() {
275        out.push(npc);
276    }
277    for (_pseg, kseg, list) in e.nested_effect_lists_labeled() {
278        if kseg == "respawn" || kseg == "caught" {
279            continue;
280        }
281        for inner in list {
282            unconditional_despawns(inner, out);
283        }
284    }
285}
286
287/// DW0195: a `talk-to` targeting an NPC despawned by an effect that runs strictly
288/// before it on the quest dependency graph. Conservative: quest-ancestor despawn
289/// (via `on_complete`) or same-quest earlier-objective despawn (via
290/// `on_objective_complete` on a prerequisite `after` objective).
291pub(crate) fn despawned_ref_check(
292    c: &Campaign,
293    _npc_ids: &BTreeSet<&str>,
294    d: &mut Vec<Diagnostic>,
295) {
296    // Quest transitive ancestors (a quest completes before its dependents start).
297    let deps: BTreeMap<&str, &Vec<crate::ids::QuestId>> = c
298        .quest_plan
299        .content
300        .quests
301        .iter()
302        .map(|q| (q.id.as_str(), &q.depends_on))
303        .collect();
304    let ancestors = |q: &str| -> BTreeSet<&str> {
305        let mut out = BTreeSet::new();
306        let mut stack = vec![q];
307        while let Some(cur) = stack.pop() {
308            if let Some(ds) = deps.get(cur) {
309                for dep in ds.iter() {
310                    if out.insert(dep.as_str()) {
311                        stack.push(dep.as_str());
312                    }
313                }
314            }
315        }
316        out
317    };
318
319    // Where each npc is despawned: quests that despawn it on completion. Deep, but
320    // only through effects that are **certain to run** (see
321    // [`unconditional_despawns`]) — a `despawn-npc` nested one level down in a
322    // `sequence` step removes the NPC exactly as thoroughly as a top-level one, and
323    // the shallow scan this replaces walked straight past it.
324    let mut despawn_quest: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
325    for q in &c.quests.content.quests {
326        for e in q
327            .on_objective_complete
328            .values()
329            .flatten()
330            .chain(&q.on_complete)
331        {
332            let mut npcs = Vec::new();
333            unconditional_despawns(e, &mut npcs);
334            for npc in npcs {
335                despawn_quest
336                    .entry(npc.as_str())
337                    .or_default()
338                    .insert(q.id.as_str());
339            }
340        }
341    }
342    if despawn_quest.is_empty() {
343        return;
344    }
345    for (qi, q) in c.quests.content.quests.iter().enumerate() {
346        let anc = ancestors(q.id.as_str());
347        for (oi, o) in q.objectives.iter().enumerate() {
348            if let Objective::TalkTo { npc, .. } = o
349                && let Some(dq) = despawn_quest.get(npc.as_str())
350                && dq.iter().any(|dqid| anc.contains(dqid))
351            {
352                d.push(Diagnostic::error(
353                    NPC_DESPAWNED_REF,
354                    "quests",
355                    format!("/content/quests/{qi}/objectives/{oi}/npc"),
356                    format!(
357                        "`talk-to` targets npc `{npc}`, which a prerequisite quest despawns — the \
358                         npc is gone by the time this objective activates; talk to `{npc}` before \
359                         the quest that despawns it, or drop the `despawn-npc`"
360                    ),
361                ));
362            }
363        }
364    }
365}
366
367/// `deferred` NPC staging proofs (DSL v0.6), the dual of `despawned_ref_check`:
368///
369/// * `DW0112` — a dialogue `spawn-npc` naming an unknown NPC (the quest-effect form
370///   is covered by `check_effect_v04`).
371/// * `DW0197` — a `deferred: true` NPC that **no** `spawn-npc` anywhere summons: it
372///   never enters the world, so its tree and any `talk-to` on it are dead content.
373/// * `DW0198` — a `talk-to` on a deferred NPC that provably activates before the
374///   NPC exists: every `spawn-npc` for it lives in a quest that is a strict DAG
375///   *descendant* of the objective's quest. Conservative by construction — a spawn
376///   from a trigger, from dialogue, or from the objective's own quest is not
377///   DAG-ordered, so it suppresses the proof rather than risking a false positive.
378pub(crate) fn deferred_npc_checks(c: &Campaign, npc_ids: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) {
379    use crate::DialogueEffect;
380    let deferred: BTreeSet<&str> = c
381        .npcs
382        .content
383        .npcs
384        .iter()
385        .filter(|n| n.deferred)
386        .map(|n| n.id.as_str())
387        .collect();
388
389    // Spawn sites. `quest_spawns`: npc -> quests whose effects spawn it (DAG-ordered).
390    // `loose_spawns`: npcs spawned from a trigger or a dialogue option — sources with
391    // no position on the quest DAG.
392    let mut quest_spawns: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
393    let mut loose_spawns: BTreeSet<String> = BTreeSet::new();
394    for q in &c.quests.content.quests {
395        let qid = q.id.as_str().to_string();
396        crate::validate::for_each_effect_deep(q, |_path, eff| {
397            if let Some(npc) = eff.spawn_npc() {
398                quest_spawns
399                    .entry(npc.as_str().to_string())
400                    .or_default()
401                    .insert(qid.clone());
402            }
403        });
404    }
405    for t in &c.quests.content.triggers {
406        crate::validate::for_each_trigger_effect_deep(t, |_path, eff| {
407            if let Some(npc) = eff.spawn_npc() {
408                loose_spawns.insert(npc.as_str().to_string());
409            }
410        });
411    }
412    for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
413        for (j, node) in tree.nodes.iter().enumerate() {
414            for (k, opt) in node.options.iter().enumerate() {
415                for (m, eff) in opt.effects.iter().enumerate() {
416                    let DialogueEffect::SpawnNpc { npc } = eff else {
417                        continue;
418                    };
419                    if !npc_ids.contains(npc.as_str()) {
420                        d.push(Diagnostic::error(
421                            codes::DANGLING_REF,
422                            "dialogue",
423                            format!("/content/dialogues/{i}/nodes/{j}/options/{k}/effects/{m}/npc"),
424                            format!(
425                                "dialogue `spawn-npc` references unknown npc `{npc}` — declare it \
426                                 in stage 2 or correct the reference"
427                            ),
428                        ));
429                        continue;
430                    }
431                    loose_spawns.insert(npc.as_str().to_string());
432                }
433            }
434        }
435    }
436
437    // DW0197: deferred but never spawned anywhere.
438    for (i, n) in c.npcs.content.npcs.iter().enumerate() {
439        if !n.deferred {
440            continue;
441        }
442        let id = n.id.as_str();
443        if quest_spawns.contains_key(id) || loose_spawns.contains(id) {
444            continue;
445        }
446        d.push(Diagnostic::error(
447            NPC_NEVER_SPAWNED,
448            "npcs",
449            format!("/content/npcs/{i}/deferred"),
450            format!(
451                "npc `{id}` is `deferred: true` but no `spawn-npc` effect anywhere in the \
452                 campaign summons it — it never enters the world, so its dialogue tree (and any \
453                 `talk-to` on it) is unreachable content. Add a `spawn-npc {{ npc: \"{id}\" }}` \
454                 effect at the beat where the character should walk in, or drop `deferred` so it \
455                 stands at its anchor from world init. Do NOT delete the dialogue tree to silence \
456                 this — every stage-2 npc needs one (`DW0152`)"
457            ),
458        ));
459    }
460    if deferred.is_empty() {
461        return;
462    }
463
464    // DW0198: a `talk-to` on a deferred npc whose every spawn site is a strict DAG
465    // descendant of the objective's quest.
466    let ancestors = crate::validate::quest_ancestors(c);
467    for (qi, q) in c.quests.content.quests.iter().enumerate() {
468        for (oi, o) in q.objectives.iter().enumerate() {
469            let Objective::TalkTo { npc, .. } = o else {
470                continue;
471            };
472            let npc = npc.as_str();
473            if !deferred.contains(npc) || loose_spawns.contains(npc) {
474                continue;
475            }
476            let Some(sqs) = quest_spawns.get(npc) else {
477                continue; // never spawned at all — already DW0197
478            };
479            let all_later = sqs.iter().all(|sq| {
480                sq.as_str() != q.id.as_str()
481                    && ancestors
482                        .get(sq.as_str())
483                        .is_some_and(|anc| anc.contains(q.id.as_str()))
484            });
485            if !all_later {
486                continue;
487            }
488            let names: Vec<&str> = sqs.iter().map(|s| s.as_str()).collect();
489            d.push(Diagnostic::error(
490                NPC_SPAWNED_LATE,
491                "quests",
492                format!("/content/quests/{qi}/objectives/{oi}/npc"),
493                format!(
494                    "`talk-to` targets deferred npc `{npc}`, but every `spawn-npc` for it fires \
495                     in a quest that depends on this one (`{}`) — the objective activates on an \
496                     empty anchor and can never complete. Move the `spawn-npc` to this quest or \
497                     one of its prerequisites, or move the `talk-to` after the entrance. Do NOT \
498                     drop `deferred` just to pass this — that puts the character back on stage \
499                     from minute one",
500                    names.join("`, `")
501                ),
502            ));
503        }
504    }
505}
506
507/// spec-0009: a mannequin skin's `texture_id` is a bare kebab token (`DW0190`).
508///
509/// A `texture_id` names a FILE, and two bodies may wear one file: the bake
510/// reads it once and both summons point at the one pack texture
511/// (`read_skins`), and a skin's per-body choices (`model`, `hidden_layers`)
512/// ride the body, not the file (spec-0097 §4.3). So only the id's shape is
513/// refused here.
514pub(crate) fn npc_skin_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
515    for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
516        if let Some(skin) = &npc.skin
517            && !is_kebab(&skin.texture_id)
518        {
519            d.push(Diagnostic::error(
520                codes::SKIN_INVALID,
521                "npcs",
522                format!("/content/npcs/{i}/skin/texture_id"),
523                format!(
524                    "skin `texture_id` `{}` is malformed — it must be a bare kebab token \
525                     (e.g. `keeper-armor`), matching the `skins/<texture_id>.png` filename",
526                    skin.texture_id
527                ),
528            ));
529        }
530    }
531}
532
533crate::dw_code! {
534    /// (spec-0097 §5) A body's `skin.hidden_layers` names one layer twice. The
535    /// list is the set of overlay layers the mannequin does not draw; a
536    /// repeat says nothing a single entry does not, and is a mistake.
537    pub const SKIN_LAYER_TWICE: DwCode = DwCode::new("DW0980", ExitTier::Build);
538}
539
540/// spec-0097 §5: a skinned body's `hidden_layers` names each layer at most once
541/// (`DW0980`). Walked over every body that declares a skin, whatever its class.
542pub(crate) fn skin_layer_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
543    for site in crate::body::body_skin_sites(c) {
544        let mut seen = BTreeSet::new();
545        for (k, layer) in site.skin.hidden_layers.iter().enumerate() {
546            if !seen.insert(*layer) {
547                d.push(Diagnostic::error(
548                    SKIN_LAYER_TWICE,
549                    site.body.stage(),
550                    format!("{}/hidden_layers/{k}", site.path),
551                    format!(
552                        "`{}` hides `{}` twice — `hidden_layers` is the set of overlay layers \
553                         the mannequin does not draw, so name each layer once",
554                        site.body.id(),
555                        layer.token()
556                    ),
557                ));
558            }
559        }
560    }
561}
562
563/// An NPC's station resolves in its area and is a `point` (`DW0142`, `DW0871`),
564/// answered by the one anchor authority, [`crate::validate::AnchorProviders`].
565pub(crate) fn npc_anchor_checks(
566    c: &Campaign,
567    providers: &crate::validate::AnchorProviders,
568    d: &mut Vec<Diagnostic>,
569) {
570    // NPC anchors.
571    for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
572        if let Some(f) = crate::validate::station_kind_diag(
573            providers,
574            npc.anchor.as_str(),
575            crate::layout::StationKind::Point,
576            "an NPC's station",
577            "npcs",
578            format!("/content/npcs/{i}/anchor"),
579        ) {
580            d.push(f);
581        } else if let Some(set) = providers.for_area(npc.area.as_str())
582            && !set.contains(npc.anchor.as_str())
583        {
584            // The one prefab remedy in this file that names the anchor back, so
585            // it is built before the call rather than passed as a literal.
586            let prefab_remedy = format!(
587                "use an anchor the prefab exposes, or bind a prefab/pool that carries `{}`. \
588                 Anchor names come from prefab metadata; do NOT invent one",
589                npc.anchor
590            );
591            d.push(Diagnostic::error(
592                codes::ANCHOR_UNRESOLVED,
593                "npcs",
594                format!("/content/npcs/{i}/anchor"),
595                format!(
596                    "npc anchor `{}` is not provided by the prefab bound to area `{}` — {}",
597                    npc.anchor,
598                    npc.area,
599                    providers.anchor_remedy(&prefab_remedy),
600                ),
601            ));
602        }
603    }
604}
605
606/// `DW0110` over the NPC ids.
607pub(crate) fn npc_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
608    for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
609        crate::ids::id_syntax!(d, npc.id, "npcs", format!("/content/npcs/{i}/id"));
610    }
611}
612
613/// `DW0111` over the NPC ids.
614pub(crate) fn npc_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
615    crate::ids::dup_check(
616        c.npcs
617            .content
618            .npcs
619            .iter()
620            .enumerate()
621            .map(|(i, n)| (n.id.as_str(), format!("/content/npcs/{i}/id"))),
622        "npcs",
623        "npc",
624        d,
625    );
626}
627
628/// `DW0112` over what an NPC names: its area, and the NPC each persona
629/// relationship is with (a same-stage reference, validated within stage 2).
630pub(crate) fn npc_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
631    use crate::ids::dangling;
632    let area_ids = crate::world::declared_area_ids(c);
633    let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
634    for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
635        dangling(
636            d,
637            area_ids.contains(npc.area.as_str()),
638            "npcs",
639            format!("/content/npcs/{i}/area"),
640            format!(
641                "npc references unknown area `{}` — {}",
642                npc.area,
643                crate::placement::Placement::of(c).area_remedy(),
644            ),
645        );
646        // Persona relationships are same-stage NPC refs (validated within stage 2).
647        for (k, rel) in npc.persona.relationships.iter().enumerate() {
648            dangling(
649                d,
650                npc_ids.contains(rel.npc.as_str()),
651                "npcs",
652                format!("/content/npcs/{i}/persona/relationships/{k}/npc"),
653                format!(
654                    "persona relationship references unknown npc `{}` — declare that npc in \
655                     stage 2 or correct the reference",
656                    rel.npc
657                ),
658            );
659        }
660    }
661}