use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{AnchorId, LootId};
#[cfg(doc)]
use crate::enchantment_component;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Loot {
pub id: LootId,
pub anchor: AnchorId,
pub items: Vec<LootItem>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LootItem {
pub item: String,
#[serde(default = "one_u32")]
pub count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub enchantments: BTreeMap<String, u32>,
}
fn one_u32() -> u32 {
1
}
use std::collections::BTreeSet;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::registry::{AnchorRegistry, ItemRegistry};
use crate::validate::{AnchorProviders, station_kind_diag};
use crate::wave::check_enchantments;
crate::dw_code! {
pub const LOOT_DUPLICATE_ANCHOR: DwCode = DwCode::new("DW0435", ExitTier::Build);
}
crate::dw_code! {
pub const ITEM_COUNT_OVER_STACK: DwCode = DwCode::new("DW0436", ExitTier::Build);
}
pub(crate) 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(
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);
}
}
}
}
pub(crate) const MIN_CONTAINER_SLOTS: usize = 27;
pub(crate) 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(
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."
),
));
}
pub(crate) 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(
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),
"quests",
&format!("/content/loot/{i}/items/{k}/enchantments"),
&ench_reg,
d,
);
}
}
}