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}