1use crate::cutscene::check_cutscene_shape;
7use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
8use crate::envelope::Campaign;
9use crate::loot::{MIN_CONTAINER_SLOTS, check_stack_count};
10use crate::registry::{AnchorRegistry, BlockRegistry, EffectRegistry, ItemRegistry};
11use crate::validate::{
12 AnchorProviders, check_block_field, for_each_effect_deep, graph_has_cycle, produced_flags,
13 station_kind_diag,
14};
15use crate::wave::check_enchantments;
16use crate::{Objective, PlannedQuest, QuestEffect, Verb};
17use std::collections::{BTreeMap, BTreeSet};
18
19crate::dw_code! {
20 pub const AFTER_CYCLE: DwCode = DwCode::new("DW0140", ExitTier::Build);
22}
23
24crate::dw_code! {
25 pub const MISSING_ITEM_HINT_WITHOUT_ITEM: DwCode = DwCode::new("DW0437", ExitTier::Build);
28}
29
30crate::dw_code! {
31 pub const QUEST_NOT_EXPANDED: DwCode = DwCode::new("DW0150", ExitTier::Build);
33}
34
35crate::dw_code! {
36 pub const QUEST_NOT_PLANNED: DwCode = DwCode::new("DW0151", ExitTier::Build);
38}
39
40crate::dw_code! {
41 pub const WAVE_NEVER_SPAWNED: DwCode = DwCode::new("DW0171", ExitTier::Build);
45}
46
47crate::dw_code! {
48 pub const EFFECT_CLEARED_LIVE: DwCode = DwCode::new("DW0540", ExitTier::Build);
60}
61
62crate::dw_code! {
63 pub const EFFECT_GRANT_BOUNDS: DwCode = DwCode::new("DW0541", ExitTier::Build);
71}
72
73crate::dw_code! {
74 pub const INTERACT_PROP_STEPPED: DwCode = DwCode::new("DW0957", ExitTier::Build);
79}
80
81crate::dw_code! {
82 pub const NESTED_SEQUENCE: DwCode = DwCode::new("DW0329", ExitTier::Build);
86}
87
88crate::dw_code! {
89 pub const PARTY_CARRIER_SCHEDULED: DwCode = DwCode::new("DW0357", ExitTier::Build);
97}
98
99pub(crate) fn after_ordering(c: &Campaign, d: &mut Vec<Diagnostic>) {
100 for (i, q) in c.quests.content.quests.iter().enumerate() {
101 let local: BTreeSet<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
102 let edges: BTreeMap<&str, Vec<&str>> = q
103 .objectives
104 .iter()
105 .map(|o| {
106 let deps = o
107 .after()
108 .iter()
109 .map(|x| x.as_str())
110 .filter(|x| local.contains(x))
111 .collect();
112 (o.id().as_str(), deps)
113 })
114 .collect();
115 let nodes: Vec<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
116 if graph_has_cycle(&nodes, &edges) {
117 d.push(Diagnostic::error(
118 AFTER_CYCLE,
119 "quests",
120 format!("/content/quests/{i}/objectives"),
121 format!(
122 "objective `after` ordering in quest `{}` contains a cycle — the `after` \
123 edges must form a DAG; remove one `after` entry to break the cycle",
124 q.id
125 ),
126 ));
127 }
128 }
129}
130
131pub(crate) fn status_effect_checks(
158 c: &Campaign,
159 effects: &dyn EffectRegistry,
160 d: &mut Vec<Diagnostic>,
161) {
162 crate::effects::for_each_effect_root(c, &mut |site, list| {
163 status_effect_bundle(site.stage, &site.path, list, effects, d);
164 });
165}
166
167fn status_effect_bundle(
170 stage: &'static str,
171 path: &str,
172 list: &[QuestEffect],
173 effects: &dyn EffectRegistry,
174 d: &mut Vec<Diagnostic>,
175) {
176 let tl = effect_timeline(path, list);
177 check_grant_removal(stage, &tl, d);
178 for (_, p, eff) in &tl {
179 check_one_status_effect(stage, p, eff, effects, d);
180 for (pseg, _key, inner) in eff.nested_effect_lists_labeled() {
181 status_effect_bundle(stage, &format!("{p}/{pseg}"), inner, effects, d);
182 }
183 }
184}
185
186fn effect_timeline<'a>(path: &str, list: &'a [QuestEffect]) -> Vec<(u32, String, &'a QuestEffect)> {
194 let mut out = Vec::new();
195 for (i, eff) in list.iter().enumerate() {
196 match &eff.verb {
197 Verb::Sequence { steps } => {
198 for (s, step) in steps.iter().enumerate() {
199 for (k, inner) in step.effects.iter().enumerate() {
200 out.push((
201 step.at_ticks,
202 format!("{path}/{i}/steps/{s}/effects/{k}"),
203 inner,
204 ));
205 }
206 }
207 }
208 _ => out.push((0, format!("{path}/{i}"), eff)),
209 }
210 }
211 out
212}
213
214fn check_grant_removal(
230 stage: &'static str,
231 timeline: &[(u32, String, &QuestEffect)],
232 d: &mut Vec<Diagnostic>,
233) {
234 for (gi, (g_tick, g_path, g_eff)) in timeline.iter().enumerate() {
235 let Some((id, seconds, _, _, _)) = g_eff.give_effect() else {
236 continue;
237 };
238 let granted = crate::registry::namespaced_effect_id(id);
243 let removal = timeline
244 .iter()
245 .enumerate()
246 .filter(|(ci, (t, _, _))| (*t, *ci) > (*g_tick, gi))
247 .filter_map(|(ci, (t, p, e))| match e.clear_effect() {
248 Some((None, _)) => Some(((*t, ci), t, p, "clears every effect")),
249 Some((Some(c), _)) if crate::registry::namespaced_effect_id(c) == granted => {
250 Some(((*t, ci), t, p, "clears it"))
251 }
252 _ => None,
253 })
254 .min_by_key(|(key, _, _, _)| *key)
255 .map(|(_, t, p, what)| (*t, p, what));
256 let Some((c_tick, c_path, what)) = removal else {
257 continue;
258 };
259 let live_ticks = seconds.saturating_mul(20);
260 let elapsed = c_tick - g_tick;
261 if elapsed >= live_ticks {
262 continue;
266 }
267 d.push(Diagnostic::error(
268 EFFECT_CLEARED_LIVE,
269 stage,
270 g_path.clone(),
271 format!(
272 "this `give-effect` grants `{id}` for {seconds}s ({live_ticks} ticks) and the \
273 `clear-effect` at `{c_path}`, {elapsed} tick(s) later in the same bundle, {what} \
274 while it is still live — so the CLEAR is what ends this grant, not its duration. \
275 Every path that does not reach that clear (a logout, a crash, a death mid-chain, \
276 a `sequence` whose remaining schedule never runs) leaves the player holding \
277 `{id}` for the rest of the {seconds}s. Set `seconds` to how long the effect \
278 should actually last — {elapsed} tick(s) here, so {} — and drop the \
279 `clear-effect`: a duration expires with no cooperation from anything. \
280 `clear-effect` is for effects this campaign did not grant.",
281 if elapsed == 0 {
282 "the grant is doing nothing at all".to_string()
283 } else {
284 format!("`seconds`: {}", elapsed.div_ceil(20).max(1))
285 }
286 ),
287 ));
288 }
289}
290
291fn check_one_status_effect(
295 stage: &'static str,
296 path: &str,
297 eff: &QuestEffect,
298 effects: &dyn EffectRegistry,
299 d: &mut Vec<Diagnostic>,
300) {
301 for (sub, id) in eff.status_effect_refs() {
302 if !effects.contains(id) {
303 d.push(Diagnostic::error(
304 codes::EFFECT_UNKNOWN,
305 stage,
306 format!("{path}/{sub}"),
307 format!(
308 "`{}` names status effect `{id}`, which is not in the pinned 1.21.11 \
309 `mob_effect` registry — use a valid namespaced effect id (e.g. \
310 `minecraft:blindness`)",
311 eff.verb.tag()
312 ),
313 ));
314 }
315 }
316 let Some((_, seconds, amplifier, _, _)) = eff.give_effect() else {
317 return;
318 };
319 if seconds == 0 || seconds > crate::MAX_EFFECT_SECONDS {
320 d.push(Diagnostic::error(
321 EFFECT_GRANT_BOUNDS,
322 stage,
323 format!("{path}/seconds"),
324 format!(
325 "`give-effect` duration {seconds}s is out of range — it must be between 1 and {} \
326 seconds. {}",
327 crate::MAX_EFFECT_SECONDS,
328 if seconds == 0 {
329 "Zero grants nothing at all: the effect is applied and gone before the next \
330 tick, so the beat reports green and the player sees nothing."
331 } else {
332 "The ceiling is vanilla's own field width (past the 10-hour delve ceiling), \
333 so a value above it is a duration typed in ticks or milliseconds."
334 }
335 ),
336 ));
337 }
338 if amplifier > crate::MAX_POTION_AMPLIFIER {
339 d.push(Diagnostic::error(
340 EFFECT_GRANT_BOUNDS,
341 stage,
342 format!("{path}/amplifier"),
343 format!(
344 "`give-effect` amplifier {amplifier} is out of range — vanilla stores it in an \
345 unsigned byte, so {} is the end of the field, not a policy",
346 crate::MAX_POTION_AMPLIFIER
347 ),
348 ));
349 }
350}
351
352fn reaches_sequence(e: &QuestEffect) -> bool {
356 match &e.verb {
357 Verb::Sequence { .. } => true,
358 Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
359 on_arrive.iter().any(reaches_sequence)
360 }
361 _ => false,
362 }
363}
364
365pub(crate) fn check_no_nested_sequence(effs: &[QuestEffect], path: &str, d: &mut Vec<Diagnostic>) {
370 for e in effs {
371 match &e.verb {
372 Verb::Sequence { steps } => {
373 for s in steps {
374 for inner in &s.effects {
375 if reaches_sequence(inner) {
376 d.push(Diagnostic::error(
377 NESTED_SEQUENCE,
378 "quests",
379 path.to_string(),
380 "a `sequence` effect is nested inside another `sequence` — \
381 timelines do not recurse (spec-0014). Flatten the inner steps \
382 into the outer timeline (shift their `at_ticks` by the inner \
383 sequence's start), or drive the second beat from a flag/objective"
384 .to_string(),
385 ));
386 }
387 }
388 }
389 }
390 Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
391 check_no_nested_sequence(on_arrive, path, d);
392 }
393 _ => {}
394 }
395 }
396}
397
398fn check_carrier_one_not_scheduled(
416 effs: &[QuestEffect],
417 path: &str,
418 has_actor: bool,
419 top: bool,
420 d: &mut Vec<Diagnostic>,
421) {
422 for e in effs {
423 if !has_actor && !top && e.gives_to_one() {
424 d.push(Diagnostic::error(
425 PARTY_CARRIER_SCHEDULED,
426 "quests",
427 path.to_string(),
428 "a `give-item` with `carrier: \"one\"` sits in a bundle only the scheduler ever \
429 runs with no acting player (a `move-npc`/`move-actor` `on_arrive`, a `bonfire`'s \
430 `on_rest`, or a `sequence` step of a timeline started where nobody acted — a \
431 trigger's effects, a trap's payload, a shortcut's `on_unlock`). Those run with \
432 the server command source — there is no acting player to hand the prop to, so \
433 the give would silently reach nobody. Drop `carrier` to arm the whole party, or \
434 move the hand-off onto the beat a player completes"
435 .to_string(),
436 ));
437 }
438 for (list, how) in e.nested_effect_dispatch() {
439 check_carrier_one_not_scheduled(list, path, how.has_actor(has_actor), false, d);
440 }
441 }
442}
443
444pub(crate) fn give_item_enchantment_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
448 let reg = crate::registry::VendoredEnchantmentRegistry::v1_21_11();
449 fn walk(
450 stage: &'static str,
451 path: &str,
452 list: &[QuestEffect],
453 reg: &dyn crate::registry::EnchantmentRegistry,
454 d: &mut Vec<Diagnostic>,
455 ) {
456 for (n, eff) in list.iter().enumerate() {
457 let p = format!("{path}/{n}");
458 if let Verb::GiveItem {
459 item, enchantments, ..
460 } = &eff.verb
461 {
462 check_enchantments(
463 enchantments,
464 &format!("`give-item` item `{item}`"),
465 stage,
466 &format!("{p}/enchantments"),
467 reg,
468 d,
469 );
470 }
471 for (seg, _key, inner) in eff.nested_effect_lists_labeled() {
472 walk(stage, &format!("{p}/{seg}"), inner, reg, d);
473 }
474 }
475 }
476 crate::effects::for_each_effect_root(c, &mut |site, list| {
477 walk(site.stage, &site.path, list, ®, d);
478 });
479}
480
481pub(crate) fn check_effect_v04(
486 eff: &QuestEffect,
487 blocks: &dyn BlockRegistry,
488 _declared_waves: &BTreeSet<&str>,
489 base_path: &str,
490 npc_ids: &BTreeSet<&str>,
491 d: &mut Vec<Diagnostic>,
492) {
493 check_cutscene_shape(eff, base_path, d);
494 match &eff.verb {
495 Verb::SetBlock { block, .. } => {
496 check_block_field(
497 blocks,
498 block,
499 format!("{base_path}/block"),
500 "set-block",
501 "minecraft:air",
502 d,
503 );
504 }
505 Verb::FillRegion { block, .. } => {
509 check_block_field(
510 blocks,
511 block,
512 format!("{base_path}/block"),
513 "fill-region",
514 "minecraft:stone",
515 d,
516 );
517 }
518 Verb::DespawnNpc { npc, .. } | Verb::MoveNpc { npc, .. } | Verb::SpawnNpc { npc, .. }
521 if !npc_ids.contains(npc.as_str()) =>
522 {
523 let verb = eff
524 .v04_effect()
525 .or_else(|| eff.v06_effect())
526 .unwrap_or("lifecycle");
527 d.push(Diagnostic::error(
528 codes::DANGLING_REF,
529 "quests",
530 format!("{base_path}/npc"),
531 format!(
532 "`{verb}` references unknown npc `{npc}` — declare it in stage 2 or correct \
533 the reference"
534 ),
535 ));
536 }
537 _ => {}
538 }
539}
540
541fn anchor_resolves(
551 providers: &AnchorProviders,
552 set: Option<&BTreeSet<String>>,
553 anchor: &crate::ids::AnchorId,
554 qi: usize,
555 oi: usize,
556 field: &str,
557 d: &mut Vec<Diagnostic>,
558) {
559 if let Some(f) = station_kind_diag(
560 providers,
561 anchor.as_str(),
562 crate::layout::StationKind::Point,
563 &format!("an objective's `{field}`"),
564 "quests",
565 format!("/content/quests/{qi}/objectives/{oi}/{field}"),
566 ) {
567 d.push(f);
568 return;
569 }
570 if let Some(set) = set
571 && !set.contains(anchor.as_str())
572 {
573 d.push(Diagnostic::error(
574 codes::ANCHOR_UNRESOLVED,
575 "quests",
576 format!("/content/quests/{qi}/objectives/{oi}/{field}"),
577 format!(
578 "objective `{field}` anchor `{anchor}` is not provided by the prefab bound to \
579 this quest's area — {remedy}",
580 remedy = providers.anchor_remedy(
581 "use an anchor the prefab exposes (anchor names come from prefab metadata; \
582 do NOT invent one)"
583 ),
584 ),
585 ));
586 }
587}
588
589fn plan_awaiting_expansion(unexpanded: &[&PlannedQuest]) -> Diagnostic {
620 let names = unexpanded
621 .iter()
622 .map(|q| format!("`{}`", q.id))
623 .collect::<Vec<_>>()
624 .join(", ");
625 Diagnostic::error(
626 QUEST_NOT_EXPANDED,
627 "quest-plan",
628 "/content/quests",
629 format!(
630 "stage 5 declares no quests at all, so none of the {n} planned quest(s) has an \
631 expansion: {names}. That is an authoring state, not a fault — the plan is written \
632 before the quests that fill it, and a campaign is written a document at a time. The \
633 refusal still stands, because a planned quest with no expansion has no trigger, no \
634 objective and no completion, so nothing of it is emitted and no campaign in this \
635 state can build. What there is NO route to is a cheaper way out: a stage-5 quest \
636 carrying only what its schema requires — a trigger and two empty arrays — is refused \
637 again by `DW0481`, because every quest must say what it does to the story, and by \
638 `DW0460` once for every NPC live in it, so writing empty expansions raises the error \
639 count instead of lowering it. Do not try to clear this code from the plan, and do \
640 not re-run validation expecting it to move. The two things that clear it are \
641 writing stage 5, which clears it for every quest at once, and dropping the quests \
642 from the stage-4 plan. Until stage 5 is written this is the expected state of a \
643 plan-only campaign and this code is what marks it.",
644 n = unexpanded.len(),
645 ),
646 )
647}
648
649pub(crate) fn cross_stage(c: &Campaign, d: &mut Vec<Diagnostic>) {
650 let planned_ids: BTreeSet<&str> = c
651 .quest_plan
652 .content
653 .quests
654 .iter()
655 .map(|q| q.id.as_str())
656 .collect();
657 let expanded_ids: BTreeSet<&str> = c
658 .quests
659 .content
660 .quests
661 .iter()
662 .map(|q| q.id.as_str())
663 .collect();
664
665 let unexpanded: Vec<&PlannedQuest> = c
673 .quest_plan
674 .content
675 .quests
676 .iter()
677 .filter(|q| !expanded_ids.contains(q.id.as_str()))
678 .collect();
679 if !unexpanded.is_empty() && c.quests.content.quests.is_empty() {
680 d.push(plan_awaiting_expansion(&unexpanded));
681 } else {
682 for (i, q) in c.quest_plan.content.quests.iter().enumerate() {
683 if !expanded_ids.contains(q.id.as_str()) {
684 d.push(Diagnostic::error(
685 QUEST_NOT_EXPANDED,
686 "quest-plan",
687 format!("/content/quests/{i}"),
688 format!(
689 "planned quest `{}` has no stage-5 expansion — stage 5 declares {n} \
690 quest(s) and none of them carries this id, so this is a mismatch rather \
691 than an unwritten stage: add a stage-5 quest with id `{}` \
692 (objectives/effects), or drop it from the stage-4 plan",
693 q.id,
694 q.id,
695 n = c.quests.content.quests.len(),
696 ),
697 ));
698 }
699 }
700 }
701 for (i, q) in c.quests.content.quests.iter().enumerate() {
702 if !planned_ids.contains(q.id.as_str()) {
703 d.push(Diagnostic::error(
704 QUEST_NOT_PLANNED,
705 "quests",
706 format!("/content/quests/{i}"),
707 format!(
708 "stage-5 quest `{}` is not planned in stage 4 — add a stage-4 plan entry with \
709 id `{}` (every quest must be planned), or remove this expansion",
710 q.id, q.id
711 ),
712 ));
713 }
714 }
715}
716
717pub(crate) fn carrier_one_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
720 crate::effects::for_each_effect_root(c, &mut |site, effs| {
723 if site.stage == "quests" {
724 check_carrier_one_not_scheduled(
725 effs,
726 &site.path,
727 site.runs_with_acting_player(),
728 true,
729 d,
730 );
731 }
732 });
733}
734
735pub(crate) fn quest_anchor_checks(
740 c: &Campaign,
741 providers: &AnchorProviders,
742 d: &mut Vec<Diagnostic>,
743) {
744 let quest_area: BTreeMap<&str, &str> = c
746 .quest_plan
747 .content
748 .quests
749 .iter()
750 .map(|q| (q.id.as_str(), q.area.as_str()))
751 .collect();
752
753 for (i, q) in c.quests.content.quests.iter().enumerate() {
755 let set = quest_area
756 .get(q.id.as_str())
757 .and_then(|area| providers.for_area(area));
758 let Some(set) = set else { continue };
759
760 for (j, obj) in q.objectives.iter().enumerate() {
761 if let crate::Objective::ReachAnchor { anchor, .. } = obj
762 && let Some(f) = station_kind_diag(
763 providers,
764 anchor.as_str(),
765 crate::layout::StationKind::Point,
766 "a `reach-anchor` objective",
767 "quests",
768 format!("/content/quests/{i}/objectives/{j}/anchor"),
769 )
770 {
771 d.push(f);
772 } else if let crate::Objective::ReachAnchor { anchor, .. } = obj
773 && !set.contains(anchor.as_str())
774 {
775 d.push(Diagnostic::error(
776 codes::ANCHOR_UNRESOLVED,
777 "quests",
778 format!("/content/quests/{i}/objectives/{j}/anchor"),
779 format!(
780 "objective anchor `{anchor}` is not provided by the prefab bound to \
781 this quest's area — {}",
782 providers.anchor_remedy(
783 "use an anchor the prefab exposes (anchor names come from prefab \
784 metadata; do NOT invent one)"
785 ),
786 ),
787 ));
788 }
789 }
790 for_each_effect_deep(q, |path, eff| {
799 let cross_area = matches!(&eff.verb, Verb::Cutscene { .. });
804 for (suffix, anchor, demands) in eff.anchor_refs() {
805 if let Some(f) = station_kind_diag(
806 providers,
807 anchor.as_str(),
808 demands,
809 &format!("`{}`", eff.verb.tag()),
810 "quests",
811 format!("/content/quests/{i}/{path}/{suffix}"),
812 ) {
813 d.push(f);
814 continue;
815 }
816 let resolves = if cross_area {
817 !providers.all_areas_known() || providers.union().contains(anchor.as_str())
820 } else {
821 set.contains(anchor.as_str())
822 };
823 if resolves {
824 continue;
825 }
826 let scope = if cross_area {
827 "any area's prefab"
828 } else {
829 "the prefab bound to this quest's area"
830 };
831 d.push(Diagnostic::error(
832 codes::ANCHOR_UNRESOLVED,
833 "quests",
834 format!("/content/quests/{i}/{path}/{suffix}"),
835 format!(
836 "`{verb}` anchor `{anchor}` is not provided by {scope} — {}",
837 providers.anchor_remedy(
838 "use an anchor a prefab exposes (anchor names come from prefab \
839 metadata; do NOT invent one)"
840 ),
841 verb = eff.verb.tag(),
842 ),
843 ));
844 }
845 });
846 }
847}
848
849pub(crate) fn quest_reference_checks(
856 c: &Campaign,
857 items: &dyn ItemRegistry,
858 anchors: &dyn AnchorRegistry,
859 d: &mut Vec<Diagnostic>,
860) {
861 let quests = &c.quests.content;
862
863 let declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
864
865 let declared_flags: BTreeSet<String> = produced_flags(c);
868 let mut spawned_waves: BTreeSet<&str> = BTreeSet::new();
869 crate::for_each_campaign_effect(c, &mut |_path, _site, e| {
870 if let Some(w) = e.spawn_wave() {
871 spawned_waves.insert(w.as_str());
872 }
873 });
874
875 let providers = AnchorProviders::build(c, anchors);
877 let quest_area: BTreeMap<&str, &str> = c
878 .quest_plan
879 .content
880 .quests
881 .iter()
882 .map(|q| (q.id.as_str(), q.area.as_str()))
883 .collect();
884
885 for (i, q) in quests.quests.iter().enumerate() {
887 let set = quest_area
888 .get(q.id.as_str())
889 .and_then(|area| providers.for_area(area));
890
891 for (j, obj) in q.objectives.iter().enumerate() {
892 match obj {
893 Objective::Kill { wave, .. } => {
894 if !declared_waves.contains(wave.as_str()) {
895 d.push(Diagnostic::error(
896 codes::WAVE_UNKNOWN,
897 "quests",
898 format!("/content/quests/{i}/objectives/{j}/wave"),
899 format!(
900 "`kill` objective references unknown wave `{wave}` — declare it \
901 in the stage-5 `waves` section or correct the reference"
902 ),
903 ));
904 } else if !spawned_waves.contains(wave.as_str()) {
905 d.push(Diagnostic::error(
906 WAVE_NEVER_SPAWNED,
907 "quests",
908 format!("/content/quests/{i}/objectives/{j}/wave"),
909 format!(
910 "wave `{wave}` is killed but never spawned by any `spawn-wave` \
911 effect — a wave must be spawned before its `kill` objective is \
912 reachable; add a `spawn-wave` effect for `{wave}` on an earlier \
913 objective/quest"
914 ),
915 ));
916 }
917 }
918 Objective::Collect {
919 id: oid,
920 item,
921 count,
922 anchor,
923 container,
924 dropped_by,
925 fill_count,
926 ..
927 } => {
928 if let Some(wave) = dropped_by
931 && !declared_waves.contains(wave.as_str())
932 {
933 d.push(Diagnostic::error(
934 codes::WAVE_UNKNOWN,
935 "quests",
936 format!("/content/quests/{i}/objectives/{j}/dropped_by"),
937 format!(
938 "`collect` `dropped_by` references unknown wave `{wave}` — \
939 declare it in the stage-5 `waves` section or correct the \
940 reference"
941 ),
942 ));
943 }
944 if !items.contains(item) {
945 d.push(Diagnostic::error(
946 codes::ITEM_UNKNOWN,
947 "quests",
948 format!("/content/quests/{i}/objectives/{j}/item"),
949 format!(
950 "collect item `{item}` is not in the pinned 1.21.11 item \
951 registry — use a valid namespaced item id (e.g. \
952 `minecraft:emerald`)"
953 ),
954 ));
955 }
956 check_stack_count(
960 item,
961 *count,
962 &format!("collect objective `{oid}`"),
963 format!("/content/quests/{i}/objectives/{j}/count"),
964 items,
965 d,
966 );
967 anchor_resolves(&providers, set, anchor, i, j, "anchor", d);
968 if let Some(cont) = container {
972 anchor_resolves(&providers, set, cont, i, j, "container", d);
973 }
974 let slots = 1usize + *fill_count as usize;
980 if slots > MIN_CONTAINER_SLOTS {
981 d.push(Diagnostic::error(
982 codes::LOOT_TOO_MANY_ITEMS,
983 "quests",
984 format!("/content/quests/{i}/objectives/{j}/fill_count"),
985 format!(
986 "collect objective `{oid}` fills {slots} slots (its own stack \
987 plus `fill_count` {fill_count}), more than the \
988 {MIN_CONTAINER_SLOTS} slots a vanilla chest or barrel has. \
989 Slots are assigned positionally, so every stack past the \
990 {MIN_CONTAINER_SLOTS}th would be dropped silently. Lower \
991 `fill_count` to at most {max} — a container that reads full \
992 does not need to overflow.",
993 max = MIN_CONTAINER_SLOTS - 1
994 ),
995 ));
996 }
997 }
998 Objective::Interact {
999 anchor,
1000 requires_item,
1001 missing_item_hint,
1002 ..
1003 } => {
1004 if missing_item_hint.is_some() && requires_item.is_none() {
1008 d.push(Diagnostic::error(
1009 MISSING_ITEM_HINT_WITHOUT_ITEM,
1010 "quests",
1011 format!("/content/quests/{i}/objectives/{j}/missing_item_hint"),
1012 "`interact.missing_item_hint` narrates the click that arrives \
1013 without the required item in hand, but this objective declares no \
1014 `requires_item` — add the `requires_item` this hint is about, or \
1015 drop the hint"
1016 .to_string(),
1017 ));
1018 }
1019 if let Some(it) = requires_item
1020 && !items.contains(it)
1021 {
1022 d.push(Diagnostic::error(
1023 codes::ITEM_UNKNOWN,
1024 "quests",
1025 format!("/content/quests/{i}/objectives/{j}/requires_item"),
1026 format!(
1027 "`interact.requires_item` `{it}` is not in the pinned 1.21.11 \
1028 item registry — use a valid namespaced item id (e.g. \
1029 `minecraft:tripwire_hook`)"
1030 ),
1031 ));
1032 }
1033 anchor_resolves(&providers, set, anchor, i, j, "anchor", d);
1034 }
1035 Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => {}
1036 }
1037
1038 for (m, f) in obj.requires_flags().iter().enumerate() {
1039 if !declared_flags.contains(f.as_str()) {
1040 d.push(Diagnostic::error(
1041 codes::FLAG_UNKNOWN,
1042 "quests",
1043 format!("/content/quests/{i}/objectives/{j}/requires_flags/{m}"),
1044 format!(
1045 "objective `requires_flags` references flag `{f}`, which no \
1046 `set-flag` effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` \
1047 effect on an earlier objective/quest, or correct the flag name"
1048 ),
1049 ));
1050 }
1051 }
1052 for (m, f) in obj.forbids_flags().iter().enumerate() {
1056 if !declared_flags.contains(f.as_str()) {
1057 d.push(Diagnostic::error(
1058 codes::FLAG_UNKNOWN,
1059 "quests",
1060 format!("/content/quests/{i}/objectives/{j}/forbids_flags/{m}"),
1061 format!(
1062 "objective `forbids_flags` references flag `{f}`, which no `set-flag` \
1063 effect ever produces — the gate can never suppress anything; add the \
1064 producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
1065 name"
1066 ),
1067 ));
1068 }
1069 }
1070 }
1071
1072 for_each_effect_deep(q, |path, eff| {
1073 if let Some(w) = eff.spawn_wave()
1074 && !declared_waves.contains(w.as_str())
1075 {
1076 d.push(Diagnostic::error(
1077 codes::WAVE_UNKNOWN,
1078 "quests",
1079 format!("/content/quests/{i}/{path}/wave"),
1080 format!(
1081 "`spawn-wave` effect references unknown wave `{w}` — declare it in the \
1082 stage-5 `waves` section or correct the reference"
1083 ),
1084 ));
1085 }
1086 if let Some(it) = eff.give_item()
1087 && !items.contains(it)
1088 {
1089 d.push(Diagnostic::error(
1090 codes::ITEM_UNKNOWN,
1091 "quests",
1092 format!("/content/quests/{i}/{path}/item"),
1093 format!(
1094 "`give-item` item `{it}` is not in the pinned 1.21.11 item registry — use \
1095 a valid namespaced item id (e.g. `minecraft:golden_apple`)"
1096 ),
1097 ));
1098 }
1099 for (n, f) in eff.requires_flags().iter().enumerate() {
1102 if !declared_flags.contains(f.as_str()) {
1103 d.push(Diagnostic::error(
1104 codes::FLAG_UNKNOWN,
1105 "quests",
1106 format!("/content/quests/{i}/{path}/when/requires_flags/{n}"),
1107 format!(
1108 "effect `requires_flags` references flag `{f}`, which no `set-flag` \
1109 effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
1110 earlier, or correct the flag name"
1111 ),
1112 ));
1113 }
1114 }
1115 for (n, f) in eff.forbids_flags().iter().enumerate() {
1117 if !declared_flags.contains(f.as_str()) {
1118 d.push(Diagnostic::error(
1119 codes::FLAG_UNKNOWN,
1120 "quests",
1121 format!("/content/quests/{i}/{path}/when/forbids_flags/{n}"),
1122 format!(
1123 "effect `forbids_flags` references flag `{f}`, which no `set-flag` \
1124 effect ever produces — the gate can never suppress anything; add the \
1125 producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
1126 name"
1127 ),
1128 ));
1129 }
1130 }
1131 });
1132 }
1133}
1134
1135pub(crate) fn quest_prop_checks(c: &Campaign, blocks: &dyn BlockRegistry, d: &mut Vec<Diagnostic>) {
1139 let quests = &c.quests.content;
1140 let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
1141 let declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
1142
1143 for (i, q) in quests.quests.iter().enumerate() {
1144 for (j, o) in q.objectives.iter().enumerate() {
1145 if let Some(prop) = o.prop() {
1146 check_block_field(
1147 blocks,
1148 &prop.block,
1149 format!("/content/quests/{i}/objectives/{j}/prop/block"),
1150 "interact.prop",
1151 "minecraft:lever",
1152 d,
1153 );
1154 if crate::fires_on_step(&prop.block) {
1155 d.push(Diagnostic::error(
1156 INTERACT_PROP_STEPPED,
1157 "quests",
1158 format!("/content/quests/{i}/objectives/{j}/prop/block"),
1159 format!(
1160 "`interact` objective `{}` uses `{}` as its prop, a block a player \
1161 fires by stepping on it — but an `interact` completes on a \
1162 right-click, so walking onto it does nothing. Prescription: give the \
1163 objective a block a hand works (a lever, a button), or make the step \
1164 the act: a `trigger` with `on: step` at an anchor whose cell holds the \
1165 plate, whose effects do what completing the objective did",
1166 o.id(),
1167 prop.block
1168 ),
1169 ));
1170 }
1171 }
1172 }
1173 for_each_effect_deep(q, |path, eff| {
1174 check_effect_v04(
1175 eff,
1176 blocks,
1177 &declared_waves,
1178 &format!("/content/quests/{i}/{path}"),
1179 &npc_ids,
1180 d,
1181 );
1182 });
1183 }
1184}
1185
1186pub(crate) fn quest_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
1188 for (i, q) in c.quests.content.quests.iter().enumerate() {
1189 crate::ids::id_syntax!(d, q.id, "quests", format!("/content/quests/{i}/id"));
1190 for (j, obj) in q.objectives.iter().enumerate() {
1191 crate::ids::id_syntax!(
1192 d,
1193 obj.id(),
1194 "quests",
1195 format!("/content/quests/{i}/objectives/{j}/id")
1196 );
1197 }
1198 }
1199}
1200
1201pub(crate) fn quest_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
1203 crate::ids::dup_check(
1204 c.quests
1205 .content
1206 .quests
1207 .iter()
1208 .enumerate()
1209 .map(|(i, q)| (q.id.as_str(), format!("/content/quests/{i}/id"))),
1210 "quests",
1211 "quest",
1212 d,
1213 );
1214 crate::ids::dup_check(
1217 c.quests
1218 .content
1219 .quests
1220 .iter()
1221 .enumerate()
1222 .flat_map(|(i, q)| {
1223 q.objectives.iter().enumerate().map(move |(j, o)| {
1224 (
1225 o.id().as_str(),
1226 format!("/content/quests/{i}/objectives/{j}/id"),
1227 )
1228 })
1229 }),
1230 "quests",
1231 "objective",
1232 d,
1233 );
1234}
1235
1236pub(crate) fn quest_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
1240 use crate::ids::dangling;
1241 let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
1242 let expanded_ids: BTreeSet<&str> = c
1243 .quests
1244 .content
1245 .quests
1246 .iter()
1247 .map(|q| q.id.as_str())
1248 .collect();
1249 for (i, q) in c.quests.content.quests.iter().enumerate() {
1250 if let crate::Trigger::QuestComplete { quest } = &q.trigger {
1251 dangling(
1252 d,
1253 expanded_ids.contains(quest.as_str()),
1254 "quests",
1255 format!("/content/quests/{i}/trigger/quest"),
1256 format!(
1257 "quest trigger `quest-complete` references unknown quest `{quest}` — declare \
1258 that quest in stage 5 or correct the reference"
1259 ),
1260 );
1261 }
1262 let local_objs: BTreeSet<&str> = q.objectives.iter().map(|o| o.id().as_str()).collect();
1263 for (j, obj) in q.objectives.iter().enumerate() {
1264 if let crate::Objective::TalkTo { npc, .. } = obj {
1265 dangling(
1266 d,
1267 npc_ids.contains(npc.as_str()),
1268 "quests",
1269 format!("/content/quests/{i}/objectives/{j}/npc"),
1270 format!(
1271 "`talk-to` objective references unknown npc `{npc}` — declare it in \
1272 stage 2 or correct the reference"
1273 ),
1274 );
1275 }
1276 for (m, aft) in obj.after().iter().enumerate() {
1277 dangling(
1278 d,
1279 local_objs.contains(aft.as_str()),
1280 "quests",
1281 format!("/content/quests/{i}/objectives/{j}/after/{m}"),
1282 format!(
1283 "objective `after` references unknown objective `{aft}` — `after` may \
1284 only name another objective in the same quest; declare it or correct \
1285 the reference"
1286 ),
1287 );
1288 }
1289 }
1290 for key in q.on_objective_complete.keys() {
1291 dangling(
1292 d,
1293 local_objs.contains(key.as_str()),
1294 "quests",
1295 format!("/content/quests/{i}/on_objective_complete/{key}"),
1296 format!(
1297 "`on_objective_complete` is keyed by unknown objective `{key}` — the key must \
1298 name an objective declared in this quest; declare it or correct the key"
1299 ),
1300 );
1301 }
1302 }
1303}