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}