use crate::cutscene::check_cutscene_shape;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::loot::{MIN_CONTAINER_SLOTS, check_stack_count};
use crate::registry::{AnchorRegistry, BlockRegistry, EffectRegistry, ItemRegistry};
use crate::validate::{
AnchorProviders, check_block_field, for_each_effect_deep, graph_has_cycle, produced_flags,
station_kind_diag,
};
use crate::wave::check_enchantments;
use crate::{Objective, PlannedQuest, QuestEffect, Verb};
use std::collections::{BTreeMap, BTreeSet};
crate::dw_code! {
pub const AFTER_CYCLE: DwCode = DwCode::new("DW0140", ExitTier::Build);
}
crate::dw_code! {
pub const MISSING_ITEM_HINT_WITHOUT_ITEM: DwCode = DwCode::new("DW0437", ExitTier::Build);
}
crate::dw_code! {
pub const QUEST_NOT_EXPANDED: DwCode = DwCode::new("DW0150", ExitTier::Build);
}
crate::dw_code! {
pub const QUEST_NOT_PLANNED: DwCode = DwCode::new("DW0151", ExitTier::Build);
}
crate::dw_code! {
pub const WAVE_NEVER_SPAWNED: DwCode = DwCode::new("DW0171", ExitTier::Build);
}
crate::dw_code! {
pub const EFFECT_CLEARED_LIVE: DwCode = DwCode::new("DW0540", ExitTier::Build);
}
crate::dw_code! {
pub const EFFECT_GRANT_BOUNDS: DwCode = DwCode::new("DW0541", ExitTier::Build);
}
crate::dw_code! {
pub const INTERACT_PROP_STEPPED: DwCode = DwCode::new("DW0957", ExitTier::Build);
}
crate::dw_code! {
pub const NESTED_SEQUENCE: DwCode = DwCode::new("DW0329", ExitTier::Build);
}
crate::dw_code! {
pub const PARTY_CARRIER_SCHEDULED: DwCode = DwCode::new("DW0357", ExitTier::Build);
}
pub(crate) 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(
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
),
));
}
}
}
pub(crate) 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(
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::MAX_EFFECT_SECONDS {
d.push(Diagnostic::error(
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::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::MAX_POTION_AMPLIFIER {
d.push(Diagnostic::error(
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::MAX_POTION_AMPLIFIER
),
));
}
}
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,
}
}
pub(crate) 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(
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,
has_actor: bool,
top: bool,
d: &mut Vec<Diagnostic>,
) {
for e in effs {
if !has_actor && !top && e.gives_to_one() {
d.push(Diagnostic::error(
PARTY_CARRIER_SCHEDULED,
"quests",
path.to_string(),
"a `give-item` with `carrier: \"one\"` sits in a bundle only the scheduler ever \
runs with no acting player (a `move-npc`/`move-actor` `on_arrive`, a `bonfire`'s \
`on_rest`, or a `sequence` step of a timeline started where nobody acted — a \
trigger's effects, a trap's payload, a shortcut's `on_unlock`). 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(),
));
}
for (list, how) in e.nested_effect_dispatch() {
check_carrier_one_not_scheduled(list, path, how.has_actor(has_actor), false, d);
}
}
}
pub(crate) fn give_item_enchantment_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let reg = crate::registry::VendoredEnchantmentRegistry::v1_21_11();
fn walk(
stage: &'static str,
path: &str,
list: &[QuestEffect],
reg: &dyn crate::registry::EnchantmentRegistry,
d: &mut Vec<Diagnostic>,
) {
for (n, eff) in list.iter().enumerate() {
let p = format!("{path}/{n}");
if let Verb::GiveItem {
item, enchantments, ..
} = &eff.verb
{
check_enchantments(
enchantments,
&format!("`give-item` item `{item}`"),
stage,
&format!("{p}/enchantments"),
reg,
d,
);
}
for (seg, _key, inner) in eff.nested_effect_lists_labeled() {
walk(stage, &format!("{p}/{seg}"), inner, reg, d);
}
}
}
crate::effects::for_each_effect_root(c, &mut |site, list| {
walk(site.stage, &site.path, list, ®, d);
});
}
pub(crate) 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 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 plan_awaiting_expansion(unexpanded: &[&PlannedQuest]) -> Diagnostic {
let names = unexpanded
.iter()
.map(|q| format!("`{}`", q.id))
.collect::<Vec<_>>()
.join(", ");
Diagnostic::error(
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(),
),
)
}
pub(crate) 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(
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(
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
),
));
}
}
}
pub(crate) fn carrier_one_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::effects::for_each_effect_root(c, &mut |site, effs| {
if site.stage == "quests" {
check_carrier_one_not_scheduled(
effs,
&site.path,
site.runs_with_acting_player(),
true,
d,
);
}
});
}
pub(crate) fn quest_anchor_checks(
c: &Campaign,
providers: &AnchorProviders,
d: &mut Vec<Diagnostic>,
) {
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 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::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::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(),
),
));
}
});
}
}
pub(crate) fn quest_reference_checks(
c: &Campaign,
items: &dyn ItemRegistry,
anchors: &dyn AnchorRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
let declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
let declared_flags: BTreeSet<String> = produced_flags(c);
let mut spawned_waves: BTreeSet<&str> = BTreeSet::new();
crate::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(
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(
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"
),
));
}
}
});
}
}
pub(crate) fn quest_prop_checks(c: &Campaign, blocks: &dyn BlockRegistry, 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 declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
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,
);
if crate::fires_on_step(&prop.block) {
d.push(Diagnostic::error(
INTERACT_PROP_STEPPED,
"quests",
format!("/content/quests/{i}/objectives/{j}/prop/block"),
format!(
"`interact` objective `{}` uses `{}` as its prop, a block a player \
fires by stepping on it — but an `interact` completes on a \
right-click, so walking onto it does nothing. Prescription: give the \
objective a block a hand works (a lever, a button), or make the step \
the act: a `trigger` with `on: step` at an anchor whose cell holds the \
plate, whose effects do what completing the objective did",
o.id(),
prop.block
),
));
}
}
}
for_each_effect_deep(q, |path, eff| {
check_effect_v04(
eff,
blocks,
&declared_waves,
&format!("/content/quests/{i}/{path}"),
&npc_ids,
d,
);
});
}
}
pub(crate) fn quest_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, q) in c.quests.content.quests.iter().enumerate() {
crate::ids::id_syntax!(d, q.id, "quests", format!("/content/quests/{i}/id"));
for (j, obj) in q.objectives.iter().enumerate() {
crate::ids::id_syntax!(
d,
obj.id(),
"quests",
format!("/content/quests/{i}/objectives/{j}/id")
);
}
}
}
pub(crate) fn quest_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::ids::dup_check(
c.quests
.content
.quests
.iter()
.enumerate()
.map(|(i, q)| (q.id.as_str(), format!("/content/quests/{i}/id"))),
"quests",
"quest",
d,
);
crate::ids::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,
);
}
pub(crate) fn quest_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
use crate::ids::dangling;
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let expanded_ids: BTreeSet<&str> = c
.quests
.content
.quests
.iter()
.map(|q| q.id.as_str())
.collect();
for (i, q) in c.quests.content.quests.iter().enumerate() {
if let crate::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::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"
),
);
}
}
}