use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::serde_fields::{is_false, is_zero3};
use crate::{AnchorId, AreaId, BodyTraversal, NpcId};
#[cfg(doc)]
use crate::{EncounterTier, Mark, Verb};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NpcsContent {
pub npcs: Vec<Npc>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Npc {
pub id: NpcId,
pub name: String,
pub role: Role,
pub area: AreaId,
pub anchor: AnchorId,
#[serde(default, skip_serializing_if = "is_zero3")]
pub offset: [i32; 3],
pub base_entity: String,
pub persona: Persona,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skin: Option<NpcSkin>,
#[serde(default, skip_serializing_if = "is_false")]
pub deferred: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub traversal: Option<BodyTraversal>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NpcSkin {
pub texture_id: String,
pub model: SkinModel,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum SkinModel {
Wide,
Slim,
}
impl SkinModel {
pub fn token(self) -> &'static str {
match self {
SkinModel::Wide => "wide",
SkinModel::Slim => "slim",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Persona {
pub archetype: String,
pub speech_style: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub demeanor: Option<String>,
pub motivation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backstory: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relationships: Vec<Relationship>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Relationship {
pub npc: NpcId,
pub attitude: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Role {
QuestGiver,
Flavor,
}
use std::collections::{BTreeMap, BTreeSet};
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::ids::is_kebab;
use crate::{Objective, QuestEffect};
crate::dw_code! {
pub const NPC_DESPAWNED_REF: DwCode = DwCode::new("DW0195", ExitTier::Build);
}
crate::dw_code! {
pub const NPC_NEVER_SPAWNED: DwCode = DwCode::new("DW0197", ExitTier::Build);
}
crate::dw_code! {
pub const NPC_SPAWNED_LATE: DwCode = DwCode::new("DW0198", ExitTier::Build);
}
fn unconditional_despawns<'a>(e: &'a QuestEffect, out: &mut Vec<&'a crate::ids::NpcId>) {
if !e.requires_flags().is_empty() || !e.forbids_flags().is_empty() {
return;
}
if let Some(npc) = e.despawn_npc() {
out.push(npc);
}
for (_pseg, kseg, list) in e.nested_effect_lists_labeled() {
if kseg == "respawn" || kseg == "caught" {
continue;
}
for inner in list {
unconditional_despawns(inner, out);
}
}
}
pub(crate) fn despawned_ref_check(
c: &Campaign,
_npc_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
let deps: BTreeMap<&str, &Vec<crate::ids::QuestId>> = c
.quest_plan
.content
.quests
.iter()
.map(|q| (q.id.as_str(), &q.depends_on))
.collect();
let ancestors = |q: &str| -> BTreeSet<&str> {
let mut out = BTreeSet::new();
let mut stack = vec![q];
while let Some(cur) = stack.pop() {
if let Some(ds) = deps.get(cur) {
for dep in ds.iter() {
if out.insert(dep.as_str()) {
stack.push(dep.as_str());
}
}
}
}
out
};
let mut despawn_quest: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for q in &c.quests.content.quests {
for e in q
.on_objective_complete
.values()
.flatten()
.chain(&q.on_complete)
{
let mut npcs = Vec::new();
unconditional_despawns(e, &mut npcs);
for npc in npcs {
despawn_quest
.entry(npc.as_str())
.or_default()
.insert(q.id.as_str());
}
}
}
if despawn_quest.is_empty() {
return;
}
for (qi, q) in c.quests.content.quests.iter().enumerate() {
let anc = ancestors(q.id.as_str());
for (oi, o) in q.objectives.iter().enumerate() {
if let Objective::TalkTo { npc, .. } = o
&& let Some(dq) = despawn_quest.get(npc.as_str())
&& dq.iter().any(|dqid| anc.contains(dqid))
{
d.push(Diagnostic::error(
NPC_DESPAWNED_REF,
"quests",
format!("/content/quests/{qi}/objectives/{oi}/npc"),
format!(
"`talk-to` targets npc `{npc}`, which a prerequisite quest despawns — the \
npc is gone by the time this objective activates; talk to `{npc}` before \
the quest that despawns it, or drop the `despawn-npc`"
),
));
}
}
}
}
pub(crate) fn deferred_npc_checks(c: &Campaign, npc_ids: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) {
use crate::DialogueEffect;
let deferred: BTreeSet<&str> = c
.npcs
.content
.npcs
.iter()
.filter(|n| n.deferred)
.map(|n| n.id.as_str())
.collect();
let mut quest_spawns: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut loose_spawns: BTreeSet<String> = BTreeSet::new();
for q in &c.quests.content.quests {
let qid = q.id.as_str().to_string();
crate::validate::for_each_effect_deep(q, |_path, eff| {
if let Some(npc) = eff.spawn_npc() {
quest_spawns
.entry(npc.as_str().to_string())
.or_default()
.insert(qid.clone());
}
});
}
for t in &c.quests.content.triggers {
crate::validate::for_each_trigger_effect_deep(t, |_path, eff| {
if let Some(npc) = eff.spawn_npc() {
loose_spawns.insert(npc.as_str().to_string());
}
});
}
for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
for (j, node) in tree.nodes.iter().enumerate() {
for (k, opt) in node.options.iter().enumerate() {
for (m, eff) in opt.effects.iter().enumerate() {
let DialogueEffect::SpawnNpc { npc } = eff else {
continue;
};
if !npc_ids.contains(npc.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"dialogue",
format!("/content/dialogues/{i}/nodes/{j}/options/{k}/effects/{m}/npc"),
format!(
"dialogue `spawn-npc` references unknown npc `{npc}` — declare it \
in stage 2 or correct the reference"
),
));
continue;
}
loose_spawns.insert(npc.as_str().to_string());
}
}
}
}
for (i, n) in c.npcs.content.npcs.iter().enumerate() {
if !n.deferred {
continue;
}
let id = n.id.as_str();
if quest_spawns.contains_key(id) || loose_spawns.contains(id) {
continue;
}
d.push(Diagnostic::error(
NPC_NEVER_SPAWNED,
"npcs",
format!("/content/npcs/{i}/deferred"),
format!(
"npc `{id}` is `deferred: true` but no `spawn-npc` effect anywhere in the \
campaign summons it — it never enters the world, so its dialogue tree (and any \
`talk-to` on it) is unreachable content. Add a `spawn-npc {{ npc: \"{id}\" }}` \
effect at the beat where the character should walk in, or drop `deferred` so it \
stands at its anchor from world init. Do NOT delete the dialogue tree to silence \
this — every stage-2 npc needs one (`DW0152`)"
),
));
}
if deferred.is_empty() {
return;
}
let ancestors = crate::validate::quest_ancestors(c);
for (qi, q) in c.quests.content.quests.iter().enumerate() {
for (oi, o) in q.objectives.iter().enumerate() {
let Objective::TalkTo { npc, .. } = o else {
continue;
};
let npc = npc.as_str();
if !deferred.contains(npc) || loose_spawns.contains(npc) {
continue;
}
let Some(sqs) = quest_spawns.get(npc) else {
continue; };
let all_later = sqs.iter().all(|sq| {
sq.as_str() != q.id.as_str()
&& ancestors
.get(sq.as_str())
.is_some_and(|anc| anc.contains(q.id.as_str()))
});
if !all_later {
continue;
}
let names: Vec<&str> = sqs.iter().map(|s| s.as_str()).collect();
d.push(Diagnostic::error(
NPC_SPAWNED_LATE,
"quests",
format!("/content/quests/{qi}/objectives/{oi}/npc"),
format!(
"`talk-to` targets deferred npc `{npc}`, but every `spawn-npc` for it fires \
in a quest that depends on this one (`{}`) — the objective activates on an \
empty anchor and can never complete. Move the `spawn-npc` to this quest or \
one of its prerequisites, or move the `talk-to` after the entrance. Do NOT \
drop `deferred` just to pass this — that puts the character back on stage \
from minute one",
names.join("`, `")
),
));
}
}
}
pub(crate) fn npc_skin_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let mut seen_skins: BTreeSet<&str> = BTreeSet::new();
for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
if let Some(skin) = &npc.skin {
if !is_kebab(&skin.texture_id) {
d.push(Diagnostic::error(
codes::SKIN_INVALID,
"npcs",
format!("/content/npcs/{i}/skin/texture_id"),
format!(
"skin `texture_id` `{}` is malformed — it must be a bare kebab token \
(e.g. `keeper-armor`), matching the `skins/<texture_id>.png` filename",
skin.texture_id
),
));
}
if !seen_skins.insert(skin.texture_id.as_str()) {
d.push(Diagnostic::error(
codes::SKIN_INVALID,
"npcs",
format!("/content/npcs/{i}/skin/texture_id"),
format!(
"duplicate skin `texture_id` `{}` — each mannequin needs a distinct \
texture; rename one (and its `skins/<id>.png`)",
skin.texture_id
),
));
}
}
}
}
pub(crate) fn npc_anchor_checks(
c: &Campaign,
providers: &crate::validate::AnchorProviders,
d: &mut Vec<Diagnostic>,
) {
for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
if let Some(f) = crate::validate::station_kind_diag(
providers,
npc.anchor.as_str(),
crate::layout::StationKind::Point,
"an NPC's station",
"npcs",
format!("/content/npcs/{i}/anchor"),
) {
d.push(f);
} else if let Some(set) = providers.for_area(npc.area.as_str())
&& !set.contains(npc.anchor.as_str())
{
let prefab_remedy = format!(
"use an anchor the prefab exposes, or bind a prefab/pool that carries `{}`. \
Anchor names come from prefab metadata; do NOT invent one",
npc.anchor
);
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"npcs",
format!("/content/npcs/{i}/anchor"),
format!(
"npc anchor `{}` is not provided by the prefab bound to area `{}` — {}",
npc.anchor,
npc.area,
providers.anchor_remedy(&prefab_remedy),
),
));
}
}
}
pub(crate) fn npc_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
crate::ids::id_syntax!(d, npc.id, "npcs", format!("/content/npcs/{i}/id"));
}
}
pub(crate) fn npc_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::ids::dup_check(
c.npcs
.content
.npcs
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), format!("/content/npcs/{i}/id"))),
"npcs",
"npc",
d,
);
}
pub(crate) fn npc_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
use crate::ids::dangling;
let area_ids = crate::world::declared_area_ids(c);
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
dangling(
d,
area_ids.contains(npc.area.as_str()),
"npcs",
format!("/content/npcs/{i}/area"),
format!(
"npc references unknown area `{}` — {}",
npc.area,
crate::placement::Placement::of(c).area_remedy(),
),
);
for (k, rel) in npc.persona.relationships.iter().enumerate() {
dangling(
d,
npc_ids.contains(rel.npc.as_str()),
"npcs",
format!("/content/npcs/{i}/persona/relationships/{k}/npc"),
format!(
"persona relationship references unknown npc `{}` — declare that npc in \
stage 2 or correct the reference",
rel.npc
),
);
}
}
}