use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::serde_fields::is_false;
use crate::{AnchorId, OnKill, WaveId};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Wave {
pub id: WaveId,
pub anchor: AnchorId,
pub mobs: Vec<WaveMob>,
#[serde(default, skip_serializing_if = "is_false")]
pub respawns_on_rest: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lane: Option<WaveLane>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summon: Option<WaveSummon>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tier: Option<EncounterTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_bar: Option<crate::healthbar::HealthBar>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_kill: Option<OnKill>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum EncounterTier {
#[default]
Ordinary,
Elite,
Boss,
}
impl EncounterTier {
pub fn token(self) -> &'static str {
match self {
EncounterTier::Ordinary => "ordinary",
EncounterTier::Elite => "elite",
EncounterTier::Boss => "boss",
}
}
pub fn has_floor_expectation(self) -> bool {
matches!(self, EncounterTier::Elite | EncounterTier::Boss)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum WaveSummon {
Anchor,
AggroEdge,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WaveLane {
pub waypoints: Vec<AnchorId>,
pub aggro_radius: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WaveMob {
pub entity: String,
pub count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attributes: Option<MobAttributes>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<MobEffect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub equipment: Option<MobEquipment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub drops: Vec<MobDrop>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MobEquipment {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<EquipItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chest: Option<EquipItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub legs: Option<EquipItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feet: Option<EquipItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub main_hand: Option<EquipItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub off_hand: Option<EquipItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<EquipItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub saddle: Option<EquipItem>,
}
impl MobEquipment {
pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); EquipSlot::ALL.len()] {
EquipSlot::ALL.map(|s| (s.field(), self.filled(s)))
}
pub fn pieces(&self) -> [(EquipSlot, Option<&EquipItem>); EquipSlot::ALL.len()] {
EquipSlot::ALL.map(|s| (s, self.filled(s)))
}
pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
match slot {
EquipSlot::Head => self.head.as_ref(),
EquipSlot::Chest => self.chest.as_ref(),
EquipSlot::Legs => self.legs.as_ref(),
EquipSlot::Feet => self.feet.as_ref(),
EquipSlot::MainHand => self.main_hand.as_ref(),
EquipSlot::OffHand => self.off_hand.as_ref(),
EquipSlot::Body => self.body.as_ref(),
EquipSlot::Saddle => self.saddle.as_ref(),
}
}
}
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum EquipSlot {
Head,
Chest,
Legs,
Feet,
MainHand,
OffHand,
Body,
Saddle,
}
impl EquipSlot {
pub const ALL: [EquipSlot; 8] = [
EquipSlot::MainHand,
EquipSlot::OffHand,
EquipSlot::Head,
EquipSlot::Chest,
EquipSlot::Legs,
EquipSlot::Feet,
EquipSlot::Body,
EquipSlot::Saddle,
];
pub fn field(self) -> &'static str {
match self {
EquipSlot::Head => "head",
EquipSlot::Chest => "chest",
EquipSlot::Legs => "legs",
EquipSlot::Feet => "feet",
EquipSlot::MainHand => "main_hand",
EquipSlot::OffHand => "off_hand",
EquipSlot::Body => "body",
EquipSlot::Saddle => "saddle",
}
}
pub fn nbt(self) -> &'static str {
match self {
EquipSlot::Head => "head",
EquipSlot::Chest => "chest",
EquipSlot::Legs => "legs",
EquipSlot::Feet => "feet",
EquipSlot::MainHand => "mainhand",
EquipSlot::OffHand => "offhand",
EquipSlot::Body => "body",
EquipSlot::Saddle => "saddle",
}
}
pub fn from_nbt(name: &str) -> Option<EquipSlot> {
EquipSlot::ALL.into_iter().find(|s| s.nbt() == name)
}
pub fn is_hand(self) -> bool {
matches!(self, EquipSlot::MainHand | EquipSlot::OffHand)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum MobDrop {
Slot(SlotDrop),
Item(ItemDrop),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SlotDrop {
pub slot: EquipSlot,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ItemDrop {
pub item: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl MobDrop {
pub fn slot(&self) -> Option<EquipSlot> {
match self {
MobDrop::Slot(s) => Some(s.slot),
MobDrop::Item(_) => None,
}
}
pub fn item(&self) -> Option<&str> {
match self {
MobDrop::Slot(_) => None,
MobDrop::Item(i) => Some(&i.item),
}
}
pub fn name(&self) -> Option<&String> {
match self {
MobDrop::Slot(_) => None,
MobDrop::Item(i) => i.name.as_ref(),
}
}
pub fn name_mut(&mut self) -> Option<&mut String> {
match self {
MobDrop::Slot(_) => None,
MobDrop::Item(i) => i.name.as_mut(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum EquipItem {
Plain(String),
Enchanted(EnchantedItem),
}
impl EquipItem {
pub fn item(&self) -> &str {
match self {
EquipItem::Plain(s) => s,
EquipItem::Enchanted(e) => &e.item,
}
}
pub fn enchantments(&self) -> &BTreeMap<String, u32> {
static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
std::sync::LazyLock::new(BTreeMap::new);
match self {
EquipItem::Plain(_) => &EMPTY,
EquipItem::Enchanted(e) => &e.enchantments,
}
}
}
pub fn enchantment_component(item: &str) -> &'static str {
match item.strip_prefix("minecraft:").unwrap_or(item) {
"enchanted_book" => "minecraft:stored_enchantments",
_ => "minecraft:enchantments",
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EnchantedItem {
pub item: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub enchantments: BTreeMap<String, u32>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MobAttributes {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_health: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attack_damage: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub movement_speed: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub follow_range: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MobEffect {
pub effect: String,
pub amplifier: u32,
}
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::registry::{AnchorRegistry, EffectRegistry, EntityRegistry, ItemRegistry};
use crate::validate::{
AnchorProviders, declares_bonfire, for_each_effect_deep, for_each_trap_payload_deep,
for_each_trigger_effect_deep, quest_ancestors, station_kind_diag,
};
use crate::{Objective, Verb};
use std::collections::BTreeSet;
crate::dw_code! {
pub const REST_RESEAT_NO_BONFIRE: DwCode = DwCode::new("DW0370", ExitTier::Build);
}
crate::dw_code! {
pub const DROP_SLOT_UNFILLED: DwCode = DwCode::new("DW0490", ExitTier::Build);
}
crate::dw_code! {
pub const DROP_NOT_TIERED: DwCode = DwCode::new("DW0491", ExitTier::Build);
}
crate::dw_code! {
pub const DROP_COLLECT_UNSOURCED: DwCode = DwCode::new("DW0492", ExitTier::Build);
}
crate::dw_code! {
pub const DROP_COLLECT_UNORDERED: DwCode = DwCode::new("DW0493", ExitTier::Build);
}
crate::dw_code! {
pub const LANE_INVALID: DwCode = DwCode::new("DW0381", ExitTier::Build);
}
crate::dw_code! {
pub const LANE_NOT_RAIDER: DwCode = DwCode::new("DW0382", ExitTier::Build);
}
crate::dw_code! {
pub const LANE_SQUAD_TOO_SMALL: DwCode = DwCode::new("DW0383", ExitTier::Build);
}
crate::dw_code! {
pub const LANE_UNARMED: DwCode = DwCode::new("DW0384", ExitTier::Build);
}
crate::dw_code! {
pub const AGGRO_EDGE_NO_RANGE: DwCode = DwCode::new("DW0385", ExitTier::Build);
}
crate::dw_code! {
pub const DIFFICULTY_UNDECLARED_ACTORS: DwCode = DwCode::new("DW0469", ExitTier::Build);
}
crate::dw_code! {
pub const BOSS_RESPAWNS_ON_REST: DwCode = DwCode::new("DW0499", ExitTier::Build);
}
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)
}
pub(crate) 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(
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(", ")
),
));
}
pub(crate) 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::WaveSummon::AggroEdge);
if aggro_edge {
if w.lane.is_some() {
d.push(Diagnostic::error(
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(
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(
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(
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(
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(
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(
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(
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(
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(
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
),
));
}
}
}
pub(crate) fn check_drops(
c: &Campaign,
quests: &crate::QuestsContent,
items: &dyn ItemRegistry,
d: &mut Vec<Diagnostic>,
) {
use crate::{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(
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(
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(
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(
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(
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(
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::MobDrop],
equipment: Option<&crate::MobEquipment>,
what: &str,
base_path: &str,
items: &dyn ItemRegistry,
d: &mut Vec<Diagnostic>,
) {
use crate::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(
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(
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::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
}
pub(crate) fn check_equipment(
eq: &crate::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}`"),
"quests",
&format!("{base_path}/{slot}/enchantments"),
&ench_reg,
d,
);
}
}
pub(crate) fn wave_equipment_checks(
c: &Campaign,
items: &dyn ItemRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
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,
);
}
}
}
pub(crate) fn rest_reseat_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let quests = &c.quests.content;
let has_bonfire = declares_bonfire(c);
if !has_bonfire {
for (i, w) in quests.waves.iter().enumerate() {
if w.respawns_on_rest {
d.push(Diagnostic::error(
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(
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()
),
));
}
}
}
pub(crate) fn wave_decl_checks(
c: &Campaign,
entities: &dyn EntityRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
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
),
));
}
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
),
));
}
}
}
}
pub(crate) fn mob_effect_checks(
c: &Campaign,
effects: &dyn EffectRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
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
),
));
}
}
}
}
}
crate::dw_code! {
pub const ENCHANTMENT_UNKNOWN: DwCode = DwCode::new("DW0433", ExitTier::Build);
}
crate::dw_code! {
pub const ENCHANTMENT_LEVEL: DwCode = DwCode::new("DW0434", ExitTier::Build);
}
pub(crate) fn check_enchantments(
ench: &std::collections::BTreeMap<String, u32>,
what: &str,
stage: &'static 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(
ENCHANTMENT_UNKNOWN,
stage,
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(
ENCHANTMENT_LEVEL,
stage,
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."
),
));
}
}
}