use crate::stages::Verb;
use std::collections::{BTreeMap, BTreeSet};
use crate::diagnostic::{Diagnostic, codes};
use crate::envelope::{Campaign, DSL_VERSION, Stage};
use crate::ids::is_kebab;
use crate::metrics::Metrics;
use crate::registry::{
AnchorRegistry, BlockRegistry, EffectRegistry, EntityRegistry, ItemBackedBlockRegistry,
ItemRegistry, VendoredAnchorRegistry, VendoredEffectRegistry, VendoredEntityRegistry,
VendoredItemRegistry,
};
use crate::stages::{
EditFrame, EncounterTier, Locomotion, MorphOp, Objective, PlannedQuest, QuestEffect,
RegionShape, TriggerOn, WorldEdit, body_traversal_sites,
};
pub fn validate_campaign(c: &Campaign) -> Vec<Diagnostic> {
let items = VendoredItemRegistry::v1_21_11();
let entities = VendoredEntityRegistry::v1_21_11();
let anchors = VendoredAnchorRegistry::hello_world();
validate_campaign_with(c, &items, &anchors, &entities)
}
pub fn validate_campaign_with(
c: &Campaign,
items: &dyn ItemRegistry,
anchors: &dyn AnchorRegistry,
entities: &dyn EntityRegistry,
) -> Vec<Diagnostic> {
let mut d: Vec<Diagnostic> = Vec::new();
envelope(c, &mut d);
syntax(c, &mut d);
uniqueness(c, &mut d);
references(c, &mut d);
dialogue(c, &mut d);
plan(c, &mut d);
after_ordering(c, &mut d);
press_answer_checks(c, &mut d);
press_obligation_checks(c, &mut d);
horizon_param_checks(c, &mut d);
world_checks(c, &mut d);
lighting_range_checks(c, &mut d);
state_checks(c, &mut d);
gate_contradiction_checks(c, &mut d);
status_effect_checks(c, &VendoredEffectRegistry::v1_21_11(), &mut d);
economy_checks(c, &mut d);
body_traversal_checks(c, &mut d);
prefab_binding(c, anchors, &mut d);
anchors_and_items(c, items, anchors, &mut d);
cross_stage(c, &mut d);
item_gate_class_checks(c, &mut d);
v03_checks(c, items, anchors, entities, &mut d);
let blocks = ItemBackedBlockRegistry::new(items);
let effects_reg = VendoredEffectRegistry::v1_21_11();
v04_checks(c, anchors, &blocks, &effects_reg, &mut d);
v06_checks(c, items, anchors, entities, &mut d);
v06_trap_checks(c, items, entities, anchors, &mut d);
shortcut_checks(c, anchors, &mut d);
ambush_checks(c, &mut d);
timed_gate_checks(c, anchors, &mut d);
loot_checks(c, items, anchors, &mut d);
lane_checks(c, anchors, &mut d);
difficulty_checks(c, &mut d);
if c.world_edits.is_some() {
let blocks = ItemBackedBlockRegistry::new(items);
world_edits_checks(c, &blocks, &mut d);
}
if c.geometry_brief.is_some() || c.layout_graph.is_some() || c.site_plan.is_some() {
let mut reads = crate::metrics::Reads::new();
crate::layout::check(c, &mut reads, &mut d);
crate::siteplan::check(c, &mut reads, &mut d);
let stage = if c.site_plan.is_some() {
"site-plan"
} else if c.layout_graph.is_some() {
"layout-graph"
} else {
"geometry-brief"
};
if let Some(notice) = Metrics::table().notice(&reads, stage) {
d.push(notice);
}
}
branch_point_checks(c, &mut d);
collect_container_claim_checks(c, &mut d);
happening_subject_checks(c, &mut d);
let effects_reg = VendoredEffectRegistry::v1_21_11();
kit_potion_checks(c, &effects_reg, &mut d);
lethal_volume_checks(c, anchors, &mut d);
shop_anchor_checks(c, anchors, &mut d);
crate::design::check(c, &mut d);
d
}
pub(crate) struct AnchorProviders {
per_area: BTreeMap<String, BTreeSet<String>>,
kinds: BTreeMap<String, crate::layout::StationKind>,
union: BTreeSet<String>,
deferred: bool,
all_areas_known: bool,
placement: crate::placement::Placement,
}
impl AnchorProviders {
pub(crate) fn build(c: &Campaign, anchors: &dyn AnchorRegistry) -> Self {
let mut per_area: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut deferred = false;
let mut declared_areas = c.world.content.areas.len();
let mut kinds: BTreeMap<String, crate::layout::StationKind> = BTreeMap::new();
if c.site_plan.is_some() {
declared_areas += 1;
if crate::placement::anchor_vocabulary_unknowable(c) {
deferred = true;
} else {
kinds = crate::siteplan::synthesized_anchor_kinds(c);
per_area.insert(
crate::siteplan::SITE_AREA.to_string(),
kinds.keys().cloned().collect(),
);
}
}
for a in &c.world.content.areas {
if let Some(prefab) = &a.prefab {
if let Some(set) = anchors.anchors_for(prefab) {
per_area.insert(a.id.as_str().to_string(), set.clone());
}
} else if a.prefab_pool.is_some() {
deferred = true;
}
}
let union: BTreeSet<String> = per_area.values().flatten().cloned().collect();
let all_areas_known = declared_areas == per_area.len();
Self {
per_area,
kinds,
union,
deferred,
all_areas_known,
placement: crate::placement::Placement::of(c),
}
}
pub(crate) fn wrong_kind(
&self,
name: &str,
demands: crate::layout::StationKind,
) -> Option<crate::layout::StationKind> {
let declared = *self.kinds.get(name)?;
(declared != demands).then_some(declared)
}
pub(crate) fn for_area(&self, area: &str) -> Option<&BTreeSet<String>> {
self.per_area.get(area)
}
pub(crate) fn resolvable(&self, name: &str) -> bool {
self.deferred || self.union.contains(name)
}
pub(crate) fn all_areas_known(&self) -> bool {
self.all_areas_known
}
pub(crate) fn union(&self) -> &BTreeSet<String> {
&self.union
}
pub(crate) fn anchor_remedy<'a>(&self, prefab: &'a str) -> &'a str {
self.placement.anchor_remedy(prefab)
}
}
fn station_kind_diag(
providers: &AnchorProviders,
name: &str,
demands: impl Into<Option<crate::layout::StationKind>>,
what: &str,
stage: &'static str,
path: String,
) -> Option<Diagnostic> {
let demands = demands.into()?;
let declared = providers.wrong_kind(name, demands)?;
Some(Diagnostic::error(
crate::layout::DW_STATION_KIND,
stage,
path,
format!(
"`{name}` is declared as a {got} station, and {what} demands a {want}. A station's \
`kind` is its SHAPE: a `point` is a cell a body is put at — a seat, a subject, an \
affordance, the centre of an anchor-centred volume — and a `gate` is a region that \
seals and clears, which is what `open-gate`, `close-gate`, a `shortcut` and a \
`timed-gate` address. This is read from the layout graph's declaration, so it is \
answered before any piece is bound and the same shape is demanded of the piece \
anchor when one is. Either change this station's `kind` to `{want}` in the layout \
graph, or name a station that is already one.",
got = declared.word(),
want = demands.word(),
),
))
}
fn lethal_volume_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
let volumes = &c.quests.content.lethal_volumes;
if volumes.is_empty() {
return;
}
let providers = AnchorProviders::build(c, anchors);
let mut seen_id: BTreeSet<&str> = BTreeSet::new();
for (i, v) in volumes.iter().enumerate() {
if !v.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/lethal_volumes/{i}/id"),
format!(
"malformed lethal-volume id `{}` — lethal-volume ids must be lowercase \
kebab-case with the `lethal/` prefix (e.g. `lethal/cliff-fall`)",
v.id
),
));
}
if !seen_id.insert(v.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/lethal_volumes/{i}/id"),
format!("duplicate lethal-volume id `{}`", v.id),
));
}
if let Some(f) = station_kind_diag(
&providers,
v.region.anchor.as_str(),
crate::layout::StationKind::Point,
"a lethal volume's region centre",
"quests",
format!("/content/lethal_volumes/{i}/region/anchor"),
) {
d.push(f);
}
if !providers.resolvable(v.region.anchor.as_str()) {
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/lethal_volumes/{i}/region/anchor"),
format!(
"lethal-volume anchor `{}` is not provided by any prefab bound in this \
campaign — {}",
v.region.anchor,
providers.anchor_remedy(
"use an anchor the prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
),
));
}
for (j, block) in v.shown_by.iter().enumerate() {
if crate::blocks::BlockRegistry::v1_21_11()
.validate_state_string(block)
.is_err()
{
d.push(Diagnostic::error(
codes::BLOCK_UNKNOWN,
"quests",
format!("/content/lethal_volumes/{i}/shown_by/{j}"),
format!(
"lethal volume `{}` declares `shown_by` block `{block}`, which is not a \
block state of Minecraft Java 1.21.11",
v.id
),
));
continue;
}
if crate::blockshape::hurts_body(block) {
continue;
}
d.push(Diagnostic::error(
codes::LETHAL_INVISIBLE,
"quests",
format!("/content/lethal_volumes/{i}/shown_by/{j}"),
format!(
"lethal volume `{}` declares `shown_by` block `{block}`, which vanilla does \
not hurt a body with — so it shows a player nothing, and floor made of it \
reads as safe however this volume is declared. `shown_by` says what the \
player SEES; it is not a word that switches the rule off. Name the block \
that shows the danger, from the set vanilla hurts a body with: {}.",
v.id,
crate::blockshape::HURTING_BLOCKS_1_21_11
.iter()
.map(|b| format!("`{b}`"))
.collect::<Vec<_>>()
.join(", "),
),
));
}
if v.message.trim().is_empty() {
d.push(Diagnostic::error(
codes::LETHAL_MESSAGE_BLANK,
"quests",
format!("/content/lethal_volumes/{i}/message"),
format!(
"lethal volume `{}` declares a blank `message`, so it would kill in silence \
— the one thing this declaration exists to prevent. Write the line the \
player reads as they die (`The undertow takes you.`); there is no compiler \
default that could be right for a cliff, a lava pit and an acid pool at \
once.",
v.id
),
));
}
}
}
fn read_after_write_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::effects::for_each_effect_root(c, &mut |site, list| {
let mut written: BTreeMap<&str, usize> = BTreeMap::new();
for (i, eff) in list.iter().enumerate() {
for cmp in eff.requires_state() {
let Some(at) = written.get(cmp.state.as_str()) else {
continue;
};
d.push(Diagnostic::warning(
codes::STATE_READ_AFTER_WRITE,
site.stage,
format!("{}/{i}/when/requires_state", site.path),
format!(
"this `{}` compares `{}`, and effect {at} of the same bundle already \
changes `{}` behind a gate on `{}` itself — so this comparison is made \
against the value THIS bundle just produced, on the far side of the \
boundary it just tested. The shape that bites is a purchase followed by \
its own refusal: buying the LAST coin debits it, and the `at-most` \
apology written after the debit then holds as well, so the player is \
charged AND told they cannot afford it. Move every reading effect ahead \
of the write. (An UNCONDITIONAL write followed by a comparison is not \
this: `set-state toll 0` and then a door gated on `toll at-most 0` \
plainly means the value the bundle just produced, and is not \
diagnosed.)",
eff.verb.tag(),
cmp.state.as_str(),
cmp.state.as_str(),
cmp.state.as_str()
),
));
}
if let Some((id, _)) = eff.writes_state()
&& eff
.requires_state()
.iter()
.any(|c| c.state.as_str() == id.as_str())
{
written.entry(id.as_str()).or_insert(i);
}
}
});
}
fn shop_anchor_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
if c.quests.content.shops.is_empty() {
return;
}
let providers = AnchorProviders::build(c, anchors);
for (i, sh) in c.quests.content.shops.iter().enumerate() {
if let Some(f) = station_kind_diag(
&providers,
sh.anchor.as_str(),
crate::layout::StationKind::Point,
"a shop counter",
"quests",
format!("/content/shops/{i}/anchor"),
) {
d.push(f);
}
if providers.resolvable(sh.anchor.as_str()) {
continue;
}
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/shops/{i}/anchor"),
format!(
"shop anchor `{}` is not provided by any prefab bound in this campaign — {}",
sh.anchor,
providers.anchor_remedy(
"use an anchor the prefab exposes (anchor names come from prefab metadata; do \
NOT invent one)"
),
),
));
}
}
fn economy_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let stakes = &c.quests.content.stakes;
let shops = &c.quests.content.shops;
if stakes.is_empty() && shops.is_empty() {
return;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, s) in stakes.iter().enumerate() {
if !s.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/stakes/{i}/id"),
format!(
"malformed stake id `{}` — stake ids are `stake/<kebab-case>`",
s.id
),
));
}
if !seen.insert(s.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/stakes/{i}/id"),
format!("duplicate stake id `{}`", s.id),
));
}
match c.quests.content.state_decl(s.state.as_str()) {
None => d.push(Diagnostic::error(
codes::STAKE_STATE_SCOPE,
"quests",
format!("/content/stakes/{i}/state"),
format!(
"stake `{}` forfeits `{}`, which the campaign never declares. Add it to the \
stage-5 `state` list with `\"scope\": \"player\"` — a stake is one player's \
wager, and a datum's scope is a fact no use site can supply",
s.id,
s.state.as_str()
),
)),
Some(decl) if decl.scope != crate::stages::StateScope::Player => {
d.push(Diagnostic::error(
codes::STAKE_STATE_SCOPE,
"quests",
format!("/content/stakes/{i}/state"),
format!(
"stake `{}` forfeits `{}`, which is declared `party`-scoped. A stake is a \
PERSONAL wager: one shared purse turns a teammate's death into a penalty \
on everyone, and nothing in the JSON would say so. Declare the datum \
`player`-scoped, or point the stake at one that is.",
s.id,
s.state.as_str()
),
));
}
Some(_) => {}
}
if let Some(crate::stages::Forfeit::Proportion { percent }) = s.forfeit
&& percent > 100
{
d.push(Diagnostic::error(
codes::STAKE_FORFEIT_RANGE,
"quests",
format!("/content/stakes/{i}/forfeit/percent"),
format!(
"stake `{}` forfeits {percent}% of `{}` — more than the whole purse. Use \
0–100, or `{{\"kind\": \"all\"}}`.",
s.id,
s.state.as_str()
),
));
}
}
let mut dropped: BTreeSet<String> = BTreeSet::new();
crate::stages::for_each_campaign_effect(c, &mut |path, _site, eff| {
let crate::stages::Verb::DropStake { stake, .. } = &eff.verb else {
return;
};
if c.quests.content.stake_decl(stake.as_str()).is_none() {
d.push(Diagnostic::error(
codes::STAKE_UNDECLARED,
"quests",
path.to_string(),
format!(
"`drop-stake` leaves `{}`, which the campaign never declares. Add it to the \
stage-5 `stakes` list, or fix the id",
stake.as_str()
),
));
return;
}
dropped.insert(stake.as_str().to_string());
});
for (i, s) in stakes.iter().enumerate() {
if dropped.contains(s.id.as_str()) {
continue;
}
d.push(Diagnostic::error(
codes::STAKE_NEVER_DROPPED,
"quests",
format!("/content/stakes/{i}/id"),
format!(
"stake `{}` is declared and no `drop-stake` effect anywhere in the campaign ever \
leaves one. Its forfeit rule, its retention policy and its whole compile-time \
placement table describe a mechanism no beat can fire. Drop it from a beat — \
`on_death` is the usual one — or delete the declaration.",
s.id
),
));
}
read_after_write_checks(c, d);
let mut seen_shop: BTreeSet<&str> = BTreeSet::new();
for (i, sh) in shops.iter().enumerate() {
if !sh.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/shops/{i}/id"),
format!(
"malformed shop id `{}` — shop ids are `shop/<kebab-case>`",
sh.id
),
));
}
if !seen_shop.insert(sh.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/shops/{i}/id"),
format!("duplicate shop id `{}`", sh.id),
));
}
if sh.offers.is_empty() {
d.push(Diagnostic::error(
codes::SHOP_OFFER_INERT,
"quests",
format!("/content/shops/{i}/offers"),
format!(
"shop `{}` declares no offers. Vanilla's dialog codec rejects an empty action \
list outright, so this is not merely an empty shop — it is a dialog that \
fails to load. Give it at least one offer, or delete the shop.",
sh.id
),
));
}
for (j, off) in sh.offers.iter().enumerate() {
if off.effects.is_empty() {
d.push(Diagnostic::error(
codes::SHOP_OFFER_INERT,
"quests",
format!("/content/shops/{i}/offers/{j}/effects"),
format!(
"offer `{}` in shop `{}` declares no effects: the button is drawn, is \
pressable, and does nothing. A control the player can operate must have \
an answer — a refusal counts, so a single `narrate` gated on \
`requires_state` (`at-most <price − 1>`) is enough.",
off.label, sh.id
),
));
}
}
}
}
fn envelope(c: &Campaign, d: &mut Vec<Diagnostic>) {
let stages = [
(Stage::World, c.world.stage, c.world.dsl_version.as_str()),
(Stage::Npcs, c.npcs.stage, c.npcs.dsl_version.as_str()),
(
Stage::Classes,
c.classes.stage,
c.classes.dsl_version.as_str(),
),
(
Stage::QuestPlan,
c.quest_plan.stage,
c.quest_plan.dsl_version.as_str(),
),
(Stage::Quests, c.quests.stage, c.quests.dsl_version.as_str()),
(
Stage::Dialogue,
c.dialogue.stage,
c.dialogue.dsl_version.as_str(),
),
];
let stages: Vec<(Stage, Stage, &str)> = stages
.into_iter()
.chain(
c.world_edits
.iter()
.map(|e| (Stage::WorldEdits, e.stage, e.dsl_version.as_str())),
)
.chain(
c.geometry_brief
.iter()
.map(|e| (Stage::GeometryBrief, e.stage, e.dsl_version.as_str())),
)
.chain(
c.layout_graph
.iter()
.map(|e| (Stage::LayoutGraph, e.stage, e.dsl_version.as_str())),
)
.chain(
c.site_plan
.iter()
.map(|e| (Stage::SitePlan, e.stage, e.dsl_version.as_str())),
)
.chain(
c.detail_plan
.iter()
.map(|e| (Stage::DetailPlan, e.stage, e.dsl_version.as_str())),
)
.chain(
c.design
.iter()
.map(|e| (Stage::Design, e.stage, e.dsl_version.as_str())),
)
.collect();
for (expected, actual, version) in stages {
if actual != expected {
d.push(Diagnostic::error(
codes::STAGE_MISMATCH,
expected.name(),
"/stage",
format!(
"`stage` is `{}` but this is the `{}` stage document — set `stage` to `{}` (or \
move this content into the `{}` document it belongs to)",
actual.name(),
expected.name(),
expected.name(),
actual.name(),
),
));
}
if version != DSL_VERSION {
d.push(Diagnostic::error(
codes::DSL_VERSION,
expected.name(),
"/dsl_version",
format!(
"dsl_version `{version}` is not the one this engine accepts — set it to \
`{DSL_VERSION}` and revise the document against that surface. An engine \
accepts exactly the number it implements (ADR-0024); a document written \
for another number is built by the engine that implements that number."
),
));
}
}
let ids: Vec<(Stage, &crate::ids::CampaignId)> = [
(Stage::World, &c.world.campaign_id),
(Stage::Npcs, &c.npcs.campaign_id),
(Stage::Classes, &c.classes.campaign_id),
(Stage::QuestPlan, &c.quest_plan.campaign_id),
(Stage::Quests, &c.quests.campaign_id),
(Stage::Dialogue, &c.dialogue.campaign_id),
]
.into_iter()
.chain(
c.world_edits
.iter()
.map(|e| (Stage::WorldEdits, &e.campaign_id)),
)
.chain(
c.geometry_brief
.iter()
.map(|e| (Stage::GeometryBrief, &e.campaign_id)),
)
.chain(
c.layout_graph
.iter()
.map(|e| (Stage::LayoutGraph, &e.campaign_id)),
)
.chain(
c.site_plan
.iter()
.map(|e| (Stage::SitePlan, &e.campaign_id)),
)
.chain(
c.detail_plan
.iter()
.map(|e| (Stage::DetailPlan, &e.campaign_id)),
)
.chain(c.design.iter().map(|e| (Stage::Design, &e.campaign_id)))
.collect();
let canonical = c.world.campaign_id.as_str();
for (stage, id) in ids {
if !id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
stage.name(),
"/campaign_id",
format!("malformed campaign_id `{id}` (expected kebab-case)"),
));
}
if id.as_str() != canonical {
d.push(Diagnostic::error(
codes::CAMPAIGN_ID_MISMATCH,
stage.name(),
"/campaign_id",
format!(
"campaign_id `{id}` differs from `{canonical}` (the world stage's id) — set \
every stage's `campaign_id` to `{canonical}` so all six documents name one \
campaign"
),
));
}
}
}
fn syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
macro_rules! chk {
($id:expr, $stage:expr, $path:expr) => {
if !$id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
$stage,
$path,
format!(
"malformed id `{}` — this field takes {}: the type prefix, a `/`, and \
one lowercase kebab-case segment after it ([a-z0-9] and `-`, no second \
`/`, no capitals, no underscores)",
$id,
$id.syntax_form()
),
));
}
};
}
for (i, a) in c.world.content.areas.iter().enumerate() {
chk!(a.id, "world", format!("/content/areas/{i}/id"));
if let Some(prefab) = &a.prefab {
chk!(prefab, "world", format!("/content/areas/{i}/prefab"));
}
if let Some(pool) = &a.prefab_pool {
chk!(pool, "world", format!("/content/areas/{i}/prefab_pool"));
}
}
for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
chk!(npc.id, "npcs", format!("/content/npcs/{i}/id"));
}
for (i, cl) in c.classes.content.classes.iter().enumerate() {
chk!(cl.id, "classes", format!("/content/classes/{i}/id"));
}
for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
chk!(q.id, "quest-plan", format!("/content/quests/{i}/id"));
}
for (i, q) in c.quests.content.quests.iter().enumerate() {
chk!(q.id, "quests", format!("/content/quests/{i}/id"));
for (j, obj) in q.objectives.iter().enumerate() {
chk!(
obj.id(),
"quests",
format!("/content/quests/{i}/objectives/{j}/id")
);
}
}
for (i, a) in c.quests.content.actors.iter().enumerate() {
chk!(a.id, "quests", format!("/content/actors/{i}/id"));
}
for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
for (j, node) in tree.nodes.iter().enumerate() {
chk!(
node.id,
"dialogue",
format!("/content/dialogues/{i}/nodes/{j}/id")
);
}
}
}
fn dup_check<'a>(
ids: impl Iterator<Item = (&'a str, String)>,
stage: &'static str,
what: &str,
d: &mut Vec<Diagnostic>,
) {
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (id, path) in ids {
if !seen.insert(id) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
stage,
path,
format!("duplicate {what} id `{id}` — rename one so every {what} id is unique"),
));
}
}
}
fn uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
dup_check(
c.world
.content
.areas
.iter()
.enumerate()
.map(|(i, a)| (a.id.as_str(), format!("/content/areas/{i}/id"))),
"world",
"area",
d,
);
dup_check(
c.npcs
.content
.npcs
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), format!("/content/npcs/{i}/id"))),
"npcs",
"npc",
d,
);
dup_check(
c.classes
.content
.classes
.iter()
.enumerate()
.map(|(i, cl)| (cl.id.as_str(), format!("/content/classes/{i}/id"))),
"classes",
"class",
d,
);
dup_check(
c.quest_plan
.content
.quests
.iter()
.enumerate()
.map(|(i, q)| (q.id.as_str(), format!("/content/quests/{i}/id"))),
"quest-plan",
"quest",
d,
);
dup_check(
c.quests
.content
.quests
.iter()
.enumerate()
.map(|(i, q)| (q.id.as_str(), format!("/content/quests/{i}/id"))),
"quests",
"quest",
d,
);
dup_check(
c.quests
.content
.quests
.iter()
.enumerate()
.flat_map(|(i, q)| {
q.objectives.iter().enumerate().map(move |(j, o)| {
(
o.id().as_str(),
format!("/content/quests/{i}/objectives/{j}/id"),
)
})
}),
"quests",
"objective",
d,
);
dup_check(
c.quests
.content
.actors
.iter()
.enumerate()
.map(|(i, a)| (a.id.as_str(), format!("/content/actors/{i}/id"))),
"quests",
"actor",
d,
);
dup_check(
c.dialogue
.content
.dialogues
.iter()
.enumerate()
.map(|(i, t)| (t.npc.as_str(), format!("/content/dialogues/{i}/npc"))),
"dialogue",
"dialogue tree for npc",
d,
);
for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
dup_check(
tree.nodes.iter().enumerate().map(|(j, node)| {
(
node.id.as_str(),
format!("/content/dialogues/{i}/nodes/{j}/id"),
)
}),
"dialogue",
"dialogue node",
d,
);
}
}
fn references(c: &Campaign, d: &mut Vec<Diagnostic>) {
let mut area_ids: BTreeSet<&str> = c
.world
.content
.areas
.iter()
.map(|a| a.id.as_str())
.collect();
if c.site_plan.is_some() {
area_ids.insert(crate::siteplan::SITE_AREA);
}
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let planned_ids: BTreeSet<&str> = c
.quest_plan
.content
.quests
.iter()
.map(|q| q.id.as_str())
.collect();
let expanded_ids: BTreeSet<&str> = c
.quests
.content
.quests
.iter()
.map(|q| q.id.as_str())
.collect();
let dangling = |d: &mut Vec<Diagnostic>, ok: bool, stage, path: String, msg: String| {
if !ok {
d.push(Diagnostic::error(codes::DANGLING_REF, stage, path, msg));
}
};
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
),
);
}
}
for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
dangling(
d,
area_ids.contains(q.area.as_str()),
"quest-plan",
format!("/content/quests/{i}/area"),
format!(
"quest references unknown area `{}` — {}",
q.area,
crate::placement::Placement::of(c).area_remedy(),
),
);
for (k, npc) in q.npcs.iter().enumerate() {
dangling(
d,
npc_ids.contains(npc.as_str()),
"quest-plan",
format!("/content/quests/{i}/npcs/{k}"),
format!(
"quest references unknown npc `{npc}` — declare it in stage 2 or correct the \
reference"
),
);
}
for (k, dep) in q.depends_on.iter().enumerate() {
dangling(
d,
planned_ids.contains(dep.as_str()),
"quest-plan",
format!("/content/quests/{i}/depends_on/{k}"),
format!(
"quest depends on unknown quest `{dep}` — declare it in the stage-4 quest \
plan or correct the `depends_on` entry"
),
);
}
}
for (i, q) in c.quests.content.quests.iter().enumerate() {
if let crate::stages::Trigger::QuestComplete { quest } = &q.trigger {
dangling(
d,
expanded_ids.contains(quest.as_str()),
"quests",
format!("/content/quests/{i}/trigger/quest"),
format!(
"quest trigger `quest-complete` references unknown quest `{quest}` — declare \
that quest in stage 5 or correct the reference"
),
);
}
let local_objs: BTreeSet<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
for (j, obj) in q.objectives.iter().enumerate() {
if let crate::stages::Objective::TalkTo { npc, .. } = obj {
dangling(
d,
npc_ids.contains(npc.as_str()),
"quests",
format!("/content/quests/{i}/objectives/{j}/npc"),
format!(
"`talk-to` objective references unknown npc `{npc}` — declare it in \
stage 2 or correct the reference"
),
);
}
for (m, aft) in obj.after().iter().enumerate() {
dangling(
d,
local_objs.contains(aft.as_str()),
"quests",
format!("/content/quests/{i}/objectives/{j}/after/{m}"),
format!(
"objective `after` references unknown objective `{aft}` — `after` may \
only name another objective in the same quest; declare it or correct \
the reference"
),
);
}
}
for key in q.on_objective_complete.keys() {
dangling(
d,
local_objs.contains(key.as_str()),
"quests",
format!("/content/quests/{i}/on_objective_complete/{key}"),
format!(
"`on_objective_complete` is keyed by unknown objective `{key}` — the key must \
name an objective declared in this quest; declare it or correct the key"
),
);
}
}
}
fn dialogue(c: &Campaign, d: &mut Vec<Diagnostic>) {
use crate::stages::{DialogueEffect, Objective};
let mut all_objectives: BTreeSet<&str> = BTreeSet::new();
let mut talk_npc: BTreeMap<&str, &str> = BTreeMap::new();
for q in &c.quests.content.quests {
for o in &q.objectives {
all_objectives.insert(o.id().as_str());
if let Objective::TalkTo { id, npc, .. } = o {
talk_npc.insert(id.as_str(), npc.as_str());
}
}
}
let mut reachable_completes: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
let node_ids: BTreeSet<&str> = tree.nodes.iter().map(|n| n.id.as_str()).collect();
for (j, node) in tree.nodes.iter().enumerate() {
for (k, opt) in node.options.iter().enumerate() {
if let Some(next) = &opt.next
&& !node_ids.contains(next.as_str())
{
d.push(Diagnostic::error(
codes::DIALOGUE_BAD_REF,
"dialogue",
format!("/content/dialogues/{i}/nodes/{j}/options/{k}/next"),
format!(
"dialogue option `next` references unknown node `{next}` — add a node \
with that id to this tree or correct the reference"
),
));
}
for (m, eff) in opt.effects.iter().enumerate() {
let DialogueEffect::CompleteObjective { objective } = eff else {
continue;
};
let oid = objective.as_str();
let path = format!(
"/content/dialogues/{i}/nodes/{j}/options/{k}/effects/{m}/objective"
);
let msg = if !all_objectives.contains(oid) {
Some(format!(
"dialogue `complete-objective` effect references unknown objective \
`{objective}` — it must name a `talk-to` objective on this tree's \
npc; declare it or correct the reference"
))
} else if let Some(owner) = talk_npc.get(oid) {
if *owner == tree.npc.as_str() {
None
} else {
Some(format!(
"dialogue effect completes `talk-to` objective `{objective}`, \
which belongs to npc `{owner}`, not this tree's npc `{}` — a tree \
may only complete its own npc's objectives; move the effect into \
`{owner}`'s tree",
tree.npc
))
}
} else {
Some(format!(
"dialogue `complete-objective` effect targets objective `{objective}`, \
which is not a `talk-to` objective — only `talk-to` objectives are \
completed through dialogue; retarget it or change the objective's type"
))
};
if let Some(msg) = msg {
d.push(Diagnostic::error(
codes::DIALOGUE_BAD_OBJECTIVE,
"dialogue",
path,
msg,
));
}
}
}
}
if !node_ids.contains(tree.root.as_str()) {
d.push(Diagnostic::error(
codes::DIALOGUE_BAD_REF,
"dialogue",
format!("/content/dialogues/{i}/root"),
format!(
"dialogue tree `root` references unknown node `{}` — add a node with that id \
or point `root` at an existing node",
tree.root
),
));
continue; }
let mut roots = vec![tree.root.as_str()];
for q in &c.quests.content.quests {
for (npc, entry) in &q.cast {
if npc.as_str() != tree.npc.as_str() {
continue;
}
for p in entry.placements() {
if let Some(crate::stages::CastDialogue::Root(r)) = &p.dialogue {
roots.push(r.as_str());
}
}
}
}
let seen = tree.reachable_from(&roots);
for (j, node) in tree.nodes.iter().enumerate() {
if !seen.contains(node.id.as_str()) {
d.push(Diagnostic::error(
codes::DIALOGUE_UNREACHABLE,
"dialogue",
format!("/content/dialogues/{i}/nodes/{j}"),
format!(
"dialogue node `{}` is unreachable from `root` — add an option whose \
`next` leads here from a reachable node, or remove this node",
node.id
),
));
}
}
let completes = reachable_completes.entry(tree.npc.as_str()).or_default();
for node in &tree.nodes {
if !seen.contains(node.id.as_str()) {
continue;
}
for opt in &node.options {
for eff in &opt.effects {
if let DialogueEffect::CompleteObjective { objective } = eff {
completes.insert(objective.as_str());
}
}
}
}
}
for (qi, q) in c.quests.content.quests.iter().enumerate() {
for (oi, o) in q.objectives.iter().enumerate() {
if let Objective::TalkTo { id, npc, .. } = o {
let covered = reachable_completes
.get(npc.as_str())
.is_some_and(|s| s.contains(id.as_str()));
if !covered {
d.push(Diagnostic::error(
codes::DIALOGUE_UNCOVERED,
"dialogue",
format!("/content/quests/{qi}/objectives/{oi}"),
format!(
"`talk-to` objective `{id}` has no reachable dialogue option in npc \
`{npc}`'s tree that completes it — add an option (reachable from \
`root`) with a `complete-objective` effect for `{id}`, else the \
objective can never finish"
),
));
}
}
}
}
}
fn plan(c: &Campaign, d: &mut Vec<Diagnostic>) {
let plan = &c.quest_plan.content;
let planned_ids: BTreeSet<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
let optional: BTreeSet<&str> = plan.optional();
let edges: BTreeMap<&str, Vec<&str>> = plan
.quests
.iter()
.map(|q| {
let deps = q
.depends_on
.iter()
.map(|x| x.as_str())
.filter(|x| planned_ids.contains(x))
.collect();
(q.id.as_str(), deps)
})
.collect();
let nodes: Vec<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
if graph_has_cycle(&nodes, &edges) {
d.push(Diagnostic::error(
codes::PLAN_CYCLE,
"quest-plan",
"/content/quests",
"stage-4 quest `depends_on` graph contains a cycle — the plan must be a DAG; remove a \
`depends_on` edge so the quests form an acyclic order",
));
return; }
if !planned_ids.contains(plan.finale.as_str()) {
d.push(Diagnostic::error(
codes::FINALE_UNKNOWN,
"quest-plan",
"/content/finale",
format!(
"stage-4 `finale` `{}` is not a declared quest — set `finale` to the id of an \
existing planned quest (the one that ends the delve)",
plan.finale
),
));
return;
}
let reach = plan.spine();
for (i, q) in plan.quests.iter().enumerate() {
if optional.contains(q.id.as_str()) {
continue;
}
if !reach.contains(q.id.as_str()) {
d.push(Diagnostic::error(
codes::PLAN_NOT_CONVERGENT,
"quest-plan",
format!("/content/quests/{i}"),
format!(
"quest `{}` is not a (transitive) dependency of finale `{}`, so the plan does \
not converge on the finale — add a `depends_on` chain so `{}` eventually \
depends on `{}` (or drop `{}` if it is not part of this delve)",
q.id, plan.finale, plan.finale, q.id, q.id
),
));
}
}
partition(c, &optional, &reach, d);
}
fn partition(
c: &Campaign,
optional: &BTreeSet<&str>,
spine: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
if optional.is_empty() {
return;
}
let plan = &c.quest_plan.content;
for (i, q) in plan.quests.iter().enumerate() {
if !optional.contains(q.id.as_str()) || !spine.contains(q.id.as_str()) {
continue;
}
let how = if q.id.as_str() == plan.finale.as_str() {
"it IS the finale".to_string()
} else {
format!("finale `{}` transitively depends on it", plan.finale)
};
d.push(Diagnostic::error(
codes::OPTIONAL_ON_SPINE,
"quest-plan",
format!("/content/quests/{i}/mandatory"),
format!(
"quest `{}` declares `mandatory: false`, but {} — so the delve cannot be \
completed without it and calling it optional would be a claim the \
completability proof then rests on. Set `mandatory: true`, or cut the \
`depends_on` chain that puts it in the finale's closure. Do not leave it for \
the proof to sort out: the skip world is exactly the world in which this \
quest is never played, and the finale never fires there",
q.id, how
),
));
}
for (i, q) in plan.quests.iter().enumerate() {
if optional.contains(q.id.as_str()) {
continue; }
for (j, dep) in q.depends_on.iter().enumerate() {
if !optional.contains(dep.as_str()) {
continue;
}
d.push(Diagnostic::error(
codes::MANDATORY_ON_OPTIONAL,
"quest-plan",
format!("/content/quests/{i}/depends_on/{j}"),
format!(
"mandatory quest `{}` declares `depends_on` `{}`, which is optional — a \
quest on the critical path cannot wait on content the party may never \
play, so this edge makes the mainline unreachable in the skip world. \
Either mark `{}` mandatory, or drop the edge and attach `{}` to the \
spine some other way",
q.id, dep, dep, dep
),
));
}
}
let declared: BTreeSet<&str> = plan.quests.iter().map(|q| q.id.as_str()).collect();
for (i, q) in c.quests.content.quests.iter().enumerate() {
if optional.contains(q.id.as_str()) || !declared.contains(q.id.as_str()) {
continue;
}
let crate::stages::Trigger::QuestComplete { quest } = &q.trigger else {
continue;
};
if !optional.contains(quest.as_str()) {
continue;
}
d.push(Diagnostic::error(
codes::MANDATORY_ON_OPTIONAL,
"quests",
format!("/content/quests/{i}/trigger/quest"),
format!(
"mandatory quest `{}` is triggered by the completion of `{}`, which is \
optional — the party may never complete `{}`, so `{}` would never activate \
and the mainline would stop there. Trigger `{}` from a mandatory quest, or \
mark `{}` mandatory",
q.id, quest, quest, q.id, q.id, quest
),
));
}
mainline_key(c, optional, d);
}
fn mainline_key(c: &Campaign, optional: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) {
let mut only_optional: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut disqualified: BTreeSet<&str> = BTreeSet::new();
crate::stages::for_each_campaign_effect(c, &mut |_path, site, eff| {
let Verb::SetFlag { flag, .. } = &eff.verb else {
return;
};
let flag = flag.as_str();
let owner = match site {
crate::stages::EffectSite::Objective { quest, .. }
| crate::stages::EffectSite::QuestComplete { quest } => quest.as_str(),
_ => {
disqualified.insert(flag);
return;
}
};
match optional.get(owner) {
Some(q) => only_optional.entry(flag).or_default().insert(*q),
None => disqualified.insert(flag),
};
});
for t in &c.dialogue.content.dialogues {
for n in &t.nodes {
for o in &n.options {
for e in &o.effects {
if let crate::stages::DialogueEffect::SetFlag { flag } = e {
disqualified.insert(flag.as_str());
}
}
}
}
}
for trap in &c.quests.content.traps {
if let Some(dis) = &trap.disarm {
disqualified.insert(dis.sets_flag.as_str());
}
}
for (i, q) in c.quests.content.quests.iter().enumerate() {
if optional.contains(q.id.as_str()) {
continue;
}
for (j, o) in q.objectives.iter().enumerate() {
for (m, f) in o.requires_flags().iter().enumerate() {
let flag = f.as_str();
if disqualified.contains(flag) {
continue;
}
let Some(producers) = only_optional.get(flag) else {
continue; };
let names = producers.iter().copied().collect::<Vec<_>>().join("`, `");
d.push(Diagnostic::error(
codes::MAINLINE_KEY_OPTIONAL,
"quests",
format!("/content/quests/{i}/objectives/{j}/requires_flags/{m}"),
format!(
"objective `{}` of mandatory quest `{}` requires flag `{}`, and the \
only effect that ever sets `{}` is rooted in optional quest(s) \
`{}` — so a party that plays only the mainline can never open \
this beat, and the delve is not completable with zero optional \
participation. Move the `set-flag` onto a mandatory quest, mark \
the producing quest mandatory, or drop the gate",
o.id(),
q.id,
flag,
flag,
names
),
));
}
}
}
}
fn after_ordering(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, q) in c.quests.content.quests.iter().enumerate() {
let local: BTreeSet<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
let edges: BTreeMap<&str, Vec<&str>> = q
.objectives
.iter()
.map(|o| {
let deps = o
.after()
.iter()
.map(|x| x.as_str())
.filter(|x| local.contains(x))
.collect();
(o.id().as_str(), deps)
})
.collect();
let nodes: Vec<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
if graph_has_cycle(&nodes, &edges) {
d.push(Diagnostic::error(
codes::AFTER_CYCLE,
"quests",
format!("/content/quests/{i}/objectives"),
format!(
"objective `after` ordering in quest `{}` contains a cycle — the `after` \
edges must form a DAG; remove one `after` entry to break the cycle",
q.id
),
));
}
}
}
fn one_piece_gap(c: &Campaign) -> String {
let areas = &c.world.content.areas;
match areas.len() {
0 => ", and no area is declared at all".to_string(),
1 => format!(
", and its one area `{id}` draws from a pool rather than binding a single `prefab`",
id = areas[0].id.as_str(),
),
n => format!(", which is {n} areas rather than one"),
}
}
fn horizon_param_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
use crate::stages::{HorizonBase, horizon_defaults};
let Some(h) = c.world.content.horizon.as_ref() else {
return;
};
let r = h.resolved();
if let crate::stages::Horizon::Spec(spec) = h {
let mut foreign: Vec<&str> = Vec::new();
if !matches!(r.base, HorizonBase::Valley) {
if spec.ratio.is_some() {
foreign.push("ratio");
}
if spec.rim_height.is_some() {
foreign.push("rim_height");
}
}
for name in foreign {
d.push(Diagnostic::error(
codes::HORIZON_PARAM,
"world",
format!("/content/horizon/{name}"),
format!(
"`{name}` is a `valley` param and this horizon declares base `{base}`, which \
reads nothing from it. Remove it, or declare `base: \"valley\"` — a param \
nothing reads is a statement the author believes is taking effect.",
base = r.base.token()
),
));
}
}
if r.base.has_surround() && !crate::placement::Extent::of(c).is_stated() {
d.push(Diagnostic::error(
codes::SURROUND_NO_REGION,
"world",
"/content/horizon/base",
format!(
"`horizon` base `{base}` builds terrain around the map, and this campaign never \
says how big the map is. A surround rings a DECLARED extent, and this campaign \
declares none: it places {n} area(s) with `areas[]`{how}. The union of whatever \
those place is not a substitute — areas sit on the compiler's fixed stride with \
void between them, and a pool's footprint is whatever the solver drew — so that \
union is mostly nothing and the horizon would be a mountain range built around \
empty space. There are three moves and all three are reachable from here: make \
the map ONE PIECE — a single area bound to a single `prefab`, whose own declared \
region is then the map's extent, which is how a site (a building with its \
island, its moat and its banks in one box) is placed; or give the campaign a \
site plan and declare `areas` empty, which is the same choice `DW0839` asks for; \
or set `horizon` to `void` or `ocean`, which need no map to be a horizon of.",
base = r.base.token(),
n = c.world.content.areas.len(),
how = one_piece_gap(c),
),
));
}
if r.base.has_surround() {
if !(horizon_defaults::RATIO_MIN..=horizon_defaults::RATIO_MAX).contains(&r.ratio)
|| !r.ratio.is_finite()
{
d.push(Diagnostic::error(
codes::HORIZON_PARAM,
"world",
"/content/horizon/ratio",
format!(
"`ratio` = {} is out of range — set it within {}..={} ({} is the default). \
It is the surround's total footprint as a multiple of the \
map's: under {} there is no room for a gap floor and a slope run \
both, and over {} the surround is mostly terrain no body reaches, at a cost \
that is all shipped bytes.",
r.ratio,
horizon_defaults::RATIO_MIN,
horizon_defaults::RATIO_MAX,
horizon_defaults::RATIO,
horizon_defaults::RATIO_MIN,
horizon_defaults::RATIO_MAX,
),
));
}
if !(horizon_defaults::RIM_HEIGHT_MIN..=horizon_defaults::RIM_HEIGHT_MAX)
.contains(&r.rim_height)
{
d.push(Diagnostic::error(
codes::HORIZON_PARAM,
"world",
"/content/horizon/rim_height",
format!(
"`rim_height` = {} is out of range — set it within {}..={} ({} is the \
default). It is the crest's height over the gap floor: under {} \
the rim does not close the horizon from a body standing on that floor, and \
over {} the surround stops fitting under whatever the map puts above it.",
r.rim_height,
horizon_defaults::RIM_HEIGHT_MIN,
horizon_defaults::RIM_HEIGHT_MAX,
horizon_defaults::RIM_HEIGHT,
horizon_defaults::RIM_HEIGHT_MIN,
horizon_defaults::RIM_HEIGHT_MAX,
),
));
}
}
}
fn body_traversal_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
for site in body_traversal_sites(c) {
if site.traversal.locomotion != Locomotion::Aquatic {
continue;
}
let (stage, path, id) = (site.body.stage(), &site.path, site.body.id());
d.push(Diagnostic::error(
codes::TRAVERSAL_UNPROVABLE,
stage,
format!("{path}/locomotion"),
format!(
"`{id}` declares `locomotion: aquatic`, which the compiler cannot hold it to. \
`aquatic` is the one class that carries no exemption and governs no rule — it is \
a ledger label derived from vanilla's own `#minecraft:aquatic` tag — so the \
declaration could never change a verdict and would be reported inert (`DW0454`). \
The gap, stated rather than left to folklore: routing has ONE reachability model, \
standable ground, and water-flooded cells are impassable and never floor for \
EVERY body, so there is nothing for an aquatic claim to feed. Prescription: \
remove the declaration — a route that crosses water is already governed by the \
flooded-cell rules, and a body vanilla itself calls aquatic still reaches the \
traversal proof's binding ledger under its derived class."
),
));
}
}
fn press_answer_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, t) in c.quests.content.triggers.iter().enumerate() {
if t.addresses_presser() && !matches!(t.on, TriggerOn::Use) {
d.push(Diagnostic::error(
codes::TRIGGER_AUDIENCE_UNATTRIBUTABLE,
"quests",
format!("/content/triggers/{i}/audience"),
format!(
"trigger `{}` watches a `{}` and asks for `audience: presser`, but vanilla \
can only attribute a RIGHT-click to a player. \
`minecraft:player_interacted_with_entity` is the one criterion that runs a \
function as the clicker; a left-click is recorded in the interaction \
entity's `attack` NBT, which names a UUID no command can become, and an \
`approach` has no click at all. Guessing — polling the record and hoping the \
nearest player is the striker — is the kind of downstream folklore this \
engine refuses (CLAUDE.md: a capability with no vanilla primitive under it \
is excluded, not faked). Prescription: make it an `on: use` trigger, or drop \
`audience` and let the beat address the party",
t.id,
t.on.kind()
),
));
}
let local = crate::l10n::local_id(t.id.as_str());
if local.starts_with(RESERVED_TRIGGER_PREFIX) {
d.push(Diagnostic::error(
codes::TRIGGER_ID_RESERVED,
"quests",
format!("/content/triggers/{i}/id"),
format!(
"trigger id `{}` opens with `{RESERVED_TRIGGER_PREFIX}`, which the compiler \
reserves for the triggers it synthesizes itself — today the press answer \
every sealed gate and shortcut door gives (`trigger/dw-press-…`). Two \
triggers with one id would share one `dw_trig_…` tag and one emitted \
function, so one of them would silently vanish. Prescription: rename it; any \
kebab id not starting with `{RESERVED_TRIGGER_PREFIX}` is yours",
t.id
),
));
}
}
}
fn press_obligation_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let quests = &c.quests.content;
let mut sealed: BTreeMap<&str, (bool, String)> = BTreeMap::new();
crate::stages::for_each_campaign_effect(c, &mut |path, _site, eff| {
let Some(anchor) = eff.close_gate_anchor() else {
return;
};
let entry = sealed
.entry(anchor.as_str())
.or_insert_with(|| (false, path.to_string()));
entry.0 |= eff.close_gate_sealed_hint().is_some();
});
for (anchor, (authored, path)) in sealed {
if authored || quests.answers_press_at(anchor) {
continue;
}
d.push(Diagnostic::error(
codes::SEALED_BODY_UNANSWERED,
"quests",
path,
format!(
"this `close-gate` seals `{anchor}`, and nothing says what the wall answers when \
the party presses it. A seal is a thing the party walks back to and pushes on, \
so the press has to say something — and the compiler will not word it for you: a \
baked default decides this wall's tone on your behalf and never tells you it \
did. Two ways to say it, and either is enough: add `\"sealed_hint\": \"<what the \
wall says>\"` to this effect, or anchor a trigger on the gate — \
`{{\"id\": \"trigger/<name>\", \"at\": \"{anchor}\", \"on\": {{\"on\": \"use\"}}, \
\"once\": false, \"audience\": \"presser\", \"effects\": [{{\"type\": \"narrate\", \
\"style\": \"actionbar\", \"text\": \"<what the wall says>\"}}]}}`. The trigger form \
is the general one and can carry a sound, a flag gate or any other effect"
),
));
}
for (i, sc) in quests.shortcuts.iter().enumerate() {
let gate = sc.gate.as_str();
if quests.answers_press_at(gate) {
continue;
}
d.push(Diagnostic::error(
codes::SEALED_BODY_UNANSWERED,
"quests",
format!("/content/shortcuts/{i}"),
format!(
"shortcut `{}` bars the gate `{gate}` from world-load, and nothing in the \
campaign answers a right-click on it — so a player who walks the long way \
round, arrives at the wrong side of the door and pushes on it is told nothing. \
That is the press a shortcut loop most invites. The compiler will not word it \
for you: a baked default would be the engine deciding this door's tone and \
never saying that it had. A `shortcut` carries no wording field, deliberately — \
the line is a trigger. Prescription: add a trigger anchored on the gate — \
`{{\"id\": \"trigger/<name>\", \"at\": \"{gate}\", \"on\": {{\"on\": \"use\"}}, \
\"once\": false, \"audience\": \"presser\", \"effects\": [{{\"type\": \"narrate\", \
\"style\": \"actionbar\", \"text\": \"<what the door says>\"}}]}}` — which rides \
the door's own hitboxes, fires only from the sealed side, and retires when the \
door opens. Any `use` trigger on `{gate}` discharges this, whatever it does",
sc.id
),
));
}
}
const RESERVED_TRIGGER_PREFIX: &str = "dw-";
fn ns_item(id: &str) -> String {
if id.contains(':') {
id.to_string()
} else {
format!("minecraft:{id}")
}
}
fn class_blind_item_sources(c: &Campaign) -> BTreeSet<String> {
let mut src: BTreeSet<String> = BTreeSet::new();
let quests = &c.quests.content;
crate::stages::for_each_campaign_effect(c, &mut |_path, _site, eff| {
if let Some(item) = eff.give_item() {
src.insert(ns_item(item));
}
});
for q in &quests.quests {
for o in &q.objectives {
if let Objective::Collect { item, .. } = o {
src.insert(ns_item(item));
}
}
}
for l in &quests.loot {
for it in &l.items {
src.insert(ns_item(&it.item));
}
}
for w in &quests.waves {
for m in &w.mobs {
for drop in &m.drops {
match (drop.item(), drop.slot()) {
(Some(item), _) => {
src.insert(ns_item(item));
}
(None, Some(slot)) => {
if let Some(eq) = m.equipment.as_ref().and_then(|e| e.filled(slot)) {
src.insert(ns_item(eq.item()));
}
}
(None, None) => {}
}
}
}
}
src
}
fn item_gate_class_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let classes = &c.classes.content.classes;
if classes.is_empty() {
return;
}
let blind = class_blind_item_sources(c);
for (i, q) in c.quests.content.quests.iter().enumerate() {
for (j, o) in q.objectives.iter().enumerate() {
let Objective::Interact {
id, requires_item, ..
} = o
else {
continue;
};
let Some(raw) = requires_item.as_deref() else {
continue;
};
let item = ns_item(raw);
if blind.contains(&item) {
continue;
}
let cannot: Vec<&str> = classes
.iter()
.filter(|cl| !cl.kit.iter().any(|k| ns_item(&k.item) == item))
.map(|cl| cl.id.as_str())
.collect();
if cannot.is_empty() {
continue;
}
let supply = if cannot.len() == classes.len() {
"nothing in this campaign supplies it at all".to_string()
} else {
format!(
"its only supply is another class's kit, so {} cannot bring it: {}",
if cannot.len() == 1 {
"one class"
} else {
"those classes"
},
cannot.join(", ")
)
};
d.push(Diagnostic::error(
codes::ITEM_GATE_UNBRINGABLE,
"quests",
format!("/content/quests/{i}/objectives/{j}/requires_item"),
format!(
"objective `{}` completes only for a player HOLDING `{raw}`, and {supply}. A \
delve is played by one to four players who each pick one class, so a solo \
player of any class is a party this campaign must be finishable by — and \
this one is assembled unable to finish, which it learns standing at the \
thing it cannot press. Three ways to supply it, and any one is enough: put \
the item in a `collect` objective or a `loot` container on the way to this \
gate; hand it out with a `give-item` effect (its default `carrier` is `all` \
— every party member); or add it to EVERY class kit rather than one. Do not \
drop `requires_item` to silence this — presenting the item is the beat.",
id.as_str()
),
));
}
}
}
fn status_effect_checks(c: &Campaign, effects: &dyn EffectRegistry, d: &mut Vec<Diagnostic>) {
crate::effects::for_each_effect_root(c, &mut |site, list| {
status_effect_bundle(site.stage, &site.path, list, effects, d);
});
}
fn status_effect_bundle(
stage: &'static str,
path: &str,
list: &[QuestEffect],
effects: &dyn EffectRegistry,
d: &mut Vec<Diagnostic>,
) {
let tl = effect_timeline(path, list);
check_grant_removal(stage, &tl, d);
for (_, p, eff) in &tl {
check_one_status_effect(stage, p, eff, effects, d);
for (pseg, _key, inner) in eff.nested_effect_lists_labeled() {
status_effect_bundle(stage, &format!("{p}/{pseg}"), inner, effects, d);
}
}
}
fn effect_timeline<'a>(path: &str, list: &'a [QuestEffect]) -> Vec<(u32, String, &'a QuestEffect)> {
let mut out = Vec::new();
for (i, eff) in list.iter().enumerate() {
match &eff.verb {
Verb::Sequence { steps } => {
for (s, step) in steps.iter().enumerate() {
for (k, inner) in step.effects.iter().enumerate() {
out.push((
step.at_ticks,
format!("{path}/{i}/steps/{s}/effects/{k}"),
inner,
));
}
}
}
_ => out.push((0, format!("{path}/{i}"), eff)),
}
}
out
}
fn check_grant_removal(
stage: &'static str,
timeline: &[(u32, String, &QuestEffect)],
d: &mut Vec<Diagnostic>,
) {
for (gi, (g_tick, g_path, g_eff)) in timeline.iter().enumerate() {
let Some((id, seconds, _, _, _)) = g_eff.give_effect() else {
continue;
};
let granted = crate::registry::namespaced_effect_id(id);
let removal = timeline
.iter()
.enumerate()
.filter(|(ci, (t, _, _))| (*t, *ci) > (*g_tick, gi))
.filter_map(|(ci, (t, p, e))| match e.clear_effect() {
Some((None, _)) => Some(((*t, ci), t, p, "clears every effect")),
Some((Some(c), _)) if crate::registry::namespaced_effect_id(c) == granted => {
Some(((*t, ci), t, p, "clears it"))
}
_ => None,
})
.min_by_key(|(key, _, _, _)| *key)
.map(|(_, t, p, what)| (*t, p, what));
let Some((c_tick, c_path, what)) = removal else {
continue;
};
let live_ticks = seconds.saturating_mul(20);
let elapsed = c_tick - g_tick;
if elapsed >= live_ticks {
continue;
}
d.push(Diagnostic::error(
codes::EFFECT_CLEARED_LIVE,
stage,
g_path.clone(),
format!(
"this `give-effect` grants `{id}` for {seconds}s ({live_ticks} ticks) and the \
`clear-effect` at `{c_path}`, {elapsed} tick(s) later in the same bundle, {what} \
while it is still live — so the CLEAR is what ends this grant, not its duration. \
Every path that does not reach that clear (a logout, a crash, a death mid-chain, \
a `sequence` whose remaining schedule never runs) leaves the player holding \
`{id}` for the rest of the {seconds}s. Set `seconds` to how long the effect \
should actually last — {elapsed} tick(s) here, so {} — and drop the \
`clear-effect`: a duration expires with no cooperation from anything. \
`clear-effect` is for effects this campaign did not grant.",
if elapsed == 0 {
"the grant is doing nothing at all".to_string()
} else {
format!("`seconds`: {}", elapsed.div_ceil(20).max(1))
}
),
));
}
}
fn check_one_status_effect(
stage: &'static str,
path: &str,
eff: &QuestEffect,
effects: &dyn EffectRegistry,
d: &mut Vec<Diagnostic>,
) {
for (sub, id) in eff.status_effect_refs() {
if !effects.contains(id) {
d.push(Diagnostic::error(
codes::EFFECT_UNKNOWN,
stage,
format!("{path}/{sub}"),
format!(
"`{}` names status effect `{id}`, which is not in the pinned 1.21.11 \
`mob_effect` registry — use a valid namespaced effect id (e.g. \
`minecraft:blindness`)",
eff.verb.tag()
),
));
}
}
let Some((_, seconds, amplifier, _, _)) = eff.give_effect() else {
return;
};
if seconds == 0 || seconds > crate::stages::MAX_EFFECT_SECONDS {
d.push(Diagnostic::error(
codes::EFFECT_GRANT_BOUNDS,
stage,
format!("{path}/seconds"),
format!(
"`give-effect` duration {seconds}s is out of range — it must be between 1 and {} \
seconds. {}",
crate::stages::MAX_EFFECT_SECONDS,
if seconds == 0 {
"Zero grants nothing at all: the effect is applied and gone before the next \
tick, so the beat reports green and the player sees nothing."
} else {
"The ceiling is vanilla's own field width (past the 10-hour delve ceiling), \
so a value above it is a duration typed in ticks or milliseconds."
}
),
));
}
if amplifier > crate::stages::MAX_POTION_AMPLIFIER {
d.push(Diagnostic::error(
codes::EFFECT_GRANT_BOUNDS,
stage,
format!("{path}/amplifier"),
format!(
"`give-effect` amplifier {amplifier} is out of range — vanilla stores it in an \
unsigned byte, so {} is the end of the field, not a policy",
crate::stages::MAX_POTION_AMPLIFIER
),
));
}
}
fn gate_contradiction_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::gate::for_each_gate(c, &mut |site, gate| {
let Some(contra) = gate.contradiction() else {
return;
};
let what = match contra {
crate::gate::GateContradiction::Flag(f) => {
format!("flag `{f}` is both required and forbidden, so the gate is never satisfied")
}
crate::gate::GateContradiction::Datum(s) => format!(
"no value of `{s}` satisfies every `requires_state` term that reads it, so the \
gate is never satisfied"
),
};
d.push(Diagnostic::error(
codes::GATE_NEVER_OPENS,
site.consumer.stage(),
site.path.clone(),
format!(
"this {}'s gate contradicts itself: {what}. Whatever it guards can never happen — \
fix the gate, or delete the thing it makes unreachable",
site.consumer.label()
),
));
});
}
fn state_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let decls = &c.quests.content.state;
let mut declared: BTreeMap<&str, &crate::stages::StateDecl> = BTreeMap::new();
for (i, s) in decls.iter().enumerate() {
if !s.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/state/{i}/id"),
format!(
"runtime-state id `{}` is malformed — ids are `state/<kebab-case>`",
s.id.as_str()
),
));
continue;
}
if declared.insert(s.id.as_str(), s).is_some() {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/state/{i}/id"),
format!(
"runtime-state id `{}` is declared more than once — one datum, one \
declaration (its scope and its initial value have to be a single fact)",
s.id.as_str()
),
));
}
}
let mut read: BTreeSet<String> = BTreeSet::new();
crate::gate::for_each_gate(c, &mut |site, gate| {
for (k, cmp) in gate.requires_state.iter().enumerate() {
let path = format!("{}/requires_state/{k}", site.path);
let stage = site.consumer.stage();
match declared.get(cmp.state.as_str()) {
None => d.push(Diagnostic::error(
codes::STATE_UNDECLARED,
stage,
path,
format!(
"`requires_state` on this {} compares `{}`, which the campaign never \
declares. Add it to the stage-5 `state` list (a datum's scope and its \
initial value are facts no use site can supply), or fix the id",
site.consumer.label(),
cmp.state.as_str()
),
)),
Some(decl) => {
read.insert(cmp.state.as_str().to_string());
if decl.scope == crate::stages::StateScope::Player
&& site.consumer.evaluates_per_player() == Some(false)
{
d.push(Diagnostic::error(
codes::STATE_SCOPE_UNREACHABLE,
stage,
path,
format!(
"`{}` is `player`-scoped, but emission evaluates a {}'s gate \
against the party holder — there is no acting player to read it \
from. Declare the datum `party`-scoped, or move the comparison \
onto a site a player drives (a dialogue option, a cast \
placement, or an effect on a beat a player completes)",
cmp.state.as_str(),
site.consumer.label()
),
));
}
}
}
}
});
let mut written: BTreeSet<String> = BTreeSet::new();
crate::stages::for_each_campaign_effect(c, &mut |path, _site, eff| {
let Some((id, _)) = eff.writes_state() else {
return;
};
match declared.get(id.as_str()) {
None => d.push(Diagnostic::error(
codes::STATE_UNDECLARED,
"quests",
format!("{path}/state"),
format!(
"`{}` writes `{}`, which the campaign never declares. Add it to the stage-5 \
`state` list, or fix the id",
eff.verb.tag(),
id.as_str()
),
)),
Some(_) => {
written.insert(id.as_str().to_string());
}
}
});
crate::effects::for_each_effect_root(c, &mut |site, effs| {
let scheduled = !site.runs_with_acting_player();
check_player_state_not_scheduled(effs, &declared, site.stage, &site.path, scheduled, d);
});
for (i, s) in decls.iter().enumerate() {
let id = s.id.as_str();
if declared.get(id).is_none_or(|kept| !std::ptr::eq(*kept, s)) {
continue;
}
if read.contains(id) && !written.contains(id) {
d.push(Diagnostic::error(
codes::STATE_NEVER_WRITTEN,
"quests",
format!("/content/state/{i}"),
format!(
"`{id}` is read by a gate but no `set-state`/`add-state`/`clear-state` \
anywhere in the campaign ever writes it — it can only ever hold its declared \
initial ({}), so every comparison against it was decided when the campaign \
was written. Write it somewhere, or drop the comparison and say what you \
meant unconditionally",
s.initial
),
));
}
if !read.contains(id) {
let tail = if written.contains(id) {
"some verb writes it and nothing ever asks"
} else {
"nothing touches it at all"
};
d.push(Diagnostic::error(
codes::STATE_NEVER_READ,
"quests",
format!("/content/state/{i}"),
format!(
"`{id}` is declared but no gate's `requires_state` anywhere in the campaign \
ever reads it — {tail}. Runtime state exists to be compared against; gate \
something on it, or delete the declaration and its writes"
),
));
}
}
}
fn check_player_state_not_scheduled(
effs: &[QuestEffect],
declared: &BTreeMap<&str, &crate::stages::StateDecl>,
stage: &str,
path: &str,
scheduled: bool,
d: &mut Vec<Diagnostic>,
) {
let is_player = |id: &str| {
declared
.get(id)
.is_some_and(|s| s.scope == crate::stages::StateScope::Player)
};
for e in effs {
if scheduled {
for cmp in e.requires_state() {
if is_player(cmp.state.as_str()) {
d.push(Diagnostic::error(
codes::STATE_SCOPE_UNREACHABLE,
stage,
path.to_string(),
format!(
"a `{}` effect's `requires_state` compares `{}`, which is \
`player`-scoped, in a bundle that runs with no acting player (a \
trigger's effects, a trap's payload and a shortcut's `on_unlock` \
are polled on the tick from the server command source; so are a \
`sequence` step and a `move-npc`/`move-actor` `on_arrive`). There \
is no player to read the datum from. Declare it `party`-scoped, or \
move the comparison onto a beat a player completes",
e.verb.tag(),
cmp.state.as_str()
),
));
}
}
}
if scheduled
&& let Some((id, _)) = e.writes_state()
&& is_player(id.as_str())
{
d.push(Diagnostic::error(
codes::STATE_SCOPE_UNREACHABLE,
stage,
path.to_string(),
format!(
"`{}` writes `{}`, which is `player`-scoped, from a bundle that runs with no \
acting player (a trigger's effects, a trap's payload and a shortcut's \
`on_unlock` are polled on the tick from the server command source; so are a \
`sequence` step and a `move-npc`/`move-actor` `on_arrive`). There is no \
acting player whose datum this would be, so the write would silently reach \
nobody. Declare the datum `party`-scoped, or move the write onto a beat a \
player completes",
e.verb.tag(),
id.as_str()
),
));
}
match &e.verb {
Verb::SetCheckpoint { on_respawn, .. } => {
check_player_state_not_scheduled(on_respawn, declared, stage, path, false, d);
}
Verb::BeginStealth { on_caught, .. } => {
check_player_state_not_scheduled(on_caught, declared, stage, path, false, d);
}
Verb::Sequence { steps } => {
for st in steps {
check_player_state_not_scheduled(&st.effects, declared, stage, path, true, d);
}
}
Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
check_player_state_not_scheduled(on_arrive, declared, stage, path, true, d);
}
Verb::Bonfire { on_rest, .. } => {
check_player_state_not_scheduled(on_rest, declared, stage, path, scheduled, d);
}
_ => {}
}
}
}
fn world_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
if let Some(n) = c.world.content.min_players
&& !(1..=4).contains(&n)
{
d.push(Diagnostic::error(
codes::PARTY_SIZE,
"world",
"/content/min_players".to_string(),
format!(
"`min_players` = {n} is out of range — a delve is played by one party of 1–4, \
so set it to a value in 1..=4 (absent = 1, a party of one)"
),
));
}
if matches!(
c.world.content.difficulty,
Some(crate::stages::WorldDifficulty::Peaceful)
) {
d.push(Diagnostic::error(
codes::DIFFICULTY_INVALID,
"world",
"/content/difficulty".to_string(),
"`difficulty: \"peaceful\"` is refused: on peaceful the server discards every \
hostile-category mob as it ticks it — being `/summon`ed, `NoAI` or \
`PersistenceRequired` does not save one — so every wave, hostile actor and \
ambush in this campaign would silently cease to exist. Declare `easy`, `normal` \
or `hard`; for a delve that is genuinely combat-free, simply omit `difficulty` \
(a campaign with no waves already ships peaceful by derivation)"
.to_string(),
));
}
let entered_base = match crate::stages::horizon_base(&c.world.content.horizon) {
crate::stages::HorizonBase::Void => None,
crate::stages::HorizonBase::Ocean => Some((
"ocean",
"an infinite swimmable sea with no return rule lets players wander off the map",
)),
crate::stages::HorizonBase::Valley => Some((
"valley",
"the gap floor between the map and the rim is walkable ground, and with no \
return rule a player who steps off the map is simply outside it",
)),
};
if let Some((base, why)) = entered_base
&& c.world.content.boundary.is_none()
{
d.push(Diagnostic::error(
codes::OCEAN_NO_BOUNDARY,
"world",
"/content/horizon".to_string(),
format!(
"`horizon` base `{base}` needs a `boundary` — {why}. Add a `boundary` (a \
bare `{{}}` uses the default margin), or set `horizon` to `void`"
),
));
}
if let Some(b) = &c.world.content.boundary
&& !(0..=64).contains(&b.margin)
{
d.push(Diagnostic::error(
codes::BOUNDARY_MARGIN,
"world",
"/content/boundary/margin".to_string(),
format!(
"`boundary.margin` = {} is out of range — set it to a value in 0..=64 (16 is \
the default)",
b.margin
),
));
}
}
fn lighting_range_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, area) in c.world.content.areas.iter().enumerate() {
if let Some(lighting) = &area.lighting
&& !(1..=14).contains(&lighting.min_light)
{
d.push(Diagnostic::error(
codes::LIGHTING_RANGE,
"world",
format!("/content/areas/{i}/lighting/min_light"),
format!(
"area `{}` `lighting.min_light` = {} is out of range — set it to a value \
in 1..=14 (7 is the default)",
area.id, lighting.min_light
),
));
}
}
}
fn for_each_effect_deep(q: &crate::stages::Quest, mut f: impl FnMut(String, &QuestEffect)) {
fn descend(path: String, eff: &QuestEffect, f: &mut dyn FnMut(String, &QuestEffect)) {
f(path.clone(), eff);
for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
for (j, inner) in list.iter().enumerate() {
descend(format!("{path}/{pseg}/{j}"), inner, f);
}
}
}
for (key, effs) in &q.on_objective_complete {
for (m, eff) in effs.iter().enumerate() {
descend(format!("on_objective_complete/{key}/{m}"), eff, &mut f);
}
}
for (m, eff) in q.on_complete.iter().enumerate() {
descend(format!("on_complete/{m}"), eff, &mut f);
}
}
fn for_each_trigger_effect_deep(
t: &crate::stages::EnvTrigger,
mut f: impl FnMut(String, &QuestEffect),
) {
fn descend(path: String, eff: &QuestEffect, f: &mut dyn FnMut(String, &QuestEffect)) {
f(path.clone(), eff);
for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
for (j, inner) in list.iter().enumerate() {
descend(format!("{path}/{pseg}/{j}"), inner, f);
}
}
}
for (m, eff) in t.effects.iter().enumerate() {
descend(format!("effects/{m}"), eff, &mut f);
}
}
fn for_each_trap_payload_deep(t: &crate::stages::Trap, mut f: impl FnMut(String, &QuestEffect)) {
fn descend(path: String, eff: &QuestEffect, f: &mut dyn FnMut(String, &QuestEffect)) {
f(path.clone(), eff);
for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
for (j, inner) in list.iter().enumerate() {
descend(format!("{path}/{pseg}/{j}"), inner, f);
}
}
}
for (m, eff) in t.payload.iter().enumerate() {
descend(format!("payload/{m}"), eff, &mut f);
}
}
fn walk_effects_deep(effs: &[QuestEffect], f: &mut dyn FnMut(&QuestEffect)) {
for e in effs {
e.visit_deep(f);
}
}
fn reaches_sequence(e: &QuestEffect) -> bool {
match &e.verb {
Verb::Sequence { .. } => true,
Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
on_arrive.iter().any(reaches_sequence)
}
_ => false,
}
}
fn check_no_nested_sequence(effs: &[QuestEffect], path: &str, d: &mut Vec<Diagnostic>) {
for e in effs {
match &e.verb {
Verb::Sequence { steps } => {
for s in steps {
for inner in &s.effects {
if reaches_sequence(inner) {
d.push(Diagnostic::error(
codes::NESTED_SEQUENCE,
"quests",
path.to_string(),
"a `sequence` effect is nested inside another `sequence` — \
timelines do not recurse (spec-0014). Flatten the inner steps \
into the outer timeline (shift their `at_ticks` by the inner \
sequence's start), or drive the second beat from a flag/objective"
.to_string(),
));
}
}
}
}
Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
check_no_nested_sequence(on_arrive, path, d);
}
_ => {}
}
}
}
fn check_carrier_one_not_scheduled(
effs: &[QuestEffect],
path: &str,
scheduled: bool,
d: &mut Vec<Diagnostic>,
) {
for e in effs {
if scheduled && e.gives_to_one() {
d.push(Diagnostic::error(
codes::PARTY_CARRIER_SCHEDULED,
"quests",
path.to_string(),
"a `give-item` with `carrier: \"one\"` sits in a bundle only the scheduler ever \
runs (a `sequence` step, or a `move-npc`/`move-actor` `on_arrive`). Those run \
with the server command source — there is no acting player to hand the prop to, \
so the give would silently reach nobody. Drop `carrier` to arm the whole party, \
or move the hand-off onto the beat a player completes"
.to_string(),
));
}
match &e.verb {
Verb::SetCheckpoint { on_respawn, .. } => {
check_carrier_one_not_scheduled(on_respawn, path, false, d);
}
Verb::BeginStealth { on_caught, .. } => {
check_carrier_one_not_scheduled(on_caught, path, false, d);
}
Verb::Sequence { steps } => {
for st in steps {
check_carrier_one_not_scheduled(&st.effects, path, true, d);
}
}
Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
check_carrier_one_not_scheduled(on_arrive, path, true, d);
}
_ => {}
}
}
}
fn v06_checks(
c: &Campaign,
items: &dyn ItemRegistry,
anchors: &dyn AnchorRegistry,
entities: &dyn EntityRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
let declared: BTreeSet<&str> = quests.actors.iter().map(|a| a.id.as_str()).collect();
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
cutscene_style_checks(quests, &npc_ids, d);
let providers = AnchorProviders::build(c, anchors);
let mut seen_skins: BTreeSet<&str> = BTreeSet::new();
for (i, a) in quests.actors.iter().enumerate() {
if !entities.contains(&a.entity) {
d.push(Diagnostic::error(
codes::ENTITY_UNKNOWN,
"quests",
format!("/content/actors/{i}/entity"),
format!(
"actor entity `{}` is not a known 1.21.11 entity id — use a valid namespaced \
entity id (e.g. `minecraft:warden`)",
a.entity
),
));
}
if let Some(skin) = &a.skin {
if !is_kebab(&skin.texture_id) {
d.push(Diagnostic::error(
codes::SKIN_INVALID,
"quests",
format!("/content/actors/{i}/skin/texture_id"),
format!(
"actor skin `texture_id` `{}` is malformed — it must be a bare kebab token \
(e.g. `giant-idle`), matching the `skins/<texture_id>.png` filename",
skin.texture_id
),
));
} else if !seen_skins.insert(skin.texture_id.as_str()) {
d.push(Diagnostic::error(
codes::SKIN_INVALID,
"quests",
format!("/content/actors/{i}/skin/texture_id"),
format!(
"duplicate actor skin `texture_id` `{}` — each mannequin needs a distinct \
texture; rename one (and its `skins/<id>.png`)",
skin.texture_id
),
));
}
}
if let Some(f) = station_kind_diag(
&providers,
a.anchor.as_str(),
crate::layout::StationKind::Point,
"an actor's station",
"quests",
format!("/content/actors/{i}/anchor"),
) {
d.push(f);
}
if !providers.resolvable(a.anchor.as_str()) {
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/actors/{i}/anchor"),
format!(
"actor anchor `{}` is not provided by any area's prefab — {}",
a.anchor,
providers.anchor_remedy(
"use an anchor a prefab exposes, or bind a prefab/pool that carries it"
),
),
));
}
}
let mut groups: Vec<(String, &[QuestEffect])> = Vec::new();
for (i, q) in quests.quests.iter().enumerate() {
for (key, effs) in &q.on_objective_complete {
groups.push((
format!("/content/quests/{i}/on_objective_complete/{key}"),
effs.as_slice(),
));
}
groups.push((
format!("/content/quests/{i}/on_complete"),
q.on_complete.as_slice(),
));
}
for (i, t) in quests.triggers.iter().enumerate() {
groups.push((
format!("/content/triggers/{i}/effects"),
t.effects.as_slice(),
));
}
for (path, effs) in &groups {
let mut visit = |e: &QuestEffect| {
if let Some(actor) = e.actor_ref()
&& !declared.contains(actor.as_str())
{
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quests",
path.clone(),
format!(
"actor staging effect references unknown actor `{actor}` — declare it in \
the stage-5 `actors` list, or fix the reference"
),
));
}
if let Verb::MoveActor { to_anchor, .. } = &e.verb
&& let Some(f) = station_kind_diag(
&providers,
to_anchor.as_str(),
crate::layout::StationKind::Point,
"a `move-actor` destination",
"quests",
path.clone(),
)
{
d.push(f);
} else if let Verb::MoveActor { to_anchor, .. } = &e.verb
&& !providers.resolvable(to_anchor.as_str())
{
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
path.clone(),
format!(
"move-actor destination anchor `{to_anchor}` is not provided by any \
area's prefab — {}",
providers.anchor_remedy("use an anchor a prefab exposes"),
),
));
}
};
walk_effects_deep(effs, &mut visit);
check_no_nested_sequence(effs, path, d);
check_carrier_one_not_scheduled(effs, path, false, d);
}
for (i, w) in quests.waves.iter().enumerate() {
for (k, m) in w.mobs.iter().enumerate() {
let Some(eq) = &m.equipment else { continue };
check_equipment(
eq,
"wave-mob",
&format!("/content/waves/{i}/mobs/{k}/equipment"),
items,
d,
);
}
}
for (i, a) in quests.actors.iter().enumerate() {
let Some(eq) = &a.equipment else { continue };
check_equipment(
eq,
"actor",
&format!("/content/actors/{i}/equipment"),
items,
d,
);
}
check_drops(c, quests, items, d);
let mut has_bonfire = false;
for q in &c.quests.content.quests {
for_each_effect_deep(q, |_path, eff| {
has_bonfire |= eff.bonfire().is_some();
});
}
for t in &c.quests.content.triggers {
for_each_trigger_effect_deep(t, |_path, eff| {
has_bonfire |= eff.bonfire().is_some();
});
}
if !has_bonfire {
for (i, w) in quests.waves.iter().enumerate() {
if w.respawns_on_rest {
d.push(Diagnostic::error(
codes::REST_RESEAT_NO_BONFIRE,
"quests",
format!("/content/waves/{i}/respawns_on_rest"),
format!(
"wave `{}` declares `respawns_on_rest: true` but this campaign declares \
no `bonfire` — nothing can ever re-seat it, so the field is inert. Add \
the `bonfire` the re-seat hangs off (spec-0016 §1), or drop the field; \
do NOT leave a silently dead declaration in the DSL.",
w.id.as_str()
),
));
}
}
}
for (i, w) in quests.waves.iter().enumerate() {
if w.respawns_on_rest && w.tier == Some(EncounterTier::Boss) {
d.push(Diagnostic::error(
codes::BOSS_RESPAWNS_ON_REST,
"quests",
format!("/content/waves/{i}/respawns_on_rest"),
format!(
"wave `{}` declares `tier: boss` AND `respawns_on_rest: true` — souls \
ruling 5/7 is that stage bosses never respawn on rest, since a \
rest-respawning boss re-fight breaks the retry economy the ruling \
protects. Drop `respawns_on_rest` if this really is the boss, or drop \
`tier: boss` (bill it `elite` instead) if the encounter is meant to \
re-seat.",
w.id.as_str()
),
));
}
}
if has_bonfire {
let flaskless: Vec<&str> = c
.classes
.content
.classes
.iter()
.filter(|cl| !cl.kit.iter().any(|k| k.flask))
.map(|cl| cl.id.as_str())
.collect();
if !flaskless.is_empty() {
d.push(Diagnostic::error(
codes::BONFIRE_NO_FLASK,
"classes",
"/content/classes".to_string(),
format!(
"this campaign places a `bonfire` but {} no `flask` kit item: {}. \
Resting at a bonfire replenishes every kit entry marked `\"flask\": true` to \
its declared `count` — with none, the rest option recovers nothing and the \
souls loop has no consumable to spend (spec-0016 §1). \
Add a recovery item to each class kit and mark it \
`\"flask\": true` (this needs `dsl_version` 0.8.0 on the classes stage). Do \
NOT drop the bonfire to silence this — the rest point is the design.",
if flaskless.len() == 1 {
"one class declares".to_string()
} else {
format!("{} classes declare", flaskless.len())
},
flaskless.join(", ")
),
));
}
}
}
fn kit_potion_checks(c: &Campaign, effects: &dyn EffectRegistry, d: &mut Vec<Diagnostic>) {
for (i, cl) in c.classes.content.classes.iter().enumerate() {
for (k, item) in cl.kit.iter().enumerate() {
let bearing = crate::stages::is_potion_bearing_item(&item.item);
let path = format!("/content/classes/{i}/kit/{k}");
let Some(contents) = &item.contents else {
if bearing {
d.push(Diagnostic::error(
codes::KIT_POTION_MISSING,
"classes",
format!("{path}/contents"),
format!(
"kit item `{}` declares no `contents`, so it compiles to the \
*Uncraftable Potion* — a bottle with no `minecraft:potion_contents` \
component, which grants nothing when drunk however it is named. \
Declare what is in it: `\"contents\": {{\"potion\": \
\"minecraft:strong_healing\"}}`, or an `\"effects\"` list of \
`{{\"effect\", \"duration\", \"amplifier\"}}`. Do NOT rename the \
bottle instead — semantics never key on player-facing text \
(spec-0016 §1).",
item.item
),
));
}
continue;
};
if !bearing {
d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{path}/contents"),
format!(
"kit item `{}` cannot carry potion `contents` — in 1.21.11 only \
`minecraft:potion`, `minecraft:splash_potion`, \
`minecraft:lingering_potion` and `minecraft:tipped_arrow` carry a \
`minecraft:potion_contents` component, and on anything else the game \
discards it. Put the contents on a potion item, or drop the field.",
item.item
),
));
continue;
}
if contents.potion.is_none() && contents.effects.is_empty() {
d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{path}/contents"),
"empty potion `contents` — it names no `potion` and lists no `effects`, so \
the bottle still pours nothing. Name a vanilla potion (e.g. \
`\"potion\": \"minecraft:strong_healing\"`) or list at least one effect."
.to_string(),
));
}
if let Some(p) = &contents.potion
&& !crate::registry::is_potion_id(p)
{
d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{path}/contents/potion"),
format!(
"`{p}` is not in the pinned 1.21.11 `potion` registry — use a real potion \
id (`minecraft:healing`, `minecraft:strong_healing`, \
`minecraft:long_night_vision`, …). Note the 1.20.5+ spelling: strength \
and duration are part of the id (`strong_`/`long_` prefixes), not \
separate fields."
),
));
}
if let Some(col) = &contents.color
&& !is_hex_color(col)
{
d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{path}/contents/color"),
format!(
"potion `color` `{col}` is malformed — write the bottle colour as \
`#rrggbb` (e.g. `#ff9c30`), or omit it and take the colour vanilla \
derives from the effects."
),
));
}
for (e, eff) in contents.effects.iter().enumerate() {
let epath = format!("{path}/contents/effects/{e}");
if !effects.contains(&eff.effect) {
d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{epath}/effect"),
format!(
"potion effect `{}` is not a known 1.21.11 status-effect id — use a \
valid namespaced effect id (e.g. `minecraft:instant_health`).",
eff.effect
),
));
}
if let Some(amp) = eff.amplifier
&& amp > crate::stages::MAX_POTION_AMPLIFIER
{
d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{epath}/amplifier"),
format!(
"potion effect `amplifier` {amp} is out of range — vanilla stores it \
in an unsigned byte, so it must be 0–{max} (0 = level I).",
max = crate::stages::MAX_POTION_AMPLIFIER
),
));
}
match (eff.is_instant(), eff.duration) {
(true, Some(dur)) => d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{epath}/duration"),
format!(
"`{}` is instantaneous — it lands once, on the tick the potion is \
drunk, so the `duration` of {dur} tick(s) here is never read. Drop \
the field; for healing that ticks over time use \
`minecraft:regeneration`, which does take a duration.",
eff.effect
),
)),
(false, None) => d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{epath}/duration"),
format!(
"potion effect `{}` lasts over time and declares no `duration` — \
vanilla would default it to zero ticks, i.e. nothing. Declare the \
duration in ticks (20 = one second).",
eff.effect
),
)),
(false, Some(dur))
if dur == 0 || dur > crate::stages::MAX_POTION_DURATION_TICKS =>
{
d.push(Diagnostic::error(
codes::KIT_POTION_INVALID,
"classes",
format!("{epath}/duration"),
format!(
"potion effect `duration` {dur} is out of range — it is in \
**ticks** (20 = one second) and must be 1–{max} \
(≈13.9 hours, past the delve ceiling).",
max = crate::stages::MAX_POTION_DURATION_TICKS
),
));
}
_ => {}
}
}
}
}
}
fn is_hex_color(s: &str) -> bool {
let Some(hex) = s.strip_prefix('#') else {
return false;
};
hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit())
}
fn anchors_and_items(
c: &Campaign,
items: &dyn ItemRegistry,
anchors: &dyn AnchorRegistry,
d: &mut Vec<Diagnostic>,
) {
let providers = AnchorProviders::build(c, anchors);
let quest_area: BTreeMap<&str, &str> = c
.quest_plan
.content
.quests
.iter()
.map(|q| (q.id.as_str(), q.area.as_str()))
.collect();
for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
if let Some(f) = 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),
),
));
}
}
for (i, q) in c.quests.content.quests.iter().enumerate() {
let set = quest_area
.get(q.id.as_str())
.and_then(|area| providers.for_area(area));
let Some(set) = set else { continue };
for (j, obj) in q.objectives.iter().enumerate() {
if let crate::stages::Objective::ReachAnchor { anchor, .. } = obj
&& let Some(f) = station_kind_diag(
&providers,
anchor.as_str(),
crate::layout::StationKind::Point,
"a `reach-anchor` objective",
"quests",
format!("/content/quests/{i}/objectives/{j}/anchor"),
)
{
d.push(f);
} else if let crate::stages::Objective::ReachAnchor { anchor, .. } = obj
&& !set.contains(anchor.as_str())
{
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/quests/{i}/objectives/{j}/anchor"),
format!(
"objective anchor `{anchor}` is not provided by the prefab bound to \
this quest's area — {}",
providers.anchor_remedy(
"use an anchor the prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
),
));
}
}
for_each_effect_deep(q, |path, eff| {
let cross_area = matches!(&eff.verb, Verb::Cutscene { .. });
for (suffix, anchor, demands) in eff.anchor_refs() {
if let Some(f) = station_kind_diag(
&providers,
anchor.as_str(),
demands,
&format!("`{}`", eff.verb.tag()),
"quests",
format!("/content/quests/{i}/{path}/{suffix}"),
) {
d.push(f);
continue;
}
let resolves = if cross_area {
!providers.all_areas_known() || providers.union().contains(anchor.as_str())
} else {
set.contains(anchor.as_str())
};
if resolves {
continue;
}
let scope = if cross_area {
"any area's prefab"
} else {
"the prefab bound to this quest's area"
};
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/quests/{i}/{path}/{suffix}"),
format!(
"`{verb}` anchor `{anchor}` is not provided by {scope} — {}",
providers.anchor_remedy(
"use an anchor a prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
verb = eff.verb.tag(),
),
));
}
});
}
if providers.all_areas_known() {
for (ti, t) in c.quests.content.triggers.iter().enumerate() {
for_each_trigger_effect_deep(t, |path, eff| {
for (suffix, anchor, demands) in eff.anchor_refs() {
if let Some(f) = station_kind_diag(
&providers,
anchor.as_str(),
demands,
&format!("`{}`", eff.verb.tag()),
"quests",
format!("/content/triggers/{ti}/{path}/{suffix}"),
) {
d.push(f);
continue;
}
if providers.union().contains(anchor.as_str()) {
continue;
}
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/triggers/{ti}/{path}/{suffix}"),
format!(
"`{verb}` anchor `{anchor}` in an environment trigger is not \
provided by any area's prefab — {}",
providers.anchor_remedy(
"use an anchor a prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
verb = eff.verb.tag(),
),
));
}
});
}
}
for (i, cl) in c.classes.content.classes.iter().enumerate() {
for (j, it) in cl.kit.iter().enumerate() {
if !items.contains(&it.item) {
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"classes",
format!("/content/classes/{i}/kit/{j}/item"),
format!(
"kit item `{}` is not in the pinned 1.21.11 item registry — use a valid \
namespaced item id (e.g. `minecraft:iron_sword`)",
it.item
),
));
}
}
}
}
fn prefab_binding(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
for (i, a) in c.world.content.areas.iter().enumerate() {
match (&a.prefab, &a.prefab_pool) {
(Some(_), Some(_)) => d.push(Diagnostic::error(
codes::PREFAB_BINDING,
"world",
format!("/content/areas/{i}"),
format!(
"area `{}` binds both `prefab` and `prefab_pool`; bind exactly one",
a.id
),
)),
(None, None) => d.push(Diagnostic::error(
codes::PREFAB_BINDING,
"world",
format!("/content/areas/{i}"),
format!(
"area `{}` binds neither `prefab` nor `prefab_pool`; bind exactly one",
a.id
),
)),
_ => {}
}
if let Some(prefab) = &a.prefab
&& prefab.is_valid_syntax()
&& anchors.has_prefab(prefab) == Some(false)
{
d.push(Diagnostic::error(
codes::PREFAB_UNKNOWN,
"world",
format!("/content/areas/{i}/prefab"),
format!(
"area `{}` binds `prefab` `{prefab}`, which is not declared in the prefab \
metadata — bind a piece that exists in the prefabs dir, or add `{prefab}` \
to the prefab library. This is a prefab-library/naming issue, not a \
quest-logic one. It is refused rather than deferred because an area whose \
piece is absent declares NO anchors, so every anchor a quest in this area \
names would be accepted without being examined — a misspelling here \
switches the anchor proof (`DW0142`) off for the whole area instead of \
failing it",
a.id
),
));
}
if let Some(pool) = &a.prefab_pool
&& pool.is_valid_syntax()
&& !anchors.has_pool(pool)
{
d.push(Diagnostic::error(
codes::POOL_UNKNOWN,
"world",
format!("/content/areas/{i}/prefab_pool"),
format!(
"area `prefab_pool` `{pool}` is not declared in the prefab metadata — bind a \
pool that exists in the prefabs dir, or add `{pool}` to the prefab library. \
This is a prefab-library/naming issue, not a quest-logic one"
),
));
}
}
}
fn plan_awaiting_expansion(unexpanded: &[&PlannedQuest]) -> Diagnostic {
let names = unexpanded
.iter()
.map(|q| format!("`{}`", q.id))
.collect::<Vec<_>>()
.join(", ");
Diagnostic::error(
codes::QUEST_NOT_EXPANDED,
"quest-plan",
"/content/quests",
format!(
"stage 5 declares no quests at all, so none of the {n} planned quest(s) has an \
expansion: {names}. That is an authoring state, not a fault — the plan is written \
before the quests that fill it, and a campaign is written a document at a time. The \
refusal still stands, because a planned quest with no expansion has no trigger, no \
objective and no completion, so nothing of it is emitted and no campaign in this \
state can build. What there is NO route to is a cheaper way out: a stage-5 quest \
carrying only what its schema requires — a trigger and two empty arrays — is refused \
again by `DW0481`, because every quest must say what it does to the story, and by \
`DW0460` once for every NPC live in it, so writing empty expansions raises the error \
count instead of lowering it. Do not try to clear this code from the plan, and do \
not re-run validation expecting it to move. The two things that clear it are \
writing stage 5, which clears it for every quest at once, and dropping the quests \
from the stage-4 plan. Until stage 5 is written this is the expected state of a \
plan-only campaign and this code is what marks it.",
n = unexpanded.len(),
),
)
}
fn cross_stage(c: &Campaign, d: &mut Vec<Diagnostic>) {
let planned_ids: BTreeSet<&str> = c
.quest_plan
.content
.quests
.iter()
.map(|q| q.id.as_str())
.collect();
let expanded_ids: BTreeSet<&str> = c
.quests
.content
.quests
.iter()
.map(|q| q.id.as_str())
.collect();
let unexpanded: Vec<&PlannedQuest> = c
.quest_plan
.content
.quests
.iter()
.filter(|q| !expanded_ids.contains(q.id.as_str()))
.collect();
if !unexpanded.is_empty() && c.quests.content.quests.is_empty() {
d.push(plan_awaiting_expansion(&unexpanded));
} else {
for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
if !expanded_ids.contains(q.id.as_str()) {
d.push(Diagnostic::error(
codes::QUEST_NOT_EXPANDED,
"quest-plan",
format!("/content/quests/{i}"),
format!(
"planned quest `{}` has no stage-5 expansion — stage 5 declares {n} \
quest(s) and none of them carries this id, so this is a mismatch rather \
than an unwritten stage: add a stage-5 quest with id `{}` \
(objectives/effects), or drop it from the stage-4 plan",
q.id,
q.id,
n = c.quests.content.quests.len(),
),
));
}
}
}
for (i, q) in c.quests.content.quests.iter().enumerate() {
if !planned_ids.contains(q.id.as_str()) {
d.push(Diagnostic::error(
codes::QUEST_NOT_PLANNED,
"quests",
format!("/content/quests/{i}"),
format!(
"stage-5 quest `{}` is not planned in stage 4 — add a stage-4 plan entry with \
id `{}` (every quest must be planned), or remove this expansion",
q.id, q.id
),
));
}
}
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let tree_npcs: BTreeSet<&str> = c
.dialogue
.content
.dialogues
.iter()
.map(|t| t.npc.as_str())
.collect();
for (i, npc) in c.npcs.content.npcs.iter().enumerate() {
if !tree_npcs.contains(npc.id.as_str()) {
d.push(Diagnostic::error(
codes::NPC_WITHOUT_TREE,
"dialogue",
format!("/content/npcs/{i}"),
format!(
"npc `{}` has no stage-6 dialogue tree — every stage-2 npc needs exactly one \
tree; add a dialogue tree for `{}`, or remove the npc",
npc.id, npc.id
),
));
}
}
for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
if !npc_ids.contains(tree.npc.as_str()) {
d.push(Diagnostic::error(
codes::TREE_WITHOUT_NPC,
"dialogue",
format!("/content/dialogues/{i}/npc"),
format!(
"dialogue tree targets npc `{}`, which is not declared in stage 2 — declare \
that npc in stage 2, or point this tree at an existing npc",
tree.npc
),
));
}
}
}
fn v03_checks(
c: &Campaign,
items: &dyn ItemRegistry,
anchors: &dyn AnchorRegistry,
entities: &dyn EntityRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
let mut declared_waves: BTreeSet<&str> = BTreeSet::new();
let mut seen_waves: BTreeSet<&str> = BTreeSet::new();
for (i, w) in quests.waves.iter().enumerate() {
if !w.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/waves/{i}/id"),
format!(
"malformed wave id `{}` — wave ids must be lowercase kebab-case with the \
`wave/` prefix (e.g. `wave/ambush`)",
w.id
),
));
}
if !seen_waves.insert(w.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/waves/{i}/id"),
format!(
"duplicate wave id `{}` — rename one so every wave id is unique",
w.id
),
));
}
declared_waves.insert(w.id.as_str());
for (k, m) in w.mobs.iter().enumerate() {
if !entities.contains(&m.entity) {
d.push(Diagnostic::error(
codes::ENTITY_UNKNOWN,
"quests",
format!("/content/waves/{i}/mobs/{k}/entity"),
format!(
"wave-mob entity `{}` is not a known 1.21.11 entity id — use a valid \
namespaced entity id (e.g. `minecraft:zombie`)",
m.entity
),
));
}
}
}
let declared_flags: BTreeSet<String> = produced_flags(c);
let mut spawned_waves: BTreeSet<&str> = BTreeSet::new();
crate::stages::for_each_campaign_effect(c, &mut |_path, _site, e| {
if let Some(w) = e.spawn_wave() {
spawned_waves.insert(w.as_str());
}
});
let providers = AnchorProviders::build(c, anchors);
let quest_area: BTreeMap<&str, &str> = c
.quest_plan
.content
.quests
.iter()
.map(|q| (q.id.as_str(), q.area.as_str()))
.collect();
for (i, q) in quests.quests.iter().enumerate() {
let set = quest_area
.get(q.id.as_str())
.and_then(|area| providers.for_area(area));
for (j, obj) in q.objectives.iter().enumerate() {
match obj {
Objective::Kill { wave, .. } => {
if !declared_waves.contains(wave.as_str()) {
d.push(Diagnostic::error(
codes::WAVE_UNKNOWN,
"quests",
format!("/content/quests/{i}/objectives/{j}/wave"),
format!(
"`kill` objective references unknown wave `{wave}` — declare it \
in the stage-5 `waves` section or correct the reference"
),
));
} else if !spawned_waves.contains(wave.as_str()) {
d.push(Diagnostic::error(
codes::WAVE_NEVER_SPAWNED,
"quests",
format!("/content/quests/{i}/objectives/{j}/wave"),
format!(
"wave `{wave}` is killed but never spawned by any `spawn-wave` \
effect — a wave must be spawned before its `kill` objective is \
reachable; add a `spawn-wave` effect for `{wave}` on an earlier \
objective/quest"
),
));
}
}
Objective::Collect {
id: oid,
item,
count,
anchor,
container,
dropped_by,
fill_count,
..
} => {
if let Some(wave) = dropped_by
&& !declared_waves.contains(wave.as_str())
{
d.push(Diagnostic::error(
codes::WAVE_UNKNOWN,
"quests",
format!("/content/quests/{i}/objectives/{j}/dropped_by"),
format!(
"`collect` `dropped_by` references unknown wave `{wave}` — \
declare it in the stage-5 `waves` section or correct the \
reference"
),
));
}
if !items.contains(item) {
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"quests",
format!("/content/quests/{i}/objectives/{j}/item"),
format!(
"collect item `{item}` is not in the pinned 1.21.11 item \
registry — use a valid namespaced item id (e.g. \
`minecraft:emerald`)"
),
));
}
check_stack_count(
item,
*count,
&format!("collect objective `{oid}`"),
format!("/content/quests/{i}/objectives/{j}/count"),
items,
d,
);
anchor_resolves(&providers, set, anchor, i, j, "anchor", d);
if let Some(cont) = container {
anchor_resolves(&providers, set, cont, i, j, "container", d);
}
let slots = 1usize + *fill_count as usize;
if slots > MIN_CONTAINER_SLOTS {
d.push(Diagnostic::error(
codes::LOOT_TOO_MANY_ITEMS,
"quests",
format!("/content/quests/{i}/objectives/{j}/fill_count"),
format!(
"collect objective `{oid}` fills {slots} slots (its own stack \
plus `fill_count` {fill_count}), more than the \
{MIN_CONTAINER_SLOTS} slots a vanilla chest or barrel has. \
Slots are assigned positionally, so every stack past the \
{MIN_CONTAINER_SLOTS}th would be dropped silently. Lower \
`fill_count` to at most {max} — a container that reads full \
does not need to overflow.",
max = MIN_CONTAINER_SLOTS - 1
),
));
}
}
Objective::Interact {
anchor,
requires_item,
missing_item_hint,
..
} => {
if missing_item_hint.is_some() && requires_item.is_none() {
d.push(Diagnostic::error(
codes::MISSING_ITEM_HINT_WITHOUT_ITEM,
"quests",
format!("/content/quests/{i}/objectives/{j}/missing_item_hint"),
"`interact.missing_item_hint` narrates the click that arrives \
without the required item in hand, but this objective declares no \
`requires_item` — add the `requires_item` this hint is about, or \
drop the hint"
.to_string(),
));
}
if let Some(it) = requires_item
&& !items.contains(it)
{
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"quests",
format!("/content/quests/{i}/objectives/{j}/requires_item"),
format!(
"`interact.requires_item` `{it}` is not in the pinned 1.21.11 \
item registry — use a valid namespaced item id (e.g. \
`minecraft:tripwire_hook`)"
),
));
}
anchor_resolves(&providers, set, anchor, i, j, "anchor", d);
}
Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => {}
}
for (m, f) in obj.requires_flags().iter().enumerate() {
if !declared_flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/quests/{i}/objectives/{j}/requires_flags/{m}"),
format!(
"objective `requires_flags` references flag `{f}`, which no \
`set-flag` effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` \
effect on an earlier objective/quest, or correct the flag name"
),
));
}
}
for (m, f) in obj.forbids_flags().iter().enumerate() {
if !declared_flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/quests/{i}/objectives/{j}/forbids_flags/{m}"),
format!(
"objective `forbids_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — the gate can never suppress anything; add the \
producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
name"
),
));
}
}
}
for_each_effect_deep(q, |path, eff| {
if let Some(w) = eff.spawn_wave()
&& !declared_waves.contains(w.as_str())
{
d.push(Diagnostic::error(
codes::WAVE_UNKNOWN,
"quests",
format!("/content/quests/{i}/{path}/wave"),
format!(
"`spawn-wave` effect references unknown wave `{w}` — declare it in the \
stage-5 `waves` section or correct the reference"
),
));
}
if let Some(it) = eff.give_item()
&& !items.contains(it)
{
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"quests",
format!("/content/quests/{i}/{path}/item"),
format!(
"`give-item` item `{it}` is not in the pinned 1.21.11 item registry — use \
a valid namespaced item id (e.g. `minecraft:golden_apple`)"
),
));
}
for (n, f) in eff.requires_flags().iter().enumerate() {
if !declared_flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/quests/{i}/{path}/when/requires_flags/{n}"),
format!(
"effect `requires_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
earlier, or correct the flag name"
),
));
}
}
for (n, f) in eff.forbids_flags().iter().enumerate() {
if !declared_flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/quests/{i}/{path}/when/forbids_flags/{n}"),
format!(
"effect `forbids_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — the gate can never suppress anything; add the \
producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
name"
),
));
}
}
});
}
for (i, t) in quests.triggers.iter().enumerate() {
for_each_trigger_effect_deep(t, |path, eff| {
for (n, f) in eff.requires_flags().iter().enumerate() {
if !declared_flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/triggers/{i}/{path}/when/requires_flags/{n}"),
format!(
"effect `requires_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
earlier, or correct the flag name"
),
));
}
}
for (n, f) in eff.forbids_flags().iter().enumerate() {
if !declared_flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/triggers/{i}/{path}/when/forbids_flags/{n}"),
format!(
"effect `forbids_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — the gate can never suppress anything; add the \
producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
name"
),
));
}
}
});
}
}
fn v04_checks(
c: &Campaign,
anchors: &dyn AnchorRegistry,
blocks: &dyn BlockRegistry,
effects: &dyn EffectRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let providers = AnchorProviders::build(c, anchors);
let flags = collect_declared_flags(c);
let declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
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
),
));
}
}
}
for (i, w) in quests.waves.iter().enumerate() {
for (k, m) in w.mobs.iter().enumerate() {
for (e, eff) in m.effects.iter().enumerate() {
if !effects.contains(&eff.effect) {
d.push(Diagnostic::error(
codes::EFFECT_UNKNOWN,
"quests",
format!("/content/waves/{i}/mobs/{k}/effects/{e}/effect"),
format!(
"wave-mob effect `{}` is not a known 1.21.11 status-effect id — use a \
valid namespaced effect id (e.g. `minecraft:strength`)",
eff.effect
),
));
}
}
}
}
for (i, q) in quests.quests.iter().enumerate() {
for (j, o) in q.objectives.iter().enumerate() {
if let Some(prop) = o.prop() {
check_block_field(
blocks,
&prop.block,
format!("/content/quests/{i}/objectives/{j}/prop/block"),
"interact.prop",
"minecraft:lever",
d,
);
}
}
for_each_effect_deep(q, |path, eff| {
check_effect_v04(
eff,
blocks,
&declared_waves,
&format!("/content/quests/{i}/{path}"),
&npc_ids,
d,
);
});
}
let mut seen_triggers: BTreeSet<&str> = BTreeSet::new();
for (i, t) in quests.triggers.iter().enumerate() {
if !t.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/id"),
format!(
"malformed trigger id `{}` — trigger ids must be lowercase kebab-case with \
the `trigger/` prefix (e.g. `trigger/pressure-plate`)",
t.id
),
));
}
if !seen_triggers.insert(t.id.as_str()) {
d.push(Diagnostic::error(
codes::TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/id"),
format!(
"duplicate trigger id `{}` — rename one so every trigger id is unique",
t.id
),
));
}
match (t.on.needs_anchor(), t.at_anchor()) {
(true, None) => d.push(Diagnostic::error(
codes::TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/at"),
format!(
"trigger `{}` fires on `{}`, which watches a place, but declares no `at` \
anchor — add one ({}), or switch to `strike-npc` if the target is an NPC's \
body",
t.id,
t.on.kind(),
providers
.anchor_remedy("anchor names come from prefab metadata; do NOT invent one"),
),
)),
(false, Some(at)) => d.push(Diagnostic::error(
codes::TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/at"),
format!(
"trigger `{}` fires on `strike-npc`, whose target is NPC `{}`'s body — it \
watches no cell, so the `at` anchor `{at}` names nothing and would be \
silently ignored. Remove `at`.",
t.id,
t.on.npc_target().map(|n| n.as_str()).unwrap_or("?")
),
)),
(true, Some(at)) if !providers.resolvable(at) => d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/triggers/{i}/at"),
format!(
"trigger `at` anchor `{at}` is not provided by any area's prefab — {}",
providers.anchor_remedy(
"set `at` to an anchor some area's prefab exposes (anchor names come from \
prefab metadata; do NOT invent one)"
),
),
)),
_ => {}
}
if let Some(npc) = t.on.npc_target()
&& !c.npcs.content.npcs.iter().any(|n| n.id == *npc)
{
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quests",
format!("/content/triggers/{i}/on/npc"),
format!(
"`strike-npc` trigger `{}` targets NPC `{npc}`, which stage 2 does not \
declare — use a declared npc id",
t.id
),
));
}
if let TriggerOn::Approach { range } = &t.on
&& *range == 0
{
d.push(Diagnostic::error(
codes::TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/on/range"),
"`approach` trigger `range` must be > 0 — set a positive block radius (e.g. 3)"
.to_string(),
));
}
if matches!(t.on, TriggerOn::Use)
&& let Some(at) = t.at_anchor()
&& let Some(npc) = c.npcs.content.npcs.iter().find(|n| n.anchor.as_str() == at)
{
d.push(Diagnostic::error(
codes::USE_TRIGGER_ON_NPC,
"quests",
format!("/content/triggers/{i}/at"),
format!(
"`use` trigger `{}` is anchored at `{}`, where NPC `{}` stands — a \
right-click there already belongs to the NPC's dialogue, and two \
interaction hitboxes in one cell race for the same click (the loser is \
silently dead, which can soft-lock the delve). Move the trigger to its \
own anchor, or express the interaction as a dialogue option on the NPC. \
(To make an NPC's body itself the target, use `strike-npc`.)",
t.id, at, npc.id
),
));
}
for (m, f) in t.requires_flags.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/triggers/{i}/requires_flags/{m}"),
format!(
"trigger `requires_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
somewhere, or correct the flag name"
),
));
}
}
for (m, f) in t.forbids_flags.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/triggers/{i}/forbids_flags/{m}"),
format!(
"trigger `forbids_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — the gate can never suppress anything; add the \
producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag name"
),
));
}
}
for_each_trigger_effect_deep(t, |path, eff| {
check_effect_v04(
eff,
blocks,
&declared_waves,
&format!("/content/triggers/{i}/{path}"),
&npc_ids,
d,
);
});
}
dialogue_v04(c, &flags, d);
despawned_ref_check(c, &npc_ids, d);
deferred_npc_checks(c, &npc_ids, d);
}
fn split_blockstate(block: &str) -> Result<&str, String> {
let Some(open) = block.find('[') else {
return Ok(block);
};
let rest = &block[open..];
if !rest.ends_with(']') {
return Err(format!(
"malformed blockstate in `{block}` — the `[...]` suffix must close with `]` \
(e.g. `minecraft:grindstone[face=floor]`)"
));
}
let inner = &rest[1..rest.len() - 1];
if inner.trim().is_empty() {
return Err(format!(
"malformed blockstate in `{block}` — the `[...]` suffix is empty; drop the brackets or \
add a `key=value` property"
));
}
let is_token = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
};
for prop in inner.split(',') {
let mut kv = prop.splitn(2, '=');
let key = kv.next().unwrap_or("").trim();
match kv.next().map(str::trim) {
Some(val) if is_token(key) && is_token(val) => {}
_ => {
return Err(format!(
"malformed blockstate in `{block}` — each property must be `key=value` with \
lowercase `[a-z0-9_]` tokens (e.g. `face=floor`)"
));
}
}
}
Ok(&block[..open])
}
fn check_block_field(
blocks: &dyn BlockRegistry,
block: &str,
path: String,
kind: &str,
example: &str,
d: &mut Vec<Diagnostic>,
) {
match split_blockstate(block) {
Ok(base) => {
if !blocks.contains(base) {
d.push(Diagnostic::error(
codes::BLOCK_UNKNOWN,
"quests",
path,
format!(
"`{kind}` block `{block}` is not a known 1.21.11 block id — use a valid \
namespaced block id (e.g. `{example}`)"
),
));
}
}
Err(reason) => {
d.push(Diagnostic::error(
codes::BLOCK_UNKNOWN,
"quests",
path,
reason,
));
}
}
}
fn collect_declared_flags(c: &Campaign) -> BTreeSet<&str> {
let mut flags: BTreeSet<&str> = BTreeSet::new();
crate::stages::for_each_campaign_effect(c, &mut |_path, _site, e| {
if let Some(f) = e.set_flag() {
flags.insert(f.as_str());
}
});
for t in &c.quests.content.traps {
if let Some(dis) = &t.disarm {
flags.insert(dis.sets_flag.as_str());
}
}
for g in &c.quests.content.timed_gates {
if let Some(dis) = &g.disarm {
flags.insert(dis.sets_flag.as_str());
}
}
for tree in &c.dialogue.content.dialogues {
for node in &tree.nodes {
for opt in &node.options {
for e in &opt.effects {
if let Some(f) = e.set_flag() {
flags.insert(f.as_str());
}
}
}
}
}
flags
}
fn v06_trap_checks(
c: &Campaign,
items: &dyn ItemRegistry,
entities: &dyn EntityRegistry,
anchors: &dyn AnchorRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
if quests.traps.is_empty() {
return;
}
let providers = AnchorProviders::build(c, anchors);
let flags = collect_declared_flags(c);
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, t) in quests.traps.iter().enumerate() {
if !t.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::TRAP_INVALID,
"quests",
format!("/content/traps/{i}/id"),
format!(
"malformed trap id `{}` — trap ids must be lowercase kebab-case with the \
`trap/` prefix (e.g. `trap/dart-hall`)",
t.id
),
));
}
if !seen.insert(t.id.as_str()) {
d.push(Diagnostic::error(
codes::TRAP_INVALID,
"quests",
format!("/content/traps/{i}/id"),
format!(
"duplicate trap id `{}` — rename one so every trap id is unique",
t.id
),
));
}
if let Some(f) = station_kind_diag(
&providers,
t.at.as_str(),
crate::layout::StationKind::Point,
"a trap's `at`",
"quests",
format!("/content/traps/{i}/at"),
) {
d.push(f);
}
if !providers.resolvable(t.at.as_str()) {
d.push(Diagnostic::error(
codes::TRAP_INVALID,
"quests",
format!("/content/traps/{i}/at"),
format!(
"trap `at` anchor `{}` is not provided by any area's prefab — {}",
t.at,
providers.anchor_remedy(
"bind the trap to a point anchor some area's prefab exposes, whatever \
that anchor is called (names come from prefab metadata; do NOT invent \
one). A `payload` trap needs nothing of the piece but that one cell — \
the compiler emits the detection; only a legacy `dispense` effect \
needs the anchor's `dispenser` socket, and only a flag-gated trap \
needs its `trigger_block`"
),
),
));
}
if let Some(dis) = &t.disarm {
if let Some(f) = station_kind_diag(
&providers,
dis.via.as_str(),
crate::layout::StationKind::Point,
"a trap's disarm affordance",
"quests",
format!("/content/traps/{i}/disarm/via"),
) {
d.push(f);
}
if !providers.resolvable(dis.via.as_str()) {
d.push(Diagnostic::error(
codes::TRAP_INVALID,
"quests",
format!("/content/traps/{i}/disarm/via"),
format!(
"trap `disarm.via` anchor `{}` is not provided by any area's prefab — \
{}",
dis.via,
providers.anchor_remedy(
"use an anchor some area's prefab exposes for the disarm affordance"
),
),
));
}
if dis.via == t.at {
d.push(Diagnostic::error(
codes::TRAP_INVALID,
"quests",
format!("/content/traps/{i}/disarm/via"),
format!(
"trap `disarm.via` anchor `{}` is the trap's own trigger anchor — the \
disarm must be a distinct, separately-reachable affordance, not the trap \
cell itself",
dis.via
),
));
}
}
if t.effect.is_none() && t.payload.is_empty() {
d.push(Diagnostic::error(
codes::TRAP_NO_CONSEQUENCE,
"quests",
format!("/content/traps/{i}"),
format!(
"trap `{}` declares no consequence — give it a `payload` (an ordered \
effect list: `volley`, `collapse`, `damage-players`, `play-sound`, \
`narrate`, `set-flag`, `spawn-wave`, …). A trigger with nothing \
downstream of it is scenery, not a trap",
t.id
),
));
}
for_each_trap_payload_deep(t, |path, eff| {
let base = format!("/content/traps/{i}/{path}");
match &eff.verb {
Verb::Volley {
projectile,
salvos,
interval,
..
} => {
let proj = projectile
.as_deref()
.unwrap_or(crate::stages::DEFAULT_VOLLEY_PROJECTILE);
if !entities.contains(proj) {
d.push(Diagnostic::error(
codes::TRAP_VERB_ID_UNKNOWN,
"quests",
format!("{base}/projectile"),
format!(
"volley `projectile` `{proj}` is not in the pinned 1.21.11 \
entity registry — use a projectile entity id (e.g. \
`minecraft:arrow`, `minecraft:spectral_arrow`)"
),
));
}
let n = salvos.unwrap_or(crate::stages::DEFAULT_VOLLEY_SALVOS);
if n == 0 || n > crate::stages::MAX_VOLLEY_SALVOS {
d.push(Diagnostic::error(
codes::VOLLEY_CADENCE,
"quests",
format!("{base}/salvos"),
format!(
"volley `salvos` is {n} — must be 1..={}. A volley fires \
its whole kill zone every salvo, so the entity count is \
`salvos x standable cells`; beyond the cap that is a \
server hazard, not a trap",
crate::stages::MAX_VOLLEY_SALVOS
),
));
}
let iv = interval.unwrap_or(crate::stages::DEFAULT_VOLLEY_INTERVAL);
if iv == 0 || iv > crate::stages::MAX_VOLLEY_INTERVAL {
d.push(Diagnostic::error(
codes::VOLLEY_CADENCE,
"quests",
format!("{base}/interval"),
format!(
"volley `interval` is {iv} ticks — must be 1..={}. Salvos \
spaced wider than that stop reading as one trap event",
crate::stages::MAX_VOLLEY_INTERVAL
),
));
}
}
Verb::Collapse {
falling_block,
then_floor,
..
} => {
let blocks = ItemBackedBlockRegistry::new(items);
let fb = falling_block
.as_deref()
.unwrap_or(crate::stages::DEFAULT_COLLAPSE_FALLING_BLOCK);
for (field, id) in [
("falling_block", Some(fb)),
("then_floor", then_floor.as_deref()),
] {
let Some(id) = id else { continue };
if !blocks.contains(id) {
d.push(Diagnostic::error(
codes::TRAP_VERB_ID_UNKNOWN,
"quests",
format!("{base}/{field}"),
format!(
"collapse `{field}` `{id}` is not in the pinned 1.21.11 \
block registry — use a placeable block id (e.g. \
`minecraft:gravel`, `minecraft:sand`)"
),
));
}
}
}
_ => {}
}
for (kind, list) in [
("requires_flags", eff.requires_flags()),
("forbids_flags", eff.forbids_flags()),
] {
for (n, f) in list.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("{base}/{kind}/{n}"),
format!(
"trap payload effect `{kind}` references flag `{f}`, which no \
`set-flag` effect ever produces — add the producing \
`set-flag {{ flag: \"{f}\" }}`, or correct the flag name"
),
));
}
}
}
if let Some(w) = eff.spawn_wave()
&& !c.quests.content.waves.iter().any(|x| x.id == *w)
{
d.push(Diagnostic::error(
codes::WAVE_UNKNOWN,
"quests",
format!("{base}/wave"),
format!("trap payload `spawn-wave` references unknown wave `{w}`"),
));
}
if let Some(item) = eff.give_item()
&& !items.contains(item)
{
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"quests",
format!("{base}/item"),
format!(
"trap payload `give-item` item `{item}` is not in the pinned \
1.21.11 item registry"
),
));
}
});
if let Some((item, count)) = t.dispense() {
if !items.contains(item) {
d.push(Diagnostic::error(
codes::TRAP_PAYLOAD_UNKNOWN,
"quests",
format!("/content/traps/{i}/effect/dispense/item"),
format!(
"trap dispense payload item `{item}` is not in the pinned 1.21.11 item \
registry — use a valid namespaced item id (e.g. `minecraft:arrow`)"
),
));
}
check_stack_count(
item,
count,
&format!("trap `{}` dispense payload", t.id),
format!("/content/traps/{i}/effect/dispense/count"),
items,
d,
);
}
for (m, f) in t.requires_flags.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/traps/{i}/requires_flags/{m}"),
format!(
"trap `requires_flags` references flag `{f}`, which no `set-flag` effect or \
trap disarm ever produces — add a producer or correct the flag name"
),
));
}
}
for (m, f) in t.forbids_flags.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/traps/{i}/forbids_flags/{m}"),
format!(
"trap `forbids_flags` references flag `{f}`, which no `set-flag` effect or \
trap disarm ever produces — the gate can never suppress anything; add a \
producer or correct the flag name"
),
));
}
}
}
}
fn check_effect_v04(
eff: &QuestEffect,
blocks: &dyn BlockRegistry,
_declared_waves: &BTreeSet<&str>,
base_path: &str,
npc_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
check_cutscene_shape(eff, base_path, d);
match &eff.verb {
Verb::SetBlock { block, .. } => {
check_block_field(
blocks,
block,
format!("{base_path}/block"),
"set-block",
"minecraft:air",
d,
);
}
Verb::FillRegion { block, .. } => {
check_block_field(
blocks,
block,
format!("{base_path}/block"),
"fill-region",
"minecraft:stone",
d,
);
}
Verb::DespawnNpc { npc, .. } | Verb::MoveNpc { npc, .. } | Verb::SpawnNpc { npc, .. }
if !npc_ids.contains(npc.as_str()) =>
{
let verb = eff
.v04_effect()
.or_else(|| eff.v06_effect())
.unwrap_or("lifecycle");
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quests",
format!("{base_path}/npc"),
format!(
"`{verb}` references unknown npc `{npc}` — declare it in stage 2 or correct \
the reference"
),
));
}
_ => {}
}
}
fn check_cutscene_shape(eff: &QuestEffect, base_path: &str, d: &mut Vec<Diagnostic>) {
let Verb::Cutscene {
shots,
path,
seconds,
..
} = &eff.verb
else {
return;
};
let single = !path.is_empty() || seconds.is_some();
let err = |d: &mut Vec<Diagnostic>, field: &str, msg: String| {
d.push(Diagnostic::error(
codes::CUTSCENE_SHAPE,
"quests",
format!("{base_path}/{field}"),
msg,
));
};
match (!shots.is_empty(), single) {
(true, true) => err(
d,
"shots",
"`cutscene` mixes the multi-shot `shots` list with the single-shot \
`path`/`seconds` fields — use one form: move the single-shot fields into a `shots` \
entry, or drop `shots`"
.to_string(),
),
(false, false) => err(
d,
"shots",
"`cutscene` declares no shot — give a `shots` list of \
`{path, seconds, look_at?}` (multi-shot), or a single-shot `path` + `seconds`"
.to_string(),
),
(false, true) if seconds.is_none() => err(
d,
"seconds",
"single-shot `cutscene` is missing `seconds` — every shot needs a duration".to_string(),
),
_ => {}
}
for (i, shot) in shots.iter().enumerate() {
if shot.shot_style.is_some() {
continue;
}
if shot.path.is_empty() {
err(
d,
&format!("shots/{i}/path"),
"`cutscene` shot has an empty camera `path` — give at least one waypoint (one \
waypoint is a static shot, two or more is a dolly), or use a `shot_style`"
.to_string(),
);
}
if shot.seconds.is_none() {
err(
d,
&format!("shots/{i}/seconds"),
"`cutscene` shot is missing `seconds` — every unstyled shot needs an explicit \
duration (a `shot_style` would supply a default)"
.to_string(),
);
}
}
if shots.is_empty() && single && path.is_empty() {
err(
d,
"path",
"single-shot `cutscene` has an empty camera `path` — give at least one waypoint (one \
waypoint is a static shot, two or more is a dolly)"
.to_string(),
);
}
}
fn cutscene_style_checks(
quests: &crate::stages::QuestsContent,
npc_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
use crate::stages::{CameraSubject, ShotStyle};
let actor_ids: BTreeSet<&str> = quests.actors.iter().map(|a| a.id.as_str()).collect();
fn moves_in(list: &[QuestEffect], scope: &mut Vec<(bool, String)>) {
for e in list {
match &e.verb {
Verb::MoveNpc { npc, .. } => scope.push((false, npc.to_string())),
Verb::MoveActor { actor, .. } => scope.push((true, actor.to_string())),
Verb::Sequence { steps } => {
for st in steps {
moves_in(&st.effects, scope);
}
}
_ => {}
}
}
}
fn subject_check(
sub: &CameraSubject,
field: &str,
path: &str,
npc_ids: &BTreeSet<&str>,
actor_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
let (unknown, kind, id) = match sub {
CameraSubject::Anchor(_) => return,
CameraSubject::Npc(s) => (!npc_ids.contains(s.npc.as_str()), "npc", s.npc.as_str()),
CameraSubject::Actor(s) => (
!actor_ids.contains(s.actor.as_str()),
"actor",
s.actor.as_str(),
),
};
if unknown {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quests",
format!("{path}/{field}"),
format!(
"shot `{field}` references unknown {kind} `{id}` — declare it (stage 2 npcs / \
stage-5 `actors`) or correct the reference"
),
));
}
}
fn check_shot_list(
eff: &QuestEffect,
scope: &[(bool, String)],
path: &str,
npc_ids: &BTreeSet<&str>,
actor_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
let Verb::Cutscene { shots, .. } = &eff.verb else {
return;
};
for (i, shot) in shots.iter().enumerate() {
let spath = format!("{path}/shots/{i}");
let err = |d: &mut Vec<Diagnostic>, field: &str, msg: String| {
d.push(Diagnostic::error(
codes::SHOT_STYLE_INVALID,
"quests",
format!("{spath}/{field}"),
msg,
));
};
let Some(style) = shot.shot_style else {
for (field, present) in [
("subject", shot.subject.is_some()),
("subject_b", shot.subject_b.is_some()),
("dist", shot.dist.is_some()),
("degrees", shot.degrees.is_some()),
("bearing", shot.bearing.is_some()),
] {
if present {
err(
d,
field,
format!(
"`{field}` is a `shot_style` parameter but this shot declares no \
`shot_style` — add one, or drop the field"
),
);
}
}
continue;
};
let token = style.token();
match &shot.subject {
None => err(
d,
"subject",
format!(
"`shot_style: {token}` needs a `subject` — the anchor, npc, or actor the \
shot frames"
),
),
Some(sub) => subject_check(sub, "subject", &spath, npc_ids, actor_ids, d),
}
match (&shot.subject_b, style == ShotStyle::TwoShot) {
(None, true) => err(
d,
"subject_b",
"`shot_style: two-shot` frames two subjects — give `subject_b`".to_string(),
),
(Some(_), false) => err(
d,
"subject_b",
format!("`subject_b` is only meaningful on `two-shot`, not `{token}`"),
),
(Some(sub), true) => subject_check(sub, "subject_b", &spath, npc_ids, actor_ids, d),
(None, false) => {}
}
if let Some(g) = shot.degrees {
if style != ShotStyle::OrbitArc {
err(
d,
"degrees",
format!("`degrees` is only meaningful on `orbit-arc`, not `{token}`"),
);
} else if !(45.0..=120.0).contains(&g) {
err(
d,
"degrees",
format!(
"`orbit-arc` sweep `{g}` is outside `45..=120` degrees (the dossier's \
readable-orbit range)"
),
);
}
}
if let Some(dist) = shot.dist
&& !(1.0..=48.0).contains(&dist)
{
err(
d,
"dist",
format!("`dist` `{dist}` is outside the sane `1..=48` block range"),
);
}
if let Some(b) = shot.bearing
&& !(-360.0..=360.0).contains(&b)
{
err(
d,
"bearing",
format!("`bearing` `{b}` is outside `-360..=360` degrees"),
);
}
if style.needs_moving_subject() {
let moved = match &shot.subject {
Some(CameraSubject::Npc(s)) => {
scope.iter().any(|(a, id)| !a && id == s.npc.as_str())
}
Some(CameraSubject::Actor(s)) => {
scope.iter().any(|(a, id)| *a && id == s.actor.as_str())
}
Some(CameraSubject::Anchor(_)) => false,
None => true,
};
if !moved {
d.push(Diagnostic::error(
codes::SHOT_SUBJECT_UNMOVED,
"quests",
format!("{spath}/subject"),
format!(
"`shot_style: {token}` dollies with a MOVING subject, but this \
subject has no matching `move-npc`/`move-actor` in the same effect \
group or sequence — add the move alongside the cutscene, or use a \
static style (`locked-off`, `push-in`)"
),
));
}
}
}
}
fn walk_list(
list: &[QuestEffect],
outer_scope: &[(bool, String)],
path: &str,
npc_ids: &BTreeSet<&str>,
actor_ids: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
let mut scope = outer_scope.to_vec();
moves_in(list, &mut scope);
for (j, e) in list.iter().enumerate() {
let epath = format!("{path}/{j}");
check_shot_list(e, &scope, &epath, npc_ids, actor_ids, d);
for (pseg, _kseg, inner) in e.nested_effect_lists_labeled() {
let inherited: &[(bool, String)] = if matches!(&e.verb, Verb::Sequence { .. }) {
&scope
} else {
&[]
};
walk_list(
inner,
inherited,
&format!("{epath}/{pseg}"),
npc_ids,
actor_ids,
d,
);
}
}
}
for (i, q) in quests.quests.iter().enumerate() {
for (key, effs) in &q.on_objective_complete {
walk_list(
effs,
&[],
&format!("/content/quests/{i}/on_objective_complete/{key}"),
npc_ids,
&actor_ids,
d,
);
}
walk_list(
&q.on_complete,
&[],
&format!("/content/quests/{i}/on_complete"),
npc_ids,
&actor_ids,
d,
);
}
for (i, t) in quests.triggers.iter().enumerate() {
walk_list(
&t.effects,
&[],
&format!("/content/triggers/{i}/effects"),
npc_ids,
&actor_ids,
d,
);
}
}
fn dialogue_v04(c: &Campaign, flags: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) {
use crate::stages::DialogueEffect;
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, f) in opt.requires_flags.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"dialogue",
format!(
"/content/dialogues/{i}/nodes/{j}/options/{k}/requires_flags/{m}"
),
format!(
"dialogue option `requires_flags` references flag `{f}`, which no \
`set-flag` effect ever produces — add a `set-flag {{ flag: \
\"{f}\" }}` effect somewhere, or correct the flag name"
),
));
}
}
}
}
}
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, f) in opt.forbids_flags.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"dialogue",
format!(
"/content/dialogues/{i}/nodes/{j}/options/{k}/forbids_flags/{m}"
),
format!(
"dialogue option `forbids_flags` references flag `{f}`, which no \
`set-flag` effect ever produces — the gate can never suppress \
anything; add the producing `set-flag {{ flag: \"{f}\" }}` \
effect, or correct the flag name"
),
));
}
}
}
}
}
let mut ungated_completes: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut any_completes: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for tree in &c.dialogue.content.dialogues {
let npc = tree.npc.as_str();
for node in &tree.nodes {
for opt in &node.options {
for eff in &opt.effects {
if let DialogueEffect::CompleteObjective { objective } = eff {
any_completes
.entry(npc)
.or_default()
.insert(objective.as_str());
if opt.requires_flags.is_empty() && opt.forbids_flags.is_empty() {
ungated_completes
.entry(npc)
.or_default()
.insert(objective.as_str());
}
}
}
}
}
}
for (qi, q) in c.quests.content.quests.iter().enumerate() {
for (oi, o) in q.objectives.iter().enumerate() {
if let Objective::TalkTo { id, npc, .. } = o {
let npc = npc.as_str();
let oid = id.as_str();
let completed = any_completes.get(npc).is_some_and(|s| s.contains(oid));
let ungated = ungated_completes.get(npc).is_some_and(|s| s.contains(oid));
if completed && !ungated {
d.push(Diagnostic::error(
codes::DIALOGUE_FLAG_DEADLOCK,
"quests",
format!("/content/quests/{qi}/objectives/{oi}"),
format!(
"`talk-to` objective `{id}` has no ungated completing dialogue option \
in npc `{npc}`'s tree — every completing option is `requires_flags`- \
or `forbids_flags`-gated, so it can be unavailable the moment it is \
needed; keep at least one completing option with no flag gate"
),
));
}
}
}
}
}
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);
}
}
}
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(
codes::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`"
),
));
}
}
}
}
fn quest_ancestors(c: &Campaign) -> BTreeMap<&str, BTreeSet<&str>> {
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 mut out = BTreeMap::new();
for q in c.quest_plan.content.quests.iter() {
let mut anc: BTreeSet<&str> = BTreeSet::new();
let mut stack = vec![q.id.as_str()];
while let Some(cur) = stack.pop() {
if let Some(ds) = deps.get(cur) {
for dep in ds.iter() {
if anc.insert(dep.as_str()) {
stack.push(dep.as_str());
}
}
}
}
out.insert(q.id.as_str(), anc);
}
out
}
fn deferred_npc_checks(c: &Campaign, npc_ids: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) {
use crate::stages::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();
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 {
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(
codes::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 = 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(
codes::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("`, `")
),
));
}
}
}
fn anchor_resolves(
providers: &AnchorProviders,
set: Option<&BTreeSet<String>>,
anchor: &crate::ids::AnchorId,
qi: usize,
oi: usize,
field: &str,
d: &mut Vec<Diagnostic>,
) {
if let Some(f) = station_kind_diag(
providers,
anchor.as_str(),
crate::layout::StationKind::Point,
&format!("an objective's `{field}`"),
"quests",
format!("/content/quests/{qi}/objectives/{oi}/{field}"),
) {
d.push(f);
return;
}
if let Some(set) = set
&& !set.contains(anchor.as_str())
{
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/quests/{qi}/objectives/{oi}/{field}"),
format!(
"objective `{field}` anchor `{anchor}` is not provided by the prefab bound to \
this quest's area — {remedy}",
remedy = providers.anchor_remedy(
"use an anchor the prefab exposes (anchor names come from prefab metadata; \
do NOT invent one)"
),
),
));
}
}
fn graph_has_cycle<'a>(nodes: &[&'a str], edges: &BTreeMap<&'a str, Vec<&'a str>>) -> bool {
let mut color: BTreeMap<&'a str, u8> = BTreeMap::new();
for &n in nodes {
if color.get(n).copied().unwrap_or(0) == 0 && dfs_cycle(n, edges, &mut color) {
return true;
}
}
false
}
fn dfs_cycle<'a>(
node: &'a str,
edges: &BTreeMap<&'a str, Vec<&'a str>>,
color: &mut BTreeMap<&'a str, u8>,
) -> bool {
color.insert(node, 1);
if let Some(neis) = edges.get(node) {
for &n in neis {
match color.get(n).copied().unwrap_or(0) {
0 => {
if dfs_cycle(n, edges, color) {
return true;
}
}
1 => return true,
_ => {}
}
}
}
color.insert(node, 2);
false
}
fn world_edits_checks(c: &Campaign, blocks: &dyn BlockRegistry, d: &mut Vec<Diagnostic>) {
let Some(env) = &c.world_edits else {
return;
};
let stage = Stage::WorldEdits.name();
let mut areas: BTreeSet<&str> = c
.world
.content
.areas
.iter()
.map(|a| a.id.as_str())
.collect();
if c.site_plan.is_some() {
areas.insert(crate::siteplan::SITE_AREA);
}
fn bad_syntax(d: &mut Vec<Diagnostic>, stage: &str, path: String, what: &str, id: &str) {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
stage,
path,
format!(
"malformed {what} id `{id}` (expected `{}`)",
what_pattern(what)
),
));
}
fn what_pattern(what: &str) -> String {
format!("{what}/<kebab>")
}
fn check_region_ref(
d: &mut Vec<Diagnostic>,
stage: &str,
regions: &BTreeSet<&str>,
path: String,
r: &crate::ids::RegionId,
) {
if !r.is_valid_syntax() {
bad_syntax(d, stage, path, "region", r.as_str());
} else if !regions.contains(r.as_str()) {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
path,
format!(
"region `{r}` is not defined by an earlier `select` in this batch — every \
region reference is strictly backward within its batch; add a `select` verb \
naming `{r}` above this edit (or fix the name)"
),
));
}
}
fn check_recipe(
d: &mut Vec<Diagnostic>,
stage: &str,
blocks: &dyn BlockRegistry,
path: &str,
recipe: &crate::stages::PaletteRecipe,
) {
if recipe.blocks.is_empty() {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{path}/blocks"),
"palette recipe has no entries — give it at least one weighted block (and \
prefer ≥ 2 so the seeded noise reads as natural variation, never a uniform \
fill)"
.to_string(),
));
}
for (i, b) in recipe.blocks.iter().enumerate() {
if !(b.weight.is_finite() && b.weight > 0.0) {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{path}/blocks/{i}/weight"),
format!(
"palette weight `{}` for `{}` must be a finite number > 0",
b.weight, b.block
),
));
}
check_edit_block(
d,
stage,
blocks,
format!("{path}/blocks/{i}/block"),
&b.block,
);
}
if let Some(scale) = recipe.scale
&& !(scale.is_finite() && scale > 0.0)
{
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{path}/scale"),
format!("recipe `scale` `{scale}` must be a finite number > 0 (blocks⁻¹)"),
));
}
}
fn check_edit_block(
d: &mut Vec<Diagnostic>,
stage: &str,
blocks: &dyn BlockRegistry,
path: String,
block: &str,
) {
match split_blockstate(block) {
Ok(base) => {
if !blocks.contains(base) {
d.push(Diagnostic::error(
codes::BLOCK_UNKNOWN,
stage,
path,
format!(
"block `{block}` is not a known 1.21.11 block id — use a valid \
namespaced block id (e.g. `minecraft:mossy_stone_bricks`)"
),
));
}
}
Err(reason) => {
d.push(Diagnostic::error(codes::BLOCK_UNKNOWN, stage, path, reason));
}
}
}
fn is_massing(edit: &WorldEdit) -> bool {
matches!(
edit,
WorldEdit::SwapPiece { .. }
| WorldEdit::InsertPiece { .. }
| WorldEdit::RemovePiece { .. }
| WorldEdit::RewireSocket { .. }
| WorldEdit::ReseedPiece { .. }
)
}
let mut batch_ids: BTreeSet<&str> = BTreeSet::new();
let mut seen_detailing = false;
for (bi, batch) in env.content.batches.iter().enumerate() {
let bpath = format!("/batches/{bi}");
let massing_count = batch.edits.iter().filter(|e| is_massing(e)).count();
if massing_count > 0 && massing_count < batch.edits.len() {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{bpath}/edits"),
format!(
"batch `{}` mixes L2 massing and L3 detailing verbs — massing applies at \
plan time (before assembly), detailing at replay time, so a mixed batch \
cannot execute in its written order. Split it into a massing batch and a \
detailing batch",
batch.id
),
));
}
if massing_count > 0 && seen_detailing {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
bpath.to_string(),
format!(
"massing batch `{}` follows a detailing batch — every massing batch must \
precede every detailing batch (massing reshapes the layout the detailing \
verbs' frames resolve against). Move it up the script",
batch.id
),
));
}
if massing_count == 0 && !batch.edits.is_empty() {
seen_detailing = true;
}
if !batch.id.is_valid_syntax() {
bad_syntax(d, stage, format!("{bpath}/id"), "batch", batch.id.as_str());
} else if !batch_ids.insert(batch.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
stage,
format!("{bpath}/id"),
format!(
"duplicate batch id `{}` — batch ids are unique across the edit script \
(they name snapshots and seed streams)",
batch.id
),
));
}
if !areas.contains(batch.area.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
stage,
format!("{bpath}/area"),
format!(
"batch `{}` targets area `{}` which this campaign does not declare — {}",
batch.id,
batch.area,
crate::placement::Placement::of(c).area_remedy(),
),
));
}
let mut regions: BTreeSet<&str> = BTreeSet::new();
for (ei, edit) in batch.edits.iter().enumerate() {
let epath = format!("{bpath}/edits/{ei}");
match edit {
WorldEdit::Select { name, shape } => {
match shape {
RegionShape::Box { frame, min, max } => {
if min.iter().zip(max).any(|(lo, hi)| lo > hi) {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/shape"),
format!(
"box region `{name}` has min {min:?} > max {max:?} on \
an axis — corners are inclusive with min ≤ max per axis"
),
));
}
match frame {
EditFrame::PieceLocal { prefab, .. } => {
if !prefab.is_valid_syntax() {
bad_syntax(
d,
stage,
format!("{epath}/shape/frame/prefab"),
"prefab",
prefab.as_str(),
);
}
}
EditFrame::AnchorRelative { anchor } => {
if !anchor.is_valid_syntax() {
bad_syntax(
d,
stage,
format!("{epath}/shape/frame/anchor"),
"anchor",
anchor.as_str(),
);
}
}
}
}
RegionShape::SurfaceBand { over, from, to } => {
check_region_ref(
d,
stage,
®ions,
format!("{epath}/shape/over"),
over,
);
if from > to {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/shape"),
format!(
"surface band `{name}` has from {from} > to {to} — the \
band is inclusive with from ≤ to (offsets relative to \
each column's surface)"
),
));
}
}
RegionShape::PaletteMatch { within, blocks: bl } => {
check_region_ref(
d,
stage,
®ions,
format!("{epath}/shape/within"),
within,
);
if bl.is_empty() {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/shape/blocks"),
format!(
"palette-match region `{name}` lists no blocks — name \
at least one base block id to match"
),
));
}
for (i, b) in bl.iter().enumerate() {
check_edit_block(
d,
stage,
blocks,
format!("{epath}/shape/blocks/{i}"),
b,
);
}
}
RegionShape::Union { of } | RegionShape::Intersect { of } => {
if of.len() < 2 {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/shape/of"),
format!(
"composition region `{name}` lists {} region(s) — a \
union/intersection needs at least 2 (a single-region \
composition is just the region; use it directly)",
of.len()
),
));
}
for (i, r) in of.iter().enumerate() {
check_region_ref(
d,
stage,
®ions,
format!("{epath}/shape/of/{i}"),
r,
);
}
}
RegionShape::Subtract { base, remove } => {
check_region_ref(
d,
stage,
®ions,
format!("{epath}/shape/base"),
base,
);
if remove.is_empty() {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/shape/remove"),
format!(
"subtract region `{name}` removes nothing — list at \
least one region to subtract (or use `base` directly)"
),
));
}
for (i, r) in remove.iter().enumerate() {
check_region_ref(
d,
stage,
®ions,
format!("{epath}/shape/remove/{i}"),
r,
);
}
}
}
if !name.is_valid_syntax() {
bad_syntax(d, stage, format!("{epath}/name"), "region", name.as_str());
} else if !regions.insert(name.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
stage,
format!("{epath}/name"),
format!(
"duplicate region name `{name}` in batch `{}` — region names \
are unique within their batch",
batch.id
),
));
}
}
WorldEdit::Fill { region, recipe } => {
check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
check_recipe(d, stage, blocks, &format!("{epath}/recipe"), recipe);
}
WorldEdit::Replace {
region,
matching,
recipe,
} => {
check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
if matching.is_empty() {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/matching"),
"replace matches no blocks — list at least one base block id to \
rewrite (an unconditional rewrite is `fill`)"
.to_string(),
));
}
for (i, b) in matching.iter().enumerate() {
check_edit_block(d, stage, blocks, format!("{epath}/matching/{i}"), b);
}
check_recipe(d, stage, blocks, &format!("{epath}/recipe"), recipe);
}
WorldEdit::Carve { region } => {
check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
}
WorldEdit::Morph { region, op } => {
check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
match op {
MorphOp::Raise { by, recipe } => {
if *by == 0 {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/op/by"),
"morph raise `by` is 0 — a zero raise is a no-op; give a \
positive height (or drop the edit)"
.to_string(),
));
}
check_recipe(d, stage, blocks, &format!("{epath}/op/recipe"), recipe);
}
MorphOp::Lower { by } => {
if *by == 0 {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/op/by"),
"morph lower `by` is 0 — a zero lower is a no-op; give a \
positive depth (or drop the edit)"
.to_string(),
));
}
}
MorphOp::Smooth { passes, recipe } => {
if *passes == 0 {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/op/passes"),
"morph smooth `passes` is 0 — a zero-pass smooth is a \
no-op; give a positive pass count (or drop the edit)"
.to_string(),
));
}
check_recipe(d, stage, blocks, &format!("{epath}/op/recipe"), recipe);
}
}
}
WorldEdit::Scatter {
region,
items,
density,
avoid,
spacing: _,
limit,
} => {
check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
for (i, r) in avoid.iter().enumerate() {
check_region_ref(d, stage, ®ions, format!("{epath}/avoid/{i}"), r);
}
if items.is_empty() {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/items"),
"scatter has no items — give it at least one weighted dressing \
block"
.to_string(),
));
}
for (i, b) in items.iter().enumerate() {
if !(b.weight.is_finite() && b.weight > 0.0) {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/items/{i}/weight"),
format!(
"scatter item weight `{}` for `{}` must be a finite \
number > 0",
b.weight, b.block
),
));
}
check_edit_block(
d,
stage,
blocks,
format!("{epath}/items/{i}/block"),
&b.block,
);
}
if !(density.is_finite() && *density > 0.0 && *density <= 1.0) {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/density"),
format!(
"scatter `density` `{density}` must be in (0, 1] — it is the \
per-candidate placement probability"
),
));
}
if let Some(limit) = limit
&& *limit == 0
{
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/limit"),
"scatter `limit` is 0 — a zero-item scatter is a no-op; give a \
positive cap (or drop the field for no cap)"
.to_string(),
));
}
}
WorldEdit::Plant {
region,
tree: _,
count,
avoid,
spacing: _,
} => {
check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
for (i, r) in avoid.iter().enumerate() {
check_region_ref(d, stage, ®ions, format!("{epath}/avoid/{i}"), r);
}
if *count == 0 {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/count"),
"plant `count` is 0 — a zero-tree plant is a no-op; give a \
positive count (or drop the edit)"
.to_string(),
));
}
}
WorldEdit::Fragment {
prefab,
frame,
at: _,
rotation: _,
} => {
if !prefab.is_valid_syntax() {
bad_syntax(
d,
stage,
format!("{epath}/prefab"),
"prefab",
prefab.as_str(),
);
}
match frame {
EditFrame::PieceLocal { prefab, .. } => {
if !prefab.is_valid_syntax() {
bad_syntax(
d,
stage,
format!("{epath}/frame/prefab"),
"prefab",
prefab.as_str(),
);
}
}
EditFrame::AnchorRelative { anchor } => {
if !anchor.is_valid_syntax() {
bad_syntax(
d,
stage,
format!("{epath}/frame/anchor"),
"anchor",
anchor.as_str(),
);
}
}
}
}
WorldEdit::Relight {
region,
fixture,
min_light,
} => {
check_region_ref(d, stage, ®ions, format!("{epath}/region"), region);
if let Some(ml) = min_light
&& !(1..=14).contains(ml)
{
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
format!("{epath}/min_light"),
format!(
"relight `min_light` {ml} out of range — vanilla block light \
is 1..=14 (15 is only at the emitter itself)"
),
));
}
let area_lighting = c
.world
.content
.areas
.iter()
.find(|a| a.id.as_str() == batch.area.as_str())
.and_then(|a| a.lighting);
if area_lighting.is_none() && (fixture.is_none() || min_light.is_none()) {
d.push(Diagnostic::error(
codes::EDIT_INVALID,
stage,
epath.to_string(),
format!(
"relight in batch `{}`: area `{}` declares no `lighting`, so \
the verb must carry BOTH `fixture` and `min_light` (there is \
nothing to default to). Declare area lighting or add the \
overrides",
batch.id, batch.area
),
));
}
}
WorldEdit::SwapPiece {
piece: _,
prefab,
with,
} => {
for (what, id) in [("prefab", prefab.as_str()), ("prefab", with.as_str())] {
if !crate::ids::is_prefixed(id, "prefab") {
bad_syntax(d, stage, epath.to_string(), what, id);
}
}
}
WorldEdit::InsertPiece {
at_piece: _,
prefab,
socket: _,
insert,
} => {
for id in [prefab.as_str(), insert.as_str()] {
if !crate::ids::is_prefixed(id, "prefab") {
bad_syntax(d, stage, epath.to_string(), "prefab", id);
}
}
}
WorldEdit::RemovePiece { piece: _, prefab }
| WorldEdit::ReseedPiece { piece: _, prefab }
| WorldEdit::RewireSocket { prefab, .. } => {
if !prefab.is_valid_syntax() {
bad_syntax(
d,
stage,
format!("{epath}/prefab"),
"prefab",
prefab.as_str(),
);
}
}
}
}
}
}
fn shortcut_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
let quests = &c.quests.content;
if quests.shortcuts.is_empty() {
return;
}
let providers = AnchorProviders::build(c, anchors);
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, sc) in quests.shortcuts.iter().enumerate() {
if !sc.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::SHORTCUT_INVALID,
"quests",
format!("/content/shortcuts/{i}/id"),
format!(
"malformed shortcut id `{}` — shortcut ids must be lowercase kebab-case with \
the `shortcut/` prefix (e.g. `shortcut/keep-lift`)",
sc.id
),
));
}
if !seen.insert(sc.id.as_str()) {
d.push(Diagnostic::error(
codes::SHORTCUT_INVALID,
"quests",
format!("/content/shortcuts/{i}/id"),
format!(
"duplicate shortcut id `{}` — rename one so every shortcut id is unique",
sc.id
),
));
}
for (field, anchor, demands) in [
("gate", &sc.gate, crate::layout::StationKind::Gate),
("unlock", &sc.unlock, crate::layout::StationKind::Point),
] {
if let Some(f) = station_kind_diag(
&providers,
anchor.as_str(),
demands,
&format!("a shortcut's `{field}`"),
"quests",
format!("/content/shortcuts/{i}/{field}"),
) {
d.push(f);
continue;
}
if !providers.resolvable(anchor.as_str()) {
d.push(Diagnostic::error(
codes::SHORTCUT_INVALID,
"quests",
format!("/content/shortcuts/{i}/{field}"),
format!(
"shortcut `{field}` anchor `{anchor}` is not provided by any area's \
prefab — {}",
providers.anchor_remedy(
"use an anchor a prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
),
));
}
}
if sc.gate == sc.unlock {
d.push(Diagnostic::error(
codes::SHORTCUT_INVALID,
"quests",
format!("/content/shortcuts/{i}/unlock"),
format!(
"shortcut `{}` unlocks at its own gate anchor `{}` — the mechanism belongs on \
the FAR side of the door you have not opened yet, which is the entire point \
of the pattern (spec-0016 §2)",
sc.id, sc.gate
),
));
}
}
let owned: BTreeSet<&str> = quests.shortcuts.iter().map(|s| s.gate.as_str()).collect();
let report = |path: String, anchor: &str, d: &mut Vec<Diagnostic>| {
d.push(Diagnostic::error(
codes::SHORTCUT_RESEALED,
"quests",
path,
format!(
"`close-gate` targets `{anchor}`, a gate a `shortcut` owns — a shortcut opens \
PERMANENTLY (spec-0016 §2), so nothing may re-seal it. Use a different gate for \
the point-of-no-return beat, or drop the shortcut declaration."
),
));
};
for (qi, q) in quests.quests.iter().enumerate() {
for_each_effect_deep(q, |path, eff| {
if let Some(a) = eff.close_gate_anchor()
&& owned.contains(a.as_str())
{
report(format!("/content/quests/{qi}/{path}/anchor"), a.as_str(), d);
}
});
}
for (ti, t) in quests.triggers.iter().enumerate() {
for_each_trigger_effect_deep(t, |path, eff| {
if let Some(a) = eff.close_gate_anchor()
&& owned.contains(a.as_str())
{
report(
format!("/content/triggers/{ti}/{path}/anchor"),
a.as_str(),
d,
);
}
});
}
}
fn ambush_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, a) in c.quests.content.ambushes.iter().enumerate() {
if !a.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::AMBUSH_INVALID,
"quests",
format!("/content/ambushes/{i}/id"),
format!(
"malformed ambush id `{}` — ambush ids must be lowercase kebab-case with the \
`ambush/` prefix (e.g. `ambush/stair-turn`)",
a.id
),
));
}
if !seen.insert(a.id.as_str()) {
d.push(Diagnostic::error(
codes::AMBUSH_INVALID,
"quests",
format!("/content/ambushes/{i}/id"),
format!(
"duplicate ambush id `{}` — rename one so every ambush id is unique (each \
desugars to a trigger named after it)",
a.id
),
));
}
if a.actors.is_empty() {
d.push(Diagnostic::error(
codes::AMBUSH_INVALID,
"quests",
format!("/content/ambushes/{i}/actors"),
format!(
"ambush `{}` lists no actors — it would spring nothing. List the actors that \
ambush the player, or delete the declaration; a beat that fires and does \
nothing is never what was meant.",
a.id
),
));
}
let mut dup: BTreeSet<&str> = BTreeSet::new();
for (j, actor) in a.actors.iter().enumerate() {
if !dup.insert(actor.as_str()) {
d.push(Diagnostic::error(
codes::AMBUSH_INVALID,
"quests",
format!("/content/ambushes/{i}/actors/{j}"),
format!(
"ambush `{}` lists actor `{actor}` twice — `spawn-actor` is idempotent, so \
the second one is a silent no-op and the ambush is half the size it \
reads as. Declare a second actor instead.",
a.id
),
));
}
}
}
}
fn timed_gate_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
let quests = &c.quests.content;
if quests.timed_gates.is_empty() {
return;
}
let providers = AnchorProviders::build(c, anchors);
let shortcut_gates: BTreeSet<&str> = quests.shortcuts.iter().map(|s| s.gate.as_str()).collect();
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut driven: BTreeSet<&str> = BTreeSet::new();
for (i, g) in quests.timed_gates.iter().enumerate() {
let err = |path: String, msg: String, d: &mut Vec<Diagnostic>| {
d.push(Diagnostic::error(
codes::TIMED_GATE_INVALID,
"quests",
path,
msg,
));
};
if !g.id.is_valid_syntax() {
err(
format!("/content/timed_gates/{i}/id"),
format!(
"malformed timed-gate id `{}` — ids must be lowercase kebab-case with the \
`timed-gate/` prefix (e.g. `timed-gate/piston-hall`)",
g.id
),
d,
);
}
if !seen.insert(g.id.as_str()) {
err(
format!("/content/timed_gates/{i}/id"),
format!("duplicate timed-gate id `{}` — rename one", g.id),
d,
);
}
for (field, ticks) in [
("open_ticks", g.open_ticks),
("closed_ticks", g.closed_ticks),
] {
if ticks == 0 {
err(
format!("/content/timed_gates/{i}/{field}"),
format!(
"timed gate `{}` declares `{field}: 0` — a gate that never {} is not a \
timing gate. Use `open-gate`/`close-gate` for a one-way state change, or \
give both halves of the cycle a real duration.",
g.id,
if field == "open_ticks" {
"opens"
} else {
"closes"
}
),
d,
);
}
}
let cycle = g.open_ticks.saturating_add(g.closed_ticks);
if cycle > 0 && g.phase >= cycle {
err(
format!("/content/timed_gates/{i}/phase"),
format!(
"timed gate `{}` declares `phase: {}` at or beyond its own {cycle}-tick cycle \
— a phase is an offset INTO the cycle, so it must be less than it (use \
`phase % cycle`).",
g.id, g.phase
),
d,
);
}
if let Some(f) = station_kind_diag(
&providers,
g.gate.as_str(),
crate::layout::StationKind::Gate,
"a timed gate's `gate`",
"quests",
format!("/content/timed_gates/{i}/gate"),
) {
d.push(f);
}
if !driven.insert(g.gate.as_str()) {
err(
format!("/content/timed_gates/{i}/gate"),
format!(
"gate `{}` is driven by two timed gates — two clocks filling and clearing the \
same region race every tick and the region's state becomes emission order, \
not design. One clock per gate.",
g.gate
),
d,
);
}
if shortcut_gates.contains(g.gate.as_str()) {
err(
format!("/content/timed_gates/{i}/gate"),
format!(
"gate `{}` is both a `shortcut` gate and a `timed-gate` — a shortcut opens \
PERMANENTLY (spec-0016 §2) and a clock would re-seal it every cycle, which \
is exactly the re-seal `DW0358` exists to forbid. Use two different gates.",
g.gate
),
d,
);
}
if let Some(dis) = &g.disarm {
if let Some(f) = station_kind_diag(
&providers,
dis.via.as_str(),
crate::layout::StationKind::Point,
"a timed gate's disarm affordance",
"quests",
format!("/content/timed_gates/{i}/disarm/via"),
) {
d.push(f);
}
if !providers.resolvable(dis.via.as_str()) {
err(
format!("/content/timed_gates/{i}/disarm/via"),
format!(
"timed-gate `disarm.via` anchor `{}` is not provided by any area's \
prefab — {}",
dis.via,
providers.anchor_remedy(
"use an anchor some area's prefab exposes for the jam affordance \
(anchor names come from prefab metadata; do NOT invent one)"
),
),
d,
);
}
if dis.via == g.gate {
err(
format!("/content/timed_gates/{i}/disarm/via"),
format!(
"timed gate `{}` puts its `disarm.via` on its own gate anchor `{}` — the \
jam lever would stand inside the span the portcullis closes on (and, \
with `crush`, kills in). The affordance belongs on ground the player \
can reach and hold WITHOUT gambling on the clock, which is the entire \
point of the third rung.",
g.id, g.gate
),
d,
);
}
}
}
let disarmed: BTreeSet<&str> = quests
.timed_gates
.iter()
.filter(|g| g.disarm.is_some())
.map(|g| g.gate.as_str())
.collect();
if disarmed.is_empty() {
return;
}
let report = |path: String, anchor: &str, d: &mut Vec<Diagnostic>| {
d.push(Diagnostic::error(
codes::TIMED_GATE_REARMED,
"quests",
path,
format!(
"`close-gate` targets `{anchor}`, the gate of a `timed-gate` that declares a \
`disarm` — a disarmed gate rests OPEN permanently (souls dossier \
§5.2: a hazard the party has switched off stays off), so nothing may re-arm \
its clock. Use a different gate for the beat that must re-seal, or drop the \
`disarm` and keep the clock running."
),
));
};
for (qi, q) in quests.quests.iter().enumerate() {
for_each_effect_deep(q, |path, eff| {
if let Some(a) = eff.close_gate_anchor()
&& disarmed.contains(a.as_str())
{
report(format!("/content/quests/{qi}/{path}/anchor"), a.as_str(), d);
}
});
}
for (ti, t) in quests.triggers.iter().enumerate() {
for_each_trigger_effect_deep(t, |path, eff| {
if let Some(a) = eff.close_gate_anchor()
&& disarmed.contains(a.as_str())
{
report(
format!("/content/triggers/{ti}/{path}/anchor"),
a.as_str(),
d,
);
}
});
}
}
const LANE_RAIDER_TAG: &str = "minecraft:raiders";
fn is_lane_raider(entity: &str) -> bool {
crate::registry::entity_in_tag(entity, LANE_RAIDER_TAG)
}
const LANE_WEAPON_GATED: [(&str, &str); 1] = [("pillager", "minecraft:crossbow")];
fn bare_entity(id: &str) -> &str {
id.strip_prefix("minecraft:").unwrap_or(id)
}
fn difficulty_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
if c.world.content.difficulty.is_some() || !c.quests.content.waves.is_empty() {
return;
}
let mut fighters: BTreeSet<String> = c
.quests
.content
.actors
.iter()
.filter(|a| a.vulnerable)
.map(|a| a.id.as_str().to_string())
.collect();
for q in &c.quests.content.quests {
for_each_effect_deep(q, |_, eff| {
if let Verb::UnleashActor { actor, .. } = &eff.verb {
fighters.insert(actor.as_str().to_string());
}
});
}
for t in &c.quests.content.triggers {
for_each_trigger_effect_deep(t, |_, eff| {
if let Verb::UnleashActor { actor, .. } = &eff.verb {
fighters.insert(actor.as_str().to_string());
}
});
}
for t in &c.quests.content.traps {
for_each_trap_payload_deep(t, |_, eff| {
if let Verb::UnleashActor { actor, .. } = &eff.verb {
fighters.insert(actor.as_str().to_string());
}
});
}
if fighters.is_empty() {
return;
}
d.push(Diagnostic::warning(
codes::DIFFICULTY_UNDECLARED_ACTORS,
"world",
"/content/difficulty".to_string(),
format!(
"this campaign stages {} actor(s) meant to FIGHT ({}) — unleashed into a real-AI twin, \
or declared `vulnerable` — but declares no `waves[]` and no `world.difficulty`, so it \
ships the compiler's derived `difficulty=peaceful`. On peaceful the server discards \
every hostile-category mob as it ticks it, so a monster among these is gone on the \
tick it spawns and the beat that summoned it plays to an empty room. Declare \
`world.difficulty` on the world stage: `easy` reproduces the halved-damage world \
existing combat numbers were tuned in, `normal` is the vanilla baseline. (If every \
one of them is a passive species, there is nothing to fix.)",
fighters.len(),
fighters
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ")
),
));
}
fn lane_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
let quests = &c.quests.content;
if quests
.waves
.iter()
.all(|w| w.lane.is_none() && w.summon.is_none())
{
return;
}
let providers = AnchorProviders::build(c, anchors);
for (i, w) in quests.waves.iter().enumerate() {
let aggro_edge = w.summon == Some(crate::stages::WaveSummon::AggroEdge);
if aggro_edge {
if w.lane.is_some() {
d.push(Diagnostic::error(
codes::LANE_INVALID,
"quests",
format!("/content/waves/{i}/summon"),
format!(
"wave `{}` declares BOTH a `lane` and `summon: aggro-edge` (spec-0016 §6) \
— a lane IS the routing (march while distant, native AI once aggroed), \
and aggro-edge is its opposite (materialize already at the edge of \
perception, no routing at all). Pick one.",
w.id
),
));
}
for (k, m) in w.mobs.iter().enumerate() {
if m.attributes.and_then(|a| a.follow_range).is_none() {
d.push(Diagnostic::error(
codes::AGGRO_EDGE_NO_RANGE,
"quests",
format!("/content/waves/{i}/mobs/{k}/attributes"),
format!(
"`summon: aggro-edge` mob `{}` in wave `{}` declares no \
`attributes.follow_range` (spec-0016 §6). That radius IS the summon \
ring — the distance at which this mob perceives the party — so it is \
authored, never guessed: the compiler will not fabricate a vanilla \
default it cannot verify against the pinned server.",
m.entity, w.id
),
));
}
}
}
let Some(lane) = &w.lane else { continue };
if lane.waypoints.is_empty() {
d.push(Diagnostic::error(
codes::LANE_INVALID,
"quests",
format!("/content/waves/{i}/lane/waypoints"),
format!(
"wave `{}` declares a `lane` with no waypoints (spec-0016 §6) — a lane is a \
polyline the squad marches; give it at least one waypoint anchor",
w.id
),
));
}
for (k, wp) in lane.waypoints.iter().enumerate() {
if let Some(f) = station_kind_diag(
&providers,
wp.as_str(),
crate::layout::StationKind::Point,
"a lane waypoint",
"quests",
format!("/content/waves/{i}/lane/waypoints/{k}"),
) {
d.push(f);
}
if !providers.resolvable(wp.as_str()) {
d.push(Diagnostic::error(
codes::LANE_INVALID,
"quests",
format!("/content/waves/{i}/lane/waypoints/{k}"),
format!(
"lane waypoint anchor `{wp}` is not provided by any area's prefab — {}",
providers.anchor_remedy(
"use an anchor a prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
),
));
}
if k > 0 && lane.waypoints[k - 1] == *wp {
d.push(Diagnostic::error(
codes::LANE_INVALID,
"quests",
format!("/content/waves/{i}/lane/waypoints/{k}"),
format!(
"lane waypoint `{wp}` repeats the one before it — the squad would be told \
to march to where it already stands, and vanilla re-rolls a patrol \
target on arrival. Remove the repeat."
),
));
}
}
if !(4..=64).contains(&lane.aggro_radius) {
d.push(Diagnostic::error(
codes::LANE_INVALID,
"quests",
format!("/content/waves/{i}/lane/aggro_radius"),
format!(
"lane `aggro_radius` {} on wave `{}` is outside 4..=64 (spec-0016 §6). It is \
emitted verbatim as the mobs' `follow_range` attribute AND as the release \
radius; below 4 the squad walks into contact before it can see anyone, and \
past 64 it aggroes across the whole delve.",
lane.aggro_radius, w.id
),
));
}
if w.mobs.iter().map(|m| m.count).sum::<u32>() < 2 {
d.push(Diagnostic::error(
codes::LANE_SQUAD_TOO_SMALL,
"quests",
format!("/content/waves/{i}/mobs"),
format!(
"lane wave `{}` fields fewer than 2 mobs (spec-0016 §6). A lone patroller \
sets `Patrolling:0b` on ITSELF when it finds no companion within its follow \
range — vanilla behaviour, live-verified — so a one-mob lane cancels its own \
routing and just stands there. Field a squad of at least 2.",
w.id
),
));
}
for (k, m) in w.mobs.iter().enumerate() {
let bare = bare_entity(&m.entity);
if !is_lane_raider(&m.entity) {
d.push(Diagnostic::error(
codes::LANE_NOT_RAIDER,
"quests",
format!("/content/waves/{i}/mobs/{k}/entity"),
format!(
"lane wave `{}` fields `{}`, which is not raider-family (spec-0016 §6). \
`Patrolling`/`patrol_target` are Raider NBT: on any other species they \
are simply dropped and the mob stands where it spawned. Lane species \
(vanilla's own `#{LANE_RAIDER_TAG}` tag): {}. For anything else use \
`summon: aggro-edge`, which needs no patrol AI.",
w.id,
m.entity,
crate::registry::entity_tag_members_bare(LANE_RAIDER_TAG).join(" / ")
),
));
}
if let Some((_, weapon)) = LANE_WEAPON_GATED.iter().find(|(s, _)| *s == bare) {
let held = m
.equipment
.as_ref()
.and_then(|e| e.main_hand.as_ref())
.map_or(*weapon, |p| p.item());
if held != *weapon {
d.push(Diagnostic::error(
codes::LANE_UNARMED,
"quests",
format!("/content/waves/{i}/mobs/{k}/equipment/main_hand"),
format!(
"lane `{bare}` in wave `{}` holds `{held}` instead of `{weapon}` \
(spec-0016 §6). Its ONLY attack goal is the crossbow goal, so on \
acquiring a target it has nothing runnable to do — and the patrol \
goal is meanwhile blocked BY that target. The mob freezes in place \
indefinitely (live-verified deadlock). Give it the crossbow, or drop \
the `main_hand` override and take the compiler's default.",
w.id
),
));
}
}
}
if let Some(bad) = w.mobs.iter().enumerate().find(|(_, m)| {
m.attributes
.and_then(|a| a.follow_range)
.is_some_and(|r| r != f64::from(lane.aggro_radius))
}) {
let (k, m) = bad;
d.push(Diagnostic::error(
codes::LANE_INVALID,
"quests",
format!("/content/waves/{i}/mobs/{k}/attributes/follow_range"),
format!(
"lane mob `{}` in wave `{}` declares `follow_range` {} but the lane's \
`aggro_radius` is {} (spec-0016 §6). They MUST be equal: the release radius \
is where routing hands over to native AI, and a patrolling raider that \
targets a player outside its engagement range HOLDS GROUND instead of \
marching — the squad stalls mid-lane. Drop the override (the compiler sets \
`follow_range` from `aggro_radius`) or make the two agree.",
m.entity,
w.id,
m.attributes
.and_then(|a| a.follow_range)
.unwrap_or_default(),
lane.aggro_radius
),
));
}
}
}
fn check_drops(
c: &Campaign,
quests: &crate::stages::QuestsContent,
items: &dyn ItemRegistry,
d: &mut Vec<Diagnostic>,
) {
use crate::stages::{EncounterTier, MobDrop};
let tiered =
|t: Option<EncounterTier>| matches!(t, Some(EncounterTier::Elite | EncounterTier::Boss));
for (i, w) in quests.waves.iter().enumerate() {
for (k, m) in w.mobs.iter().enumerate() {
if m.drops.is_empty() {
continue;
}
if !tiered(w.tier) {
d.push(Diagnostic::error(
codes::DROP_NOT_TIERED,
"quests",
format!("/content/waves/{i}/mobs/{k}/drops"),
format!(
"wave `{}` declares drops but is not billed `elite` or `boss` — only a \
named fight leaves anything behind; an ordinary mob's kit is never \
farmable. Declare the wave's `tier`, or remove the `drops`",
w.id
),
));
}
check_drop_list(
&m.drops,
m.equipment.as_ref(),
&format!("wave `{}` mob {k}", w.id),
&format!("/content/waves/{i}/mobs/{k}/drops"),
items,
d,
);
}
}
for (i, a) in quests.actors.iter().enumerate() {
if a.drops.is_empty() {
continue;
}
if !tiered(a.tier) {
d.push(Diagnostic::error(
codes::DROP_NOT_TIERED,
"quests",
format!("/content/actors/{i}/drops"),
format!(
"actor `{}` declares drops but is not billed `elite` or `boss` — only a named \
fight leaves anything behind; a staged puppet's kit is never farmable. \
Declare the actor's `tier`, or remove the `drops`",
a.id
),
));
}
check_drop_list(
&a.drops,
a.equipment.as_ref(),
&format!("actor `{}`", a.id),
&format!("/content/actors/{i}/drops"),
items,
d,
);
}
let mut yielded: BTreeMap<&str, BTreeMap<&str, u32>> = BTreeMap::new();
for w in &quests.waves {
let per = yielded.entry(w.id.as_str()).or_default();
for m in &w.mobs {
for dr in &m.drops {
if let MobDrop::Item(it) = dr {
*per.entry(it.item.as_str()).or_default() += m.count;
}
}
}
}
let anc = quest_ancestors(c);
let mut kills: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
for q in &quests.quests {
for o in &q.objectives {
if let Objective::Kill { wave, id, .. } = o {
kills
.entry(wave.as_str())
.or_default()
.push((q.id.as_str(), id.as_str()));
}
}
}
for (i, q) in quests.quests.iter().enumerate() {
let after_anc = objective_ancestors(q);
for (j, o) in q.objectives.iter().enumerate() {
let Objective::Collect {
id,
item,
count,
container,
dropped_by: Some(wave),
..
} = o
else {
continue;
};
let path = format!("/content/quests/{i}/objectives/{j}/dropped_by");
if container.is_some() {
d.push(Diagnostic::error(
codes::DROP_COLLECT_UNSOURCED,
"quests",
path.clone(),
format!(
"`collect` `{id}` declares both `dropped_by` (wave `{wave}`) and a \
`container` — the item comes off a body or out of a box, not both; drop \
whichever provisioning this beat does not use"
),
));
}
let Some(per) = yielded.get(wave.as_str()) else {
continue;
};
match per.get(item.as_str()).copied() {
None => d.push(Diagnostic::error(
codes::DROP_COLLECT_UNSOURCED,
"quests",
path.clone(),
format!(
"`collect` `{id}` takes `{item}` off wave `{wave}`, but no mob of that \
wave declares a `{{\"item\": \"{item}\"}}` drop — {}. Declare the drop on \
the wave's mob, or point `dropped_by` at the wave that really carries it",
if per.is_empty() {
"the wave declares no item drops at all".to_string()
} else {
format!(
"it declares {}",
per.keys().cloned().collect::<Vec<_>>().join(", ")
)
}
),
)),
Some(have) if have < *count => d.push(Diagnostic::error(
codes::DROP_COLLECT_UNSOURCED,
"quests",
path.clone(),
format!(
"`collect` `{id}` asks for {count} × `{item}`, but wave `{wave}` yields \
only {have} — a body drops its declared item once. Lower the `count`, or \
raise the declaring mob's `count`"
),
)),
Some(_) => {}
}
let ordered = kills.get(wave.as_str()).is_some_and(|ks| {
ks.iter().any(|(kq, ko)| {
if *kq == q.id.as_str() {
after_anc
.get(id.as_str())
.is_some_and(|set| set.contains(ko))
} else {
anc.get(q.id.as_str()).is_some_and(|set| set.contains(kq))
}
})
});
if !ordered {
d.push(Diagnostic::error(
codes::DROP_COLLECT_UNORDERED,
"quests",
path,
format!(
"`collect` `{id}` takes `{item}` off wave `{wave}`, but no `kill` \
objective for `{wave}` is proven to run first — the item would be \
unreachable while the objective reads as active from the campaign's \
first tick. Add a `kill` objective for `{wave}` and list it in this \
objective's `after`, or put the kill in a quest this one `depends_on`"
),
));
}
}
}
}
fn check_drop_list(
drops: &[crate::stages::MobDrop],
equipment: Option<&crate::stages::MobEquipment>,
what: &str,
base_path: &str,
items: &dyn ItemRegistry,
d: &mut Vec<Diagnostic>,
) {
use crate::stages::MobDrop;
let mut seen_slots: BTreeSet<&'static str> = BTreeSet::new();
for (n, dr) in drops.iter().enumerate() {
match dr {
MobDrop::Slot(s) => {
let field = s.slot.field();
let filled: Vec<&str> = equipment
.map(|eq| {
eq.slots()
.into_iter()
.filter(|(_, p)| p.is_some())
.map(|(name, _)| name)
.collect()
})
.unwrap_or_default();
if equipment.is_none_or(|eq| eq.filled(s.slot).is_none()) {
d.push(Diagnostic::error(
codes::DROP_SLOT_UNFILLED,
"quests",
format!("{base_path}/{n}/slot"),
format!(
"{what} declares a `{field}` drop, but its `equipment` puts nothing \
in `{field}` — {}. A body can only leave behind a piece it wears: \
equip the slot, or drop a slot it fills",
if filled.is_empty() {
"it declares no equipment at all".to_string()
} else {
format!("it fills {}", filled.join(", "))
}
),
));
} else if !seen_slots.insert(field) {
d.push(Diagnostic::error(
codes::DROP_SLOT_UNFILLED,
"quests",
format!("{base_path}/{n}/slot"),
format!(
"{what} declares the `{field}` drop twice — a body leaves each piece \
behind once; remove the duplicate entry"
),
));
}
}
MobDrop::Item(it) => {
if !items.contains(&it.item) {
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"quests",
format!("{base_path}/{n}/item"),
format!(
"{what} declares a drop of `{}`, which is not in the pinned 1.21.11 \
item registry — use a valid namespaced item id (e.g. \
`minecraft:tripwire_hook`)",
it.item
),
));
}
}
}
}
}
fn objective_ancestors(q: &crate::stages::Quest) -> BTreeMap<&str, BTreeSet<&str>> {
let direct: BTreeMap<&str, Vec<&str>> = q
.objectives
.iter()
.map(|o| {
(
o.id().as_str(),
o.after().iter().map(|a| a.as_str()).collect::<Vec<_>>(),
)
})
.collect();
let mut out = BTreeMap::new();
for o in &q.objectives {
let mut anc: BTreeSet<&str> = BTreeSet::new();
let mut stack = vec![o.id().as_str()];
while let Some(cur) = stack.pop() {
if let Some(ds) = direct.get(cur) {
for dep in ds {
if anc.insert(dep) {
stack.push(dep);
}
}
}
}
out.insert(o.id().as_str(), anc);
}
out
}
fn check_equipment(
eq: &crate::stages::MobEquipment,
what: &str,
base_path: &str,
items: &dyn ItemRegistry,
d: &mut Vec<Diagnostic>,
) {
let ench_reg = crate::registry::VendoredEnchantmentRegistry::v1_21_11();
for (slot, piece) in eq.slots() {
let Some(piece) = piece else { continue };
let it = piece.item();
if !items.contains(it) {
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"quests",
format!("{base_path}/{slot}"),
format!(
"{what} equipment `{slot}` item `{it}` is not in the pinned 1.21.11 \
item registry — use a valid namespaced item id (e.g. \
`minecraft:iron_helmet`)"
),
));
}
check_enchantments(
piece.enchantments(),
&format!("{what} equipment `{slot}`"),
&format!("{base_path}/{slot}/enchantments"),
&ench_reg,
d,
);
}
}
fn check_enchantments(
ench: &std::collections::BTreeMap<String, u32>,
what: &str,
path: &str,
reg: &dyn crate::registry::EnchantmentRegistry,
d: &mut Vec<Diagnostic>,
) {
for (id, level) in ench {
if !reg.contains(id) {
d.push(Diagnostic::error(
codes::ENCHANTMENT_UNKNOWN,
"quests",
format!("{path}/{id}"),
format!(
"{what} enchantment `{id}` is not in the pinned 1.21.11 enchantment \
registry — use a valid namespaced enchantment id (e.g. \
`minecraft:protection`, `minecraft:sharpness`). Note the vanilla \
ids for curses are `minecraft:binding_curse` and \
`minecraft:vanishing_curse`, NOT `curse_of_binding`."
),
));
}
if *level == 0 || *level > 255 {
d.push(Diagnostic::error(
codes::ENCHANTMENT_LEVEL,
"quests",
format!("{path}/{id}"),
format!(
"{what} enchantment `{id}` has level {level}, outside the 1..=255 range \
the `minecraft:enchantments` component stores. Levels above an \
enchantment's survival maximum ARE allowed (that is how a set-piece \
elite is built) — but 0 means \"not enchanted\" and is silently \
dropped by the game, so declare the level you want or remove the entry."
),
));
}
}
}
fn collect_container_claim_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let mut claimed: BTreeMap<&str, String> = BTreeMap::new();
for l in &c.quests.content.loot {
claimed
.entry(l.anchor.as_str())
.or_insert_with(|| format!("loot `{}`", l.id));
}
for (i, q) in c.quests.content.quests.iter().enumerate() {
for (j, o) in q.objectives.iter().enumerate() {
let Some(cont) = o.collect_container() else {
continue;
};
let mine = format!("collect objective `{}`", o.id());
if let Some(prev) = claimed.get(cont.as_str()) {
d.push(Diagnostic::error(
codes::LOOT_DUPLICATE_ANCHOR,
"quests",
format!("/content/quests/{i}/objectives/{j}/container"),
format!(
"{prev} and {mine} both fill the container at anchor `{cont}`. Slots \
are assigned positionally from `container.0`, so one fill overwrites \
the other slot-for-slot and its items never reach the player. Give the \
collect its own container anchor (prefabs may expose several), or \
fold the other fill's items into it — do NOT rely on declaration \
order to combine them."
),
));
} else {
claimed.insert(cont.as_str(), mine);
}
}
}
}
const MIN_CONTAINER_SLOTS: usize = 27;
fn check_stack_count(
item: &str,
count: u32,
what: &str,
path: String,
items: &dyn ItemRegistry,
d: &mut Vec<Diagnostic>,
) {
let Some(cap) = items.max_stack_size(item) else {
return;
};
if count <= cap {
return;
}
d.push(Diagnostic::error(
codes::ITEM_COUNT_OVER_STACK,
"quests",
path,
format!(
"{what} declares `{item}` × {count}, but `{item}` stacks to at most {cap} in \
1.21.11. This is filled with `item replace … container.<n>`, which fails \
SILENTLY above the cap — the slot ships empty and nothing is logged. Lower \
the count to {cap} or fewer, or declare additional entries/containers."
),
));
}
fn loot_checks(
c: &Campaign,
items: &dyn ItemRegistry,
anchors: &dyn AnchorRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
if quests.loot.is_empty() {
return;
}
let ench_reg = crate::registry::VendoredEnchantmentRegistry::v1_21_11();
let providers = AnchorProviders::build(c, anchors);
let mut seen_id: BTreeSet<&str> = BTreeSet::new();
let mut seen_anchor: BTreeMap<&str, usize> = BTreeMap::new();
for (i, l) in quests.loot.iter().enumerate() {
if !l.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quests",
format!("/content/loot/{i}/id"),
format!(
"malformed loot id `{}` — loot ids must be lowercase kebab-case with the \
`loot/` prefix (e.g. `loot/galley-stores`)",
l.id
),
));
}
if !seen_id.insert(l.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quests",
format!("/content/loot/{i}/id"),
format!("duplicate loot id `{}`", l.id),
));
}
if let Some(f) = station_kind_diag(
&providers,
l.anchor.as_str(),
crate::layout::StationKind::Point,
"a loot chest",
"quests",
format!("/content/loot/{i}/anchor"),
) {
d.push(f);
}
if !providers.resolvable(l.anchor.as_str()) {
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/loot/{i}/anchor"),
format!(
"loot anchor `{}` is not provided by any prefab bound in this campaign — {}",
l.anchor,
providers.anchor_remedy(
"use an anchor the prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
),
));
}
if let Some(prev) = seen_anchor.insert(l.anchor.as_str(), i) {
d.push(Diagnostic::error(
codes::LOOT_DUPLICATE_ANCHOR,
"quests",
format!("/content/loot/{i}/anchor"),
format!(
"loot `{}` and loot `{}` both fill anchor `{}`. Slots are assigned \
positionally from `container.0`, so the later declaration overwrites the \
earlier one and its items never appear. Merge the two `items` lists into \
ONE `loot` entry — do NOT rely on declaration order to combine them.",
quests.loot[prev].id, l.id, l.anchor
),
));
}
if l.items.len() > MIN_CONTAINER_SLOTS {
d.push(Diagnostic::error(
codes::LOOT_TOO_MANY_ITEMS,
"quests",
format!("/content/loot/{i}/items"),
format!(
"loot `{}` declares {} stacks, more than the {MIN_CONTAINER_SLOTS} slots a \
vanilla chest or barrel has. Slots are assigned positionally, so every \
stack past the {MIN_CONTAINER_SLOTS}th would be dropped silently. Split \
the contents across more than one container.",
l.id,
l.items.len()
),
));
}
for (k, it) in l.items.iter().enumerate() {
if !items.contains(&it.item) {
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"quests",
format!("/content/loot/{i}/items/{k}/item"),
format!(
"loot item `{}` is not in the pinned 1.21.11 item registry — use a \
valid namespaced item id (e.g. `minecraft:cooked_cod`)",
it.item
),
));
}
check_stack_count(
&it.item,
it.count,
&format!("loot `{}`", l.id),
format!("/content/loot/{i}/items/{k}/count"),
items,
d,
);
check_enchantments(
&it.enchantments,
&format!("loot `{}` item `{}`", l.id, it.item),
&format!("/content/loot/{i}/items/{k}/enchantments"),
&ench_reg,
d,
);
}
}
}
fn branch_point_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let quests: BTreeSet<&str> = c
.quest_plan
.content
.quests
.iter()
.map(|q| q.id.as_str())
.collect();
let endings: BTreeSet<String> = declared_endings(c);
let flags: BTreeSet<String> = produced_flags(c);
let mut seen_points: BTreeSet<&str> = BTreeSet::new();
let mut seen_branches: BTreeSet<&str> = BTreeSet::new();
for (i, bp) in c.quest_plan.content.branch_points.iter().enumerate() {
let base = format!("/content/branch_points/{i}");
if !bp.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quest-plan",
format!("{base}/id"),
format!(
"`{}` is not a valid branch-point id — use `branch-point/<kebab-case>`",
bp.id.as_str()
),
));
} else if !seen_points.insert(bp.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quest-plan",
format!("{base}/id"),
format!("duplicate branch-point id `{}`", bp.id.as_str()),
));
}
if !quests.contains(bp.opens_at.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quest-plan",
format!("{base}/opens_at"),
format!(
"branch point `{}` opens at `{}`, which is not a planned quest — name the \
quest at which the story actually forks",
bp.id.as_str(),
bp.opens_at.as_str()
),
));
}
for (j, f) in bp.forks_on.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quest-plan",
format!("{base}/forks_on/{j}"),
format!(
"branch point `{}` forks on `{}`, which no `set-flag` effect produces — a \
fork nothing can set is not a fork",
bp.id.as_str(),
f.as_str()
),
));
}
}
let fork_set: BTreeSet<&str> = bp.forks_on.iter().map(|f| f.as_str()).collect();
for (j, b) in bp.branches.iter().enumerate() {
let bpath = format!("{base}/branches/{j}");
if !b.id.is_valid_syntax() {
d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quest-plan",
format!("{bpath}/id"),
format!(
"`{}` is not a valid branch id — use `branch/<kebab-case>`",
b.id.as_str()
),
));
} else if !seen_branches.insert(b.id.as_str()) {
d.push(Diagnostic::error(
codes::ID_DUPLICATE,
"quest-plan",
format!("{bpath}/id"),
format!(
"duplicate branch id `{}` — branch ids are campaign-wide unique because \
each one names an emitted `validation/branch-chronicle-<id>.md`",
b.id.as_str()
),
));
}
for (k, f) in b.flags.iter().enumerate() {
if !fork_set.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quest-plan",
format!("{bpath}/flags/{k}"),
format!(
"branch `{}` holds `{}`, which its branch point does not list in \
`forks_on` — a branch may only pin flags its own fork owns",
b.id.as_str(),
f.as_str()
),
));
}
}
match (b.converges_at(), b.ending()) {
(Some(q), _) => {
if !quests.contains(q.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quest-plan",
format!("{bpath}/leads_to"),
format!(
"branch `{}` converges at `{}`, which is not a planned quest",
b.id.as_str(),
q.as_str()
),
));
}
}
(None, Some(e)) => {
if !endings.contains(e.as_str()) {
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quest-plan",
format!("{bpath}/leads_to"),
format!(
"branch `{}` runs to `{}`, which no `campaign-complete` effect \
declares — name the ending on the `campaign-complete` that ends \
this branch",
b.id.as_str(),
e.as_str()
),
));
}
}
(None, None) => d.push(Diagnostic::error(
codes::ID_SYNTAX,
"quest-plan",
format!("{bpath}/leads_to"),
format!(
"`{}` is neither a `quest/<kebab>` (the branches converge there) nor an \
`ending/<kebab>` (this branch runs to it) — the prefix is what says which \
one a branch leads to",
b.leads_to
),
)),
}
}
}
}
fn happening_subject_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let npcs: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let actors: BTreeSet<&str> = c
.quests
.content
.actors
.iter()
.map(|a| a.id.as_str())
.collect();
let waves: BTreeSet<&str> = c
.quests
.content
.waves
.iter()
.map(|w| w.id.as_str())
.collect();
let check = |subject: &str, stage: &str, path: String, d: &mut Vec<Diagnostic>| {
let known = match subject.split_once('/') {
Some(("npc", _)) => npcs.contains(subject),
Some(("actor", _)) => actors.contains(subject),
Some(("wave", _)) => waves.contains(subject),
Some(("anchor", _)) | Some(("item", _)) => true,
_ => false,
};
if !known {
d.push(Diagnostic::error(
codes::DANGLING_REF,
stage,
path,
format!(
"`happening.subject` names `{subject}`, which is not a declared `npc/`, \
`actor/` or `wave/` id (`anchor/` and `item/` labels are also accepted). A \
subject the compiler cannot resolve cannot be reasoned about, so the \
contradiction proof would silently skip this beat"
),
));
}
};
for (i, q) in c.quests.content.quests.iter().enumerate() {
if let Some(h) = &q.happening
&& let Some(s) = &h.subject
{
check(
s,
"quests",
format!("/content/quests/{i}/happening/subject"),
d,
);
}
for (j, o) in q.objectives.iter().enumerate() {
if let Some(h) = o.happening()
&& let Some(s) = &h.subject
{
check(
s,
"quests",
format!("/content/quests/{i}/objectives/{j}/happening/subject"),
d,
);
}
}
}
let mut effect_subjects: Vec<(String, String)> = Vec::new();
crate::stages::for_each_campaign_effect(c, &mut |path, _site, eff| {
if let Some(h) = eff.happening.as_ref()
&& let Some(s) = &h.subject
{
effect_subjects.push((format!("{path}/happening/subject"), s.clone()));
}
});
for (path, s) in effect_subjects {
check(&s, "quests", path, d);
}
for (i, t) in c.dialogue.content.dialogues.iter().enumerate() {
for (j, n) in t.nodes.iter().enumerate() {
for (k, o) in n.options.iter().enumerate() {
if let Some(h) = &o.happening
&& let Some(s) = &h.subject
{
check(
s,
"dialogue",
format!("/content/dialogues/{i}/nodes/{j}/options/{k}/happening/subject"),
d,
);
}
}
}
}
}
pub fn declared_endings(c: &Campaign) -> BTreeSet<String> {
let mut out = BTreeSet::new();
crate::stages::for_each_campaign_effect(c, &mut |_p, _site, eff| {
if let Verb::CampaignComplete {
ending: Some(e), ..
} = &eff.verb
{
out.insert(e.as_str().to_string());
}
});
out
}
pub fn produced_flags(c: &Campaign) -> BTreeSet<String> {
let mut out = BTreeSet::new();
crate::stages::for_each_campaign_effect(c, &mut |_p, _site, eff| {
if let Verb::SetFlag { flag, .. } = &eff.verb {
out.insert(flag.as_str().to_string());
}
});
for t in &c.dialogue.content.dialogues {
for n in &t.nodes {
for o in &n.options {
for e in &o.effects {
if let crate::stages::DialogueEffect::SetFlag { flag } = e {
out.insert(flag.as_str().to_string());
}
}
}
}
}
for trap in &c.quests.content.traps {
if let Some(dis) = &trap.disarm {
out.insert(dis.sets_flag.as_str().to_string());
}
}
out
}