delvewright_dsl/loot.rs
1//! Loot containers and what they hold.
2
3use std::collections::BTreeMap;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::{AnchorId, LootId};
9
10#[cfg(doc)]
11use crate::enchantment_component;
12
13/// A stage-5 container fill (DSL v0.6, spec-0021): contents for a chest or
14/// barrel the prefab already placed.
15///
16/// The container is **hardware the prefab authored**, exactly like a trap's
17/// dispenser: this declaration gives an already-placed, already-lit, already
18/// composed piece of furniture its contents. The compiler never places the
19/// container itself — if the anchor's cell does not already hold one, that is a
20/// content defect and a build error (`DW0431`), not something to paper over by
21/// setblock-ing a chest into a wall.
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
23#[serde(deny_unknown_fields)]
24pub struct Loot {
25 /// Unique loot id (`loot/<kebab>`).
26 pub id: LootId,
27 /// The anchor whose cell holds the container to fill.
28 pub anchor: AnchorId,
29 /// Contents, in declaration order. Slot assignment is positional and
30 /// deterministic — the first entry lands in `container.0`, the second in
31 /// `container.1`, and so on (ADR-0006: no RNG, no loot tables).
32 pub items: Vec<LootItem>,
33}
34
35/// One stack inside a [`Loot`] container.
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct LootItem {
39 /// Item id (e.g. `minecraft:cooked_cod`). Validated against the pinned
40 /// 1.21.11 item registry (`DW0143`).
41 pub item: String,
42 /// Stack size. Defaults to 1.
43 #[serde(default = "one_u32")]
44 pub count: u32,
45 /// Optional custom item name. Enters the l10n string inventory exactly like
46 /// a class kit item's name.
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub name: Option<String>,
49 /// Enchantments on this stack (`{"minecraft:sharpness": 3}`), emitted as the
50 /// 1.21 `minecraft:enchantments` item component — or, on a
51 /// `minecraft:enchanted_book`, `minecraft:stored_enchantments`
52 /// ([`enchantment_component`]).
53 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
54 pub enchantments: BTreeMap<String, u32>,
55}
56
57fn one_u32() -> u32 {
58 1
59}
60
61// ---------------------------------------------------------------------------
62// Validation
63// ---------------------------------------------------------------------------
64
65use std::collections::BTreeSet;
66
67use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
68use crate::envelope::Campaign;
69use crate::registry::{AnchorRegistry, ItemRegistry};
70use crate::validate::{AnchorProviders, station_kind_diag};
71use crate::wave::check_enchantments;
72
73crate::dw_code! {
74 /// (spec-0021) Two `loot` entries target the same anchor, so one would
75 /// silently overwrite the other's contents.
76 pub const LOOT_DUPLICATE_ANCHOR: DwCode = DwCode::new("DW0435", ExitTier::Build);
77}
78
79crate::dw_code! {
80 /// A single-slot fill's `count` exceeds the item's `minecraft:max_stack_size`
81 /// in the pinned 1.21.11 registry. `item replace … container.<n> with <item>
82 /// <count>` fails **silently** above the cap, shipping an empty slot.
83 pub const ITEM_COUNT_OVER_STACK: DwCode = DwCode::new("DW0436", ExitTier::Build);
84}
85
86/// Exclusive ownership of an adopted container (DSL v0.8, `DW0435`).
87///
88/// Both container-fill surfaces write **positionally** from `container.0`: a
89/// `loot` entry and a `collect`'s adopted container filling one cell overwrite
90/// each other slot-for-slot, and the loser vanishes without a word — the same
91/// silent-overwrite defect `DW0435` already names for two `loot` entries, reached
92/// through a second door. Two `collect` objectives sharing a container is the
93/// same collision (and worse: whichever activates second replaces the first
94/// objective's items with its own).
95///
96/// Only claims involving at least one `collect` are reported here; `loot`-vs-
97/// `loot` stays in [`loot_checks`], so nothing is diagnosed twice.
98pub(crate) fn collect_container_claim_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
99 // (anchor -> what claims it), in declaration order. `loot` first: a loot
100 // entry is the surface that exists to fill a container, so it reads as the
101 // incumbent in the message.
102 let mut claimed: BTreeMap<&str, String> = BTreeMap::new();
103 for l in &c.quests.content.loot {
104 claimed
105 .entry(l.anchor.as_str())
106 .or_insert_with(|| format!("loot `{}`", l.id));
107 }
108 for (i, q) in c.quests.content.quests.iter().enumerate() {
109 for (j, o) in q.objectives.iter().enumerate() {
110 let Some(cont) = o.collect_container() else {
111 continue;
112 };
113 let mine = format!("collect objective `{}`", o.id());
114 if let Some(prev) = claimed.get(cont.as_str()) {
115 d.push(Diagnostic::error(
116 LOOT_DUPLICATE_ANCHOR,
117 "quests",
118 format!("/content/quests/{i}/objectives/{j}/container"),
119 format!(
120 "{prev} and {mine} both fill the container at anchor `{cont}`. Slots \
121 are assigned positionally from `container.0`, so one fill overwrites \
122 the other slot-for-slot and its items never reach the player. Give the \
123 collect its own container anchor (prefabs may expose several), or \
124 fold the other fill's items into it — do NOT rely on declaration \
125 order to combine them."
126 ),
127 ));
128 } else {
129 claimed.insert(cont.as_str(), mine);
130 }
131 }
132 }
133}
134
135/// The smallest vanilla container the container-fill surfaces admit. A barrel and
136/// a single chest both hold 27; refusing >27 up front keeps the overflow from
137/// being discovered as a silently dropped stack on a live server. Shared by the
138/// `loot` stack ceiling and the v0.8 `collect` `fill_count` ceiling, which are the
139/// same positional-fill rule (`DW0432`) on two surfaces.
140pub(crate) const MIN_CONTAINER_SLOTS: usize = 27;
141
142/// `DW0436`: a **single-slot fill** whose `count` exceeds the item's
143/// `minecraft:max_stack_size` in the pinned 1.21.11 registry.
144///
145/// Every one of these compiles to `item replace … container.<n> with <item>
146/// <count>`, and that command fails **silently** above the cap: the slot simply
147/// stays empty and the server logs nothing. A `count: 2` of `minecraft:rabbit_stew`
148/// (cap 1) shipped an empty chest slot in the-drowned-bell round 2 — exactly the
149/// silent-failure class `DW0431` exists for, one tier too late. The cap comes from
150/// Mojang's own item-components data, vendored per MC pin
151/// (`crates/delvec/data/item-stack-sizes-1.21.11.json`), never a hand table.
152///
153/// Skipped when the registry does not carry stack sizes (the small vendored DSL-side
154/// subset) or the item id is unknown — the latter is already `DW0143`, and stacking
155/// a second diagnostic on one typo is noise.
156pub(crate) fn check_stack_count(
157 item: &str,
158 count: u32,
159 what: &str,
160 path: String,
161 items: &dyn ItemRegistry,
162 d: &mut Vec<Diagnostic>,
163) {
164 let Some(cap) = items.max_stack_size(item) else {
165 return;
166 };
167 if count <= cap {
168 return;
169 }
170 d.push(Diagnostic::error(
171 ITEM_COUNT_OVER_STACK,
172 "quests",
173 path,
174 format!(
175 "{what} declares `{item}` × {count}, but `{item}` stacks to at most {cap} in \
176 1.21.11. This is filled with `item replace … container.<n>`, which fails \
177 SILENTLY above the cap — the slot ships empty and nothing is logged. Lower \
178 the count to {cap} or fewer, or declare additional entries/containers."
179 ),
180 ));
181}
182
183/// Stage-5 `loot` declarations (spec-0021): id syntax/uniqueness (`DW0110`/
184/// `DW0111`), anchor resolution (`DW0142`), item ids (`DW0143`), enchantments
185/// (`DW0433`/`DW0434`), duplicate anchors (`DW0435`) and slot overflow
186/// (`DW0432`).
187///
188/// The *container-ness* of the anchor's cell is deliberately NOT checked here:
189/// it needs the assembled world, so it is a build-tier proof (`DW0431`) in the
190/// compiler. This tier checks everything decidable from the DSL alone.
191pub(crate) fn loot_checks(
192 c: &Campaign,
193 items: &dyn ItemRegistry,
194 anchors: &dyn AnchorRegistry,
195 d: &mut Vec<Diagnostic>,
196) {
197 let quests = &c.quests.content;
198 if quests.loot.is_empty() {
199 return;
200 }
201 let ench_reg = crate::registry::VendoredEnchantmentRegistry::v1_21_11();
202
203 let providers = AnchorProviders::build(c, anchors);
204
205 let mut seen_id: BTreeSet<&str> = BTreeSet::new();
206 let mut seen_anchor: BTreeMap<&str, usize> = BTreeMap::new();
207 for (i, l) in quests.loot.iter().enumerate() {
208 if !l.id.is_valid_syntax() {
209 d.push(Diagnostic::error(
210 codes::ID_SYNTAX,
211 "quests",
212 format!("/content/loot/{i}/id"),
213 format!(
214 "malformed loot id `{}` — loot ids must be lowercase kebab-case with the \
215 `loot/` prefix (e.g. `loot/galley-stores`)",
216 l.id
217 ),
218 ));
219 }
220 if !seen_id.insert(l.id.as_str()) {
221 d.push(Diagnostic::error(
222 codes::ID_DUPLICATE,
223 "quests",
224 format!("/content/loot/{i}/id"),
225 format!("duplicate loot id `{}`", l.id),
226 ));
227 }
228 if let Some(f) = station_kind_diag(
229 &providers,
230 l.anchor.as_str(),
231 crate::layout::StationKind::Point,
232 "a loot chest",
233 "quests",
234 format!("/content/loot/{i}/anchor"),
235 ) {
236 d.push(f);
237 }
238 if !providers.resolvable(l.anchor.as_str()) {
239 d.push(Diagnostic::error(
240 codes::ANCHOR_UNRESOLVED,
241 "quests",
242 format!("/content/loot/{i}/anchor"),
243 format!(
244 "loot anchor `{}` is not provided by any prefab bound in this campaign — {}",
245 l.anchor,
246 providers.anchor_remedy(
247 "use an anchor the prefab exposes (anchor names come from prefab \
248 metadata; do NOT invent one)"
249 ),
250 ),
251 ));
252 }
253 // Two fills on one container: the second `item replace block` overwrites
254 // the first slot-for-slot, so one declaration silently loses.
255 if let Some(prev) = seen_anchor.insert(l.anchor.as_str(), i) {
256 d.push(Diagnostic::error(
257 LOOT_DUPLICATE_ANCHOR,
258 "quests",
259 format!("/content/loot/{i}/anchor"),
260 format!(
261 "loot `{}` and loot `{}` both fill anchor `{}`. Slots are assigned \
262 positionally from `container.0`, so the later declaration overwrites the \
263 earlier one and its items never appear. Merge the two `items` lists into \
264 ONE `loot` entry — do NOT rely on declaration order to combine them.",
265 quests.loot[prev].id, l.id, l.anchor
266 ),
267 ));
268 }
269 if l.items.len() > MIN_CONTAINER_SLOTS {
270 d.push(Diagnostic::error(
271 codes::LOOT_TOO_MANY_ITEMS,
272 "quests",
273 format!("/content/loot/{i}/items"),
274 format!(
275 "loot `{}` declares {} stacks, more than the {MIN_CONTAINER_SLOTS} slots a \
276 vanilla chest or barrel has. Slots are assigned positionally, so every \
277 stack past the {MIN_CONTAINER_SLOTS}th would be dropped silently. Split \
278 the contents across more than one container.",
279 l.id,
280 l.items.len()
281 ),
282 ));
283 }
284 for (k, it) in l.items.iter().enumerate() {
285 if !items.contains(&it.item) {
286 d.push(Diagnostic::error(
287 codes::ITEM_UNKNOWN,
288 "quests",
289 format!("/content/loot/{i}/items/{k}/item"),
290 format!(
291 "loot item `{}` is not in the pinned 1.21.11 item registry — use a \
292 valid namespaced item id (e.g. `minecraft:cooked_cod`)",
293 it.item
294 ),
295 ));
296 }
297 check_stack_count(
298 &it.item,
299 it.count,
300 &format!("loot `{}`", l.id),
301 format!("/content/loot/{i}/items/{k}/count"),
302 items,
303 d,
304 );
305 check_enchantments(
306 &it.enchantments,
307 &format!("loot `{}` item `{}`", l.id, it.item),
308 "quests",
309 &format!("/content/loot/{i}/items/{k}/enchantments"),
310 &ench_reg,
311 d,
312 );
313 }
314 }
315}