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}