Skip to main content

delvewright_dsl/
lethal.rs

1//! Lethal volumes and the kinds of damage they deal.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{Guard, LethalVolumeId, StealthZone};
7
8#[cfg(doc)]
9use crate::Verb;
10
11/// The damage type of a [`Verb::DamagePlayers`] effect (DSL v0.6). A
12/// **curated** subset of the vanilla 1.21.11 damage-type registry: every variant
13/// respects the `keepInventory` death flow (a gamerule, so all deaths do) and does
14/// **not** bypass a totem of undying — the totem-bypassing `out_of_world` /
15/// `generic_kill` types are deliberately excluded, so a scripted consequence can
16/// never silently void a player's held totem. Modelled as an enum (not a free
17/// string) so an unknown type is a schema rejection (`DW0100`) and needs no separate
18/// registry / diagnostic. `generic` is the default: command damage that respects
19/// totems + absorption but ignores armor, so a scripted hit lands regardless of gear.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "kebab-case")]
22pub enum DamageKind {
23    /// `minecraft:generic` — armor-ignoring command damage (default).
24    Generic,
25    /// `minecraft:magic` — magical damage.
26    Magic,
27    /// `minecraft:wither` — the wither/withering effect's damage type.
28    Wither,
29    /// `minecraft:on_fire` — burning damage.
30    Fire,
31    /// `minecraft:drown` — drowning damage.
32    Drown,
33    /// `minecraft:freeze` — powder-snow freezing damage.
34    Freeze,
35    /// `minecraft:fall` — fall damage.
36    Fall,
37    /// `minecraft:lightning_bolt` — a lightning strike's damage type.
38    LightningBolt,
39    /// `minecraft:explosion` — a (non-player) explosion's damage type.
40    Explosion,
41}
42
43impl DamageKind {
44    /// The vanilla `minecraft:` damage-type id emitted to `/damage`.
45    pub fn id(self) -> &'static str {
46        match self {
47            DamageKind::Generic => "minecraft:generic",
48            DamageKind::Magic => "minecraft:magic",
49            DamageKind::Wither => "minecraft:wither",
50            DamageKind::Fire => "minecraft:on_fire",
51            DamageKind::Drown => "minecraft:drown",
52            DamageKind::Freeze => "minecraft:freeze",
53            DamageKind::Fall => "minecraft:fall",
54            DamageKind::LightningBolt => "minecraft:lightning_bolt",
55            DamageKind::Explosion => "minecraft:explosion",
56        }
57    }
58}
59
60/// A stage-5 **lethal volume** (DSL v0.10, spec-0031): a declared box that kills
61/// whatever enters it, and states — in the campaign's own words — what killed it.
62///
63/// # A mechanism, not a fiction
64///
65/// The commissioning case was a cliff whose fall must be fatal, but nothing here
66/// knows what a cliff is: a lava pit, an acid pool, an out-of-bounds plane and the
67/// bottom of a lift shaft are the same declaration, differently dressed and
68/// differently worded. The alternative considered and **rejected** for the cliff
69/// was making the world's horizon void so the fall kills anyway — that changes
70/// approved art to obtain a behaviour, and it serves exactly one fiction.
71///
72/// # It is geometry, so the completability proof owns it
73///
74/// A volume that kills is, for a route, a volume that cannot be crossed. The
75/// compiler models its cells as impassable in the same navigation world every
76/// other reachability proof runs on, exactly as a `close-gate`'s sealed region is
77/// modelled solid — so a forced path that has no way to an objective except
78/// through a lethal volume is a build failure (`DW0510`) naming the volume, never
79/// a shipped delve that kills the player on the critical path. A respawn seat
80/// inside one is the death loop that failure mode ends in, and is its own
81/// error (`DW0511`).
82///
83/// # It rides the death edge that already exists
84///
85/// The kill is a `/damage`, exactly like `damage-players`, a trap payload or a
86/// timed gate's crush. Everything downstream — the vanilla `deathCount` edge
87/// (`dw.deaths` / `dw.death_ack`), the checkpoint re-seat (`cp_respawn_check`),
88/// `keep_inventory` — sees an ordinary death and needs no second detector.
89#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct LethalVolume {
92    /// Unique lethal-volume id (`lethal/<kebab>`).
93    pub id: LethalVolumeId,
94    /// The volume: an anchor-centred box (`anchor ± extent`).
95    ///
96    /// **Deliberately the existing zone type**, not a second struct with the same
97    /// two fields. `StealthZone` is already the engine's anchor-centred box object
98    /// class — `damage-players`'s `in` filter reuses it, and the compiler resolves
99    /// every one of them through the single `Plan::zone_box`. A private twin here
100    /// would be `tools/ci/check-capability-ownership.py` check C by construction, and
101    /// would fork the resolution the very next time a box grew a capability.
102    pub region: StealthZone,
103    /// What this volume says when it kills — a player-visible line, inventoried
104    /// under `lethal.<id>.message` and translated like every other one.
105    ///
106    /// Required, and deliberately so: a volume with no words is a player who dies
107    /// with no idea why, and there is no compiler-owned default that could be
108    /// right for a cliff, a lava pit and an acid pool at once.
109    pub message: String,
110    /// The damage type the kill is dealt with (default [`DamageKind::Generic`]).
111    ///
112    /// This is what words vanilla's own broadcast — `fall` says the party member
113    /// fell from a high place, `on_fire` that they burnt to a crisp — while
114    /// [`Self::message`] says what the *place* was. The curated enum is shared
115    /// with `damage-players`, so a lethal volume can no more void a held totem
116    /// than a scripted hit can.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub damage_type: Option<DamageKind>,
119    /// **The blocks that show a player this floor kills** (spec-0062 §3).
120    ///
121    /// A lava surface, a magma floor, a bed of spikes, a burning strip: the
122    /// danger is level with the footing because the block *is* the signal. This
123    /// is where a volume says so, and it is a claim about the assembled bytes
124    /// rather than a word that switches a rule off — `DW0891` checks it per
125    /// caught cell against the block under or in that cell, refuses a listed
126    /// block no caught cell bears out, and refuses at validation any id vanilla
127    /// does not hurt a body with.
128    ///
129    /// Empty is the ordinary case and means the ordinary thing: this volume
130    /// catches no floor the party walks, because it sits at the bottom of a pit
131    /// or a course under a lake's surface. It does **not** exempt a cell from
132    /// the walk graph — a visible hazard is still a hazard, and the router
133    /// refuses every cell of the keep-out either way.
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub shown_by: Vec<String>,
136    /// **When this volume kills** (spec-0088): the one [`Guard`] every other
137    /// gated object carries, verbatim. Absent, the volume is live from
138    /// world-load to the end. Present, it is live while its gate holds —
139    /// `requires_flags` is a pit that kills from a beat on, `forbids_flags` a
140    /// shaft that kills until one, `requires_state` a chamber that kills while
141    /// a party datum stands in range.
142    ///
143    /// A volume's liveness is a fact about the place, so the gate is a fact
144    /// about the party: `when: {}` (a stage with no term) and a term on a
145    /// `player`-scoped datum are refused at the document (`DW0953`). The
146    /// navigation world holds the volume per quest configuration, and `DW0891`
147    /// judges what shows it in every configuration a body can meet it in,
148    /// including the last one before it goes live.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub when: Option<Guard>,
151}
152
153// ---------------------------------------------------------------------------
154// Validation
155// ---------------------------------------------------------------------------
156
157use std::collections::BTreeSet;
158
159use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
160use crate::envelope::Campaign;
161use crate::registry::AnchorRegistry;
162use crate::validate::{AnchorProviders, station_kind_diag};
163
164crate::dw_code! {
165    /// (spec-0031, DSL v0.10) A `lethal_volumes[]` entry's `message` is blank.
166    ///
167    /// The volume would still kill — and would kill in silence, which is the one
168    /// thing the declaration exists to prevent. There is no compiler default that
169    /// could be right for a cliff, a lava pit and an acid pool at once, so a blank
170    /// wording is refused rather than papered over: a gate that reports green
171    /// while the player learns nothing is exactly the vacuous pass CLAUDE.md names.
172    pub const LETHAL_MESSAGE_BLANK: DwCode = DwCode::new("DW0512", ExitTier::Build);
173}
174
175crate::dw_code! {
176    /// (spec-0062) **A killing volume and what shows it disagree.**
177    ///
178    /// One rule, three shapes, and every remedy each names is admitted by the
179    /// others — which is why one code carries all of them (spec-0062 §4).
180    ///
181    /// * **Caught floor that shows nothing.** Some cell the party can walk to
182    ///   lies in the volume's keep-out, and the block under or in it is not one
183    ///   of the volume's `shown_by`. The player reads stone and dies on it. The
184    ///   remedy is the geometry's: lower the volume so its keep-out's top course
185    ///   lies under the floor, draw its `extent` in, or author a block vanilla
186    ///   hurts with under those cells and declare it. Never mark walkable-looking
187    ///   ground unwalkable — the compiler knows and the player does not.
188    /// * **A declared signal the bytes do not hold.** A `shown_by` block under
189    ///   or in no caught cell, whether the list is wrong or the volume catches
190    ///   nothing at all. The `DW0887` shape, on a volume instead of a waterline.
191    /// * **A `shown_by` naming a block vanilla does not hurt a body with.** The
192    ///   document arm, and the only one answerable with nothing placed:
193    ///   `minecraft:stone` over stone is borne out by the bytes and shows
194    ///   nothing.
195    ///
196    /// The world arm is raised by `delvec::compiler::lethal` over the final
197    /// assembled world (whether a cell is floor is a fact about the settled
198    /// bytes, so nothing before assembly can answer it); the document arm here,
199    /// by [`crate::validate`].
200    pub const LETHAL_INVISIBLE: DwCode = DwCode::new("DW0891", ExitTier::Build);
201}
202
203/// Stage-5 lethal-volume structural checks (DSL v0.10, spec-0031): id syntax and
204/// uniqueness, a resolvable region anchor, and a wording the player can actually
205/// read (`DW0512`).
206///
207/// Everything geometric is deliberately absent here and lives in the compiler:
208/// where the box lands, what it overlaps and whether the party can still finish
209/// are questions about the *solved layout*, which the DSL crate does not have.
210pub(crate) fn lethal_volume_checks(
211    c: &Campaign,
212    anchors: &dyn AnchorRegistry,
213    d: &mut Vec<Diagnostic>,
214) {
215    let volumes = &c.quests.content.lethal_volumes;
216    if volumes.is_empty() {
217        return;
218    }
219    // The same "is this anchor provided by some bound prefab" rule every stage-5
220    // anchor reference uses; a pool area defers the answer to the compiler.
221    let providers = AnchorProviders::build(c, anchors);
222    let mut seen_id: BTreeSet<&str> = BTreeSet::new();
223    for (i, v) in volumes.iter().enumerate() {
224        if !v.id.is_valid_syntax() {
225            d.push(Diagnostic::error(
226                codes::ID_SYNTAX,
227                "quests",
228                format!("/content/lethal_volumes/{i}/id"),
229                format!(
230                    "malformed lethal-volume id `{}` — lethal-volume ids must be lowercase \
231                     kebab-case with the `lethal/` prefix (e.g. `lethal/cliff-fall`)",
232                    v.id
233                ),
234            ));
235        }
236        if !seen_id.insert(v.id.as_str()) {
237            d.push(Diagnostic::error(
238                codes::ID_DUPLICATE,
239                "quests",
240                format!("/content/lethal_volumes/{i}/id"),
241                format!("duplicate lethal-volume id `{}`", v.id),
242            ));
243        }
244        if let Some(f) = station_kind_diag(
245            &providers,
246            v.region.anchor.as_str(),
247            crate::layout::StationKind::Point,
248            "a lethal volume's region centre",
249            "quests",
250            format!("/content/lethal_volumes/{i}/region/anchor"),
251        ) {
252            d.push(f);
253        }
254        if !providers.resolvable(v.region.anchor.as_str()) {
255            d.push(Diagnostic::error(
256                codes::ANCHOR_UNRESOLVED,
257                "quests",
258                format!("/content/lethal_volumes/{i}/region/anchor"),
259                format!(
260                    "lethal-volume anchor `{}` is not provided by any prefab bound in this \
261                     campaign — {}",
262                    v.region.anchor,
263                    providers.anchor_remedy(
264                        "use an anchor the prefab exposes (anchor names come from prefab \
265                         metadata; do NOT invent one)"
266                    ),
267                ),
268            ));
269        }
270        // `DW0891`, document arm (spec-0062 §4): a `shown_by` id that is a known
271        // block and not one vanilla hurts a body with. Nothing has to be placed
272        // to know it, so it is refused here rather than three passes later, and
273        // the message prints the set the author may choose from — a remedy that
274        // named no candidates would be a remedy an author has to guess at.
275        //
276        // An id the pinned version does not have at all is `DW0193`, the code
277        // every block id in the DSL validates under, and deliberately not this
278        // rule's: a typo is a typo wherever it is written.
279        for (j, block) in v.shown_by.iter().enumerate() {
280            if crate::blocks::BlockRegistry::v1_21_11()
281                .validate_state_string(block)
282                .is_err()
283            {
284                d.push(Diagnostic::error(
285                    codes::BLOCK_UNKNOWN,
286                    "quests",
287                    format!("/content/lethal_volumes/{i}/shown_by/{j}"),
288                    format!(
289                        "lethal volume `{}` declares `shown_by` block `{block}`, which is not a \
290                         block state of Minecraft Java 1.21.11",
291                        v.id
292                    ),
293                ));
294                continue;
295            }
296            if crate::blockshape::hurts_body(block) {
297                continue;
298            }
299            d.push(Diagnostic::error(
300                LETHAL_INVISIBLE,
301                "quests",
302                format!("/content/lethal_volumes/{i}/shown_by/{j}"),
303                format!(
304                    "lethal volume `{}` declares `shown_by` block `{block}`, which vanilla does \
305                     not hurt a body with — so it shows a player nothing, and floor made of it \
306                     reads as safe however this volume is declared. `shown_by` says what the \
307                     player SEES; it is not a word that switches the rule off. Name the block \
308                     that shows the danger, from the set vanilla hurts a body with: {}.",
309                    v.id,
310                    crate::blockshape::HURTING_BLOCKS_1_21_11
311                        .iter()
312                        .map(|b| format!("`{b}`"))
313                        .collect::<Vec<_>>()
314                        .join(", "),
315                ),
316            ));
317        }
318        if v.message.trim().is_empty() {
319            d.push(Diagnostic::error(
320                LETHAL_MESSAGE_BLANK,
321                "quests",
322                format!("/content/lethal_volumes/{i}/message"),
323                format!(
324                    "lethal volume `{}` declares a blank `message`, so it would kill in silence \
325                     — the one thing this declaration exists to prevent. Write the line the \
326                     player reads as they die (`The undertow takes you.`); there is no compiler \
327                     default that could be right for a cliff, a lava pit and an acid pool at \
328                     once.",
329                    v.id
330                ),
331            ));
332        }
333    }
334}
335
336/// `DW0953`'s empty-gate shape (spec-0088 §3.2): a `when` with no term is not a
337/// stage. The player-scoped shape is raised beside `DW0503` in
338/// [`state_checks`], where every gate's `requires_state` is already read.
339pub(crate) fn lethal_stage_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
340    for (i, v) in c.quests.content.lethal_volumes.iter().enumerate() {
341        if v.when.is_some() && v.gate().is_empty() {
342            d.push(Diagnostic::error(
343                codes::LETHAL_STAGE_GATE,
344                "quests",
345                format!("/content/lethal_volumes/{i}/when"),
346                format!(
347                    "lethal volume `{}` declares `when: {{}}` — a stage with no term. An \
348                     always-live volume is spelled by leaving `when` out; to stage it, name a \
349                     flag (`requires_flags` / `forbids_flags`) or a `party`-scoped datum \
350                     (`requires_state`)",
351                    v.id.as_str()
352                ),
353            ));
354        }
355    }
356}