Skip to main content

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}