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