Skip to main content

delvewright_dsl/
class.rs

1//! Stage 3 — classes: the kits a party chooses from, and the item grants and
2//! potion contents a kit or a `give-item` carries.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::ClassId;
8use crate::serde_fields::is_false;
9
10#[cfg(doc)]
11use crate::Verb;
12
13/// Who a granted item goes to (DSL v0.6, spec-0018).
14///
15/// Progression is a fact about the party, so the default for every `give-item` and
16/// every class-kit entry is **all**: a quest beat that arms the party arms all of
17/// it. `one` is the deliberate exception for a single quest prop (the wine-skin,
18/// the stake): exactly one copy enters the party, handed to the player whose action
19/// earned it, and the party passes it around physically.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "kebab-case")]
22pub enum Carrier {
23    /// Every party member receives the item (the default).
24    #[default]
25    All,
26    /// Exactly one copy, to the player whose action fired the effect.
27    One,
28}
29
30/// Stage 3 payload: 1..4 selectable classes.
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
32#[serde(deny_unknown_fields)]
33pub struct ClassesContent {
34    /// The selectable classes.
35    pub classes: Vec<Class>,
36}
37
38/// A player class with a starting kit.
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields)]
41pub struct Class {
42    /// Unique class id.
43    pub id: ClassId,
44    /// Player-facing name.
45    pub name: String,
46    /// Selection-screen blurb.
47    pub blurb: String,
48    /// Granted items.
49    pub kit: Vec<KitItem>,
50}
51
52/// One item in a class kit.
53///
54/// Note: `lore`, `enchantments` and `attributes` are reserved for M2/M3
55/// (spec-0001). They are intentionally *not* defined as fields in v0, so a
56/// document using them is rejected as an unknown field (`DW0100`).
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct KitItem {
60    /// Vanilla item id, validated against the pinned 1.21.11 registry.
61    pub item: String,
62    /// Stack count.
63    pub count: u32,
64    /// Optional display name.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub name: Option<String>,
67    /// Who gets it (DSL v0.6, spec-0018). Absent = [`Carrier::All`]. A class kit is
68    /// per-player gear by construction — every player who picks the class gets the
69    /// kit — so `carrier` here marks a **party-unique** kit item: exactly one copy
70    /// enters the party, given to the first player to pick this class.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub carrier: Option<Carrier>,
73    /// **The flask** (DSL v0.8, spec-0016 §1): this kit
74    /// entry is the class's recovery item, and resting at a bonfire replenishes
75    /// it to exactly `count`. A campaign that places a `bonfire` and declares no
76    /// flask anywhere in its kits is `DW0476` — the estus loop is what makes
77    /// dying an investment, so a souls campaign without one is a build error, not
78    /// a design choice. Absent on every pre-0.8 kit → emission byte-identical.
79    #[serde(default, skip_serializing_if = "is_false")]
80    pub flask: bool,
81    /// **What is in the bottle** (DSL v0.8, spec-0016 §1): the vanilla
82    /// `minecraft:potion_contents` component of a
83    /// potion-bearing item ([`POTION_BEARING_ITEMS`]). Without it a
84    /// `minecraft:potion` is the *Uncraftable Potion* — a bottle that heals
85    /// nothing — which is exactly the placeholder flask this field exists to
86    /// abolish, so at `dsl_version` 0.8.0 a potion-bearing kit item that declares
87    /// no `contents` is `DW0487`.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub contents: Option<PotionContents>,
90}
91
92/// The items whose vanilla item definition carries a `minecraft:potion_contents`
93/// component — the only items a kit `contents` may be declared on (`DW0486`).
94///
95/// Read off the pinned 1.21.11 `item_components` summary (SHA-256
96/// `51b191e13f86813ca02f1498942e5bc235947edb71eb8105a78401670b3665c4`, the same
97/// misode/mcmeta ref `crates/delvec/data/PROVENANCE.md` pins): exactly these
98/// four items declare the component, and on any other item the game drops the
99/// data on the floor.
100pub const POTION_BEARING_ITEMS: &[&str] = &[
101    "minecraft:lingering_potion",
102    "minecraft:potion",
103    "minecraft:splash_potion",
104    "minecraft:tipped_arrow",
105];
106
107/// True if `item_id` (optionally un-namespaced) is one of the four
108/// [`POTION_BEARING_ITEMS`].
109pub fn is_potion_bearing_item(item_id: &str) -> bool {
110    let norm = if item_id.contains(':') {
111        item_id.to_string()
112    } else {
113        format!("minecraft:{item_id}")
114    };
115    POTION_BEARING_ITEMS.contains(&norm.as_str())
116}
117
118/// The two **instantaneous** status effects: they are applied once, on the tick
119/// the potion is drunk (`PotionContents.applyToLivingEntity` branches on
120/// `isInstantenous` before any effect instance is ever added), so a `duration` on
121/// one is a statement the game never reads — `DW0486` says so rather than letting
122/// an author believe they wrote a thirty-second heal.
123pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
124
125/// A potion-bearing kit item's `minecraft:potion_contents` component (DSL v0.8),
126/// modelled field for field on vanilla rather than invented: a **named** potion,
127/// a list of **custom effects**, or both, plus the bottle-colour override.
128///
129/// Vanilla resolves a drink as the named potion's effects followed by the custom
130/// ones, and derives the bottle colour from those effects unless `color`
131/// overrides it — so `{"potion": "minecraft:strong_healing"}` is literally the
132/// Potion of Healing II a player would brew, not an approximation of one.
133#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
134#[serde(deny_unknown_fields)]
135pub struct PotionContents {
136    /// A named vanilla potion id (`minecraft:strong_healing`,
137    /// `minecraft:long_night_vision`, …), validated against the pinned 1.21.11
138    /// `potion` registry (`DW0486`).
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub potion: Option<String>,
141    /// Custom effects applied on top of (or instead of) the named potion — the
142    /// escape hatch for a recovery item vanilla has no brew for.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub effects: Vec<PotionEffect>,
145    /// Bottle-colour override, `#rrggbb` (vanilla `custom_color`). Absent → the
146    /// colour vanilla derives from the effects themselves.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub color: Option<String>,
149}
150
151/// One entry of a potion's `custom_effects` list.
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
153#[serde(deny_unknown_fields)]
154pub struct PotionEffect {
155    /// Vanilla status-effect id (e.g. `minecraft:regeneration`), validated
156    /// against the pinned registry (`DW0486`).
157    pub effect: String,
158    /// How long it lasts, in **ticks** (20 = one second). Required for every
159    /// effect except the two [`INSTANT_EFFECTS`], which must NOT declare one.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub duration: Option<u32>,
162    /// Amplifier, 0 = level I (vanilla's unsigned byte, so 0–255). Absent = 0.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub amplifier: Option<u32>,
165}
166
167impl PotionEffect {
168    /// True if this effect is applied once on drinking rather than over time.
169    pub fn is_instant(&self) -> bool {
170        let norm = crate::registry::namespaced_effect_id(&self.effect);
171        INSTANT_EFFECTS.contains(&norm.as_str())
172    }
173}
174
175/// The largest `duration` a potion effect may declare, in ticks: 1 000 000 ticks
176/// ≈ 13.9 hours, past the 10-hour delve ceiling, so nothing a delve can legally
177/// need is refused — while a duration typed in *milliseconds*, or one that would
178/// overflow vanilla's int, is caught (`DW0486`).
179pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
180
181/// The largest `amplifier` a potion effect may declare: vanilla stores it in an
182/// unsigned byte, so 255 is not a policy but the end of the field.
183pub const MAX_POTION_AMPLIFIER: u32 = 255;
184
185/// The largest `seconds` a [`Verb::GiveEffect`] may declare, derived from
186/// [`MAX_POTION_DURATION_TICKS`] rather than chosen again: the two are the same
187/// quantity in different units, and a second independently-picked ceiling is how
188/// two limits for one fact drift apart. ≈13.9 hours, past the 10-hour delve
189/// ceiling, so nothing a delve can legally need is refused — while a duration
190/// typed in *ticks* or in milliseconds is caught (`DW0541`).
191pub const MAX_EFFECT_SECONDS: u32 = MAX_POTION_DURATION_TICKS / 20;
192
193// ---------------------------------------------------------------------------
194// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
195// ---------------------------------------------------------------------------
196
197use std::collections::BTreeSet;
198
199use crate::Objective;
200use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
201use crate::envelope::Campaign;
202use crate::registry::EffectRegistry;
203
204crate::dw_code! {
205    /// (spec-0016 §1) The campaign places a `bonfire`
206    /// but no class kit declares a `flask`. Resting replenishes the flask to its
207    /// declared count; with no flask the rest interaction's whole recovery half
208    /// is a no-op and the souls loop has no consumable to spend, so this is a
209    /// build error rather than a design choice.
210    pub const BONFIRE_NO_FLASK: DwCode = DwCode::new("DW0476", ExitTier::Build);
211}
212
213crate::dw_code! {
214    /// **An item gate a class cannot bring.** An objective completes only for a
215    /// player holding a named item, and some class's player has no way to be
216    /// holding it: the item's only source in the whole campaign is *another*
217    /// class's kit, or it has no source at all.
218    ///
219    /// A delve is played by one to four players who each pick one class, so an
220    /// objective reachable only by one class's pick is an objective a party can
221    /// be assembled unable to finish — and the party finds out at the thing they
222    /// cannot press. Quantified over EVERY class for the same reason
223    /// [`BONFIRE_NO_FLASK`] is: one class that cannot bring it is as broken as
224    /// none, because a solo player of that class is a supported party.
225    pub const ITEM_GATE_UNBRINGABLE: DwCode = DwCode::new("DW0849", ExitTier::Build);
226}
227
228crate::dw_code! {
229    /// (spec-0016 §1) A kit item's potion `contents`
230    /// is not something 1.21.11 can pour: declared on an item that carries no
231    /// `minecraft:potion_contents` component, empty (neither a named potion nor
232    /// an effect), an unknown potion or status-effect id, an amplifier or
233    /// duration outside the field vanilla stores it in, a lasting effect with no
234    /// `duration`, an instantaneous one *with* a duration, or a malformed
235    /// `color`.
236    pub const KIT_POTION_INVALID: DwCode = DwCode::new("DW0486", ExitTier::Build);
237}
238
239crate::dw_code! {
240    /// (spec-0016 §1) A potion-bearing kit item
241    /// declares no `contents` at `dsl_version` 0.8.0 — the Uncraftable Potion, a
242    /// bottle that pours nothing. The placeholder flask, as a build error.
243    pub const KIT_POTION_MISSING: DwCode = DwCode::new("DW0487", ExitTier::Build);
244}
245
246/// Normalise an authored item id to its namespaced form, so `stripped_oak_log`
247/// and `minecraft:stripped_oak_log` are the same item to every comparison here.
248/// Same rule [`crate::is_potion_bearing_item`] applies to its own list.
249fn ns_item(id: &str) -> String {
250    if id.contains(':') {
251        id.to_string()
252    } else {
253        format!("minecraft:{id}")
254    }
255}
256
257/// Every item this campaign can put into the hands of a player **whatever class
258/// they picked** — the class-blind half of the provenance question `DW0849`
259/// asks.
260///
261/// The five ways an item enters a player's inventory are the class kit
262/// ([`crate::Class::kit`], which is class-BOUND and therefore
263/// deliberately absent here) and these four. They are gathered from the closed
264/// enumerations rather than from a walk of the sites this function's author
265/// happened to remember: effects come through
266/// [`crate::for_each_campaign_effect`], which is
267/// [`crate::effects::for_each_effect_root`] underneath — the same eight roots
268/// emission lowers from, and the one `tools/ci/check-effect-roots.py` holds closed.
269///
270/// A trap's `dispense` payload is **not** a source, and the exclusion is about
271/// the object rather than about effort: a dispenser fires its stack at the party
272/// as a hazard. Being shot with a thing is not being handed it, and a campaign
273/// whose only supply of a required item is a trap firing it has a defect this
274/// check should name rather than excuse.
275fn class_blind_item_sources(c: &Campaign) -> BTreeSet<String> {
276    let mut src: BTreeSet<String> = BTreeSet::new();
277    let quests = &c.quests.content;
278
279    // A `give-item` anywhere. Deliberately unconditional on its flag gate and on
280    // its position in the quest DAG: a gated grant is still a way the item can
281    // be had, and treating one as no source at all would red campaigns that are
282    // fine. The direction of the approximation is chosen — this check refuses
283    // only where NOTHING class-blind supplies the item.
284    crate::for_each_campaign_effect(c, &mut |_path, _site, eff| {
285        if let Some(item) = eff.give_item() {
286            src.insert(ns_item(item));
287        }
288    });
289
290    for q in &quests.quests {
291        for o in &q.objectives {
292            // A `collect` is provisioned into a container the compiler fills or
293            // adopts, or dropped by a wave — every one of those is open to
294            // whoever walks up to it.
295            if let Objective::Collect { item, .. } = o {
296                src.insert(ns_item(item));
297            }
298        }
299    }
300
301    for l in &quests.loot {
302        for it in &l.items {
303            src.insert(ns_item(&it.item));
304        }
305    }
306
307    for w in &quests.waves {
308        for m in &w.mobs {
309            for drop in &m.drops {
310                match (drop.item(), drop.slot()) {
311                    (Some(item), _) => {
312                        src.insert(ns_item(item));
313                    }
314                    // A worn piece drops the item the same mob's `equipment`
315                    // declares in that slot (`DW0490` already refuses a slot the
316                    // equipment leaves empty, so this lookup is total on a
317                    // campaign that got that far).
318                    (None, Some(slot)) => {
319                        if let Some(eq) = m.equipment.as_ref().and_then(|e| e.filled(slot)) {
320                            src.insert(ns_item(eq.item()));
321                        }
322                    }
323                    (None, None) => {}
324                }
325            }
326        }
327    }
328
329    src
330}
331
332/// `DW0849`: **an item gate a class cannot bring.**
333///
334/// ## The finding this is the general form of
335///
336/// A required item was issued through one class's kit rather than to the party,
337/// so a player who picked any other class arrived at the objective that consumed
338/// it and could do nothing. The instance was repaired by moving the item; the
339/// class of defect — *completability that depends on which class was picked* —
340/// had no check, and a campaign is free to reintroduce it at every new item
341/// gate.
342///
343/// ## Why this is a property of the object class, not of `interact`
344///
345/// The object is an **item gate**: a place where an objective completes only for
346/// a player who holds a named thing. Today the DSL has exactly one such site
347/// ([`Objective::Interact::requires_item`]) — a shop's price is a
348/// [`crate::StateCompare`] over a datum and not an item at all, and no
349/// verb removes an item from an inventory. So the enumeration is one arm wide
350/// today and is written as an enumeration anyway, because the second site is
351/// where a rule keyed to the first verb leaves the next author with no surface.
352///
353/// ## The quantifier, and why it is `for all` rather than `there exists`
354///
355/// A delve is played by one to four players who each pick one class, so **a solo
356/// player of any class is a supported party**. An item only one class can bring
357/// is therefore an objective some real party is assembled unable to finish, and
358/// it finds out at the thing it cannot press. This is exactly the reasoning
359/// [`BONFIRE_NO_FLASK`] already states for the flask: one class without it
360/// is as broken as none.
361///
362/// ## The direction the approximation runs
363///
364/// [`class_blind_item_sources`] is deliberately generous — a flag-gated
365/// `give-item` late in the DAG counts as a source. The refusal therefore fires
366/// only where the item has **no** class-blind supply anywhere in the campaign,
367/// which is the shape the finding had and the shape a typo has. Making it
368/// stricter would need the reachability model, which does not model items at
369/// all; making it stricter *without* that model would red correct campaigns,
370/// and a check that reds correct work is how a check gets weakened.
371pub(crate) fn item_gate_class_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
372    let classes = &c.classes.content.classes;
373    if classes.is_empty() {
374        // The schema requires 1..4, so this is unreachable on a parsed campaign;
375        // returning rather than dividing by an empty quantifier keeps the "for
376        // all classes" reading honest instead of vacuously true.
377        return;
378    }
379    let blind = class_blind_item_sources(c);
380
381    for (i, q) in c.quests.content.quests.iter().enumerate() {
382        for (j, o) in q.objectives.iter().enumerate() {
383            let Objective::Interact {
384                id, requires_item, ..
385            } = o
386            else {
387                continue;
388            };
389            let Some(raw) = requires_item.as_deref() else {
390                continue;
391            };
392            let item = ns_item(raw);
393            if blind.contains(&item) {
394                continue;
395            }
396            let cannot: Vec<&str> = classes
397                .iter()
398                .filter(|cl| !cl.kit.iter().any(|k| ns_item(&k.item) == item))
399                .map(|cl| cl.id.as_str())
400                .collect();
401            if cannot.is_empty() {
402                continue;
403            }
404            let supply = if cannot.len() == classes.len() {
405                "nothing in this campaign supplies it at all".to_string()
406            } else {
407                format!(
408                    "its only supply is another class's kit, so {} cannot bring it: {}",
409                    if cannot.len() == 1 {
410                        "one class"
411                    } else {
412                        "those classes"
413                    },
414                    cannot.join(", ")
415                )
416            };
417            d.push(Diagnostic::error(
418                ITEM_GATE_UNBRINGABLE,
419                "quests",
420                format!("/content/quests/{i}/objectives/{j}/requires_item"),
421                format!(
422                    "objective `{}` completes only for a player HOLDING `{raw}`, and {supply}. A \
423                     delve is played by one to four players who each pick one class, so a solo \
424                     player of any class is a party this campaign must be finishable by — and \
425                     this one is assembled unable to finish, which it learns standing at the \
426                     thing it cannot press. Three ways to supply it, and any one is enough: put \
427                     the item in a `collect` objective or a `loot` container on the way to this \
428                     gate; hand it out with a `give-item` effect (its default `carrier` is `all` \
429                     — every party member); or add it to EVERY class kit rather than one. Do not \
430                     drop `requires_item` to silence this — presenting the item is the beat.",
431                    id.as_str()
432                ),
433            ));
434        }
435    }
436}
437
438/// spec-0016 §1: **what is actually in the flask.**
439///
440/// The kit `flask` marker landed with no way to say what the bottle pours, so
441/// every flask shipped as `minecraft:potion` with no `minecraft:potion_contents`
442/// component — the Uncraftable Potion, which a player can drink all day for
443/// nothing. `contents` closes that, and these are the two halves of keeping it
444/// honest: `DW0487` refuses the placeholder (a potion-bearing kit item that
445/// declares no contents), `DW0486` refuses contents 1.21.11 cannot pour.
446///
447pub(crate) fn kit_potion_checks(
448    c: &Campaign,
449    effects: &dyn EffectRegistry,
450    d: &mut Vec<Diagnostic>,
451) {
452    for (i, cl) in c.classes.content.classes.iter().enumerate() {
453        for (k, item) in cl.kit.iter().enumerate() {
454            let bearing = crate::is_potion_bearing_item(&item.item);
455            let path = format!("/content/classes/{i}/kit/{k}");
456            let Some(contents) = &item.contents else {
457                // The placeholder flask, as a build error.
458                if bearing {
459                    d.push(Diagnostic::error(
460                        KIT_POTION_MISSING,
461                        "classes",
462                        format!("{path}/contents"),
463                        format!(
464                            "kit item `{}` declares no `contents`, so it compiles to the \
465                             *Uncraftable Potion* — a bottle with no `minecraft:potion_contents` \
466                             component, which grants nothing when drunk however it is named. \
467                             Declare what is in it: `\"contents\": {{\"potion\": \
468                             \"minecraft:strong_healing\"}}`, or an `\"effects\"` list of \
469                             `{{\"effect\", \"duration\", \"amplifier\"}}`. Do NOT rename the \
470                             bottle instead — semantics never key on player-facing text \
471                             (spec-0016 §1).",
472                            item.item
473                        ),
474                    ));
475                }
476                continue;
477            };
478            // `contents` on an item with no such component: the data would be
479            // dropped on the floor, silently.
480            if !bearing {
481                d.push(Diagnostic::error(
482                    KIT_POTION_INVALID,
483                    "classes",
484                    format!("{path}/contents"),
485                    format!(
486                        "kit item `{}` cannot carry potion `contents` — in 1.21.11 only \
487                         `minecraft:potion`, `minecraft:splash_potion`, \
488                         `minecraft:lingering_potion` and `minecraft:tipped_arrow` carry a \
489                         `minecraft:potion_contents` component, and on anything else the game \
490                         discards it. Put the contents on a potion item, or drop the field.",
491                        item.item
492                    ),
493                ));
494                continue;
495            }
496            if contents.potion.is_none() && contents.effects.is_empty() {
497                d.push(Diagnostic::error(
498                    KIT_POTION_INVALID,
499                    "classes",
500                    format!("{path}/contents"),
501                    "empty potion `contents` — it names no `potion` and lists no `effects`, so \
502                     the bottle still pours nothing. Name a vanilla potion (e.g. \
503                     `\"potion\": \"minecraft:strong_healing\"`) or list at least one effect."
504                        .to_string(),
505                ));
506            }
507            if let Some(p) = &contents.potion
508                && !crate::registry::is_potion_id(p)
509            {
510                d.push(Diagnostic::error(
511                    KIT_POTION_INVALID,
512                    "classes",
513                    format!("{path}/contents/potion"),
514                    format!(
515                        "`{p}` is not in the pinned 1.21.11 `potion` registry — use a real potion \
516                         id (`minecraft:healing`, `minecraft:strong_healing`, \
517                         `minecraft:long_night_vision`, …). Note the 1.20.5+ spelling: strength \
518                         and duration are part of the id (`strong_`/`long_` prefixes), not \
519                         separate fields."
520                    ),
521                ));
522            }
523            if let Some(col) = &contents.color
524                && !is_hex_color(col)
525            {
526                d.push(Diagnostic::error(
527                    KIT_POTION_INVALID,
528                    "classes",
529                    format!("{path}/contents/color"),
530                    format!(
531                        "potion `color` `{col}` is malformed — write the bottle colour as \
532                         `#rrggbb` (e.g. `#ff9c30`), or omit it and take the colour vanilla \
533                         derives from the effects."
534                    ),
535                ));
536            }
537            for (e, eff) in contents.effects.iter().enumerate() {
538                let epath = format!("{path}/contents/effects/{e}");
539                if !effects.contains(&eff.effect) {
540                    d.push(Diagnostic::error(
541                        KIT_POTION_INVALID,
542                        "classes",
543                        format!("{epath}/effect"),
544                        format!(
545                            "potion effect `{}` is not a known 1.21.11 status-effect id — use a \
546                             valid namespaced effect id (e.g. `minecraft:instant_health`).",
547                            eff.effect
548                        ),
549                    ));
550                }
551                if let Some(amp) = eff.amplifier
552                    && amp > crate::MAX_POTION_AMPLIFIER
553                {
554                    d.push(Diagnostic::error(
555                        KIT_POTION_INVALID,
556                        "classes",
557                        format!("{epath}/amplifier"),
558                        format!(
559                            "potion effect `amplifier` {amp} is out of range — vanilla stores it \
560                             in an unsigned byte, so it must be 0–{max} (0 = level I).",
561                            max = crate::MAX_POTION_AMPLIFIER
562                        ),
563                    ));
564                }
565                match (eff.is_instant(), eff.duration) {
566                    // An instantaneous effect is applied once on drinking; a
567                    // duration on it is a sentence the game never reads.
568                    (true, Some(dur)) => d.push(Diagnostic::error(
569                        KIT_POTION_INVALID,
570                        "classes",
571                        format!("{epath}/duration"),
572                        format!(
573                            "`{}` is instantaneous — it lands once, on the tick the potion is \
574                             drunk, so the `duration` of {dur} tick(s) here is never read. Drop \
575                             the field; for healing that ticks over time use \
576                             `minecraft:regeneration`, which does take a duration.",
577                            eff.effect
578                        ),
579                    )),
580                    (false, None) => d.push(Diagnostic::error(
581                        KIT_POTION_INVALID,
582                        "classes",
583                        format!("{epath}/duration"),
584                        format!(
585                            "potion effect `{}` lasts over time and declares no `duration` — \
586                             vanilla would default it to zero ticks, i.e. nothing. Declare the \
587                             duration in ticks (20 = one second).",
588                            eff.effect
589                        ),
590                    )),
591                    (false, Some(dur)) if dur == 0 || dur > crate::MAX_POTION_DURATION_TICKS => {
592                        d.push(Diagnostic::error(
593                            KIT_POTION_INVALID,
594                            "classes",
595                            format!("{epath}/duration"),
596                            format!(
597                                "potion effect `duration` {dur} is out of range — it is in \
598                                 **ticks** (20 = one second) and must be 1–{max} \
599                                 (≈13.9 hours, past the delve ceiling).",
600                                max = crate::MAX_POTION_DURATION_TICKS
601                            ),
602                        ));
603                    }
604                    _ => {}
605                }
606            }
607        }
608    }
609}
610
611/// True if `s` is a `#rrggbb` colour literal — [`crate::color::is_hex`], the one
612/// rule every hex-colour surface reads.
613fn is_hex_color(s: &str) -> bool {
614    crate::color::is_hex(s)
615}
616
617/// Every kit item is in the pinned item registry (`DW0143`).
618pub(crate) fn kit_item_checks(
619    c: &Campaign,
620    items: &dyn crate::registry::ItemRegistry,
621    d: &mut Vec<Diagnostic>,
622) {
623    // Kit items.
624    for (i, cl) in c.classes.content.classes.iter().enumerate() {
625        for (j, it) in cl.kit.iter().enumerate() {
626            if !items.contains(&it.item) {
627                d.push(Diagnostic::error(
628                    codes::ITEM_UNKNOWN,
629                    "classes",
630                    format!("/content/classes/{i}/kit/{j}/item"),
631                    format!(
632                        "kit item `{}` is not in the pinned 1.21.11 item registry — use a valid \
633                         namespaced item id (e.g. `minecraft:iron_sword`)",
634                        it.item
635                    ),
636                ));
637            }
638        }
639    }
640}
641
642/// spec-0016 §1: a campaign that places a bonfire owes every class a flask
643/// (`DW0476`). `has_bonfire` is [`crate::declares_bonfire`], read once by the
644/// caller.
645pub(crate) fn bonfire_flask_checks(c: &Campaign, has_bonfire: bool, d: &mut Vec<Diagnostic>) {
646    // spec-0016 §1: a campaign that places a bonfire is
647    // a souls campaign, and a souls campaign owes the party a flask. Resting
648    // replenishes every `flask` kit entry to its declared count — with none
649    // declared, "rest and save" and "save only" collapse into the same button and
650    // the recovery economy the bonfire exists to serve does not exist (`DW0476`).
651    // Campaign-global on purpose: the flask is per-class gear, and one class
652    // without a flask is as broken as none, so the requirement is on EVERY class.
653    if has_bonfire {
654        let flaskless: Vec<&str> = c
655            .classes
656            .content
657            .classes
658            .iter()
659            .filter(|cl| !cl.kit.iter().any(|k| k.flask))
660            .map(|cl| cl.id.as_str())
661            .collect();
662        if !flaskless.is_empty() {
663            d.push(Diagnostic::error(
664                BONFIRE_NO_FLASK,
665                "classes",
666                "/content/classes".to_string(),
667                format!(
668                    "this campaign places a `bonfire` but {} no `flask` kit item: {}. \
669                     Resting at a bonfire replenishes every kit entry marked `\"flask\": true` to \
670                     its declared `count` — with none, the rest option recovers nothing and the \
671                     souls loop has no consumable to spend (spec-0016 §1). \
672                     Add a recovery item to each class kit and mark it \
673                     `\"flask\": true` (this needs `dsl_version` 0.8.0 on the classes stage). Do \
674                     NOT drop the bonfire to silence this — the rest point is the design.",
675                    if flaskless.len() == 1 {
676                        "one class declares".to_string()
677                    } else {
678                        format!("{} classes declare", flaskless.len())
679                    },
680                    flaskless.join(", ")
681                ),
682            ));
683        }
684    }
685}
686
687/// `DW0110` over the class ids.
688pub(crate) fn class_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
689    for (i, cl) in c.classes.content.classes.iter().enumerate() {
690        crate::ids::id_syntax!(d, cl.id, "classes", format!("/content/classes/{i}/id"));
691    }
692}
693
694/// `DW0111` over the class ids.
695pub(crate) fn class_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
696    crate::ids::dup_check(
697        c.classes
698            .content
699            .classes
700            .iter()
701            .enumerate()
702            .map(|(i, cl)| (cl.id.as_str(), format!("/content/classes/{i}/id"))),
703        "classes",
704        "class",
705        d,
706    );
707}