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