delvewright_dsl/stages.rs
1//! serde types for the six stage `content` payloads (spec-0001 v0.2).
2//!
3//! Every struct is `deny_unknown_fields`. Reserved enum values (objective and
4//! effect types a campaign's `dsl_version` is too low for) parse successfully
5//! and are checked by validation ([`crate::validate`]).
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::firework::FireworkExplosion;
13use crate::layout::StationKind;
14
15use crate::ids::{
16 ActorId, AmbushId, AnchorId, AreaId, BranchId, BranchPointId, ClassId, DialogueId, EditBatchId,
17 EndingId, FlagId, LethalVolumeId, LootId, NpcId, ObjectiveId, PoolId, PrefabId, QuestId,
18 RegionId, ShopId, ShortcutId, StakeId, StateId, TimedGateId, TrapId, TriggerId, WaveId,
19};
20
21/// serde default helper: `true` (used by DSL v0.4 `trigger.once`).
22fn default_true() -> bool {
23 true
24}
25
26/// serde `skip_serializing_if` helper: skip a `false` bool (DSL v0.4
27/// `objective.stealth`), keeping older campaigns byte-identical.
28fn is_false(b: &bool) -> bool {
29 !*b
30}
31
32// ---------------------------------------------------------------------------
33// Stage 1 — world
34// ---------------------------------------------------------------------------
35
36/// Stage 1 payload: setting, seed and the areas that make up the delve.
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
38#[serde(deny_unknown_fields)]
39pub struct WorldContent {
40 /// Player-facing delve title.
41 pub title: String,
42 /// One-line thematic description.
43 pub theme: String,
44 /// Short narrative premise.
45 pub premise: String,
46 /// The single downstream randomness source (ADR-0006).
47 pub seed: u64,
48 /// Informational pacing target in minutes (v0: not enforced).
49 pub target_minutes: u32,
50 /// The areas the delve is made of; each binds exactly one of `prefab` /
51 /// `prefab_pool`.
52 ///
53 /// **A campaign declares its placement in exactly one document, so this
54 /// list is empty on a site-plan campaign** (`DW0839`): where a
55 /// `site-plan.json` is present the plan is the placement authority, its one
56 /// place is `area/site`, and declaring `areas[]` as well gives every
57 /// question about where something is two answers. Empty is therefore a
58 /// legitimate and common value, not a campaign that forgot to place
59 /// anything.
60 pub areas: Vec<Area>,
61 /// Additional author-declared translation languages (BCP-47-style codes, e.g.
62 /// `["zh-cn"]`). English (`en`) is implicit, always canonical, and is **never**
63 /// listed here (spec-0001 i18n addendum). Absent or empty = English-only. Every
64 /// declared language must ship a fully-covering `l10n/<code>.json` sidecar
65 /// (`DW0180`/`DW0181`). Stage docs themselves stay pure English.
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub languages: Vec<String>,
68 /// **The hour this delve is played at** (DSL v0.5, spec-0010; required since
69 /// spec-0061). Dimension-global; frozen by environment sealing
70 /// (`advance_time false`) so the set state persists. Affects sky attenuation
71 /// in the compiler's assembled-light model.
72 ///
73 /// **Required, and it has no default.** "This delve is played at noon" is a
74 /// design decision, and a mechanism that supplies one silently when the
75 /// author said nothing is exactly what `CLAUDE.md` forbids a primitive from
76 /// encoding — the same ruling spec-0060 §4.1 made for `walk_y`. It is also
77 /// the world half of the comparison `DW0890` makes against the approved
78 /// design's rows, so every campaign has to state it for the comparison to
79 /// have two sides. Emission is unchanged: `time set <kw>` was always
80 /// emitted, so a campaign that already declared this builds
81 /// byte-identically.
82 pub time: WorldTime,
83 /// **The weather this delve is played in** (DSL v0.5, spec-0010; required
84 /// since spec-0061). Dimension-global; frozen by environment sealing
85 /// (`advance_weather false`). Rain and thunder attenuate effective sky
86 /// brightness in the assembled-light model.
87 ///
88 /// Required, with no default, for the reason [`WorldContent::time`] gives.
89 /// Emission is unchanged: `weather <kw>` is emitted only for a declared
90 /// non-`clear` weather, because `clear` is vanilla's own state.
91 pub weather: WorldWeather,
92 /// Declared combat difficulty (DSL v0.6). Absent =
93 /// the compiler's historical derivation — `easy` when the campaign fields any
94 /// wave, `peaceful` when it fields none — which is what keeps every campaign
95 /// written before this field byte-identical. Declaring it overrides the
96 /// derivation for **both** the shipped `server.properties` and a `/difficulty`
97 /// in the sealing baseline, so the declaration also holds when the datapack is
98 /// dropped into somebody else's world.
99 ///
100 /// `peaceful` is rejected (`DW0468`). Raising difficulty changes the damage
101 /// players take — easy halves it — so combat arithmetic tuned under the old
102 /// implicit `easy` must be redone.
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub difficulty: Option<WorldDifficulty>,
105 /// The scenic horizon: the ground and the sky the map stands in
106 /// (spec-0026). Absent or `void` is the void world. `ocean` swaps the world
107 /// generator for a deterministic superflat sea (bedrock/stone/water, sea
108 /// level y=62) and drops the area datum to y=60 so island pieces meet the
109 /// sea at their authored waterline. `valley` rings the map in a generated
110 /// mountain annulus — the one base that builds terrain rather than picking
111 /// a generator. Either a string shorthand or the object form
112 /// `{base, …params}`; see [`Horizon`].
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub horizon: Option<Horizon>,
115 /// Playable-region boundary (DSL v0.6, spec-0013). When present, the compiler
116 /// derives a region from the placed geometry and a per-second clock returns any
117 /// player who leaves it to the last checkpoint. Required when `horizon` is
118 /// `ocean` (an infinite swimmable sea with no return rule is `DW0320`).
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub boundary: Option<Boundary>,
121 /// The closing line on the campaign-completion advancement — the last
122 /// player-visible sentence of the delve (DSL v0.6). Player-visible, so it is
123 /// l10n-inventoried as `world.outro` and sidecars translate it. Absent = the
124 /// finale quest's `goal`, which is already both campaign-derived and
125 /// inventoried; the description was previously the hardcoded English
126 /// "You left the keep." on *every* delve, whatever its theme or language.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub outro: Option<String>,
129 /// The party size this delve **requires** (DSL v0.6, spec-0018). Absent = 1: a
130 /// party of one is always legal and every pre-0.6 campaign keeps that reading.
131 /// A design whose beats genuinely need `n` players — two rooms whose switches
132 /// are two arms of one AND-join — declares `min_players: n` (max 4), and the
133 /// lobby then refuses to start below it: the class-selection dialog stays shut
134 /// and the waiting players get a party-count actionbar instead.
135 ///
136 /// Progression is party state either way (spec-0018), so this is a *declaration
137 /// of intent*, not a mechanism: it makes a mandatory-n design first-class
138 /// and turns on the analyzer's n-agent division
139 /// proof. Out of `1..=4` is `DW0370`.
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub min_players: Option<u8>,
142}
143
144/// Who a granted item goes to (DSL v0.6, spec-0018).
145///
146/// Progression is a fact about the party, so the default for every `give-item` and
147/// every class-kit entry is **all**: a quest beat that arms the party arms all of
148/// it. `one` is the deliberate exception for a single quest prop (the wine-skin,
149/// the stake): exactly one copy enters the party, handed to the player whose action
150/// earned it, and the party passes it around physically.
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
152#[serde(rename_all = "kebab-case")]
153pub enum Carrier {
154 /// Every party member receives the item (the default).
155 #[default]
156 All,
157 /// Exactly one copy, to the player whose action fired the effect.
158 One,
159}
160
161/// A declared world time state (DSL v0.5, spec-0010). The sole difference from
162/// vanilla is that the daylight cycle is frozen (`advance_time false`), so a set
163/// state persists for the whole delve until a `set-time` effect cuts to another.
164///
165/// Vanilla's `/time set` primitive takes **either** one of four keywords or a raw
166/// tick count, and the tick form is the general one — so the states worth naming
167/// for a delve's pacing are not limited to the four keywords. `dusk` and `dawn`
168/// are the tick form exposed first-class, per the
169/// no-hack rule: the DSL names the beat, the compiler emits `/time set <ticks>`.
170/// Every keyword-to-tick mapping lives in exactly one table ([`WorldTime::spec`]),
171/// and the four vanilla keywords still emit their keyword verbatim, so existing
172/// campaigns are byte-identical.
173///
174/// **There is no `Default`** (spec-0061 §4). A default hour is a design decision
175/// wearing a mechanism's clothes, and `#[default] Noon` is what let a delve whose
176/// whole approved look was night build, light-check and render under a blue noon
177/// sky. Removing the impl is what makes that unwritable rather than merely
178/// discouraged: `WorldContent::time` is required, and nothing can supply an hour
179/// the author did not.
180#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "kebab-case")]
182pub enum WorldTime {
183 /// Morning daylight (`/time set day`, 1000 ticks).
184 Day,
185 /// Midday, brightest (`/time set noon`, 6000 ticks).
186 Noon,
187 /// Sunset — the sky visibly going orange and the day ending
188 /// (`/time set 12000`). Deliberately NOT 13000: that is the instant the sun
189 /// has finished setting, which is what the `night` keyword already sets, so
190 /// 13000 would make `dusk` a synonym rather than its own beat.
191 Dusk,
192 /// Night, sun fully down (`/time set night`, 13000 ticks).
193 Night,
194 /// Deep night, darkest (`/time set midnight`, 18000 ticks).
195 Midnight,
196 /// First light, just before sunrise (`/time set 23000`). Spelled `dawn`;
197 /// `sunrise` is accepted as a synonym on input.
198 #[serde(alias = "sunrise")]
199 Dawn,
200}
201
202impl WorldTime {
203 /// The single keyword/tick table: `(the /time set argument, daytime ticks)`.
204 ///
205 /// A state vanilla names keeps its keyword — the argument the compiler has
206 /// always emitted — so no shipped campaign's bytes move. A state vanilla does
207 /// not name emits the equivalent tick count, which is the same primitive.
208 const fn spec(self) -> (&'static str, i64) {
209 match self {
210 WorldTime::Day => ("day", 1000),
211 WorldTime::Noon => ("noon", 6000),
212 WorldTime::Dusk => ("12000", 12000),
213 WorldTime::Night => ("night", 13000),
214 WorldTime::Midnight => ("midnight", 18000),
215 WorldTime::Dawn => ("23000", 23000),
216 }
217 }
218
219 /// The vanilla `/time set` argument — a keyword for the four states vanilla
220 /// names, a tick count otherwise.
221 pub fn token(self) -> &'static str {
222 self.spec().0
223 }
224
225 /// The `daytime` tick value this state sets (the `time query daytime`
226 /// read-back). Vanilla constants: day=1000, noon=6000, dusk=12000 (sunset
227 /// onset), night=13000, midnight=18000, dawn=23000.
228 pub fn daytime_ticks(self) -> i64 {
229 self.spec().1
230 }
231
232 /// **The word an author writes** — this state's spelling in a document.
233 ///
234 /// Not [`WorldTime::token`], which is the `/time set` argument and is a raw
235 /// tick count for the two states vanilla does not name. A diagnostic that
236 /// asks an author to declare an hour has to say `dusk`, not `12000`: the
237 /// number is what the compiler emits and is not writable in `world.json`.
238 pub fn keyword(self) -> &'static str {
239 match self {
240 WorldTime::Day => "day",
241 WorldTime::Noon => "noon",
242 WorldTime::Dusk => "dusk",
243 WorldTime::Night => "night",
244 WorldTime::Midnight => "midnight",
245 WorldTime::Dawn => "dawn",
246 }
247 }
248}
249
250/// A declared weather state (DSL v0.5, spec-0010). Values are the vanilla
251/// `/weather` keywords; frozen (`advance_weather false`), so a set state persists.
252///
253/// **No `Default`**, for the reason [`WorldTime`] gives.
254#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
255#[serde(rename_all = "kebab-case")]
256pub enum WorldWeather {
257 /// Clear sky (`/weather clear`). Vanilla's own state, so emission writes no
258 /// `/weather` command for it.
259 Clear,
260 /// Rain (`/weather rain`).
261 Rain,
262 /// Thunderstorm (`/weather thunder`).
263 Thunder,
264}
265
266impl WorldWeather {
267 /// The word an author writes — identical to [`WorldWeather::token`] for
268 /// every state, and stated separately so a message that names a document's
269 /// vocabulary reads the document's vocabulary. See [`WorldTime::keyword`],
270 /// where the two differ.
271 pub fn keyword(self) -> &'static str {
272 self.token()
273 }
274
275 /// The vanilla `/weather` keyword.
276 pub fn token(self) -> &'static str {
277 match self {
278 WorldWeather::Clear => "clear",
279 WorldWeather::Rain => "rain",
280 WorldWeather::Thunder => "thunder",
281 }
282 }
283}
284
285/// The declared combat difficulty of the delve (DSL v0.6). Values are the
286/// vanilla `/difficulty` keywords.
287///
288/// Difficulty is the single largest lever on how hard a delve *feels*, so the
289/// campaign declares it rather than letting the compiler choose. Easy **halves
290/// incoming player damage** — `min(dmg / 2 + 1, dmg)` — so a campaign tuned
291/// under `easy` is tuned against a halved world. A campaign that
292/// raises this must redo that arithmetic.
293///
294/// [`WorldDifficulty::Peaceful`] parses but is **rejected** by validation
295/// (`DW0468`): peaceful makes the engine discard every hostile-category mob on
296/// the tick it is ticked, summoned or not, so every wave, actor and ambush in the
297/// campaign would silently vanish. It is a variant only so the compiler can say
298/// that in a diagnostic instead of a serde "unknown variant" parse error.
299#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
300#[serde(rename_all = "kebab-case")]
301pub enum WorldDifficulty {
302 /// `/difficulty easy` — the compiler's historical choice for a wave
303 /// campaign, and the default reading of an absent field. Incoming player
304 /// damage is halved (`min(dmg / 2 + 1, dmg)`).
305 #[default]
306 Easy,
307 /// `/difficulty normal` — vanilla-baseline damage. The souls-style baseline.
308 Normal,
309 /// `/difficulty hard` — amplified damage, and zombies reinforce.
310 Hard,
311 /// `/difficulty peaceful` — **always rejected** (`DW0468`). Present only so
312 /// the rejection can be a diagnostic with a rationale.
313 Peaceful,
314}
315
316impl WorldDifficulty {
317 /// The vanilla `/difficulty` keyword.
318 pub fn token(self) -> &'static str {
319 match self {
320 WorldDifficulty::Peaceful => "peaceful",
321 WorldDifficulty::Easy => "easy",
322 WorldDifficulty::Normal => "normal",
323 WorldDifficulty::Hard => "hard",
324 }
325 }
326
327 /// The vanilla `Difficulty#getId()` ordinal, which is also what the bare
328 /// `/difficulty` query command returns — the only vanilla read-back path for
329 /// the setting, and so what the generated PackTest asserts on.
330 pub fn id(self) -> i32 {
331 match self {
332 WorldDifficulty::Peaceful => 0,
333 WorldDifficulty::Easy => 1,
334 WorldDifficulty::Normal => 2,
335 WorldDifficulty::Hard => 3,
336 }
337 }
338}
339
340/// A scenic horizon (spec-0026). A horizon is a **composition of orthogonal
341/// axes**, not an enum of monoliths: a **base** — what surrounds the map — and
342/// that base's params. The field accepts a plain string shorthand
343/// ([`HorizonName`]) or the object form [`HorizonSpec`] `{base, …params}`.
344///
345/// Consumers never match this wire enum. [`Horizon::resolved`] desugars both
346/// forms into one [`ResolvedHorizon`] with the pinned defaults applied, and
347/// [`horizon_base`] answers the one question most callers have.
348#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
349#[serde(untagged)]
350pub enum Horizon {
351 /// A bare base name — `"ocean"` is exactly `{base: "ocean"}`. The two
352 /// bases that predate the horizon library were spelled this way and still
353 /// are, byte-identically; a base added since is spellable this way too,
354 /// because a base with every param at its default has nothing else to say.
355 Name(HorizonBase),
356 /// The object form `{base, …params}`.
357 Spec(HorizonSpec),
358}
359
360/// What surrounds the map.
361///
362/// **One enumeration of bases, reachable two ways.** The shorthand
363/// `horizon: "ocean"` and the object form `horizon: {base: "ocean"}` name this
364/// same variant, which is what stops a base from existing in one spelling and
365/// not the other. A separate list of "names" beside this one would be two
366/// enumerations of the same thing, and the second base added would land in
367/// whichever of them its author was looking at.
368///
369/// What is deliberately NOT here is a name that stands for a base plus a set of
370/// params. Such a name reads as a thing, and the whole claim of this design is
371/// that it is not one — it is a base with params set. A spelling that hides
372/// which params it sets makes that claim unverifiable by looking at the
373/// document, and buys a few saved keystrokes for it. Each base carries its own params on
374/// [`HorizonSpec`], and a param foreign to the declared base is refused rather
375/// than ignored.
376#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
377#[serde(rename_all = "kebab-case")]
378pub enum HorizonBase {
379 /// Void superflat; no surround. The default.
380 #[default]
381 Void,
382 /// Pinned water superflat, sea level 62; no surround.
383 Ocean,
384 /// A mountain annulus around a flat gap floor, with void ambient below the
385 /// tile skirt. The one base that generates terrain.
386 Valley,
387}
388
389impl HorizonBase {
390 /// The kebab wire name.
391 pub fn token(self) -> &'static str {
392 match self {
393 HorizonBase::Void => "void",
394 HorizonBase::Ocean => "ocean",
395 HorizonBase::Valley => "valley",
396 }
397 }
398
399 /// Whether this base generates a surround — compiler-built terrain outside
400 /// the map's own extent. `void` and `ocean` are pure ambient and build
401 /// nothing.
402 pub fn has_surround(self) -> bool {
403 matches!(self, HorizonBase::Valley)
404 }
405}
406
407/// The `horizon` object form: a `base` plus that base's params, all optional
408/// with pinned defaults ([`horizon_defaults`]).
409///
410/// The `valley` surround generator carries a second flora and a second surface
411/// palette (a cherry grove over `minecraft:cherry_grove`) and **this struct
412/// does not expose them.** Every engine surface owes a gallery element in the
413/// change that lands it; the element a second flora needs is a valley overlay,
414/// and one is writable now that a one-area campaign's single prefab states an
415/// extent ([`crate::placement::Extent`], `DW0855`) — the reason recorded here
416/// was that no two-file overlay could ring a map, and that reason is spent.
417/// What is left is that nothing has written the element, and a surface lands
418/// with its element or it does not land. The shape is flat rather than
419/// per-base tagged, and a param foreign to the declared base is refused
420/// (`DW0853`) — so an `ocean` cannot quietly carry a `rim_height` that nothing
421/// reads.
422#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
423#[serde(deny_unknown_fields)]
424pub struct HorizonSpec {
425 /// The base — what surrounds the map.
426 pub base: HorizonBase,
427 /// `valley`: the surround's total footprint as a multiple of the map's, on
428 /// each axis (`2.0..=3.0`, default 2.5).
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub ratio: Option<f64>,
431 /// `valley`: crest height of the rim over the gap floor (`16..=128`,
432 /// default 48).
433 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub rim_height: Option<i32>,
435}
436
437/// Pinned horizon param defaults. One table, so the doc comments, the resolver
438/// and the diagnostics cannot drift.
439pub mod horizon_defaults {
440 /// `valley.ratio`.
441 pub const RATIO: f64 = 2.5;
442 /// `valley.ratio` lower bound — below 2.0 the annulus has no room for a
443 /// gap floor and a slope run both.
444 pub const RATIO_MIN: f64 = 2.0;
445 /// `valley.ratio` upper bound — above 3.0 the surround is mostly terrain a
446 /// body never reaches, at a cost that is all shipped bytes.
447 pub const RATIO_MAX: f64 = 3.0;
448 /// `valley.rim_height`.
449 pub const RIM_HEIGHT: i32 = 48;
450 /// `valley.rim_height` lower bound — a rim under 16 does not close the
451 /// horizon from a body standing on the gap floor.
452 pub const RIM_HEIGHT_MIN: i32 = 16;
453 /// `valley.rim_height` upper bound — the build range is 384 blocks tall and
454 /// the surround has to fit under whatever the map puts above it.
455 pub const RIM_HEIGHT_MAX: i32 = 128;
456}
457
458/// A horizon with both wire forms desugared and every default applied — the
459/// only view downstream code reads.
460#[derive(Clone, Copy, Debug, PartialEq)]
461pub struct ResolvedHorizon {
462 /// The base.
463 pub base: HorizonBase,
464 /// `valley.ratio`.
465 pub ratio: f64,
466 /// `valley.rim_height`.
467 pub rim_height: i32,
468}
469
470impl Default for ResolvedHorizon {
471 fn default() -> Self {
472 ResolvedHorizon {
473 base: HorizonBase::Void,
474 ratio: horizon_defaults::RATIO,
475 rim_height: horizon_defaults::RIM_HEIGHT,
476 }
477 }
478}
479
480impl ResolvedHorizon {
481 /// The resolved horizon of `base` with every param at its pinned default.
482 pub fn of_base(base: HorizonBase) -> Self {
483 ResolvedHorizon {
484 base,
485 ..Default::default()
486 }
487 }
488}
489
490impl Horizon {
491 /// Desugar either wire form to the one resolved view, defaults applied.
492 pub fn resolved(&self) -> ResolvedHorizon {
493 match self {
494 Horizon::Name(base) => ResolvedHorizon::of_base(*base),
495 Horizon::Spec(s) => ResolvedHorizon {
496 base: s.base,
497 ratio: s.ratio.unwrap_or(horizon_defaults::RATIO),
498 rim_height: s.rim_height.unwrap_or(horizon_defaults::RIM_HEIGHT),
499 },
500 }
501 }
502
503 /// The resolved base.
504 pub fn base(&self) -> HorizonBase {
505 self.resolved().base
506 }
507
508 /// True when this declaration needs the horizon-library surface: the object
509 /// form, or a bare name for a base that did not exist before it.
510 ///
511 /// The two bases that predate the library stay writable as bare names at
512 /// the version that introduced them, and their emission does not move —
513 /// which is what makes this a widening rather than a break. What is fenced
514 /// is saying something the old surface had no spelling for.
515 pub fn needs_horizon_library(&self) -> bool {
516 match self {
517 Horizon::Spec(_) => true,
518 Horizon::Name(base) => match base {
519 HorizonBase::Void | HorizonBase::Ocean => false,
520 HorizonBase::Valley => true,
521 },
522 }
523 }
524}
525
526/// The resolved base of an optional stage-1 `horizon` field — `Void` when
527/// absent. The one helper every downstream consumer (placement, the ambient
528/// model, emission) goes through, so that a new base cannot be forgotten at one
529/// of them.
530pub fn horizon_base(horizon: &Option<Horizon>) -> HorizonBase {
531 horizon.as_ref().map(|h| h.base()).unwrap_or_default()
532}
533
534/// The resolved view of an optional stage-1 `horizon` field, with defaults
535/// applied for an absent one.
536pub fn resolved_horizon(horizon: &Option<Horizon>) -> ResolvedHorizon {
537 horizon.as_ref().map(|h| h.resolved()).unwrap_or_default()
538}
539
540/// The default boundary `margin` (blocks of horizontal breathing room added
541/// around the derived region). Separate function so `serde(default = …)` and the
542/// documented literal cannot drift.
543fn default_margin() -> u16 {
544 16
545}
546
547/// A playable-region boundary declaration (DSL v0.6, spec-0013). The region
548/// itself is **derived** by the compiler (union of the final placed-piece AABBs,
549/// inflated horizontally by `margin`, unbounded upward, floored at the lowest
550/// placed block − 8) — never authored — so "every anchor is inside" is structural.
551/// Enforcement is a per-second clock that returns any player outside the region to
552/// the last checkpoint (`dw:cp`) with an actionbar message and a soft sound; no
553/// damage, no items lost.
554#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
555#[serde(deny_unknown_fields)]
556pub struct Boundary {
557 /// Horizontal breathing room in blocks added around the derived region on
558 /// every side (default 16). Range-checked to `0..=64` (`DW0321`).
559 #[serde(default = "default_margin")]
560 pub margin: u16,
561 /// Actionbar message shown on return. Absent = the compiler's English default.
562 /// When set, it is inventoried under l10n key `world.boundary.message` and is
563 /// translated like every other player-facing string.
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub message: Option<String>,
566}
567
568/// A supplemental-lighting fixture the relight pass may place (DSL v0.5,
569/// spec-0010 fixture registry v1). The theme choice stays in the DSL layer; the
570/// compiler owns the placement rule and block-light emission.
571#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
572#[serde(rename_all = "kebab-case")]
573pub enum Fixture {
574 /// Floor torch (block light 14); `wall_torch` on a wall face as fallback.
575 Torch,
576 /// Ceiling-hung lantern (block light 15); floor-sitting as fallback.
577 Lantern,
578 /// Floor campfire (block light 15); never on or adjacent to a required path
579 /// cell (it is a damage source).
580 Campfire,
581 /// Embedded shroomlight (block light 15); replaces a solid wall/ceiling block.
582 Shroomlight,
583}
584
585impl Fixture {
586 /// The kebab id (`torch` / `lantern` / `campfire` / `shroomlight`).
587 pub fn token(self) -> &'static str {
588 match self {
589 Fixture::Torch => "torch",
590 Fixture::Lantern => "lantern",
591 Fixture::Campfire => "campfire",
592 Fixture::Shroomlight => "shroomlight",
593 }
594 }
595}
596
597/// A per-area supplemental-lighting declaration (DSL v0.5, spec-0010). Its
598/// presence puts the area on the relight path: the compiler guarantees every
599/// reachable walkable cell reaches `min_light` by placing `fixture`s, or fails
600/// with `DW0211` if it cannot.
601#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
602#[serde(deny_unknown_fields)]
603pub struct AreaLighting {
604 /// The fixture the relight pass places.
605 pub fixture: Fixture,
606 /// The minimum block+sky light guaranteed on reachable walkable cells
607 /// (1..=14, default 7). Range-checked (`DW0196`).
608 #[serde(default = "default_min_light")]
609 pub min_light: u8,
610}
611
612/// Default `min_light` for an [`AreaLighting`] declaration (spec-0010).
613fn default_min_light() -> u8 {
614 7
615}
616
617/// A per-area **darkness mitigation** declaration (DSL v0.6).
618///
619/// The first-class answer to "this area is meant to be dark, and the players are
620/// equipped for it". Declaring it is what makes the compiler *emit* the mitigation
621/// (a clocked `effect give … night_vision` scoped to the area's placed bounds) and
622/// what satisfies the `DW0210` darkness gate — one declaration, one mechanism, no
623/// gap between the check and the feature.
624///
625/// It replaces the pre-0.6 heuristic that read a class kit item's display *name*
626/// for `night vision`: that accepted a renamed water bottle, so the gate passed
627/// while nothing in the world granted night vision (owner, island QA).
628#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
629#[serde(rename_all = "kebab-case")]
630pub enum AreaMitigation {
631 /// Every player inside the area's placed bounds is kept under
632 /// `minecraft:night_vision` by a compiler-emitted 1 s clock.
633 NightVision,
634}
635
636/// One area of the world, bound to a single prefab or a jigsaw prefab pool.
637///
638/// An area binds **exactly one of** `prefab` (single piece) or `prefab_pool`
639/// (+ `pieces`, jigsaw multi-piece assembly, ADR-0004). The exclusivity and
640/// pool-existence rules are enforced by validation (`DW0160` / `DW0161`); the
641/// full jigsaw layout semantics are spec-0002's.
642#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
643#[serde(deny_unknown_fields)]
644pub struct Area {
645 /// Unique area id.
646 pub id: AreaId,
647 /// Player-facing area name.
648 pub name: String,
649 /// The single prefab bound to this area (mutually exclusive with
650 /// `prefab_pool`).
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub prefab: Option<PrefabId>,
653 /// The jigsaw prefab pool bound to this area (mutually exclusive with
654 /// `prefab`); requires `pieces`.
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub prefab_pool: Option<PoolId>,
657 /// Jigsaw piece-count bounds (only with `prefab_pool`).
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub pieces: Option<Pieces>,
660 /// Optional supplemental-lighting declaration (DSL v0.5, spec-0010). When
661 /// present, the compiler's relight pass guarantees `min_light` on every
662 /// reachable walkable cell of this area by placing the declared fixture, or
663 /// fails with `DW0211`. Absent = no relight (the area is judged as-assembled,
664 /// with `DW0210` if a reachable walkable cell is dark and unmitigated).
665 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub lighting: Option<AreaLighting>,
667 /// Optional darkness-mitigation declaration (DSL v0.6). `night-vision` makes
668 /// the compiler emit a clocked `effect give` over this area's placed bounds and
669 /// is the (only) declaration that satisfies `DW0210` without `lighting`.
670 /// Independent of `lighting`: an area may declare both (fixtures *and* the
671 /// effect), either, or neither.
672 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub mitigation: Option<AreaMitigation>,
674}
675
676/// Inclusive piece-count bounds for a jigsaw `prefab_pool` area.
677#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
678#[serde(deny_unknown_fields)]
679pub struct Pieces {
680 /// Minimum number of pieces to assemble.
681 pub min: u32,
682 /// Maximum number of pieces to assemble.
683 pub max: u32,
684}
685
686// ---------------------------------------------------------------------------
687// Body traversal — the declaration every body that moves carries (DSL v0.11,
688// spec-0034)
689// ---------------------------------------------------------------------------
690
691/// How a body gets around.
692///
693/// The compiler DERIVES this from the entity id for every body (spiders climb,
694/// ghasts fly, `#minecraft:aquatic` swims, and everything else — including every
695/// id the table has never heard of — is [`Locomotion::Ground`], the checked
696/// class). [`BodyTraversal`] is the author's side of the same vocabulary: one
697/// enum, so a declaration and a derivation can never mean different things.
698///
699/// The vocabulary lives in this crate rather than in the compiler because it is
700/// now DSL surface; the compiler re-exports it and owns the derivation table
701/// (`compiler::traversal`).
702#[derive(
703 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
704)]
705#[serde(rename_all = "kebab-case")]
706pub enum Locomotion {
707 /// Walks, steps and jumps — the default and the CHECKED class.
708 Ground,
709 /// Climbs sheer vertical surfaces (vanilla's `Spider` class).
710 Climber,
711 /// Leaves the ground under its own power.
712 Flier,
713 /// A member of vanilla's `#minecraft:aquatic` tag. A ledger classification
714 /// that exempts nothing, which is why it may not be **declared**
715 /// (`DW0455`) — see [`BodyTraversal`].
716 Aquatic,
717}
718
719impl Locomotion {
720 /// The stable kebab token this class is written and reported under.
721 pub fn token(self) -> &'static str {
722 match self {
723 Locomotion::Ground => "ground",
724 Locomotion::Climber => "climber",
725 Locomotion::Flier => "flier",
726 Locomotion::Aquatic => "aquatic",
727 }
728 }
729
730 /// Every class, in ledger order — so a report can never silently drop a row
731 /// when a class is added.
732 pub const ALL: [Locomotion; 4] = [
733 Locomotion::Ground,
734 Locomotion::Climber,
735 Locomotion::Flier,
736 Locomotion::Aquatic,
737 ];
738}
739
740/// What a body can do when it moves, **declared by the author** (DSL v0.11,
741/// spec-0034).
742///
743/// Carried by every object class in the DSL that has a body and a position and
744/// is walked by a compiler-emitted route — the stage-2 [`Npc`] and the stage-5
745/// [`Actor`]. It is deliberately one shared type on both rather than a field
746/// per consumer: traversal is a property of a body that moves, not of the verb
747/// that first needed it (CLAUDE.md), and a second bespoke field would be the
748/// defect rather than the fix.
749///
750/// **A declaration is a claim the build holds you to, never an opt-out.** The
751/// compiler compares the verdicts this body earns under the declared class
752/// against the ones it earns under its species' derived class; a declaration
753/// that changes no verdict is inert and is `DW0454`. So declaring `climber` on
754/// a sheep is only accepted where that sheep's route really does go over a
755/// barrier line — the exception is authored and proven, instead of happening by
756/// accident and merely rendering.
757///
758/// **What is deliberately NOT here: `opens_gates`.** Passing a closed fence gate
759/// is a right-click, a scripted walk is a compiler-emitted `tp` polyline whose
760/// puppet performs no interaction at all, and no runtime verb changes a fence
761/// gate's block state. Declaring it would not make it true, so the error tier
762/// (`DW0452`) has no authorable exemption and a declaration can never reach it.
763#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
764#[serde(deny_unknown_fields)]
765pub struct BodyTraversal {
766 /// How this body gets around, overriding what its entity id implies.
767 pub locomotion: Locomotion,
768}
769
770/// One object class that has a **body the compiler stages**: a declared
771/// position, a declared species, and a compiler-emitted route.
772///
773/// A sum type rather than a flattened tuple, so **adding a body class is a
774/// compile error at every consumer** until each one says what it does with it.
775/// The alternative — each rule walking the classes it happens to remember — is
776/// the defect class CLAUDE.md names: a hand-rolled walk that
777/// enumerated three of five effect roots.
778///
779/// **Keyed to the object, not to the verb that first needed it.** This type was
780/// introduced for [`BodyTraversal`] and read, for a while, as "one consumer of
781/// `BodyTraversal`" — which is why its only enumeration
782/// ([`body_traversal_sites`]) was filtered to bodies declaring a traversal, and
783/// why the second property a body carries, its [`NpcSkin`], had no enumeration
784/// at all: the bake walked the stage-2 npc list by hand, and an actor's skin was
785/// emitted into the summon, never baked into the pack, and never refused.
786/// A body's properties belong to the body. [`body_sites`] is the unfiltered
787/// walk; a per-property enumeration is a *filter* over it, never a second walk.
788///
789/// Deliberately NOT a member: [`WaveMob`]. A wave mob has a body and a position,
790/// but it is driven by **native vanilla AI**, never by a compiler-emitted route,
791/// so the compiler makes no claim about the moves it makes and a locomotion
792/// declaration on it could change no verdict. It declares no `skin` either — the
793/// exclusion holds for both properties, and the schema says so
794/// (`crates/dsl/tests/body_skin_sites.rs`). It becomes a member the day the lane
795/// proof reasons about how its bodies move — and it joins here, through this
796/// same type, rather than through a field of its own.
797#[derive(Clone, Copy, Debug)]
798pub enum BodyRef<'a> {
799 /// A stage-2 NPC, walked by `move-npc`.
800 Npc(&'a Npc),
801 /// A stage-5 scripted actor, walked by `move-actor`.
802 Actor(&'a Actor),
803}
804
805impl<'a> BodyRef<'a> {
806 /// The declaring stage's wire name (`npcs` / `quests`) — also the stage
807 /// whose `dsl_version` fences this body's declaration.
808 pub fn stage(self) -> &'static str {
809 match self {
810 BodyRef::Npc(_) => "npcs",
811 BodyRef::Actor(_) => "quests",
812 }
813 }
814
815 /// The body's declared id.
816 pub fn id(self) -> &'a str {
817 match self {
818 BodyRef::Npc(n) => n.id.as_str(),
819 BodyRef::Actor(a) => a.id.as_str(),
820 }
821 }
822
823 /// The entity id written on the body. **Not necessarily the body that
824 /// ships**: a `skin` re-dresses it as a `minecraft:mannequin` — see
825 /// [`Self::worn_entity`].
826 pub fn declared_entity(self) -> &'a str {
827 match self {
828 BodyRef::Npc(n) => n.base_entity.as_str(),
829 BodyRef::Actor(a) => a.entity.as_str(),
830 }
831 }
832
833 /// **The entity id the body ships as**: `minecraft:mannequin` when it
834 /// declares a `skin`, else the declared entity. The one authority for that
835 /// rule — the compiler's geometric proofs (`nav::npc_body_entity`,
836 /// `nav::actor_body_entity`) and the equipment fit rule (`DW0898`) all read
837 /// it.
838 pub fn worn_entity(self) -> &'a str {
839 match self.skin() {
840 Some(_) => "minecraft:mannequin",
841 None => self.declared_entity(),
842 }
843 }
844
845 /// **The mark this body is placed on** — the anchor and offset the engine
846 /// summons it at, for every class alike (spec-0066).
847 ///
848 /// A body's placement is a property of the body, not of the stage list that
849 /// happens to declare it: a mark is a cell, and a cell holds one body. The
850 /// rule that reads this ([`crate::compiler`]'s `DW0896`, via
851 /// [`body_sites`]) therefore quantifies over npcs and actors in one pass
852 /// rather than over `actors[]`, which is where the seven-men-one-anchor
853 /// muster came from.
854 pub fn mark(self) -> Mark {
855 let (anchor, offset) = match self {
856 BodyRef::Npc(n) => (&n.anchor, n.offset),
857 BodyRef::Actor(a) => (&a.anchor, a.offset),
858 };
859 Mark {
860 anchor: anchor.clone(),
861 offset,
862 }
863 }
864
865 /// The area whose anchor table resolves [`Self::mark`]'s anchor first, when this
866 /// class declares one.
867 ///
868 /// A stage-2 npc names its area and is resolved inside it; a stage-5 actor
869 /// names none and is resolved across every placed piece, exactly as an
870 /// `open-gate` / `move-actor` destination is. Stated here so the resolution
871 /// rule is one rule over both classes and not a per-call-site habit.
872 pub fn area(self) -> Option<&'a AreaId> {
873 match self {
874 BodyRef::Npc(n) => Some(&n.area),
875 BodyRef::Actor(_) => None,
876 }
877 }
878
879 /// Whether this body stands on its mark from **world init**, with no effect
880 /// having to fire.
881 ///
882 /// A stage-2 npc does unless it is `deferred`; a stage-5 actor never does —
883 /// a puppet exists only from the `spawn-actor` that summons it, which is why
884 /// an actor no `spawn-actor` names never exists at all.
885 pub fn at_world_init(self) -> bool {
886 match self {
887 BodyRef::Npc(n) => !n.deferred,
888 BodyRef::Actor(_) => false,
889 }
890 }
891
892 /// Whether a **player** can end this body's life.
893 ///
894 /// An npc body is emitted `Invulnerable:1b` unconditionally, so nothing a
895 /// player does removes it; an actor's puppet is `Invulnerable` unless it
896 /// declares [`Actor::vulnerable`]. A body a player can kill is one whose
897 /// lifetime the compiler cannot bound, which is the whole of what this
898 /// answers.
899 pub fn killable_by_players(self) -> bool {
900 match self {
901 BodyRef::Npc(_) => false,
902 BodyRef::Actor(a) => a.vulnerable,
903 }
904 }
905
906 /// This body's traversal declaration, if it carries one.
907 pub fn traversal(self) -> Option<&'a BodyTraversal> {
908 match self {
909 BodyRef::Npc(n) => n.traversal.as_ref(),
910 BodyRef::Actor(a) => a.traversal.as_ref(),
911 }
912 }
913
914 /// This body's skin declaration, if it carries one.
915 ///
916 /// A skinned body of **either** class ships as a `minecraft:mannequin`
917 /// whose `profile.texture` resolves to `delvewright:npc/<campaign_id>/<texture_id>`,
918 /// so either one owes the same `skins/<texture_id>.png` under the same refusal
919 /// (`DW0309`). Answering it here is what stops the bake from being a
920 /// property of one class.
921 pub fn skin(self) -> Option<&'a NpcSkin> {
922 match self {
923 BodyRef::Npc(n) => n.skin.as_ref(),
924 BodyRef::Actor(a) => a.skin.as_ref(),
925 }
926 }
927
928 /// This class's name in the JSON Schema export (`delvec schema --stage all`).
929 ///
930 /// The join between the closed Rust set and the schema, which is the only
931 /// authority on *which object classes declare what*. `body_skin_sites.rs`
932 /// compares the two.
933 pub fn class(self) -> &'static str {
934 match self {
935 BodyRef::Npc(_) => "Npc",
936 BodyRef::Actor(_) => "Actor",
937 }
938 }
939
940 /// Every body class, by schema name. The closed set, stated once.
941 pub const ALL_CLASSES: [&'static str; 2] = ["Npc", "Actor"];
942}
943
944/// A staged body declaration, with the JSON pointer at the declaration itself.
945///
946/// The pointer is at the OBJECT (`/content/npcs/3`), not at any one of its
947/// fields: a per-property site appends its own field name. A pointer built per
948/// property is how two walks of one population start disagreeing about where a
949/// thing was declared.
950#[derive(Clone, Debug)]
951pub struct BodySite<'a> {
952 /// Which object class declared it, and the object itself.
953 pub body: BodyRef<'a>,
954 /// JSON pointer at the declaration, for a diagnostic path.
955 pub path: String,
956}
957
958/// **Every** body the campaign declares, in stage order: stage-2 npcs in
959/// declaration order, then stage-5 actors in declaration order.
960///
961/// The one walk of the campaign's bodies. A rule about a property a body carries
962/// is a *filter* over this ([`body_traversal_sites`], [`body_skin_sites`]) — never
963/// a second traversal, and never a hand-written loop over one stage's list,
964/// which is exactly how an actor's skin came to be emitted but never baked.
965pub fn body_sites(c: &crate::envelope::Campaign) -> Vec<BodySite<'_>> {
966 let mut out: Vec<BodySite<'_>> = Vec::new();
967 for (i, n) in c.npcs.content.npcs.iter().enumerate() {
968 out.push(BodySite {
969 body: BodyRef::Npc(n),
970 path: format!("/content/npcs/{i}"),
971 });
972 }
973 for (i, a) in c.quests.content.actors.iter().enumerate() {
974 out.push(BodySite {
975 body: BodyRef::Actor(a),
976 path: format!("/content/actors/{i}"),
977 });
978 }
979 out
980}
981
982/// A body that carries a [`BodyTraversal`] declaration, with the JSON pointer at
983/// it.
984#[derive(Clone, Debug)]
985pub struct BodyTraversalSite<'a> {
986 /// Which object class declared it, and the object itself.
987 pub body: BodyRef<'a>,
988 /// JSON pointer at the `traversal` field, for a diagnostic path.
989 pub path: String,
990 /// The declaration.
991 pub traversal: &'a BodyTraversal,
992}
993
994/// Every body in the campaign that DECLARES a traversal, in stage order.
995///
996/// The one enumeration of the declaration's consumers, shared by the DSL's value
997/// check (`DW0455`) and by the compiler's proof (`DW0454`), so "which object
998/// classes carry this" is answered in exactly one place.
999pub fn body_traversal_sites(c: &crate::envelope::Campaign) -> Vec<BodyTraversalSite<'_>> {
1000 body_sites(c)
1001 .into_iter()
1002 .filter_map(|s| {
1003 s.body.traversal().map(|t| BodyTraversalSite {
1004 body: s.body,
1005 path: format!("{}/traversal", s.path),
1006 traversal: t,
1007 })
1008 })
1009 .collect()
1010}
1011
1012/// A body that carries an [`NpcSkin`] declaration, with the JSON pointer at it.
1013#[derive(Clone, Debug)]
1014pub struct BodySkinSite<'a> {
1015 /// Which object class declared it, and the object itself.
1016 pub body: BodyRef<'a>,
1017 /// JSON pointer at the `skin` field, for a diagnostic path.
1018 pub path: String,
1019 /// The declaration.
1020 pub skin: &'a NpcSkin,
1021}
1022
1023/// Every body in the campaign that DECLARES a skin, in stage order.
1024///
1025/// The one enumeration of what the resource-pack bake must serve and what
1026/// `DW0309` must refuse, for every class alike — a skinned npc and a skinned
1027/// actor are the same fact about two bodies.
1028///
1029/// **Declarations, not textures.** Two bodies may name one `texture_id` (an npc
1030/// and the puppet that plays it), and the caller decides what that means; the
1031/// bake reads each file once.
1032pub fn body_skin_sites(c: &crate::envelope::Campaign) -> Vec<BodySkinSite<'_>> {
1033 body_sites(c)
1034 .into_iter()
1035 .filter_map(|s| {
1036 s.body.skin().map(|k| BodySkinSite {
1037 body: s.body,
1038 path: format!("{}/skin", s.path),
1039 skin: k,
1040 })
1041 })
1042 .collect()
1043}
1044
1045/// The **mutable mirror** of [`body_skin_sites`]: every skin declaration in the
1046/// campaign, in the identical order, exposed mutably so one pass can rewrite what
1047/// every emitter will read ([`crate::l10n::namespace_skin_textures`]).
1048///
1049/// It carries no [`BodyRef`] and no pointer, because a rewrite needs neither and a
1050/// borrow of the whole body would forbid the field it is there to change. What it
1051/// does owe is the **same population**: a body class that declares a skin and is
1052/// missing here would keep an un-namespaced texture and collide with every other
1053/// delve, silently. `body_skin_sites_mut_is_the_same_walk`
1054/// (`crates/dsl/tests/body_skin_sites.rs`) pins that over a campaign carrying one
1055/// body of every class in [`BodyRef::ALL_CLASSES`] — the closed set the schema
1056/// export is compared against in the same file, so a new body class turns that
1057/// coverage red and both walks are visited together.
1058pub fn body_skins_mut(c: &mut crate::envelope::Campaign) -> Vec<&mut NpcSkin> {
1059 let mut out: Vec<&mut NpcSkin> = Vec::new();
1060 out.extend(
1061 c.npcs
1062 .content
1063 .npcs
1064 .iter_mut()
1065 .filter_map(|n| n.skin.as_mut()),
1066 );
1067 out.extend(
1068 c.quests
1069 .content
1070 .actors
1071 .iter_mut()
1072 .filter_map(|a| a.skin.as_mut()),
1073 );
1074 out
1075}
1076
1077// ---------------------------------------------------------------------------
1078// Stage 2 — npcs
1079// ---------------------------------------------------------------------------
1080
1081/// Stage 2 payload: the campaign's NPCs (casting sheets).
1082#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1083#[serde(deny_unknown_fields)]
1084pub struct NpcsContent {
1085 /// All NPCs in the campaign.
1086 pub npcs: Vec<Npc>,
1087}
1088
1089/// A stationary NPC bound to an area anchor (a casting sheet, spec-0001 v0.2).
1090///
1091/// Stage 2 carries **no dialogue** — the structured [`Persona`] is the character
1092/// contract the stage-6 `dialogue` tree must honor.
1093#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1094#[serde(deny_unknown_fields)]
1095pub struct Npc {
1096 /// Unique NPC id.
1097 pub id: NpcId,
1098 /// Player-facing name.
1099 pub name: String,
1100 /// NPC role.
1101 pub role: Role,
1102 /// The area this NPC stands in (stage-1 ref).
1103 pub area: AreaId,
1104 /// The prefab anchor this NPC stands on.
1105 pub anchor: AnchorId,
1106 /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
1107 /// `[0, 0, 0]`): the NPC stands at the [`Mark`] the two fields spell.
1108 #[serde(default, skip_serializing_if = "is_zero3")]
1109 pub offset: [i32; 3],
1110 /// The vanilla entity to re-dress, e.g. `minecraft:villager`.
1111 pub base_entity: String,
1112 /// The structured persona (character contract for stage 6).
1113 pub persona: Persona,
1114 /// Optional player-model skin (DSL v0.4, spec-0008 §6 / spec-0009). When set,
1115 /// the compiler emits a `minecraft:mannequin` body carrying this skin profile
1116 /// instead of re-dressing `base_entity`; the interaction hitbox is unchanged.
1117 /// Non-skinned NPCs are byte-identical to v0.3.
1118 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub skin: Option<NpcSkin>,
1120 /// Deferred entrance (DSL v0.6): when `true` the NPC is **not** summoned at
1121 /// world init — its body and interaction hitbox only appear when a
1122 /// [`Verb::SpawnNpc`] fires, at this same `anchor`. The dual of
1123 /// `despawn-npc`: a character with a scripted entrance must not stand at its
1124 /// mark as a statue from minute one. A deferred NPC that no `spawn-npc` ever
1125 /// spawns is unreachable content (`DW0197`). Default `false` = summoned at
1126 /// init, byte-identical to pre-0.6.
1127 #[serde(default, skip_serializing_if = "is_false")]
1128 pub deferred: bool,
1129 /// What this body can do when it moves (DSL v0.11, spec-0034). Absent = the
1130 /// class the compiler derives from `base_entity` (or from `minecraft:mannequin`
1131 /// when `skin` is set — the body that actually ships). See [`BodyTraversal`]:
1132 /// the declaration must change a verdict or it is `DW0454`, and it can never
1133 /// reach the error tier.
1134 #[serde(default, skip_serializing_if = "Option::is_none")]
1135 pub traversal: Option<BodyTraversal>,
1136}
1137
1138/// A mannequin NPC's player-model skin (DSL v0.4). The skin PNG is sourced from
1139/// the campaign dir's `skins/<texture_id>.png` and ships in the per-delve resource
1140/// pack at `assets/delvewright/textures/npc/<campaign_id>/<texture_id>.png`, which
1141/// is what the mannequin's `profile.texture` resolves to. The delve's own
1142/// directory is stamped on at emission ([`crate::l10n::namespace_skin_textures`])
1143/// — a client merges every applied pack's textures into ONE space, so two delves
1144/// that both cast a `keeper` would otherwise wear each other's faces. Nothing a
1145/// creator writes or names on disk carries it.
1146#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1147#[serde(deny_unknown_fields)]
1148pub struct NpcSkin {
1149 /// Skin id: the PNG basename under `skins/`, and the last segment of the
1150 /// resource-pack texture path (a bare kebab token; validated by `DW0190`).
1151 pub texture_id: String,
1152 /// Player model. **Required** (spec-0009): an omitted model renders slim, so
1153 /// a wide skin on a slim model is distorted — the compiler always emits it.
1154 pub model: SkinModel,
1155}
1156
1157/// Player-model shape for a mannequin skin (`wide` = classic/Steve, `slim` =
1158/// Alex). Emitted verbatim into the mannequin `profile.model`.
1159#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1160#[serde(rename_all = "kebab-case")]
1161pub enum SkinModel {
1162 /// Classic 4-pixel arms (Steve).
1163 Wide,
1164 /// Slim 3-pixel arms (Alex).
1165 Slim,
1166}
1167
1168impl SkinModel {
1169 /// The vanilla `profile.model` token.
1170 pub fn token(self) -> &'static str {
1171 match self {
1172 SkinModel::Wide => "wide",
1173 SkinModel::Slim => "slim",
1174 }
1175 }
1176}
1177
1178/// A structured casting sheet. Structure lives in the
1179/// keys; every value is free text. `archetype`, `speech_style` and `motivation`
1180/// are required; the rest are optional.
1181#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1182#[serde(deny_unknown_fields)]
1183pub struct Persona {
1184 /// One-line character archetype (required).
1185 pub archetype: String,
1186 /// How the NPC speaks — register, tics, formality (required).
1187 pub speech_style: String,
1188 /// Emotional bearing toward the player (optional).
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1190 pub demeanor: Option<String>,
1191 /// What the NPC wants (required).
1192 pub motivation: String,
1193 /// Something the NPC hides (optional).
1194 #[serde(default, skip_serializing_if = "Option::is_none")]
1195 pub secret: Option<String>,
1196 /// Backstory colour (optional).
1197 #[serde(default, skip_serializing_if = "Option::is_none")]
1198 pub backstory: Option<String>,
1199 /// Attitudes toward other same-stage NPCs (optional; refs validated).
1200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1201 pub relationships: Vec<Relationship>,
1202}
1203
1204/// One persona relationship: an attitude toward another same-stage NPC.
1205#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1206#[serde(deny_unknown_fields)]
1207pub struct Relationship {
1208 /// The other NPC (stage-2 ref, validated within stage 2).
1209 pub npc: NpcId,
1210 /// Free-text attitude toward that NPC.
1211 pub attitude: String,
1212}
1213
1214/// What a speaking part does. A schema enum offers what the engine accepts,
1215/// so there are two of them: how hard a fight is billed is [`EncounterTier`] on
1216/// the body that fights (a `waves[]` entry or a stage-5 actor), not a role here.
1217#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1218#[serde(rename_all = "kebab-case")]
1219pub enum Role {
1220 /// Gives and advances quests.
1221 QuestGiver,
1222 /// Flavor only.
1223 Flavor,
1224}
1225
1226// ---------------------------------------------------------------------------
1227// Stage 6 — dialogue
1228// ---------------------------------------------------------------------------
1229
1230/// Stage 6 payload: one dialogue tree per stage-2 NPC (spec-0001 v0.2).
1231#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1232#[serde(deny_unknown_fields)]
1233pub struct DialogueContent {
1234 /// One tree per NPC (1:1 with stage 2, both directions).
1235 pub dialogues: Vec<NpcDialogue>,
1236}
1237
1238impl DialogueContent {
1239 /// The dialogue tree for an NPC id, if present.
1240 pub fn tree_for(&self, npc: &str) -> Option<&NpcDialogue> {
1241 self.dialogues.iter().find(|t| t.npc.as_str() == npc)
1242 }
1243}
1244
1245/// One NPC's dialogue tree: a root node plus a set of nodes.
1246#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1247#[serde(deny_unknown_fields)]
1248pub struct NpcDialogue {
1249 /// The NPC this tree belongs to (stage-2 ref).
1250 pub npc: NpcId,
1251 /// The entry node id; every node must be reachable from it.
1252 pub root: DialogueId,
1253 /// The dialogue nodes.
1254 pub nodes: Vec<DialogueNode>,
1255}
1256
1257impl NpcDialogue {
1258 /// The ids of the nodes reachable from `roots` by following option `next`
1259 /// edges, ignoring every option gate.
1260 ///
1261 /// **The one authority for "what can this tree show, entered here".** A
1262 /// dialogue tree has more than one entry point — the stage-6 `root`, and
1263 /// every node a quest's `cast` ledger names as a scene — so "reachable" is
1264 /// always relative to a root SET, and each consumer supplies the set its own
1265 /// question is about. `DW0120`/`DW0123` ask about every entry point at once;
1266 /// the cast ledger's `DW0858` asks about the scenes live during one
1267 /// objective. Gates are ignored on purpose: an option's flag gate is
1268 /// `DW0191`'s question, not this one's.
1269 ///
1270 /// Root ids that name no node of this tree contribute nothing (a dangling
1271 /// scene root is `DW0464`, and a dangling stage-6 `root` is `DW0121`).
1272 pub fn reachable_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1273 let by_id: BTreeMap<&'a str, &'a DialogueNode> =
1274 self.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
1275 let mut seen: BTreeSet<&'a str> = BTreeSet::new();
1276 let mut stack: Vec<&'a str> = roots
1277 .iter()
1278 .filter_map(|r| by_id.get_key_value(*r).map(|(k, _)| *k))
1279 .collect();
1280 while let Some(cur) = stack.pop() {
1281 if !seen.insert(cur) {
1282 continue;
1283 }
1284 let Some(node) = by_id.get(cur) else { continue };
1285 for opt in &node.options {
1286 if let Some(next) = &opt.next
1287 && let Some((k, _)) = by_id.get_key_value(next.as_str())
1288 {
1289 stack.push(k);
1290 }
1291 }
1292 }
1293 seen
1294 }
1295
1296 /// The objective ids some option reachable from `roots` completes — what a
1297 /// player entering this tree at those scenes can actually finish.
1298 pub fn completes_from<'a>(&'a self, roots: &[&str]) -> BTreeSet<&'a str> {
1299 let seen = self.reachable_from(roots);
1300 let mut out: BTreeSet<&'a str> = BTreeSet::new();
1301 for node in &self.nodes {
1302 if !seen.contains(node.id.as_str()) {
1303 continue;
1304 }
1305 for opt in &node.options {
1306 for eff in &opt.effects {
1307 if let DialogueEffect::CompleteObjective { objective } = eff {
1308 out.insert(objective.as_str());
1309 }
1310 }
1311 }
1312 }
1313 out
1314 }
1315}
1316
1317/// One dialogue node: text plus branching options.
1318#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1319#[serde(deny_unknown_fields)]
1320pub struct DialogueNode {
1321 /// Node id (unique within this NPC's dialogue).
1322 pub id: DialogueId,
1323 /// The line the NPC speaks.
1324 pub text: String,
1325 /// Branching options; empty closes the dialog.
1326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1327 pub options: Vec<DialogueOption>,
1328}
1329
1330/// One selectable dialogue option.
1331#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1332#[serde(deny_unknown_fields)]
1333pub struct DialogueOption {
1334 /// Button label.
1335 pub label: String,
1336 /// The full line this button is the caption of (DSL v0.8). Vanilla's dialog button codec
1337 /// (`CommonButtonData`) carries an optional `tooltip` component beside
1338 /// `label`, and the client hangs it on the button as a real hover tooltip —
1339 /// so a caption on the button and the sentence the character actually says
1340 /// can both exist. **Not** subject to `DW0331`: a tooltip is not drawn on the
1341 /// 150 px button, it is wrapped at 170 px into its own hover box, so it never
1342 /// scrolls. Player-visible, so it translates like the label
1343 /// (`dlg.<npc>.<node>.opt.<i>.tooltip`).
1344 #[serde(default, skip_serializing_if = "Option::is_none")]
1345 pub tooltip: Option<String>,
1346 /// Next node; omitted closes the dialog.
1347 #[serde(default, skip_serializing_if = "Option::is_none")]
1348 pub next: Option<DialogueId>,
1349 /// Flags that must be set for this option to be shown (DSL v0.4). Mirrors an
1350 /// objective's `requires_flags`: an ungated option (empty) is unchanged; a
1351 /// gated option is hidden until every referenced flag has been set by a
1352 /// `set-flag` effect (quest or dialogue). Validation guarantees a flag-gated
1353 /// option cannot make a critical-path node unreachable (`DW0191`).
1354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1355 pub requires_flags: Vec<FlagId>,
1356 /// Negative flag gate (DSL v0.6): the option is
1357 /// **hidden** (and its `/trigger` handler inert) while ANY listed flag is set
1358 /// for the player — the dual of `requires_flags`. A `forbids_flags`-gated
1359 /// option counts as *gated* for the `DW0191` deadlock guard: it can be
1360 /// suppressed at any point, so it cannot be the only completing path.
1361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1362 pub forbids_flags: Vec<FlagId>,
1363 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
1364 /// hold for this gate to be open. The third field of the one gate, carried by
1365 /// every gate consumer — never by the verb that first wanted it. Default
1366 /// empty, so a pre-0.10 campaign is byte-identical.
1367 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1368 pub requires_state: Vec<StateCompare>,
1369 /// Effects fired when this option is chosen.
1370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1371 pub effects: Vec<DialogueEffect>,
1372 /// What choosing this option does to the story (spec-0025). Required for a
1373 /// **story-weight** beat
1374 /// — an option carrying a `set-flag` effect, which is how a player's choice
1375 /// forks the world (`DW0481`). An option that only walks the tree or
1376 /// completes an objective needs none: the objective already declares one.
1377 #[serde(default, skip_serializing_if = "Option::is_none")]
1378 pub happening: Option<Happening>,
1379}
1380
1381/// Effect fired by a dialogue option. `complete-objective` (v0.2) and, from DSL
1382/// v0.4, `set-flag` (mirrors the quest effect — sets a campaign flag from a
1383/// dialogue choice, enabling flag-gated options/objectives/triggers).
1384#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1385#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
1386pub enum DialogueEffect {
1387 /// Marks a stage-5 objective complete.
1388 CompleteObjective {
1389 /// The objective to complete (resolved at the stage-5 boundary).
1390 objective: ObjectiveId,
1391 },
1392 /// Sets a campaign flag (DSL v0.4), mirroring [`Verb::SetFlag`].
1393 SetFlag {
1394 /// The flag to set.
1395 flag: FlagId,
1396 },
1397 /// Cuts the world time (DSL v0.5), mirroring [`Verb::SetTime`].
1398 SetTime {
1399 /// The time state to cut to.
1400 time: WorldTime,
1401 },
1402 /// Cuts the weather (DSL v0.5), mirroring [`Verb::SetWeather`].
1403 SetWeather {
1404 /// The weather state to cut to.
1405 weather: WorldWeather,
1406 },
1407 /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012), mirroring
1408 /// [`Verb::SetCheckpoint`] — usable from a dialogue outcome.
1409 SetCheckpoint {
1410 /// The prefab checkpoint anchor the party respawns at.
1411 anchor: AnchorId,
1412 /// Per-player effects re-run on respawn while this checkpoint is active
1413 /// (scene reset). Empty = no hook.
1414 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1415 on_respawn: Vec<QuestEffect>,
1416 },
1417 /// Summons a `deferred` stage-2 NPC (DSL v0.6), mirroring
1418 /// [`Verb::SpawnNpc`] — a character who walks in mid-conversation.
1419 SpawnNpc {
1420 /// The NPC (stage-2 ref) to summon.
1421 npc: NpcId,
1422 },
1423}
1424
1425impl DialogueEffect {
1426 /// The v0.5 effect name if this dialogue effect is one introduced in DSL v0.5
1427 /// (`set-time`/`set-weather`, spec-0010).
1428 pub fn v05_effect(&self) -> Option<&'static str> {
1429 match self {
1430 DialogueEffect::SetTime { .. } => Some("set-time"),
1431 DialogueEffect::SetWeather { .. } => Some("set-weather"),
1432 _ => None,
1433 }
1434 }
1435
1436 /// The v0.6 effect name if this dialogue effect is one introduced in DSL v0.6
1437 /// (`set-checkpoint`, spec-0012; `spawn-npc`).
1438 pub fn v06_effect(&self) -> Option<&'static str> {
1439 match self {
1440 DialogueEffect::SetCheckpoint { .. } => Some("set-checkpoint"),
1441 DialogueEffect::SpawnNpc { .. } => Some("spawn-npc"),
1442 _ => None,
1443 }
1444 }
1445
1446 /// The NPC id if this is a v0.6 `spawn-npc` dialogue effect.
1447 pub fn spawn_npc(&self) -> Option<&NpcId> {
1448 match self {
1449 DialogueEffect::SpawnNpc { npc } => Some(npc),
1450 _ => None,
1451 }
1452 }
1453
1454 /// **The body this dialogue effect puts into the world**, by id — the
1455 /// dialogue half of [`QuestEffect::body_entry`].
1456 ///
1457 /// A body can enter the world from a conversation as well as from a quest
1458 /// bundle, and a rule about what is standing where has to enumerate **every**
1459 /// entry point or it is a gate with a door beside it. This enum carries no
1460 /// exit at all: nothing a dialogue option does removes a body.
1461 pub fn body_entry(&self) -> Option<&str> {
1462 match self {
1463 DialogueEffect::SpawnNpc { npc } => Some(npc.as_str()),
1464 _ => None,
1465 }
1466 }
1467
1468 /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` dialogue effect.
1469 pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
1470 match self {
1471 DialogueEffect::SetCheckpoint { anchor, on_respawn } => {
1472 Some((anchor, on_respawn.as_slice()))
1473 }
1474 _ => None,
1475 }
1476 }
1477
1478 /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect —
1479 /// **effect root 5** ([`crate::effects`]). Named separately from
1480 /// [`Self::set_checkpoint`] so the root walk and its mutable mirror name the
1481 /// same accessor modulo mutability, which is what lets one macro body generate
1482 /// both.
1483 pub fn set_checkpoint_on_respawn(&self) -> Option<&[QuestEffect]> {
1484 match self {
1485 DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_slice()),
1486 _ => None,
1487 }
1488 }
1489
1490 /// The `on_respawn` bundle of a v0.6 `set-checkpoint` dialogue effect, exposed
1491 /// mutably so the localization pass can rewrite the player-visible strings
1492 /// nested inside it. Lockstep sibling of [`Self::set_checkpoint`] — the bundle
1493 /// is a plain `Vec<QuestEffect>` that emission really lowers, so every scan
1494 /// that reaches it read-only needs a way to reach it writable too.
1495 pub fn set_checkpoint_on_respawn_mut(&mut self) -> Option<&mut [QuestEffect]> {
1496 match self {
1497 DialogueEffect::SetCheckpoint { on_respawn, .. } => Some(on_respawn.as_mut_slice()),
1498 _ => None,
1499 }
1500 }
1501}
1502
1503impl DialogueEffect {
1504 /// The `set-flag` flag id if this is a v0.4 `set-flag` dialogue effect.
1505 pub fn set_flag(&self) -> Option<&FlagId> {
1506 match self {
1507 DialogueEffect::SetFlag { flag } => Some(flag),
1508 _ => None,
1509 }
1510 }
1511
1512 /// The v0.4 effect name if this dialogue effect is one introduced in DSL v0.4
1513 /// (`set-flag`).
1514 pub fn v04_effect(&self) -> Option<&'static str> {
1515 match self {
1516 DialogueEffect::SetFlag { .. } => Some("set-flag"),
1517 _ => None,
1518 }
1519 }
1520
1521 /// The target time if this is a v0.5 `set-time` dialogue effect.
1522 pub fn set_time(&self) -> Option<WorldTime> {
1523 match self {
1524 DialogueEffect::SetTime { time } => Some(*time),
1525 _ => None,
1526 }
1527 }
1528
1529 /// The target weather if this is a v0.5 `set-weather` dialogue effect.
1530 pub fn set_weather(&self) -> Option<WorldWeather> {
1531 match self {
1532 DialogueEffect::SetWeather { weather } => Some(*weather),
1533 _ => None,
1534 }
1535 }
1536}
1537
1538// ---------------------------------------------------------------------------
1539// Stage 3 — classes
1540// ---------------------------------------------------------------------------
1541
1542/// Stage 3 payload: 1..4 selectable classes.
1543#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1544#[serde(deny_unknown_fields)]
1545pub struct ClassesContent {
1546 /// The selectable classes.
1547 pub classes: Vec<Class>,
1548}
1549
1550/// A player class with a starting kit.
1551#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1552#[serde(deny_unknown_fields)]
1553pub struct Class {
1554 /// Unique class id.
1555 pub id: ClassId,
1556 /// Player-facing name.
1557 pub name: String,
1558 /// Selection-screen blurb.
1559 pub blurb: String,
1560 /// Granted items.
1561 pub kit: Vec<KitItem>,
1562}
1563
1564/// One item in a class kit.
1565///
1566/// Note: `lore`, `enchantments` and `attributes` are reserved for M2/M3
1567/// (spec-0001). They are intentionally *not* defined as fields in v0, so a
1568/// document using them is rejected as an unknown field (`DW0100`).
1569#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1570#[serde(deny_unknown_fields)]
1571pub struct KitItem {
1572 /// Vanilla item id, validated against the pinned 1.21.11 registry.
1573 pub item: String,
1574 /// Stack count.
1575 pub count: u32,
1576 /// Optional display name.
1577 #[serde(default, skip_serializing_if = "Option::is_none")]
1578 pub name: Option<String>,
1579 /// Who gets it (DSL v0.6, spec-0018). Absent = [`Carrier::All`]. A class kit is
1580 /// per-player gear by construction — every player who picks the class gets the
1581 /// kit — so `carrier` here marks a **party-unique** kit item: exactly one copy
1582 /// enters the party, given to the first player to pick this class.
1583 #[serde(default, skip_serializing_if = "Option::is_none")]
1584 pub carrier: Option<Carrier>,
1585 /// **The flask** (DSL v0.8, spec-0016 §1): this kit
1586 /// entry is the class's recovery item, and resting at a bonfire replenishes
1587 /// it to exactly `count`. A campaign that places a `bonfire` and declares no
1588 /// flask anywhere in its kits is `DW0476` — the estus loop is what makes
1589 /// dying an investment, so a souls campaign without one is a build error, not
1590 /// a design choice. Absent on every pre-0.8 kit → emission byte-identical.
1591 #[serde(default, skip_serializing_if = "is_false")]
1592 pub flask: bool,
1593 /// **What is in the bottle** (DSL v0.8, spec-0016 §1): the vanilla
1594 /// `minecraft:potion_contents` component of a
1595 /// potion-bearing item ([`POTION_BEARING_ITEMS`]). Without it a
1596 /// `minecraft:potion` is the *Uncraftable Potion* — a bottle that heals
1597 /// nothing — which is exactly the placeholder flask this field exists to
1598 /// abolish, so at `dsl_version` 0.8.0 a potion-bearing kit item that declares
1599 /// no `contents` is `DW0487`.
1600 #[serde(default, skip_serializing_if = "Option::is_none")]
1601 pub contents: Option<PotionContents>,
1602}
1603
1604/// The items whose vanilla item definition carries a `minecraft:potion_contents`
1605/// component — the only items a kit `contents` may be declared on (`DW0486`).
1606///
1607/// Read off the pinned 1.21.11 `item_components` summary (SHA-256
1608/// `51b191e13f86813ca02f1498942e5bc235947edb71eb8105a78401670b3665c4`, the same
1609/// misode/mcmeta ref `crates/delvec/data/PROVENANCE.md` pins): exactly these
1610/// four items declare the component, and on any other item the game drops the
1611/// data on the floor.
1612pub const POTION_BEARING_ITEMS: &[&str] = &[
1613 "minecraft:lingering_potion",
1614 "minecraft:potion",
1615 "minecraft:splash_potion",
1616 "minecraft:tipped_arrow",
1617];
1618
1619/// True if `item_id` (optionally un-namespaced) is one of the four
1620/// [`POTION_BEARING_ITEMS`].
1621pub fn is_potion_bearing_item(item_id: &str) -> bool {
1622 let norm = if item_id.contains(':') {
1623 item_id.to_string()
1624 } else {
1625 format!("minecraft:{item_id}")
1626 };
1627 POTION_BEARING_ITEMS.contains(&norm.as_str())
1628}
1629
1630/// The two **instantaneous** status effects: they are applied once, on the tick
1631/// the potion is drunk (`PotionContents.applyToLivingEntity` branches on
1632/// `isInstantenous` before any effect instance is ever added), so a `duration` on
1633/// one is a statement the game never reads — `DW0486` says so rather than letting
1634/// an author believe they wrote a thirty-second heal.
1635pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
1636
1637/// A potion-bearing kit item's `minecraft:potion_contents` component (DSL v0.8),
1638/// modelled field for field on vanilla rather than invented: a **named** potion,
1639/// a list of **custom effects**, or both, plus the bottle-colour override.
1640///
1641/// Vanilla resolves a drink as the named potion's effects followed by the custom
1642/// ones, and derives the bottle colour from those effects unless `color`
1643/// overrides it — so `{"potion": "minecraft:strong_healing"}` is literally the
1644/// Potion of Healing II a player would brew, not an approximation of one.
1645#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1646#[serde(deny_unknown_fields)]
1647pub struct PotionContents {
1648 /// A named vanilla potion id (`minecraft:strong_healing`,
1649 /// `minecraft:long_night_vision`, …), validated against the pinned 1.21.11
1650 /// `potion` registry (`DW0486`).
1651 #[serde(default, skip_serializing_if = "Option::is_none")]
1652 pub potion: Option<String>,
1653 /// Custom effects applied on top of (or instead of) the named potion — the
1654 /// escape hatch for a recovery item vanilla has no brew for.
1655 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1656 pub effects: Vec<PotionEffect>,
1657 /// Bottle-colour override, `#rrggbb` (vanilla `custom_color`). Absent → the
1658 /// colour vanilla derives from the effects themselves.
1659 #[serde(default, skip_serializing_if = "Option::is_none")]
1660 pub color: Option<String>,
1661}
1662
1663/// One entry of a potion's `custom_effects` list.
1664#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1665#[serde(deny_unknown_fields)]
1666pub struct PotionEffect {
1667 /// Vanilla status-effect id (e.g. `minecraft:regeneration`), validated
1668 /// against the pinned registry (`DW0486`).
1669 pub effect: String,
1670 /// How long it lasts, in **ticks** (20 = one second). Required for every
1671 /// effect except the two [`INSTANT_EFFECTS`], which must NOT declare one.
1672 #[serde(default, skip_serializing_if = "Option::is_none")]
1673 pub duration: Option<u32>,
1674 /// Amplifier, 0 = level I (vanilla's unsigned byte, so 0–255). Absent = 0.
1675 #[serde(default, skip_serializing_if = "Option::is_none")]
1676 pub amplifier: Option<u32>,
1677}
1678
1679impl PotionEffect {
1680 /// True if this effect is applied once on drinking rather than over time.
1681 pub fn is_instant(&self) -> bool {
1682 let norm = crate::registry::namespaced_effect_id(&self.effect);
1683 INSTANT_EFFECTS.contains(&norm.as_str())
1684 }
1685}
1686
1687/// The largest `duration` a potion effect may declare, in ticks: 1 000 000 ticks
1688/// ≈ 13.9 hours, past the 10-hour delve ceiling, so nothing a delve can legally
1689/// need is refused — while a duration typed in *milliseconds*, or one that would
1690/// overflow vanilla's int, is caught (`DW0486`).
1691pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
1692
1693/// The largest `amplifier` a potion effect may declare: vanilla stores it in an
1694/// unsigned byte, so 255 is not a policy but the end of the field.
1695pub const MAX_POTION_AMPLIFIER: u32 = 255;
1696
1697/// The largest `seconds` a [`Verb::GiveEffect`] may declare, derived from
1698/// [`MAX_POTION_DURATION_TICKS`] rather than chosen again: the two are the same
1699/// quantity in different units, and a second independently-picked ceiling is how
1700/// two limits for one fact drift apart. ≈13.9 hours, past the 10-hour delve
1701/// ceiling, so nothing a delve can legally need is refused — while a duration
1702/// typed in *ticks* or in milliseconds is caught (`DW0541`).
1703pub const MAX_EFFECT_SECONDS: u32 = MAX_POTION_DURATION_TICKS / 20;
1704
1705/// The canonical English title of the bonfire rest dialog. Baked at emit time
1706/// when the campaign authors no `prompt`, in the
1707/// `world.boundary.message` tradition: a compiler default is not inventoried, an
1708/// authored line is — so a delve that wants this sentence in `zh-cn` authors it.
1709pub const BONFIRE_PROMPT_EN: &str = "Bonfire";
1710/// The canonical English label of the **rest and save** option.
1711pub const BONFIRE_REST_LABEL_EN: &str = "Rest and save";
1712/// The canonical English label of the **save only** option.
1713pub const BONFIRE_SAVE_LABEL_EN: &str = "Save only";
1714
1715/// A `bonfire`'s authored rest-dialog strings, each `None` when the campaign
1716/// leaves the compiler's canonical English in place
1717/// ([`QuestEffect::bonfire_labels`]).
1718#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1719pub struct BonfireLabels<'a> {
1720 /// Dialog title; `None` → [`BONFIRE_PROMPT_EN`].
1721 pub prompt: Option<&'a str>,
1722 /// **Rest and save** button label; `None` → [`BONFIRE_REST_LABEL_EN`].
1723 pub rest_label: Option<&'a str>,
1724 /// **Save only** button label; `None` → [`BONFIRE_SAVE_LABEL_EN`].
1725 pub save_label: Option<&'a str>,
1726}
1727
1728impl BonfireLabels<'_> {
1729 /// The dialog title actually emitted.
1730 pub fn prompt_or_default(&self) -> &str {
1731 self.prompt.unwrap_or(BONFIRE_PROMPT_EN)
1732 }
1733 /// The **rest and save** label actually emitted.
1734 pub fn rest_or_default(&self) -> &str {
1735 self.rest_label.unwrap_or(BONFIRE_REST_LABEL_EN)
1736 }
1737 /// The **save only** label actually emitted.
1738 pub fn save_or_default(&self) -> &str {
1739 self.save_label.unwrap_or(BONFIRE_SAVE_LABEL_EN)
1740 }
1741}
1742
1743// ---------------------------------------------------------------------------
1744// Stage 4 — quest-plan
1745// ---------------------------------------------------------------------------
1746
1747/// Stage 4 payload: the quest dependency plan.
1748#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1749#[serde(deny_unknown_fields)]
1750pub struct QuestPlanContent {
1751 /// Planned quests (expanded in stage 5).
1752 pub quests: Vec<PlannedQuest>,
1753 /// The quest whose completion ends the campaign.
1754 pub finale: QuestId,
1755 /// The campaign's declared **story forks** (DSL v0.8, spec-0025). Empty/absent = a campaign that claims to have no
1756 /// branch — which the compiler then *verifies* rather than assumes: any flag
1757 /// that gates casts, staging or structure and is set on some playthroughs and
1758 /// not others belongs to no declared point and is `DW0480`.
1759 ///
1760 /// Enumerated branches are the **product of the declared points**, so the
1761 /// branch set is authored and small — never a combinatorial sweep of every
1762 /// flag in the campaign.
1763 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1764 pub branch_points: Vec<BranchPoint>,
1765}
1766
1767impl QuestPlanContent {
1768 /// **The ONE authority on which quests are the spine**: the finale and every
1769 /// quest its `depends_on` chain transitively demands — the quests a body
1770 /// cannot reach the finale without.
1771 ///
1772 /// The capability belongs here, on the stage-4 document, because the spine is
1773 /// a fact about the quest plan and about nothing else. It had grown two
1774 /// derivations of the same closure in two files — one inline in
1775 /// [`crate::validate`]'s `DW0132` convergence check, one a private
1776 /// `mandatory_quests` in [`crate::layout`] read by the layout binding and by
1777 /// the critical-path spine obligation. Both were correct and neither said it
1778 /// was the authority, which is exactly the shape a later clean merge turns
1779 /// into two rules that disagree.
1780 ///
1781 /// **Why the closure is taken over the raw `depends_on` edges, unfiltered.**
1782 /// The `validate` copy first dropped every dep naming a quest the plan does
1783 /// not declare. That filtering is not this function's question: a dangling
1784 /// `depends_on` is `DW0112`'s finding, and silently pruning it here would
1785 /// make the set disagree with the document it is derived from. So an id the
1786 /// plan does not declare is reported in the spine and expands no further —
1787 /// and the one reader that could care, `DW0132`, only ever asks whether a
1788 /// **declared** quest is a member, so an undeclared member cannot change its
1789 /// verdict.
1790 ///
1791 /// Cycle-safe by construction (a quest already in the set is not expanded
1792 /// again), so a plan `DW0130` will refuse still yields a set rather than
1793 /// hanging.
1794 ///
1795 /// **Not the same question as the `mandatory` field**, and the name says so
1796 /// deliberately. Today the two sets always coincide, because `DW0132` demands
1797 /// every declared quest be a transitive dependency of the finale and `DW0866`
1798 /// demands every quest set `mandatory: true`. If `mandatory: false` ever
1799 /// becomes legal those coincide no longer, and this function keeps answering
1800 /// the graph question it has always answered.
1801 #[must_use]
1802 pub fn spine(&self) -> BTreeSet<&str> {
1803 let deps: BTreeMap<&str, &[QuestId]> = self
1804 .quests
1805 .iter()
1806 .map(|q| (q.id.as_str(), q.depends_on.as_slice()))
1807 .collect();
1808 let mut spine: BTreeSet<&str> = BTreeSet::new();
1809 let mut stack = vec![self.finale.as_str()];
1810 while let Some(q) = stack.pop() {
1811 if !spine.insert(q) {
1812 continue;
1813 }
1814 for dep in deps.get(q).copied().unwrap_or(&[]) {
1815 stack.push(dep.as_str());
1816 }
1817 }
1818 spine
1819 }
1820
1821 /// **The ONE authority on which quests are elective** (spec-0051): the
1822 /// quests declaring `mandatory: false`.
1823 ///
1824 /// The counterpart to [`Self::spine`], and deliberately a *different*
1825 /// question. `spine` asks what the graph demands; this asks what the author
1826 /// claims. `DW0866`/`DW0867` are exactly the rules that keep the two
1827 /// answers honest about each other, and they can only do that while each
1828 /// side has one derivation — which is the defect the spine function was
1829 /// created to end, and which a second private `!q.mandatory` filter in the
1830 /// compiler would re-introduce on the other half.
1831 #[must_use]
1832 pub fn optional(&self) -> BTreeSet<&str> {
1833 self.quests
1834 .iter()
1835 .filter(|q| !q.mandatory)
1836 .map(|q| q.id.as_str())
1837 .collect()
1838 }
1839}
1840
1841/// One declared story fork (DSL v0.8, spec-0025).
1842///
1843/// A branch point names the flag set the story forks on, the quest at which the
1844/// fork opens, and every branch it offers. Each branch pins the point's whole
1845/// flag set: the flags it lists are **set**, and every other flag of `forks_on`
1846/// is **not** — which is what makes exclusive-content leakage (`DW0484`) and
1847/// per-branch cast resolution (`DW0483`) decidable instead of hopeful.
1848#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1849#[serde(deny_unknown_fields)]
1850pub struct BranchPoint {
1851 /// Unique branch-point id.
1852 pub id: BranchPointId,
1853 /// The quest at which the fork opens — every branch's divergent content is at
1854 /// or after it in the stage-4 DAG.
1855 pub opens_at: QuestId,
1856 /// The flag set the story forks on. Every branch's `flags` is a subset of
1857 /// this, and the flags it does not list are pinned **unset** on that branch.
1858 pub forks_on: Vec<FlagId>,
1859 /// The alternatives (≥ 2).
1860 pub branches: Vec<BranchDecl>,
1861}
1862
1863/// One alternative of a [`BranchPoint`] (DSL v0.8, spec-0025).
1864#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1865#[serde(deny_unknown_fields)]
1866pub struct BranchDecl {
1867 /// Unique branch id (campaign-wide — it names the emitted chronicle file).
1868 pub id: BranchId,
1869 /// The subset of the point's `forks_on` that is SET on this branch. The rest
1870 /// of `forks_on` is pinned unset. An empty list is legal — the "took neither
1871 /// option" branch — as long as it is genuinely reachable.
1872 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1873 pub flags: Vec<FlagId>,
1874 /// Where this branch goes: either the `quest/<kebab>` the branches converge
1875 /// at, or the `ending/<kebab>` this branch runs to.
1876 ///
1877 /// One field, not two mutually exclusive ones, because the **id prefix
1878 /// already says which it is** — the same convention every cross-stage
1879 /// reference in the DSL uses. That is what makes "exactly one of them" an
1880 /// unrepresentable state rather than a rule some diagnostic has to police: a
1881 /// value that is neither a syntactically valid `quest/…` nor `ending/…` is
1882 /// the ordinary malformed-id `DW0110`, and one that names nothing is the
1883 /// ordinary dangling-reference `DW0112`.
1884 pub leads_to: String,
1885}
1886
1887impl BranchDecl {
1888 /// The convergence quest, if [`Self::leads_to`] names one.
1889 pub fn converges_at(&self) -> Option<QuestId> {
1890 let q = QuestId(self.leads_to.clone());
1891 q.is_valid_syntax().then_some(q)
1892 }
1893
1894 /// The ending, if [`Self::leads_to`] names one.
1895 pub fn ending(&self) -> Option<EndingId> {
1896 let e = EndingId(self.leads_to.clone());
1897 e.is_valid_syntax().then_some(e)
1898 }
1899}
1900
1901/// What a story node does to the story (DSL v0.8, spec-0025).
1902///
1903/// The generalization of spec-0020's `doing` from NPC presence to event flow: a
1904/// design that never got written down node by node cannot compile. It is
1905/// **node-local on purpose** — there is no parallel per-branch script document
1906/// that could itself drift from the graph.
1907///
1908/// `text` is authoring/validation metadata, never shown to a player, so it is
1909/// deliberately **excluded from the l10n inventory** exactly like `doing`. The
1910/// compiler reads only `verb` and `subject`; `text` is the flesh the per-branch
1911/// chronicle is assembled from.
1912#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1913#[serde(deny_unknown_fields)]
1914pub struct Happening {
1915 /// The structured event verb — the machine-decidable part.
1916 pub verb: HappeningVerb,
1917 /// One line of prose stating what this node does to the story.
1918 pub text: String,
1919 /// What the event happens TO: an `npc/`, `actor/`, `wave/` or `anchor/` id
1920 /// (validated — a dangling one is `DW0112`), or an `item/<kebab>` label for a
1921 /// story token the campaign tracks by hand. Optional, because not every beat
1922 /// is about somebody; the hard-contradiction proof (`DW0485`) reasons only
1923 /// over the beats that name one.
1924 #[serde(default, skip_serializing_if = "Option::is_none")]
1925 pub subject: Option<String>,
1926}
1927
1928/// The subject a beat is about, and whether the document said so (spec-0071 §3).
1929///
1930/// Answered by [`QuestEffect::happening_subject`], the one derivation. `derived`
1931/// is carried rather than dropped because the two readers want different things
1932/// from it: the namespace check reports on what an author **wrote**, and the
1933/// chronicle reasons over what the beat **is about**.
1934#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1935pub struct HappeningSubject<'a> {
1936 /// The subject id (`npc/`, `actor/`, `wave/`, `anchor/` or an `item/` label).
1937 pub id: &'a str,
1938 /// `true` when the effect's own single object supplied it, `false` when the
1939 /// `happening` states it.
1940 pub derived: bool,
1941}
1942
1943/// The structured event vocabulary (DSL v0.8, spec-0025).
1944///
1945/// Deliberately small and closed. These ten verbs are what make a subset of
1946/// narrative errors machine-decidable per branch (`DW0485`); everything else a
1947/// beat means lives in [`Happening::text`], which the compiler never interprets.
1948/// Extend only when a real campaign cannot state its beat with what is here.
1949#[derive(
1950 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1951)]
1952#[serde(rename_all = "kebab-case")]
1953pub enum HappeningVerb {
1954 /// The subject is killed / destroyed. Terminal: nothing the subject does may
1955 /// follow it on the same branch.
1956 Dies,
1957 /// The subject comes through alive — the explicit counterpart of `dies`,
1958 /// which is what lets a branch state that somebody *did not* die.
1959 Survives,
1960 /// The subject leaves the stage (offstage, not dead). Cleared by `arrives`.
1961 Departs,
1962 /// The subject enters the stage.
1963 Arrives,
1964 /// Somebody learns a fact — the true-information beat.
1965 Learns,
1966 /// Somebody comes to believe something (whether or not it is true) — the beat
1967 /// that carries a wrong belief forward, which is where branch drift shows.
1968 Believes,
1969 /// The party (or the subject) gains a thing.
1970 Gains,
1971 /// The party (or the subject) loses a thing. A second `loses` with no
1972 /// intervening `gains` is spending what is already spent (`DW0485`).
1973 Loses,
1974 /// A way is opened.
1975 Opens,
1976 /// A way is sealed. Cleared by `opens`.
1977 Seals,
1978}
1979
1980/// One planned quest (dependency-graph node).
1981#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1982#[serde(deny_unknown_fields)]
1983pub struct PlannedQuest {
1984 /// Unique quest id.
1985 pub id: QuestId,
1986 /// Human-readable goal.
1987 pub goal: String,
1988 /// Area this quest takes place in (stage-1 ref).
1989 pub area: AreaId,
1990 /// NPCs involved (stage-2 refs).
1991 pub npcs: Vec<NpcId>,
1992 /// Prerequisite quests; edges must form a DAG.
1993 pub depends_on: Vec<QuestId>,
1994 /// `false` declares an optional quest (spec-0051).
1995 pub mandatory: bool,
1996 /// Act number (informational).
1997 pub act: u32,
1998}
1999
2000// ---------------------------------------------------------------------------
2001// Stage 5 — quests
2002// ---------------------------------------------------------------------------
2003
2004/// Stage 5 payload: quest expansions.
2005#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2006#[serde(deny_unknown_fields)]
2007pub struct QuestsContent {
2008 /// The expanded quests (1:1 with stage 4).
2009 pub quests: Vec<Quest>,
2010 /// Combat waves (DSL v0.3). Each wave is spawned by a `spawn-wave` effect and
2011 /// slain to complete a `kill` objective. Empty/absent in v0.2 campaigns.
2012 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2013 pub waves: Vec<Wave>,
2014 /// Environment triggers (DSL v0.4, spec-0008 §7): "the world answers". Each
2015 /// watches an anchor for a strike / use / approach event and fires a bundle
2016 /// of [`QuestEffect`]s. Empty/absent in v0.2/v0.3 campaigns.
2017 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2018 pub triggers: Vec<EnvTrigger>,
2019 /// Scripted actors (DSL v0.6, spec-0014): NoAI/Silent/no-loot puppets moved by
2020 /// compiler-emitted per-tick teleport. Distinct from stage-2 NPCs
2021 /// (no dialogue, any mob type). Summoned/removed/moved/unleashed by the actor
2022 /// staging effects. Empty/absent before v0.6.
2023 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2024 pub actors: Vec<Actor>,
2025 /// Traps (DSL v0.6, spec-0011; command payloads spec-0022): environmental
2026 /// hazards, each at one point anchor an area's prefab provides. Each says
2027 /// what springs the trap, what it then does (a command `payload`, a legacy
2028 /// dispenser `effect`, or both), how dangerous it is, how it is disarmed and
2029 /// whether it re-arms. Empty/absent in pre-0.6 campaigns, so a v0.5-or-earlier campaign that declares none stays
2030 /// byte-identical.
2031 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2032 pub traps: Vec<Trap>,
2033 /// Shortcut doors (spec-0016 §2): a gate that is sealed from world-load and
2034 /// is opened — permanently — from the FAR side. Empty/absent in pre-0.6
2035 /// campaigns, so a campaign that declares none stays
2036 /// byte-identical.
2037 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2038 pub shortcuts: Vec<Shortcut>,
2039 /// Ambushes (spec-0016 §3): sugar over "deferred actors + a trigger that
2040 /// springs them". Empty/absent in pre-0.6 campaigns.
2041 ///
2042 /// **Never serialized.** [`parse_campaign`](crate::parse_campaign) expands
2043 /// each ambush into `triggers`, so the canonical form of a campaign is its
2044 /// **desugared** form. That is what keeps the canonical round-trip idempotent
2045 /// (re-parsing canonical output finds no `ambushes` and so cannot expand a
2046 /// second time and duplicate the trigger ids), and it means the sugar exists
2047 /// at exactly one layer boundary — the authored `.json` — with nothing
2048 /// downstream needing to know it was ever there. The list itself is kept in
2049 /// memory so diagnostics can name the ambush the author wrote.
2050 /// Timed gates (spec-0016 §4): gates on a deterministic open/close clock.
2051 /// Empty/absent in pre-0.6 campaigns.
2052 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2053 pub timed_gates: Vec<TimedGate>,
2054 /// Container fills (spec-0021): pre-placed chests/barrels in the prefabs
2055 /// given contents at world init. Empty/absent in pre-0.6 campaigns
2056 ///.
2057 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2058 pub loot: Vec<Loot>,
2059 /// Runtime state data (DSL v0.10, spec-0031): named, scoped, integer-valued
2060 /// counters the campaign sets, adds to and clears at runtime, and compares
2061 /// against in any gate. A campaign that declares none emits none of it.
2062 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2063 pub state: Vec<StateDecl>,
2064 /// **The campaign's death beat** (DSL v0.10, spec-0031): effects run at the
2065 /// moment a player dies, for that player. Effect root **R7**
2066 /// ([`crate::EffectRootKind::OnDeath`]); a campaign that declares none emits
2067 /// none of it.
2068 ///
2069 /// **Why this is campaign-wide and not a field on a checkpoint.** The engine
2070 /// already has `on_respawn`, and it hangs off a `set-checkpoint` because
2071 /// *where you come back* is a property of the checkpoint. *That you died* is
2072 /// not: it is true at every point of the delve, under every checkpoint, and a
2073 /// bundle repeated on each checkpoint would be the same content written N
2074 /// times with N chances to forget one. So death is a moment in the campaign,
2075 /// and this is the one place it is named. Anything that should only happen in
2076 /// some phase of the delve is expressed by the ordinary per-effect
2077 /// `requires_flags` / `forbids_flags` gate every other root already carries —
2078 /// no second gating surface.
2079 ///
2080 /// **Audience is the dying player** (`Audience::Solo`, the audience
2081 /// `on_respawn` and `on_caught` already use): a death is one player's, and
2082 /// re-broadcasting it to the party would duplicate their narration and their
2083 /// kit. A beat the whole party should see is a `narrate` addressed by the
2084 /// author to the party through the effect's own vocabulary, not a different
2085 /// default here.
2086 ///
2087 /// **Timing.** It fires on the death edge while the player is still a corpse
2088 /// (`Health: 0.0f`, on the death screen) — see
2089 /// `emit::emit_checkpoint_functions`. That is the difference between this and
2090 /// `on_respawn`, which deliberately waits for the player to come back.
2091 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2092 pub on_death: Vec<QuestEffect>,
2093 /// Lethal volumes (DSL v0.10, spec-0031): declared boxes that kill whatever
2094 /// enters them. Empty/absent in pre-0.10 campaigns, so a
2095 /// campaign that declares none stays byte-identical.
2096 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2097 pub lethal_volumes: Vec<LethalVolume>,
2098 /// Shops (DSL v0.10, spec-0032): interaction points that open a list of
2099 /// gated offers. Empty/absent in pre-0.10 campaigns, so a
2100 /// campaign that declares none stays byte-identical.
2101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2102 pub shops: Vec<Shop>,
2103 /// Recovery stakes (DSL v0.10, spec-0032): what a death forfeits, where the
2104 /// marker lands, and how it comes back. Empty/absent in pre-0.10 campaigns
2105 ///, so a campaign that declares none stays byte-identical.
2106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2107 pub stakes: Vec<Stake>,
2108 #[serde(default, skip_serializing)]
2109 pub ambushes: Vec<Ambush>,
2110 /// Whether [`Self::expand_ambushes`] has already run (never serialized). The
2111 /// authored `ambushes` are deliberately KEPT after expansion so validation
2112 /// and the counterplay proof can attribute diagnostics to the ambush the
2113 /// author actually wrote; this flag is what makes a second expansion a no-op.
2114 #[serde(skip)]
2115 pub ambushes_expanded: bool,
2116}
2117
2118impl QuestsContent {
2119 /// Every environment trigger this stage produces: the authored `triggers`
2120 /// followed by the ones each `ambush` desugars to (spec-0016 §3), in
2121 /// declared order.
2122 ///
2123 /// **This is the single trigger authority.** Validation, the l10n
2124 /// inventory, the flag/wave producer scans, the nav proofs and emission all
2125 /// read triggers through it, so an ambush behaves exactly like the trigger
2126 /// an author would otherwise hand-write — there is no second code path for
2127 /// the sugar to drift down. Deterministic (declaration order, no hashing).
2128 pub fn all_triggers(&self) -> Vec<EnvTrigger> {
2129 let mut out = self.triggers.clone();
2130 out.extend(self.ambushes.iter().map(Ambush::to_trigger));
2131 out
2132 }
2133
2134 /// **Does the campaign itself answer a right-click at `anchor`?**
2135 ///
2136 /// One predicate, read by both consumers of the press-answer rule, so they can
2137 /// never disagree about what "the campaign answered it" means: the compiler's
2138 /// synthesis (`plan::collect_press_answers`, which stands down where this is
2139 /// true) and the obligation on a shortcut door (`DW0429`, which fires where it
2140 /// is false). Split across the two crates they would drift, and the drift
2141 /// would read as "the compiler refused a door I answered".
2142 ///
2143 /// Deliberately the widest reading — *any* `use` trigger anchored there.
2144 /// Pressing it already does something the author chose, and the engine does
2145 /// not adjudicate whether what they chose counts as an answer.
2146 pub fn answers_press_at(&self, anchor: &str) -> bool {
2147 self.all_triggers()
2148 .iter()
2149 .any(|t| matches!(t.on, TriggerOn::Use) && t.at_anchor() == Some(anchor))
2150 }
2151
2152 /// Desugar every `ambush` into a real environment trigger and clear the
2153 /// ambush list (spec-0016 §3). Called once, by
2154 /// [`parse_campaign`](crate::parse_campaign); idempotent by construction
2155 /// (a second call sees no ambushes left to expand).
2156 pub fn expand_ambushes(&mut self) {
2157 if self.ambushes_expanded || self.ambushes.is_empty() {
2158 return;
2159 }
2160 self.triggers = self.all_triggers();
2161 self.ambushes_expanded = true;
2162 }
2163
2164 /// The declared datum with this id, if any (DSL v0.10).
2165 pub fn state_decl(&self, id: &str) -> Option<&StateDecl> {
2166 self.state.iter().find(|s| s.id.as_str() == id)
2167 }
2168
2169 /// The declared stake with this id, if any (DSL v0.10, spec-0032).
2170 pub fn stake_decl(&self, id: &str) -> Option<&Stake> {
2171 self.stakes.iter().find(|s| s.id.as_str() == id)
2172 }
2173}
2174
2175// ---------------------------------------------------------------------------
2176// Stage 5 — runtime state (DSL v0.10, spec-0031)
2177// ---------------------------------------------------------------------------
2178
2179/// Who holds a datum's value (DSL v0.10, spec-0031).
2180///
2181/// **Declared, never inferred.** A datum's multiplayer semantics is the one
2182/// thing about it that cannot be recovered from its uses: a purse read on a
2183/// `talk-to` looks identical whether every player has their own or the party
2184/// shares one, and the difference decides the whole design. spec-0031 states it
2185/// as a rule, and the type makes it un-omittable — there is no default.
2186#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2187#[serde(rename_all = "kebab-case")]
2188pub enum StateScope {
2189 /// Each player holds their own value. Read and written against the acting
2190 /// player, so a bundle with no acting player (the scheduler) cannot touch one
2191 /// (`DW0503`).
2192 Player,
2193 /// One value the whole party shares — the same holder story flags use
2194 /// (spec-0018). Readable and writable from every audience, including the
2195 /// scheduler.
2196 Party,
2197}
2198
2199impl StateScope {
2200 /// The wire token (`player` / `party`).
2201 pub fn token(self) -> &'static str {
2202 match self {
2203 StateScope::Player => "player",
2204 StateScope::Party => "party",
2205 }
2206 }
2207}
2208
2209/// One declared runtime datum (DSL v0.10, spec-0031): a named, scoped,
2210/// integer-valued counter.
2211///
2212/// This is what [`FlagId`] is not. A flag is boolean, party-wide and
2213/// **monotonic** — no verb clears one — which is exactly right for "this has
2214/// happened" and useless for a balance, a floor number, or "a ride is in
2215/// progress". A datum clears, counts down as well as up, and states its scope.
2216#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2217#[serde(deny_unknown_fields)]
2218pub struct StateDecl {
2219 /// Unique datum id (`state/<kebab>`).
2220 pub id: StateId,
2221 /// Who holds the value. Required — see [`StateScope`].
2222 pub scope: StateScope,
2223 /// The value the datum starts at, and the value `clear-state` returns it to.
2224 /// Defaults to `0`.
2225 ///
2226 /// One field rather than a separate `initial` and `cleared`: "the value this
2227 /// datum has when nothing has happened to it yet" is one fact, and two fields
2228 /// would let a campaign declare a datum that can never be returned to its own
2229 /// starting state.
2230 #[serde(default, skip_serializing_if = "is_zero_i32")]
2231 pub initial: i32,
2232 /// Free prose: what this datum means. Never machine-checked, never shown to a
2233 /// player — the forcing function that makes an author say what the number is,
2234 /// the same role `cast[].doing` plays.
2235 #[serde(default, skip_serializing_if = "Option::is_none")]
2236 pub note: Option<String>,
2237 /// The player-visible name of this datum (DSL v0.10, spec-0032).
2238 ///
2239 /// **A named datum is a currency.** There is no separate `currencies` section
2240 /// and there deliberately is not: a purse is a runtime datum that the player
2241 /// can see, and "the player can see it" is a property of the datum, not a
2242 /// different object class. A second struct carrying `id` + `scope` +
2243 /// `initial` + `name` would be a private copy of this one, which is the defect
2244 /// CLAUDE.md names second.
2245 ///
2246 /// Present ⇒ every `set-state` / `add-state` / `clear-state` on this datum
2247 /// also states the new balance to whoever holds it, on the action bar, as
2248 /// `<name>: <value>` with the value carried by vanilla's own `score`
2249 /// component. Absent ⇒ the datum is silent bookkeeping and emission is exactly
2250 /// what it was.
2251 ///
2252 /// Player-visible, so it is inventoried under `state.<id>.name` and
2253 /// translated like any other authored line.
2254 #[serde(default, skip_serializing_if = "Option::is_none")]
2255 pub name: Option<String>,
2256 /// Where this datum **stands** on screen between changes (spec-0076).
2257 ///
2258 /// The announcement `name` buys fades with the action bar; a datum that
2259 /// declares `display` also occupies a vanilla display slot, so its balance is
2260 /// on screen at every moment for every player. Declared, never automatic: a
2261 /// creator may want a named tally that is spoken only when it moves, and the
2262 /// engine does not decide which of two named datums is the purse.
2263 ///
2264 /// Present ⇒ `setup` heads the datum's objective with its translated `name`,
2265 /// paints the value gold, and puts the objective in the slot. Requires `name`
2266 /// (the slot's heading is the display name, and without one it would show the
2267 /// objective's id) and a `player` scope (the sidebar hides `#`-prefixed
2268 /// holders, which is what a `party` datum's value lives on); one datum per
2269 /// campaign may stand, because the slot holds one objective — all three are
2270 /// `DW0919`. Absent ⇒ the datum announces itself and stands nowhere.
2271 #[serde(default, skip_serializing_if = "Option::is_none")]
2272 pub display: Option<StateDisplay>,
2273}
2274
2275/// The vanilla display slot a named datum stands in (spec-0076).
2276///
2277/// One value, because vanilla has one slot that stands for the viewer: `list`
2278/// shows only while the tab key is held and `below_name` draws under *other*
2279/// players' name tags, never over the viewer's own body. A second variant is
2280/// added when the pinned game offers a second standing surface, not before.
2281#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2282#[serde(rename_all = "kebab-case")]
2283pub enum StateDisplay {
2284 /// The right-hand sidebar: a heading (the datum's `name`) and one line per
2285 /// player, each showing that player's own balance.
2286 Sidebar,
2287}
2288
2289impl StateDisplay {
2290 /// The `minecraft:scoreboard_slot` token the slot is addressed by.
2291 pub fn slot(self) -> &'static str {
2292 match self {
2293 StateDisplay::Sidebar => "sidebar",
2294 }
2295 }
2296}
2297
2298/// serde `skip_serializing_if` helper: skip a zero `i32` (`StateDecl.initial`).
2299fn is_zero_i32(v: &i32) -> bool {
2300 *v == 0
2301}
2302
2303/// How a [`StateCompare`] relates a datum to its operand (DSL v0.10).
2304///
2305/// Four operators, not six: over integers `less-than n` is `at-most n-1` and
2306/// `greater-than n` is `at-least n+1`, so the extra spellings would add a second
2307/// way to say one thing and a second emission path to keep honest.
2308#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2309#[serde(rename_all = "kebab-case")]
2310pub enum CompareOp {
2311 /// The datum is exactly `value`.
2312 Equals,
2313 /// The datum is anything but `value`.
2314 NotEquals,
2315 /// The datum is `value` or more.
2316 AtLeast,
2317 /// The datum is `value` or less.
2318 AtMost,
2319}
2320
2321impl CompareOp {
2322 /// The wire token (`equals` / `not-equals` / `at-least` / `at-most`).
2323 pub fn token(self) -> &'static str {
2324 match self {
2325 CompareOp::Equals => "equals",
2326 CompareOp::NotEquals => "not-equals",
2327 CompareOp::AtLeast => "at-least",
2328 CompareOp::AtMost => "at-most",
2329 }
2330 }
2331}
2332
2333/// What one of the DSL v0.10 state verbs does to a datum — the value half of
2334/// [`QuestEffect::writes_state`].
2335#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2336pub enum StateWrite {
2337 /// `set-state`: the datum becomes this value.
2338 Set(i32),
2339 /// `add-state`: the datum moves by this signed amount.
2340 Add(i32),
2341 /// `clear-state`: the datum returns to its declared `initial`.
2342 Clear,
2343}
2344
2345/// One numeric term of a gate (DSL v0.10, spec-0031): *this datum compares thus
2346/// to this value*.
2347///
2348/// It rides [`Gate`](crate::gate::Gate) — the shared gate, carried by every
2349/// consumer of `requires_flags`/`forbids_flags` — and not any one verb. The
2350/// comparison's consumers are exactly the gate's consumers ("this door opens at
2351/// 500", "this line is withheld below 200", "this lever does nothing while the
2352/// car is moving"), so hanging it off the first verb that asked would leave the
2353/// second with no surface and make a second bespoke field look like the fix.
2354#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2355#[serde(deny_unknown_fields)]
2356pub struct StateCompare {
2357 /// The datum to read. Must be declared in the stage-5 `state` list
2358 /// (`DW0502`).
2359 pub state: StateId,
2360 /// How to compare it.
2361 pub op: CompareOp,
2362 /// What to compare it against.
2363 pub value: i32,
2364}
2365
2366/// A stage-5 container fill (DSL v0.6, spec-0021): contents for a chest or
2367/// barrel the prefab already placed.
2368///
2369/// The container is **hardware the prefab authored**, exactly like a trap's
2370/// dispenser: this declaration gives an already-placed, already-lit, already
2371/// composed piece of furniture its contents. The compiler never places the
2372/// container itself — if the anchor's cell does not already hold one, that is a
2373/// content defect and a build error (`DW0431`), not something to paper over by
2374/// setblock-ing a chest into a wall.
2375#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2376#[serde(deny_unknown_fields)]
2377pub struct Loot {
2378 /// Unique loot id (`loot/<kebab>`).
2379 pub id: LootId,
2380 /// The anchor whose cell holds the container to fill.
2381 pub anchor: AnchorId,
2382 /// Contents, in declaration order. Slot assignment is positional and
2383 /// deterministic — the first entry lands in `container.0`, the second in
2384 /// `container.1`, and so on (ADR-0006: no RNG, no loot tables).
2385 pub items: Vec<LootItem>,
2386}
2387
2388/// One stack inside a [`Loot`] container.
2389#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2390#[serde(deny_unknown_fields)]
2391pub struct LootItem {
2392 /// Item id (e.g. `minecraft:cooked_cod`). Validated against the pinned
2393 /// 1.21.11 item registry (`DW0143`).
2394 pub item: String,
2395 /// Stack size. Defaults to 1.
2396 #[serde(default = "one_u32")]
2397 pub count: u32,
2398 /// Optional custom item name. Enters the l10n string inventory exactly like
2399 /// a class kit item's name.
2400 #[serde(default, skip_serializing_if = "Option::is_none")]
2401 pub name: Option<String>,
2402 /// Enchantments on this stack (`{"minecraft:sharpness": 3}`), emitted as the
2403 /// 1.21 `minecraft:enchantments` item component — or, on a
2404 /// `minecraft:enchanted_book`, `minecraft:stored_enchantments`
2405 /// ([`enchantment_component`]).
2406 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2407 pub enchantments: BTreeMap<String, u32>,
2408}
2409
2410fn one_u32() -> u32 {
2411 1
2412}
2413
2414/// A stage-5 trap (DSL v0.6, spec-0011; command payloads spec-0022): an
2415/// environmental hazard at one cell of a placed piece.
2416///
2417/// **What the prefab has to provide is one point anchor with the trigger block
2418/// in its cell, and for most traps that is all.** [`Trap::at`] names the
2419/// trigger/hazard cell; the piece places the plate, tripwire or trapped chest
2420/// there (`DW0917`); the compiler models it as a hazard for the completability
2421/// proofs (`DW0342`) and, for a disarmable trap, emits the disarm affordance. A
2422/// [`payload`](Trap::payload) trap needs nothing else: **the compiler owns the
2423/// detection**, emitting a per-tick, edge-latched `execute … if entity @a[<cell>]` and running the authored effect
2424/// bundle from it.
2425///
2426/// Two things a piece must pre-wire, each for one case and neither for the
2427/// common one:
2428///
2429/// * the legacy [`effect`](Trap::effect) — a `dispense` payload the prefab's own
2430/// redstone fires — needs the anchor's `dispenser` socket cell, which the
2431/// compiler fills. That is the case "harm is redstone-native" (spec-0011) was
2432/// written about, and the only one in which no detection is emitted.
2433/// * a **flag-gated** trap ([`requires_flags`](Trap::requires_flags) /
2434/// [`forbids_flags`](Trap::forbids_flags)) needs the anchor's `trigger_block`,
2435/// because gating removes the trigger block from the world while the gate is
2436/// shut and puts it back verbatim (`DW0363`).
2437///
2438/// Player-vs-mob distinguishing matters in a sealed box-garden with controlled
2439/// mobs, so `trapped-chest` (opened by a player) is called out as the only
2440/// player-distinct trigger.
2441#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2442#[serde(deny_unknown_fields)]
2443pub struct Trap {
2444 /// Unique trap id (`trap/<kebab>`).
2445 pub id: TrapId,
2446 /// **The point anchor this trap sits on** — any anchor an area's prefab
2447 /// provides, whatever it is called. Its cell is the trigger/hazard cell the
2448 /// compiler models, and for a `payload` trap that cell, holding the block
2449 /// its [`trigger`](Trap::trigger) names, is the whole of what the piece has
2450 /// to provide: detection is the compiler's, the block is the piece's
2451 /// (`DW0917`).
2452 ///
2453 /// The anchor additionally needs a `dispenser` socket for a legacy
2454 /// [`effect`](Trap::effect) trap, and a `trigger_block` for a flag-gated one
2455 /// (`DW0363`). `anchor/trap` is the name the shipped pieces use, and a name
2456 /// is all it is.
2457 pub at: AnchorId,
2458 /// What springs the trap (all redstone-native).
2459 pub trigger: TrapTrigger,
2460 /// The **legacy** redstone consequence (spec-0011): a static dispenser
2461 /// payload the prefab's own wiring fires. Superseded by [`Trap::payload`]
2462 /// (spec-0022) — redstone now keeps only the trigger — but kept meaningful
2463 /// so existing campaigns build unchanged. Optional since spec-0022; a trap
2464 /// must declare `effect`, `payload`, or both (`DW0440`).
2465 #[serde(default, skip_serializing_if = "Option::is_none")]
2466 pub effect: Option<TrapEffect>,
2467 /// The **command payload** (spec-0022): an ordered effect list in the same
2468 /// vocabulary quests use, run when the trigger fires. This is where a trap's
2469 /// consequence lives now — the compiler owns the detection tick and the
2470 /// effect vocabulary, so a trap's payload is authored like any other effect
2471 /// bundle rather than built out of dust and repeaters. Expressiveness moves
2472 /// from "what dust can carry" to "what the effect vocabulary can say":
2473 /// `volley` and `collapse` (spec-0022's trap verbs) join `damage-players`,
2474 /// `play-sound`, `narrate`, `set-flag` and `spawn-wave`.
2475 ///
2476 /// Empty = a pure spec-0011 redstone trap, which emits exactly what it
2477 /// emitted before (byte-identical).
2478 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2479 pub payload: Vec<QuestEffect>,
2480 /// How dangerous the trap is. A `lethal` trap on the forced critical path
2481 /// carries the completability obligation (`DW0342`); `harmful`/`nonlethal`
2482 /// carry none. Defaults to `harmful`.
2483 #[serde(default)]
2484 pub lethality: Lethality,
2485 /// Optional disarm affordance (quest-coupling): an anchor the player acts on to
2486 /// turn the trap off — setting a flag and emptying the dispenser — before the
2487 /// trap cell is forced.
2488 #[serde(default, skip_serializing_if = "Option::is_none")]
2489 pub disarm: Option<TrapDisarm>,
2490 /// Whether the trap re-arms after firing. `once` = single-shot (fires, then
2491 /// spent — the survivability path); `rearm` = re-fires each trigger (default).
2492 #[serde(default)]
2493 pub reset: TrapReset,
2494 /// Flags that must be set before the trap is considered active (mirrors
2495 /// [`EnvTrigger::requires_flags`]). Default empty.
2496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2497 pub requires_flags: Vec<FlagId>,
2498 /// Negative flag gate (DSL v0.6): the trap is considered inactive while ANY
2499 /// listed flag is set (mirrors [`EnvTrigger::forbids_flags`]). Default empty.
2500 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2501 pub forbids_flags: Vec<FlagId>,
2502 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
2503 /// hold for this gate to be open. The third field of the one gate, carried by
2504 /// every gate consumer — never by the verb that first wanted it. Default
2505 /// empty, so a pre-0.10 campaign is byte-identical.
2506 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2507 pub requires_state: Vec<StateCompare>,
2508}
2509
2510impl Trap {
2511 /// `(item, count)` if this trap declares a legacy `dispense` effect.
2512 pub fn dispense(&self) -> Option<(&str, u32)> {
2513 match &self.effect {
2514 Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
2515 None => None,
2516 }
2517 }
2518
2519 /// Whether this trap is `lethal` (carries the `DW0342` obligation on the
2520 /// forced critical path).
2521 pub fn is_lethal(&self) -> bool {
2522 matches!(self.lethality, Lethality::Lethal)
2523 }
2524}
2525
2526/// The mechanism that springs a [`Trap`] (DSL v0.6, spec-0011). All three are
2527/// redstone-native — the hardware fires without any command — so the compiler
2528/// emits no detection for them; it only models the trigger cell as a hazard and
2529/// fills the dispenser payload. (`approach`, the compiler-detected v0.4 primitive,
2530/// is deliberately *not* a trap trigger: it is already fully expressible as an
2531/// [`EnvTrigger`], so admitting it here would only duplicate that surface.)
2532#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2533#[serde(rename_all = "kebab-case")]
2534pub enum TrapTrigger {
2535 /// A pressure plate: any entity stepping on the cell. Auto-rearms on step-off.
2536 PressurePlate,
2537 /// A tripwire line: any entity crossing it. Auto-rearms.
2538 Tripwire,
2539 /// A trapped chest: a *player opening it* (comparator pulse). The only
2540 /// player-distinct trigger — a controlled mob cannot spring it.
2541 TrappedChest,
2542}
2543
2544impl TrapTrigger {
2545 /// The kebab tag (`pressure-plate` / `tripwire` / `trapped-chest`).
2546 pub fn kind(&self) -> &'static str {
2547 match self {
2548 TrapTrigger::PressurePlate => "pressure-plate",
2549 TrapTrigger::Tripwire => "tripwire",
2550 TrapTrigger::TrappedChest => "trapped-chest",
2551 }
2552 }
2553
2554 /// Whether `block` (an id, with or without its blockstate) is the hardware
2555 /// this trigger kind names: the block the party sees and springs. A plate
2556 /// is any `*_pressure_plate`, a tripwire is the string itself
2557 /// (`minecraft:tripwire`, not the hook), a trapped chest is
2558 /// `minecraft:trapped_chest`.
2559 pub fn is_trigger_block(&self, block: &str) -> bool {
2560 let id = block.split('[').next().unwrap_or(block);
2561 match self {
2562 TrapTrigger::PressurePlate => id.ends_with("_pressure_plate"),
2563 TrapTrigger::Tripwire => id == "minecraft:tripwire",
2564 TrapTrigger::TrappedChest => id == "minecraft:trapped_chest",
2565 }
2566 }
2567
2568 /// The block [`TrapTrigger::is_trigger_block`] accepts, as a refusal names it.
2569 pub fn trigger_block_name(&self) -> &'static str {
2570 match self {
2571 TrapTrigger::PressurePlate => "a pressure plate (`minecraft:*_pressure_plate`)",
2572 TrapTrigger::Tripwire => "a tripwire string (`minecraft:tripwire`)",
2573 TrapTrigger::TrappedChest => "a trapped chest (`minecraft:trapped_chest`)",
2574 }
2575 }
2576}
2577
2578/// What a [`Trap`] does when sprung (DSL v0.6, spec-0011). Externally tagged so a
2579/// future effect adds a variant; a non-`dispense` key (e.g. `tnt`,
2580/// `release-falling-block`, `crusher`) is an unknown variant → `DW0100`, keeping
2581/// block-destroying and unmodeled effects out of the schema by construction
2582/// (spec-0011 non-goals — no hardware the compiler cannot model reaches a world).
2583#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2584#[serde(rename_all = "kebab-case", deny_unknown_fields)]
2585pub enum TrapEffect {
2586 /// Load the prefab's pre-wired dispenser with `count` of `item` (arrows, tipped
2587 /// arrows, splash potions). The redstone fires it; terrain is untouched
2588 /// (spec-0011 "primary lethal"). The item is round-tripped into the dispenser
2589 /// `Items` NBT — a deterministic, static payload.
2590 Dispense {
2591 /// Vanilla item id (validated against the pinned 1.21.11 registry, `DW0341`).
2592 item: String,
2593 /// How many to load into the dispenser stack.
2594 count: u32,
2595 },
2596}
2597
2598/// How dangerous a [`Trap`] is (DSL v0.6, spec-0011). Only `lethal` carries the
2599/// forced-critical-path completability obligation (`DW0342`).
2600#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2601#[serde(rename_all = "kebab-case")]
2602pub enum Lethality {
2603 /// Can kill a full-health player — carries the `DW0342` obligation on the path.
2604 Lethal,
2605 /// Hurts but is not designed to kill (the default).
2606 #[default]
2607 Harmful,
2608 /// Cosmetic / trivial (a stumble, a scare).
2609 Nonlethal,
2610}
2611
2612/// Whether a [`Trap`] re-arms after firing (DSL v0.6, spec-0011).
2613#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2614#[serde(rename_all = "kebab-case")]
2615pub enum TrapReset {
2616 /// Fires once, then is spent — the survivability path for a forced lethal trap
2617 /// (respawn-safe with `keep_inventory`, non-re-triggering on the walk back; no
2618 /// soft-loop).
2619 Once,
2620 /// Re-fires every time the trigger is met (default). A forced lethal `rearm`
2621 /// trap must be avoidable or disarmable, else `DW0342`.
2622 #[default]
2623 Rearm,
2624}
2625
2626/// A [`Trap`]'s disarm affordance (DSL v0.6, spec-0011): the player acts on the
2627/// `via` anchor (an interaction the compiler emits, reusing the v0.4 interaction
2628/// entity) to turn the trap off — setting `sets_flag` and emptying the dispenser —
2629/// before the trap cell is forced. Discharges the `DW0342` obligation when the
2630/// affordance is reachable ahead of the trap without crossing it.
2631#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2632#[serde(deny_unknown_fields)]
2633pub struct TrapDisarm {
2634 /// The anchor the player interacts with to disarm.
2635 pub via: AnchorId,
2636 /// The flag set when the trap is disarmed (a new flag this trap produces; other
2637 /// objectives/triggers may read it via `requires_flags`).
2638 pub sets_flag: FlagId,
2639}
2640
2641/// A stage-5 **timed gate** (spec-0016 §4): a gate region driven by a
2642/// deterministic open/close clock, so passage is a timing read rather than a
2643/// permanent state.
2644///
2645/// **The proof is deliberately NOT all-phase passability** — a gate that
2646/// punishes bad timing is the entire point. What the
2647/// compiler requires is that the gate is *readable*: the set of entry phases from
2648/// which a walking player clears the span before it shuts must cover **≥ 20% of
2649/// the cycle** (`DW0378`). Below that it is a coin flip, not a skill, and no
2650/// amount of learning the level makes it fair.
2651#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2652#[serde(deny_unknown_fields)]
2653pub struct TimedGate {
2654 /// Unique timed-gate id (`timed-gate/<kebab>`).
2655 pub id: TimedGateId,
2656 /// The gate anchor the clock drives. Its prefab metadata must declare a fill
2657 /// `block` (`DW0343`), the same requirement `close-gate` and `shortcut` have.
2658 pub gate: AnchorId,
2659 /// Ticks the gate stays OPEN each cycle (> 0).
2660 pub open_ticks: u32,
2661 /// Ticks the gate stays CLOSED each cycle (> 0).
2662 pub closed_ticks: u32,
2663 /// Ticks after world init before the first open window begins (default 0) —
2664 /// how two gates in the same room are put out of step with each other. Must
2665 /// be less than the full cycle.
2666 #[serde(default, skip_serializing_if = "is_zero")]
2667 pub phase: u32,
2668 /// Whether the gate **kills** a player caught inside its region when it
2669 /// shuts (spec-0016 §4 addendum). A portcullis
2670 /// that merely shoves you aside teaches nothing; mistiming the crossing is
2671 /// supposed to be a death you learn from, which is the whole point of §4's
2672 /// ≥20%-of-cycle window proof — the window is fair, so the penalty can be
2673 /// absolute.
2674 ///
2675 /// This is a real judgement issued by command on the closing tick, not
2676 /// suffocation: vanilla's in-wall damage is slow, gear-dependent and
2677 /// escapable, so it would make the portcullis a suggestion. `damage` at the
2678 /// closing edge is exact and unarguable.
2679 ///
2680 /// **Defaults to `false`**, so every campaign authored before this field
2681 /// existed compiles byte-identically; a delve opts its portcullis in.
2682 #[serde(default, skip_serializing_if = "is_false")]
2683 pub crush: bool,
2684 /// Optional **disarm** affordance (souls dossier §5.2): the third
2685 /// rung of the hazard ladder — readable, avoidable, and finally *disable-able*.
2686 /// The real games' best timed hazards can be removed for good (Smouldering
2687 /// Lake's ballista, the Fringefolk chariot); a clock the party can only ever
2688 /// dance with is one rung short of the vocabulary.
2689 ///
2690 /// Interacting with the affordance suppresses the clock **permanently, with
2691 /// the gate resting OPEN** — a jammed portcullis stays up. Permanence is
2692 /// structural exactly as a `shortcut`'s is: no emitted function ever re-arms
2693 /// the clock, and `DW0389` refuses a campaign that spells a re-seal.
2694 ///
2695 /// **Defaults to absent**, so every campaign authored before this field
2696 /// existed compiles byte-identically; a delve opts its portcullis in.
2697 #[serde(default, skip_serializing_if = "Option::is_none")]
2698 pub disarm: Option<TimedGateDisarm>,
2699}
2700
2701/// A [`TimedGate`]'s disarm affordance (souls dossier §5.2) — the
2702/// exact shape a trap's [`TrapDisarm`] takes, and deliberately so: one affordance
2703/// grammar for every mechanism the party can switch off.
2704///
2705/// The player acts on the `via` anchor (a compiler-emitted interaction entity
2706/// plus its visible hardware, `DW0420`) to jam the gate. The clock stops with the
2707/// span cleared, `sets_flag` is raised party-wide, and nothing in the delve can
2708/// put the gate back.
2709#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2710#[serde(deny_unknown_fields)]
2711pub struct TimedGateDisarm {
2712 /// The anchor the player interacts with to jam the gate. Must be an anchor
2713 /// some area's prefab provides, and never the gate anchor itself — the
2714 /// mechanism belongs beside the doorway, not inside the span that crushes.
2715 pub via: AnchorId,
2716 /// The flag set when the gate is disarmed (a new flag this gate produces;
2717 /// other objectives/triggers may read it via `requires_flags`).
2718 pub sets_flag: FlagId,
2719}
2720
2721/// serde `skip_serializing_if` helper: skip a `0`.
2722fn is_zero(n: &u32) -> bool {
2723 *n == 0
2724}
2725
2726/// A stage-5 **ambush** (spec-0016 §3) — one declaration for a beat that
2727/// otherwise takes a deferred actor set plus a hand-wired trigger.
2728///
2729/// **`telegraph` is optional, and that is a design ruling, not an oversight.**
2730/// The un-telegraphed ambush — the shove off the cliff you
2731/// could not have known about — is core souls vocabulary: 初见杀 is how the level
2732/// teaches. The engine does not sand that edge off.
2733///
2734/// What the engine *does* owe the player is **counterplay on the retry**: having
2735/// died once, an informed player must have something to do about it. Determinism
2736/// guarantees the second attempt meets the same ambushers in the same cells; the
2737/// compiler adds the missing half — `DW0376` proves the trigger cell is not a
2738/// sealed pocket, i.e. that with every ambusher standing where it will stand,
2739/// a route out still exists (a retreat, luring ground, a positioning line).
2740/// Dying uninformed is a lesson; dying with no play available is a broken beat.
2741#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2742#[serde(deny_unknown_fields)]
2743pub struct Ambush {
2744 /// Unique ambush id (`ambush/<kebab>`).
2745 pub id: AmbushId,
2746 /// The anchor the trigger watches — the corner, doorway or ledge the player
2747 /// walks into.
2748 pub at: AnchorId,
2749 /// The stage-5 actors that spring (1..N). Each is summoned at its own
2750 /// declared anchor and immediately unleashed to real AI.
2751 pub actors: Vec<ActorId>,
2752 /// What springs it (`approach{range}` / `strike` / `use`), exactly the v0.4
2753 /// environment-trigger vocabulary.
2754 pub trigger: TriggerOn,
2755 /// The **optional** tell, fired at the trigger before the ambushers exist:
2756 /// a sound, a shadow, a line of narration. Empty = un-telegraphed, which is
2757 /// fully legal.
2758 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2759 pub telegraph: Vec<QuestEffect>,
2760 /// What the spring does to the story (DSL v0.8, spec-0025; required at
2761 /// 0.8.0, `DW0481`).
2762 ///
2763 /// **The declaration lives here because this is the object the author
2764 /// wrote.** An ambush desugars into a `spawn-actor` plus an `unleash-actor`
2765 /// per listed actor, and every one of those is a story node `DW0481` demands
2766 /// a `happening` from — on effects the author never wrote and cannot reach.
2767 /// So for as long as this field did not exist, an `ambushes[]` entry could
2768 /// not compile at 0.8.0 or above at all: declared in the schema, accepted by
2769 /// the schema check, and refused at validation with a prescription naming a
2770 /// field that was not there. Nothing caught it because no campaign and no
2771 /// fixture had ever written an ambush at a version where the obligation was
2772 /// live — which is the case spec-0039 exists to make impossible.
2773 ///
2774 /// An ambush is **one** beat, not `2N` of them: the ambushers appearing and
2775 /// coming at you is a single dramatic moment, so one declaration covers the
2776 /// whole spring. [`Self::to_trigger`] stamps it onto the first generated
2777 /// `spawn-actor` so the chronicle carries the line at the right position,
2778 /// and `branch::check_happenings` reads the obligation off the ambush rather
2779 /// than off the beats derived from it. Repeating it on all `2N` is the
2780 /// obvious alternative and it is wrong twice over: the chronicle would gain
2781 /// `N` duplicate lines, and `DW0485` would see one subject act repeatedly
2782 /// and be right to call it a contradiction.
2783 #[serde(default, skip_serializing_if = "Option::is_none")]
2784 pub happening: Option<Happening>,
2785}
2786
2787impl Ambush {
2788 /// The environment trigger this ambush desugars to (spec-0016 §3): a
2789 /// one-shot trigger at `at` whose effects are the telegraph, then a
2790 /// `spawn-actor` + `unleash-actor` per listed actor, in declared order.
2791 ///
2792 /// This is the **single** expansion authority. Every consumer — validation,
2793 /// the l10n inventory, the flag/wave producer scans, nav, emission — reads
2794 /// triggers through [`QuestsContent::all_triggers`], so the sugar cannot
2795 /// diverge from a hand-written equivalent, and an ambush is exactly as
2796 /// debuggable as the trigger an author would otherwise type.
2797 pub fn to_trigger(&self) -> EnvTrigger {
2798 let mut effects = self.telegraph.clone();
2799 for (i, a) in self.actors.iter().enumerate() {
2800 effects.push(QuestEffect {
2801 when: None,
2802 // The ambush declaration, on the beat where the spring becomes
2803 // real. Only the FIRST, for the reason the field documents: one
2804 // ambush is one beat, and stamping the line on every generated
2805 // effect would pad the chronicle and trip `DW0485`.
2806 happening: if i == 0 { self.happening.clone() } else { None },
2807 verb: Verb::SpawnActor { actor: a.clone() },
2808 });
2809 }
2810 for a in &self.actors {
2811 effects.push(Verb::UnleashActor { actor: a.clone() }.into());
2812 }
2813 EnvTrigger {
2814 id: TriggerId(format!(
2815 "trigger/{}",
2816 crate::l10n::local_id(self.id.as_str())
2817 )),
2818 at: Some(self.at.clone()),
2819 on: self.trigger.clone(),
2820 requires_flags: Vec::new(),
2821 forbids_flags: Vec::new(),
2822 requires_state: Vec::new(),
2823 once: true,
2824 // An ambush is a party beat by construction — it springs actors at
2825 // the room, not a reply to the one who walked in.
2826 audience: TriggerAudience::Party,
2827 effects,
2828 }
2829 }
2830}
2831
2832/// A stage-5 **shortcut door** (spec-0016 §2) — the souls loop-back.
2833///
2834/// The owner's definition of the pattern: between two rest points there are two
2835/// routes. The **short** one starts sealed and holds nothing; the **long** one is
2836/// full of enemies and mechanisms. You earn the far side the hard way, pull one
2837/// mechanism, and the short route opens **forever**. That moment is the design.
2838///
2839/// The compiler owns three obligations, none of them optional:
2840/// 1. the `unlock` affordance is reachable while the gate is still sealed — the
2841/// long route genuinely exists (`DW0373`);
2842/// 2. opening the gate genuinely shortens the trip across it — a shortcut that
2843/// pays nothing is a leak, not a shortcut (`DW0360`);
2844/// 3. permanence is **structural**: no `close-gate` may target a shortcut gate
2845/// (`DW0372`). There is no re-sealing verb to reach for.
2846///
2847/// `close-gate` on a NON-shortcut gate (the point-of-no-return staging beat) is
2848/// untouched by this — the two verbs are deliberately disjoint.
2849#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2850#[serde(deny_unknown_fields)]
2851pub struct Shortcut {
2852 /// Unique shortcut id (`shortcut/<kebab>`).
2853 pub id: ShortcutId,
2854 /// The gate anchor this shortcut opens. Sealed from world-load (the prefab
2855 /// carries the physical fill), and its metadata must declare the fill `block`
2856 /// the compiler clears — the same requirement `close-gate` has.
2857 pub gate: AnchorId,
2858 /// The FAR-side anchor whose interaction fires the permanent open. The
2859 /// compiler summons the affordance there and polls it, reusing the v0.4
2860 /// interaction-entity `use` primitive.
2861 pub unlock: AnchorId,
2862 /// Effects fired once, when the shortcut opens — the bar lifting, the
2863 /// elevator descending, the sound of a door you will never have to earn
2864 /// again. Emitted server-source-safe (the poll lives on the tick).
2865 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2866 pub on_unlock: Vec<QuestEffect>,
2867}
2868
2869/// A stage-5 environment trigger (DSL v0.4). Emission uses vanilla-intended
2870/// primitives only (spec-0008 §7): `strike`/`use` read a `minecraft:interaction`
2871/// entity's attack/interaction records; `approach` is a `distance` selector on
2872/// the tick. Look-at / break-attempt detection is excluded on principle (no
2873/// vanilla primitive).
2874#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2875#[serde(deny_unknown_fields)]
2876pub struct EnvTrigger {
2877 /// Unique trigger id (`trigger/<kebab>`).
2878 pub id: TriggerId,
2879 /// The anchor this trigger watches. Required for `strike` / `use` /
2880 /// `approach`, which watch a *place*; **absent** for `strike-npc` (DSL
2881 /// v0.6), which watches a *character* and names it in `on.npc` instead —
2882 /// there is no cell for the author to supply and no cell the compiler
2883 /// would use. Either mismatch is `DW0194`.
2884 #[serde(default, skip_serializing_if = "Option::is_none")]
2885 pub at: Option<AnchorId>,
2886 /// The event that fires it.
2887 pub on: TriggerOn,
2888 /// Flags that must be set before the trigger can fire (DSL v0.4).
2889 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2890 pub requires_flags: Vec<FlagId>,
2891 /// Negative flag gate (DSL v0.6): the trigger is
2892 /// **suppressed** while ANY listed flag is set (by any player — flags are
2893 /// campaign state). The dual of `requires_flags`, so an "armed between two
2894 /// story beats" trigger needs no re-arm plumbing: e.g. a strike-the-giant
2895 /// retaliation trigger with `requires_flags: [flag/sealed]` and
2896 /// `forbids_flags: [flag/asleep]` arms when the cave seals and stands down
2897 /// the moment the wake beat takes over.
2898 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2899 pub forbids_flags: Vec<FlagId>,
2900 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
2901 /// hold for this gate to be open. The third field of the one gate, carried by
2902 /// every gate consumer — never by the verb that first wanted it. Default
2903 /// empty, so a pre-0.10 campaign is byte-identical.
2904 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2905 pub requires_state: Vec<StateCompare>,
2906 /// Fire at most once (default `true`, mirroring objective completion). Set
2907 /// `false` to allow re-firing every time the condition is met.
2908 #[serde(default = "default_true")]
2909 pub once: bool,
2910 /// **Who the trigger's effects address** (DSL v0.11). Default
2911 /// [`TriggerAudience::Party`], so every campaign written before this field
2912 /// existed is byte-identical.
2913 ///
2914 /// A trigger is two different things depending on what the author means by
2915 /// it. A pressure plate that opens a gate and narrates the room is a **party
2916 /// beat**: everyone should see it, and it does not matter who stepped on the
2917 /// plate. A barred door that answers *"this cannot be opened from this
2918 /// side"* is a **reply to one person**: broadcasting it tells four players
2919 /// about a door three of them are nowhere near.
2920 ///
2921 /// Until this field the second was inexpressible, which is why the two verbs
2922 /// that needed it (`close-gate`'s seal answer, and nothing at all for a
2923 /// shortcut door) grew their own private reply machinery instead. The
2924 /// capability belongs to the press, not to the verb.
2925 #[serde(default, skip_serializing_if = "TriggerAudience::is_party")]
2926 pub audience: TriggerAudience,
2927 /// Effects fired when the trigger matches.
2928 pub effects: Vec<QuestEffect>,
2929}
2930
2931impl EnvTrigger {
2932 /// The anchor this trigger watches, if it watches a place at all. `None`
2933 /// for `strike-npc`, whose target is a character.
2934 pub fn at_anchor(&self) -> Option<&str> {
2935 self.at.as_ref().map(|a| a.as_str())
2936 }
2937
2938 /// Whether this trigger's bundle is addressed to the player who pressed it.
2939 pub fn addresses_presser(&self) -> bool {
2940 self.audience == TriggerAudience::Presser
2941 }
2942}
2943
2944/// Who an [`EnvTrigger`]'s effects address (DSL v0.11).
2945///
2946/// **This is a dispatch decision, not a cosmetic one.** A `party` trigger is
2947/// polled on the tick with no executor, so `@s` does not exist and every
2948/// player-facing command addresses `@a`. A `presser` trigger is dispatched by a
2949/// `minecraft:player_interacted_with_entity` advancement — the one vanilla
2950/// primitive that runs a function *as the player who clicked* — so `@s` is the
2951/// presser and the bundle addresses them alone.
2952///
2953/// That primitive exists for **right-clicks only**. Vanilla records a left-click
2954/// on an interaction entity in NBT (which names a UUID no command can become) and
2955/// offers no criterion for it, so `presser` on a `strike` is refused (`DW0427`)
2956/// rather than approximated: per CLAUDE.md's no-hack rule, a capability with no
2957/// vanilla primitive under it is excluded, never faked downstream.
2958#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2959#[serde(rename_all = "kebab-case")]
2960pub enum TriggerAudience {
2961 /// The whole party (the default, and what every trigger did before v0.11).
2962 #[default]
2963 Party,
2964 /// The one player whose click fired it.
2965 Presser,
2966}
2967
2968impl TriggerAudience {
2969 /// Serde skip predicate: the default needs no field on the wire, so a
2970 /// canonical round-trip of a pre-0.11 campaign is byte-identical.
2971 fn is_party(&self) -> bool {
2972 *self == TriggerAudience::Party
2973 }
2974}
2975
2976/// The event an [`EnvTrigger`] watches (DSL v0.4).
2977#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2978#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
2979pub enum TriggerOn {
2980 /// The player attacks (left-clicks) the interaction entity at the anchor.
2981 Strike,
2982 /// The player uses (right-clicks) the interaction entity at the anchor.
2983 Use,
2984 /// The player comes within `range` blocks of the anchor.
2985 Approach {
2986 /// Approach radius (blocks).
2987 range: u32,
2988 },
2989 /// The player attacks (left-clicks) an **NPC's body** (DSL v0.6).
2990 ///
2991 /// The place-based [`TriggerOn::Strike`] cannot express "hit the giant": it
2992 /// summons its own `minecraft:interaction` at a *cell*, and a large NPC's
2993 /// body eclipses that cell (`DW0359`), so the click never reaches the
2994 /// trigger — the owner's island round-7 finding. This form has no cell. It
2995 /// rides the interaction entity the NPC already owns, which is the entity a
2996 /// click on that NPC reaches by construction.
2997 ///
2998 /// Right-click and left-click stay separate all the way down: a
2999 /// `minecraft:interaction` records them in two distinct NBT fields
3000 /// (`interaction` and `attack`), so the NPC's dialogue keeps the right-click
3001 /// and this trigger takes the left-click, on one shared hitbox.
3002 StrikeNpc {
3003 /// The NPC (stage-2 ref) whose body is the target.
3004 npc: NpcId,
3005 },
3006}
3007
3008impl TriggerOn {
3009 /// The kebab tag (`strike` / `use` / `approach` / `strike-npc`).
3010 pub fn kind(&self) -> &'static str {
3011 match self {
3012 TriggerOn::Strike => "strike",
3013 TriggerOn::Use => "use",
3014 TriggerOn::Approach { .. } => "approach",
3015 TriggerOn::StrikeNpc { .. } => "strike-npc",
3016 }
3017 }
3018
3019 /// Whether this event needs an `at` anchor — true for everything that
3020 /// watches a place, false for `strike-npc`, which watches a character.
3021 pub fn needs_anchor(&self) -> bool {
3022 !matches!(self, TriggerOn::StrikeNpc { .. })
3023 }
3024
3025 /// The NPC whose body this event watches (`strike-npc` only).
3026 pub fn npc_target(&self) -> Option<&NpcId> {
3027 match self {
3028 TriggerOn::StrikeNpc { npc } => Some(npc),
3029 _ => None,
3030 }
3031 }
3032}
3033
3034/// A combat wave (DSL v0.3): a bundle of mobs spawned at an anchor and slain to
3035/// complete a `kill` objective. Emission (spec-0002): a `spawn-wave` effect
3036/// summons the mobs tagged `dw_wave_<id>` (AI enabled — they fight); a
3037/// `player_killed_entity` advancement per tag decrements a scoreboard countdown,
3038/// and the `kill` objective completes when the count reaches zero.
3039#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3040#[serde(deny_unknown_fields)]
3041pub struct Wave {
3042 /// Unique wave id.
3043 pub id: WaveId,
3044 /// The anchor the wave's mobs spawn at.
3045 pub anchor: AnchorId,
3046 /// The mobs that make up the wave (1..N).
3047 pub mobs: Vec<WaveMob>,
3048 /// Re-seat this wave every time the party rests at (or respawns from) a
3049 /// bonfire (spec-0016 §1) — the souls contract: progress is kept, the
3050 /// enemies come back. The compiler kills any survivor carrying the wave tag
3051 /// and re-runs the wave's own spawn function, so the room is restored to its
3052 /// authored composition and spawn cells.
3053 ///
3054 /// Inert without a `bonfire` in the campaign, which is a compile error
3055 /// (`DW0370`) rather than a silent no-op.
3056 #[serde(default, skip_serializing_if = "is_false")]
3057 pub respawns_on_rest: bool,
3058 /// Tower-defense lane routing (spec-0016 §6): march this wave along a
3059 /// waypoint polyline while distant, hand it to native AI the instant a
3060 /// player is inside `aggro_radius`. Absent = today's behaviour (spawn and
3061 /// stand), byte-identical.
3062 #[serde(default, skip_serializing_if = "Option::is_none")]
3063 pub lane: Option<WaveLane>,
3064 /// Where the wave's mobs materialize (spec-0016 §6). Absent =
3065 /// [`WaveSummon::Anchor`], the pre-0.6 behaviour: standable cells around the
3066 /// wave `anchor`.
3067 #[serde(default, skip_serializing_if = "Option::is_none")]
3068 pub summon: Option<WaveSummon>,
3069 /// How hard this encounter is *meant* to be (DSL v0.7, spec-0023). Absent =
3070 /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.7 campaign.
3071 ///
3072 /// This is a **declaration, not a knob**: the compiler never scales content
3073 /// from it (spec-0023 "Out of scope"). It exists because the validation
3074 /// ladder's inverted floor gate needs to know which fights the content
3075 /// *claims* are hard — an `elite`/`boss` encounter the unassisted bot beats
3076 /// on its first attempt is reported as too easy for its billing. Marking it
3077 /// is how the author opts into that scrutiny; the alternative — inferring
3078 /// "elite" from how tuned a stack looks — is exactly the downstream folklore
3079 /// CLAUDE.md's no-hack rule forbids.
3080 ///
3081 /// **It does reach emission in exactly one place** (spec-0016 §1): in a
3082 /// campaign with a `bonfire`, a billed `elite`/
3083 /// `boss` wave that does not declare `respawns_on_rest` is refreshed by a
3084 /// rest *while it is still standing* — deleted and re-seated at full count
3085 /// and full health, so chipping it down one life at a time is never a path.
3086 /// Beat it and it stays beaten. `DW0499` forbids billing a wave `boss` and
3087 /// `respawns_on_rest` at once.
3088 #[serde(default, skip_serializing_if = "Option::is_none")]
3089 pub tier: Option<EncounterTier>,
3090 /// A health bar over this wave's bodies (DSL v0.31, spec-0073): a named bar
3091 /// over their total health, drawn for every player within `range` blocks of a
3092 /// live one. Absent = no bar, byte-identical. Declared, never derived from
3093 /// `tier`; a `boss`-billed wave without one is advised (`DW0912`).
3094 #[serde(default, skip_serializing_if = "Option::is_none")]
3095 pub health_bar: Option<crate::healthbar::HealthBar>,
3096 /// What happens each time a player is credited with killing one of this
3097 /// wave's bodies (spec-0074) — effect root R9, the same
3098 /// [`OnKill`] an actor declares. Absent = no bundle, and the
3099 /// wave's emission is byte-identical.
3100 #[serde(default, skip_serializing_if = "Option::is_none")]
3101 pub on_kill: Option<OnKill>,
3102}
3103
3104/// What happens each time a body of this fight is killed (spec-0074): the
3105/// `on_kill` of a wave or an actor.
3106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3107#[serde(deny_unknown_fields)]
3108pub struct OnKill {
3109 /// Whether a body that comes back pays again. Required where the fight comes
3110 /// back after the party has met it — a bonfire re-seats it, or the beat that
3111 /// seats it can fire more than once (`DW0915`); left off where it does not,
3112 /// and `every-kill` there is refused as inert (`DW0914`). No default: whether
3113 /// an economy can be farmed is the creator's judgement.
3114 #[serde(default, skip_serializing_if = "Option::is_none")]
3115 pub fires: Option<KillFires>,
3116 /// The effects, run as the credited player for each credited kill. Every verb
3117 /// an `on_objective_complete` bundle accepts, each gated by its own `when`.
3118 #[schemars(length(min = 1))]
3119 pub effects: Vec<QuestEffect>,
3120}
3121
3122/// Whether a body that comes back after a rest pays again (spec-0074 §4).
3123#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3124#[serde(rename_all = "kebab-case")]
3125pub enum KillFires {
3126 /// Over the whole delve the fight pays at most once per body it seats — the
3127 /// wave's body count, or once for an actor. A re-seat does not renew it.
3128 FirstKill,
3129 /// Every credited kill pays, however many times the fight is re-seated.
3130 EveryKill,
3131}
3132
3133impl KillFires {
3134 /// The kebab token, as it appears in the DSL.
3135 pub fn token(self) -> &'static str {
3136 match self {
3137 KillFires::FirstKill => "first-kill",
3138 KillFires::EveryKill => "every-kill",
3139 }
3140 }
3141}
3142
3143/// What a wave is billed as (DSL v0.7, spec-0023). Consumed by the validation
3144/// ladder (the run's combat plan) and — since spec-0016 §1's undefeated re-seat
3145/// — by one emission site: a bonfire refreshes a billed wave that is still
3146/// standing. Nothing about the encounter itself is scaled from it.
3147#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3148#[serde(rename_all = "kebab-case")]
3149pub enum EncounterTier {
3150 /// Trash / pressure. No floor expectation: a bot that wins it cold proves
3151 /// nothing either way. The default.
3152 #[default]
3153 Ordinary,
3154 /// A set-piece the content bills as a hard fight (spec-0016's optional-elite
3155 /// and spawn-and-unleash vocabulary).
3156 Elite,
3157 /// A campaign's named fight. Same floor rule as `elite`; the distinction is
3158 /// for the run report a human reads.
3159 Boss,
3160}
3161
3162impl EncounterTier {
3163 /// The kebab tag, as it appears in the DSL and in the emitted combat plan.
3164 pub fn token(self) -> &'static str {
3165 match self {
3166 EncounterTier::Ordinary => "ordinary",
3167 EncounterTier::Elite => "elite",
3168 EncounterTier::Boss => "boss",
3169 }
3170 }
3171
3172 /// Does the inverted floor gate (spec-0023) apply to this tier? A fight the
3173 /// content bills as hard carries an expectation the bot can measure; an
3174 /// ordinary one does not.
3175 pub fn has_floor_expectation(self) -> bool {
3176 matches!(self, EncounterTier::Elite | EncounterTier::Boss)
3177 }
3178}
3179
3180/// Where a wave's mobs materialize (spec-0016 §6).
3181#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3182#[serde(rename_all = "kebab-case")]
3183pub enum WaveSummon {
3184 /// Standable cells around the wave `anchor`, nearest first — the pre-0.6
3185 /// behaviour and the default.
3186 Anchor,
3187 /// **Spirit-summoned at the edge of perception**:
3188 /// each mob appears on the ring at its own `attributes.follow_range` from
3189 /// the wave `anchor`, so it acquires a target the instant it exists and
3190 /// closes under pure native AI. Species without patrol AI never march a
3191 /// lane; this is what they do instead — never a spawn on top of the party,
3192 /// never a mob that brushes past.
3193 ///
3194 /// With this mode the wave `anchor` is the **defended point** (what the ring
3195 /// is drawn around), not the spawn point. Each mob stack must declare its
3196 /// own `attributes.follow_range` (`DW0385`) — the ring radius is authored,
3197 /// never guessed from a vanilla defaults table the compiler cannot verify.
3198 AggroEdge,
3199}
3200
3201/// Tower-defense lane routing for a wave (spec-0016 §6), built on vanilla's
3202/// **Raider patrol system** — the intended primitive, live-verified on 1.21.11
3203/// (`docs/notes/td-routing-spike.md`).
3204///
3205/// The squad spawns `Patrolling:1b` with one `PatrolLeader:1b` and a snake_case
3206/// `patrol_target` int-array; a compiler-emitted clock walks the shared waypoint
3207/// index forward, and per mob a player-proximity check releases `Patrolling:0b`.
3208/// From that instant the mob is a plain native hostile. "Combat preempts
3209/// routing" is engine semantics — vanilla's patrol goal is hard-gated on having
3210/// no target — so the owner's rule (march while distant, fight with NATIVE AI
3211/// once aggroed, never brush past) falls out of the primitive unforced.
3212///
3213/// Lanes are raider-family only (`DW0382`), squad ≥ 2 (`DW0383`), and a lane
3214/// pillager must keep its crossbow (`DW0384`).
3215#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3216#[serde(deny_unknown_fields)]
3217pub struct WaveLane {
3218 /// The lane polyline, in march order: anchors some area's prefab provides.
3219 /// At least one; consecutive legs (counting the wave `anchor` as the origin)
3220 /// must be more than 10 blocks apart — vanilla re-rolls a patrol target
3221 /// within 10 blocks of arrival, so a tighter lane is a lane the engine
3222 /// quietly stops following (`DW0386`).
3223 pub waypoints: Vec<AnchorId>,
3224 /// The release radius, in blocks: the compiler sets every lane mob's
3225 /// `follow_range` attribute to exactly this and releases `Patrolling:0b`
3226 /// at the same distance. The two MUST be equal — a patrolling raider that
3227 /// targets a player it cannot engage holds ground instead of marching, so a
3228 /// per-mob `attributes.follow_range` that disagrees is `DW0381`.
3229 pub aggro_radius: u32,
3230}
3231
3232/// One mob stack in a [`Wave`].
3233#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3234#[serde(deny_unknown_fields)]
3235pub struct WaveMob {
3236 /// Vanilla entity id, validated against the pinned 1.21.11 registry.
3237 pub entity: String,
3238 /// How many to spawn.
3239 pub count: u32,
3240 /// Optional custom name (shown above the mob).
3241 #[serde(default, skip_serializing_if = "Option::is_none")]
3242 pub name: Option<String>,
3243 /// Optional attribute overrides (DSL v0.4), emitted as 1.21.11 attribute
3244 /// components. Enables e.g. a weakened live warden as a survivable stealth
3245 /// threat. Omitted = vanilla defaults.
3246 #[serde(default, skip_serializing_if = "Option::is_none")]
3247 pub attributes: Option<MobAttributes>,
3248 /// Optional permanent, ambient status effects (DSL v0.4).
3249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3250 pub effects: Vec<MobEffect>,
3251 /// Optional worn/held equipment (DSL v0.6). A helmet is the
3252 /// sanctioned fix for daylight-burning undead — never
3253 /// `set-time`. Item ids validate against the pinned 1.21.11 item registry
3254 /// (`DW0143`, the give-item family); every emitted slot carries drop
3255 /// chance 0 so players can never farm wave gear (no-grind constitution).
3256 #[serde(default, skip_serializing_if = "Option::is_none")]
3257 pub equipment: Option<MobEquipment>,
3258 /// What this mob leaves behind when it dies (DSL v0.9). Only an `elite`/`boss` wave may declare it (`DW0491`)
3259 /// — an ordinary mob's kit is never farmable. Empty = drop chance 0 on
3260 /// every slot.
3261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3262 pub drops: Vec<MobDrop>,
3263}
3264
3265/// Worn/held equipment for a wave mob (DSL v0.6). Each field is a vanilla item
3266/// id for the matching vanilla equipment slot; an unset slot stays empty —
3267/// except `main_hand`, where the compiler's armed-mob default (skeleton bow,
3268/// wither-skeleton sword) still applies unless overridden. Emitted as the
3269/// component-era `equipment`/`drop_chances` summon NBT (1.21.11 silently
3270/// ignores legacy `ArmorItems`/`HandItems` on `/summon`), all drop chances 0.
3271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3272#[serde(deny_unknown_fields)]
3273pub struct MobEquipment {
3274 /// Head slot.
3275 #[serde(default, skip_serializing_if = "Option::is_none")]
3276 pub head: Option<EquipItem>,
3277 /// Chest slot.
3278 #[serde(default, skip_serializing_if = "Option::is_none")]
3279 pub chest: Option<EquipItem>,
3280 /// Legs slot.
3281 #[serde(default, skip_serializing_if = "Option::is_none")]
3282 pub legs: Option<EquipItem>,
3283 /// Feet slot.
3284 #[serde(default, skip_serializing_if = "Option::is_none")]
3285 pub feet: Option<EquipItem>,
3286 /// Main-hand slot. Overrides the compiler's armed-mob default.
3287 #[serde(default, skip_serializing_if = "Option::is_none")]
3288 pub main_hand: Option<EquipItem>,
3289 /// Off-hand slot.
3290 #[serde(default, skip_serializing_if = "Option::is_none")]
3291 pub off_hand: Option<EquipItem>,
3292 /// Body slot: horse armour, wolf armour, a llama's carpet, a nautilus's
3293 /// armour, a happy ghast's harness (spec-0067). Shown only on a body whose
3294 /// entity type draws it (`DW0898`).
3295 #[serde(default, skip_serializing_if = "Option::is_none")]
3296 pub body: Option<EquipItem>,
3297 /// Saddle slot (spec-0067). Shown only on a body whose entity type draws a
3298 /// saddle (`DW0898`).
3299 #[serde(default, skip_serializing_if = "Option::is_none")]
3300 pub saddle: Option<EquipItem>,
3301}
3302
3303impl MobEquipment {
3304 /// Every slot as `(dsl_field_name, piece)`, in [`EquipSlot::ALL`]'s order —
3305 /// the single iteration source for validation paths and emission.
3306 pub fn slots(&self) -> [(&'static str, Option<&EquipItem>); EquipSlot::ALL.len()] {
3307 EquipSlot::ALL.map(|s| (s.field(), self.filled(s)))
3308 }
3309
3310 /// Every slot as `(slot, piece)`, in [`EquipSlot::ALL`]'s order.
3311 pub fn pieces(&self) -> [(EquipSlot, Option<&EquipItem>); EquipSlot::ALL.len()] {
3312 EquipSlot::ALL.map(|s| (s, self.filled(s)))
3313 }
3314
3315 /// The piece this equipment declaration puts in `slot`, if any. The single
3316 /// question a `drops[]` `slot` entry asks: a mob can only drop a piece it
3317 /// actually wears (`DW0490`).
3318 pub fn filled(&self, slot: EquipSlot) -> Option<&EquipItem> {
3319 match slot {
3320 EquipSlot::Head => self.head.as_ref(),
3321 EquipSlot::Chest => self.chest.as_ref(),
3322 EquipSlot::Legs => self.legs.as_ref(),
3323 EquipSlot::Feet => self.feet.as_ref(),
3324 EquipSlot::MainHand => self.main_hand.as_ref(),
3325 EquipSlot::OffHand => self.off_hand.as_ref(),
3326 EquipSlot::Body => self.body.as_ref(),
3327 EquipSlot::Saddle => self.saddle.as_ref(),
3328 }
3329 }
3330}
3331
3332/// One vanilla equipment slot, named exactly as the [`MobEquipment`] field that
3333/// fills it. The DSL name and the summon-NBT key differ (`main_hand` vs
3334/// `mainhand`), so both live here and nowhere else.
3335///
3336/// **The set is the pinned game's equipment-slot set** (spec-0067 §2), a
3337/// [`crate::metrics::Provenance::VanillaRule`]: the eight values the
3338/// `minecraft:equippable` component's `slot` field takes, per the Minecraft
3339/// Wiki page *Data component format/equippable* for Java 1.21.11, which are the
3340/// serialised names of the client's `EquipmentSlot` enum. The pinned item data
3341/// is the cross-check, not the source: every `slot` value an item declares is
3342/// asserted to be one of these, and no item declares `mainhand`, because a hand
3343/// takes anything.
3344#[derive(
3345 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
3346)]
3347#[serde(rename_all = "snake_case")]
3348pub enum EquipSlot {
3349 /// Head slot.
3350 Head,
3351 /// Chest slot.
3352 Chest,
3353 /// Legs slot.
3354 Legs,
3355 /// Feet slot.
3356 Feet,
3357 /// Main-hand slot.
3358 MainHand,
3359 /// Off-hand slot.
3360 OffHand,
3361 /// Body slot (horse armour, wolf armour, carpet, harness).
3362 Body,
3363 /// Saddle slot.
3364 Saddle,
3365}
3366
3367impl EquipSlot {
3368 /// Every slot, in emission order: the two hands, the four armour slots,
3369 /// then `body` and `saddle`. The one enumeration every slot list derives
3370 /// from — `MobEquipment::slots()`, the summon `equipment` / `drop_chances`
3371 /// compounds and the drop-strip line.
3372 pub const ALL: [EquipSlot; 8] = [
3373 EquipSlot::MainHand,
3374 EquipSlot::OffHand,
3375 EquipSlot::Head,
3376 EquipSlot::Chest,
3377 EquipSlot::Legs,
3378 EquipSlot::Feet,
3379 EquipSlot::Body,
3380 EquipSlot::Saddle,
3381 ];
3382
3383 /// The DSL field name (`main_hand`), for diagnostics and JSON pointers.
3384 pub fn field(self) -> &'static str {
3385 match self {
3386 EquipSlot::Head => "head",
3387 EquipSlot::Chest => "chest",
3388 EquipSlot::Legs => "legs",
3389 EquipSlot::Feet => "feet",
3390 EquipSlot::MainHand => "main_hand",
3391 EquipSlot::OffHand => "off_hand",
3392 EquipSlot::Body => "body",
3393 EquipSlot::Saddle => "saddle",
3394 }
3395 }
3396
3397 /// The 1.21.11 `equipment` / `drop_chances` NBT key (`mainhand`) — also the
3398 /// game's own name for the slot, as an `equippable` component spells it.
3399 pub fn nbt(self) -> &'static str {
3400 match self {
3401 EquipSlot::Head => "head",
3402 EquipSlot::Chest => "chest",
3403 EquipSlot::Legs => "legs",
3404 EquipSlot::Feet => "feet",
3405 EquipSlot::MainHand => "mainhand",
3406 EquipSlot::OffHand => "offhand",
3407 EquipSlot::Body => "body",
3408 EquipSlot::Saddle => "saddle",
3409 }
3410 }
3411
3412 /// The slot the game names `name` (`mainhand`), if it is one.
3413 pub fn from_nbt(name: &str) -> Option<EquipSlot> {
3414 EquipSlot::ALL.into_iter().find(|s| s.nbt() == name)
3415 }
3416
3417 /// Whether this is a hand: a hand takes any item, so an item's own declared
3418 /// slot never contradicts it.
3419 pub fn is_hand(self) -> bool {
3420 matches!(self, EquipSlot::MainHand | EquipSlot::OffHand)
3421 }
3422}
3423
3424/// One declared drop of an elite or boss (DSL v0.9).
3425///
3426/// A mob may wear many pieces; what it *leaves behind* is a **declared subset**,
3427/// usually one piece and never automatically everything. Two forms, told apart
3428/// by which field is present:
3429///
3430/// ```json
3431/// { "slot": "main_hand" } // its axe
3432/// { "item": "minecraft:tripwire_hook", "name": "Gate Key" } // a quest item
3433/// ```
3434///
3435/// A `slot` entry must name a slot the same entity's `equipment` really fills
3436/// (`DW0490`) — the drop is the piece the player has been *looking at* through
3437/// the whole fight, so the two declarations cannot disagree. An `item` entry is
3438/// a token the fight *yields* rather than wears, and rides the entity's own
3439/// death loot table.
3440///
3441/// Undeclared slots keep drop chance `0.0` — byte-for-byte today's behaviour, and
3442/// the no-grind constitution's guarantee that an ordinary kit is never farmable.
3443#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3444#[serde(untagged)]
3445pub enum MobDrop {
3446 /// A worn/held piece, named by its equipment slot.
3447 Slot(SlotDrop),
3448 /// A quest token the fight yields (not worn).
3449 Item(ItemDrop),
3450}
3451
3452/// The worn-piece form of a [`MobDrop`].
3453#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3454#[serde(deny_unknown_fields)]
3455pub struct SlotDrop {
3456 /// The equipment slot whose piece drops. Must be filled by the entity's own
3457 /// `equipment` declaration (`DW0490`).
3458 pub slot: EquipSlot,
3459}
3460
3461/// The quest-token form of a [`MobDrop`].
3462#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3463#[serde(deny_unknown_fields)]
3464pub struct ItemDrop {
3465 /// Vanilla item id, validated against the pinned 1.21.11 registry
3466 /// (`DW0143`, the give-item family).
3467 pub item: String,
3468 /// Display name the dropped stack carries as a `custom_name` component.
3469 /// Player-visible, so it enters the l10n string inventory and translates
3470 /// like any other line.
3471 #[serde(default, skip_serializing_if = "Option::is_none")]
3472 pub name: Option<String>,
3473}
3474
3475impl MobDrop {
3476 /// The equipment slot this entry names, or `None` for a quest-item drop.
3477 pub fn slot(&self) -> Option<EquipSlot> {
3478 match self {
3479 MobDrop::Slot(s) => Some(s.slot),
3480 MobDrop::Item(_) => None,
3481 }
3482 }
3483
3484 /// The item id this entry names, or `None` for a worn-piece drop.
3485 pub fn item(&self) -> Option<&str> {
3486 match self {
3487 MobDrop::Slot(_) => None,
3488 MobDrop::Item(i) => Some(&i.item),
3489 }
3490 }
3491
3492 /// The declared display name of a quest-item drop, when set.
3493 pub fn name(&self) -> Option<&String> {
3494 match self {
3495 MobDrop::Slot(_) => None,
3496 MobDrop::Item(i) => i.name.as_ref(),
3497 }
3498 }
3499
3500 /// Mutable access to the display name — the l10n traversal's hook.
3501 pub fn name_mut(&mut self) -> Option<&mut String> {
3502 match self {
3503 MobDrop::Slot(_) => None,
3504 MobDrop::Item(i) => i.name.as_mut(),
3505 }
3506 }
3507}
3508
3509/// One equipped item: either a bare item id, or an id carrying enchantments.
3510///
3511/// The plain form is the common case and stays a plain JSON string, which is
3512/// what keeps every campaign written before enchantments existed byte-identical
3513/// on re-serialisation:
3514///
3515/// ```json
3516/// "main_hand": "minecraft:netherite_sword"
3517/// "head": { "item": "minecraft:netherite_helmet",
3518/// "enchantments": { "minecraft:protection": 4 } }
3519/// ```
3520#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3521#[serde(untagged)]
3522pub enum EquipItem {
3523 /// A bare item id — no enchantments.
3524 Plain(String),
3525 /// An item id plus its enchantments.
3526 Enchanted(EnchantedItem),
3527}
3528
3529impl EquipItem {
3530 /// The item id, whichever form was authored.
3531 pub fn item(&self) -> &str {
3532 match self {
3533 EquipItem::Plain(s) => s,
3534 EquipItem::Enchanted(e) => &e.item,
3535 }
3536 }
3537
3538 /// The enchantments on this piece — empty for the plain form. `BTreeMap`
3539 /// ordered, so emission order is the id order and never hash order
3540 /// (ADR-0006).
3541 pub fn enchantments(&self) -> &BTreeMap<String, u32> {
3542 static EMPTY: std::sync::LazyLock<BTreeMap<String, u32>> =
3543 std::sync::LazyLock::new(BTreeMap::new);
3544 match self {
3545 EquipItem::Plain(_) => &EMPTY,
3546 EquipItem::Enchanted(e) => &e.enchantments,
3547 }
3548 }
3549}
3550
3551/// **The item component a stack's enchantments are written to** — vanilla's own
3552/// rule (`EnchantmentHelper.getComponentType` at 1.21.11): an enchanted book
3553/// STORES its enchantments (`minecraft:stored_enchantments`, what an anvil
3554/// applies to the item it is combined with), and every other item CARRIES them
3555/// (`minecraft:enchantments`). Both are in the pinned 1.21.11
3556/// `data_component_type` registry.
3557///
3558/// A property of the item, not of the surface that writes the stack: a book in
3559/// a `loot` chest, a book handed over by `give-item` and a book on an equipped
3560/// piece are one object, so every emitter asks this one function. An
3561/// enchantment map written to the other component is a book that glints and
3562/// an anvil ignores, which the game accepts without a word.
3563pub fn enchantment_component(item: &str) -> &'static str {
3564 match item.strip_prefix("minecraft:").unwrap_or(item) {
3565 "enchanted_book" => "minecraft:stored_enchantments",
3566 _ => "minecraft:enchantments",
3567 }
3568}
3569
3570/// The enchanted form of [`EquipItem`].
3571#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3572#[serde(deny_unknown_fields)]
3573pub struct EnchantedItem {
3574 /// Item id (e.g. `minecraft:netherite_chestplate`).
3575 pub item: String,
3576 /// Enchantment id → level (e.g. `{"minecraft:protection": 4}`). Emitted as
3577 /// the 1.21 `minecraft:enchantments` item component.
3578 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3579 pub enchantments: BTreeMap<String, u32>,
3580}
3581
3582/// Attribute overrides for a wave mob (DSL v0.4). Each field maps to a 1.21.11
3583/// `minecraft:` attribute component; an unset field keeps the vanilla base value.
3584#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3585#[serde(deny_unknown_fields)]
3586pub struct MobAttributes {
3587 /// `minecraft:max_health` base value.
3588 #[serde(default, skip_serializing_if = "Option::is_none")]
3589 pub max_health: Option<f64>,
3590 /// `minecraft:attack_damage` base value.
3591 #[serde(default, skip_serializing_if = "Option::is_none")]
3592 pub attack_damage: Option<f64>,
3593 /// `minecraft:movement_speed` base value.
3594 #[serde(default, skip_serializing_if = "Option::is_none")]
3595 pub movement_speed: Option<f64>,
3596 /// `minecraft:follow_range` base value.
3597 #[serde(default, skip_serializing_if = "Option::is_none")]
3598 pub follow_range: Option<f64>,
3599}
3600
3601/// One permanent status effect on a wave mob (DSL v0.4), emitted as an ambient,
3602/// non-expiring `effect give`.
3603#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3604#[serde(deny_unknown_fields)]
3605pub struct MobEffect {
3606 /// Vanilla effect id (e.g. `minecraft:slowness`), validated against the
3607 /// pinned registry (`DW0192`).
3608 pub effect: String,
3609 /// Amplifier (0 = level I).
3610 pub amplifier: u32,
3611}
3612
3613/// One expanded quest.
3614#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3615#[serde(deny_unknown_fields)]
3616pub struct Quest {
3617 /// Quest id (matches a stage-4 planned quest).
3618 pub id: QuestId,
3619 /// What starts the quest.
3620 pub trigger: Trigger,
3621 /// Ordered objectives (intra-quest DAG via `after`).
3622 pub objectives: Vec<Objective>,
3623 /// Effects fired when a given objective completes.
3624 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3625 pub on_objective_complete: BTreeMap<ObjectiveId, Vec<QuestEffect>>,
3626 /// Effects fired when the whole quest completes.
3627 pub on_complete: Vec<QuestEffect>,
3628 /// The **cast ledger** (DSL v0.7, spec-0020): for every stage-2 NPC that is
3629 /// live during this quest — spawned and not explicitly removed — where it
3630 /// stands, what it is doing, and what its right-click offers *for this
3631 /// quest's duration*.
3632 ///
3633 /// The ledger exists because an NPC's dialogue used to be one tree for the
3634 /// whole campaign: after the climactic escape a crew member still offered
3635 /// "Tell me what he is." — a premise question absurd once the story moved on.
3636 /// Declaring the scene per quest makes the compiler able to check it
3637 /// (`DW0460`–`DW0467`) and makes the declaration itself the gate: the
3638 /// emitted right-click shows the root this quest declares, so a stale root
3639 /// retires *because the ledger says so*, not because an author remembered a
3640 /// flag.
3641 ///
3642 /// Every NPC live during the quest owes an entry (`DW0460`).
3643 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3644 pub cast: BTreeMap<NpcId, CastEntry>,
3645 /// What this quest does to the story (DSL v0.8, spec-0025; required at 0.8.0,
3646 /// `DW0481`).
3647 #[serde(default, skip_serializing_if = "Option::is_none")]
3648 pub happening: Option<Happening>,
3649}
3650
3651/// One NPC's entry in a quest's [`cast`](Quest::cast) ledger.
3652///
3653/// Three authored shapes, tried in order (untagged): the bare keyword `"dead"` /
3654/// `"offstage"`, a single flat [`CastPlacement`], or a **list** of placements —
3655/// per-branch casts, each gated by the flags that select its branch (spec-0020
3656/// proof 4: where the effect history is branch-dependent, a single flat
3657/// declaration cannot hold on every reachable branch, and `DW0462` says so).
3658#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3659#[serde(untagged)]
3660pub enum CastEntry {
3661 /// The bare keyword form: `"npc/antiphos": "dead"`. Shorthand for a
3662 /// placement whose `at` is that keyword and which carries no `doing` or
3663 /// `dialogue` — a character who is not in the world has no business and
3664 /// answers no right-click.
3665 Absent(CastAbsence),
3666 /// A single placement that must hold on every reachable branch.
3667 Placement(CastPlacement),
3668 /// Per-branch placements (spec-0020 proof 4). Each carries the
3669 /// `requires_flags`/`forbids_flags` that select its branch.
3670 Branches(Vec<CastPlacement>),
3671}
3672
3673impl CastEntry {
3674 /// Every placement this entry declares, in declared order. The bare-keyword
3675 /// form yields none — there is no scene to check.
3676 pub fn placements(&self) -> Vec<&CastPlacement> {
3677 match self {
3678 CastEntry::Absent(_) => Vec::new(),
3679 CastEntry::Placement(p) => vec![p],
3680 CastEntry::Branches(ps) => ps.iter().collect(),
3681 }
3682 }
3683
3684 /// Every placement this entry declares, mutably (the l10n traversal).
3685 pub fn placements_mut(&mut self) -> Vec<&mut CastPlacement> {
3686 match self {
3687 CastEntry::Absent(_) => Vec::new(),
3688 CastEntry::Placement(p) => vec![p],
3689 CastEntry::Branches(ps) => ps.iter_mut().collect(),
3690 }
3691 }
3692
3693 /// The bare-keyword absence this entry declares, if it is that form.
3694 pub fn absence(&self) -> Option<CastAbsence> {
3695 match self {
3696 CastEntry::Absent(a) => Some(*a),
3697 _ => None,
3698 }
3699 }
3700
3701 /// True if this entry declares the NPC out of the world entirely — the bare
3702 /// keyword, or every placement's `at` being a keyword.
3703 pub fn is_absent(&self) -> bool {
3704 match self {
3705 CastEntry::Absent(_) => true,
3706 CastEntry::Placement(p) => p.at.absence().is_some(),
3707 CastEntry::Branches(ps) => {
3708 !ps.is_empty() && ps.iter().all(|p| p.at.absence().is_some())
3709 }
3710 }
3711 }
3712}
3713
3714/// A declared absence: the NPC is deliberately not in the world.
3715#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3716#[serde(rename_all = "kebab-case")]
3717pub enum CastAbsence {
3718 /// Removed from the story for good (must match a `despawn-npc` with no later
3719 /// `spawn-npc`).
3720 Dead,
3721 /// Not in the world for this quest, but may return (must match a
3722 /// `despawn-npc`).
3723 Offstage,
3724}
3725
3726impl CastAbsence {
3727 /// The authored keyword (`dead` / `offstage`).
3728 pub fn token(self) -> &'static str {
3729 match self {
3730 CastAbsence::Dead => "dead",
3731 CastAbsence::Offstage => "offstage",
3732 }
3733 }
3734}
3735
3736/// Where a cast entry puts an NPC: a prefab anchor, a mark, or a declared
3737/// absence.
3738#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3739#[serde(untagged)]
3740pub enum CastPlace {
3741 /// `"dead"` or `"offstage"` — explicitly not in the world.
3742 Absent(CastAbsence),
3743 /// The anchor the NPC stands on for this quest's duration. Must equal the
3744 /// position the effect history actually produces (`DW0461`): declaring an
3745 /// anchor does not teleport anybody.
3746 Anchor(AnchorId),
3747 /// The mark the NPC stands on for this quest's duration (spec-0066): the
3748 /// spelling for a body that stands at an offset from its anchor. `DW0461`
3749 /// compares anchor and offset both.
3750 Mark(Mark),
3751}
3752
3753impl CastPlace {
3754 /// The anchor this place names, if it names one.
3755 pub fn anchor(&self) -> Option<&AnchorId> {
3756 match self {
3757 CastPlace::Anchor(a) => Some(a),
3758 CastPlace::Mark(m) => Some(&m.anchor),
3759 CastPlace::Absent(_) => None,
3760 }
3761 }
3762
3763 /// The mark this place names, if it names one: a bare anchor is the mark at
3764 /// a zero offset.
3765 pub fn mark(&self) -> Option<Mark> {
3766 match self {
3767 CastPlace::Anchor(a) => Some(Mark::at(a.clone())),
3768 CastPlace::Mark(m) => Some(m.clone()),
3769 CastPlace::Absent(_) => None,
3770 }
3771 }
3772
3773 /// The declared absence, if this place is one.
3774 pub fn absence(&self) -> Option<CastAbsence> {
3775 match self {
3776 CastPlace::Absent(a) => Some(*a),
3777 CastPlace::Anchor(_) | CastPlace::Mark(_) => None,
3778 }
3779 }
3780
3781 /// The authored token, for diagnostics.
3782 pub fn token(&self) -> String {
3783 match self {
3784 CastPlace::Absent(a) => a.token().to_string(),
3785 CastPlace::Anchor(a) => a.as_str().to_string(),
3786 CastPlace::Mark(m) => m.display(),
3787 }
3788 }
3789}
3790
3791/// One declared scene for one NPC in one quest.
3792#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3793#[serde(deny_unknown_fields)]
3794pub struct CastPlacement {
3795 /// Where the NPC is: an anchor, or `"offstage"` / `"dead"`.
3796 pub at: CastPlace,
3797 /// What the character is *doing* — free prose, never machine-checked.
3798 ///
3799 /// This field is the forcing function, which is the whole reason it is
3800 /// required (`DW0463`) despite being unverifiable: you cannot fill it in
3801 /// without deciding the character's business in this beat, and stage 6
3802 /// receives it as the context the NPC's lines are written against.
3803 #[serde(default, skip_serializing_if = "Option::is_none")]
3804 pub doing: Option<String>,
3805 /// What right-click offers during this quest. Required for an on-stage
3806 /// placement (`DW0463`) — including the explicit `"none"`.
3807 #[serde(default, skip_serializing_if = "Option::is_none")]
3808 pub dialogue: Option<CastDialogue>,
3809 /// Branch gate (per-branch casts): this placement describes the world only
3810 /// once every listed flag is set. Mirrors an option's `requires_flags`.
3811 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3812 pub requires_flags: Vec<FlagId>,
3813 /// Negative branch gate: this placement describes the world only while no
3814 /// listed flag is set. Mirrors an option's `forbids_flags`.
3815 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3816 pub forbids_flags: Vec<FlagId>,
3817 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
3818 /// hold for this gate to be open. The third field of the one gate, carried by
3819 /// every gate consumer — never by the verb that first wanted it. Default
3820 /// empty, so a pre-0.10 campaign is byte-identical.
3821 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3822 pub requires_state: Vec<StateCompare>,
3823}
3824
3825/// What an NPC's right-click offers for a quest's duration.
3826///
3827/// Untagged, tried in order: the keywords `"none"` / `"unchanged"`, a
3828/// `{"barks": […]}` pool, then a dialogue root id.
3829#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3830#[serde(untagged)]
3831pub enum CastDialogue {
3832 /// A keyword: `"none"` or `"unchanged"`.
3833 Keyword(CastDialogueKeyword),
3834 /// A bark pool: right-click yields one inconsequential in-character line —
3835 /// no tree, no options, no consequences.
3836 Barks(CastBarks),
3837 /// A dialogue root id: right-click opens this node of the NPC's stage-6
3838 /// tree. Must be a node of *that* NPC's tree (`DW0464`).
3839 Root(DialogueId),
3840}
3841
3842/// The keyword forms of [`CastDialogue`].
3843#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3844#[serde(rename_all = "kebab-case")]
3845pub enum CastDialogueKeyword {
3846 /// Genuinely no reaction: the right-click is recorded and consumed, and
3847 /// nothing opens. Legal, but a last resort — if a body is clickable, the
3848 /// world should answer; prefer a bark.
3849 None,
3850 /// Carry forward whatever this NPC's dialogue was at its **previous**
3851 /// appearance in the quest-DAG ordering.
3852 ///
3853 /// The point is that carrying dialogue forward is a *conscious, declared
3854 /// act*. It is never an implicit default — an omitted `dialogue` is
3855 /// `DW0463`, not a carry-forward — and writing `"unchanged"` states the
3856 /// intent without re-spelling a root id that then drifts out of sync. It
3857 /// resolves transitively (`unchanged` → `unchanged` → a root), and using it
3858 /// at an NPC's first appearance is `DW0466`: there is nothing to carry.
3859 ///
3860 /// Emission is a **no-op** — no root swap is emitted for that NPC at that
3861 /// quest — which is what makes the sugar cheap and byte-stable.
3862 Unchanged,
3863}
3864
3865/// A bark pool: inconsequential in-character lines, cycled deterministically.
3866#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3867#[serde(deny_unknown_fields)]
3868pub struct CastBarks {
3869 /// The lines, in cycle order. At least one (`DW0464`). Player-visible, so
3870 /// they enter the l10n inventory like any narrate text.
3871 pub barks: Vec<String>,
3872}
3873
3874/// What triggers a quest.
3875#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3876#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
3877pub enum Trigger {
3878 /// Fires when the campaign starts.
3879 CampaignStart,
3880 /// Fires when another quest completes.
3881 QuestComplete {
3882 /// The prerequisite quest.
3883 quest: QuestId,
3884 },
3885}
3886
3887/// A quest objective.
3888///
3889/// Every variant may carry `requires_flags`:
3890/// flag-gated activation, satisfied only once each referenced flag has been set
3891/// by a `set-flag` effect.
3892#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3893#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
3894pub enum Objective {
3895 /// Completed by a dialogue option's `complete-objective` effect.
3896 TalkTo {
3897 /// Objective id.
3898 id: ObjectiveId,
3899 /// Short player-facing objective name (v0.3, optional).
3900 #[serde(default, skip_serializing_if = "Option::is_none")]
3901 title: Option<String>,
3902 /// One-line location/direction hint (v0.3, optional).
3903 #[serde(default, skip_serializing_if = "Option::is_none")]
3904 hint: Option<String>,
3905 /// The NPC to talk to.
3906 npc: NpcId,
3907 /// Prerequisite objectives (intra-quest ordering).
3908 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3909 after: Vec<ObjectiveId>,
3910 /// Flags that must be set before this objective activates (v0.3).
3911 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3912 requires_flags: Vec<FlagId>,
3913 /// Negative flag gate (DSL v0.6): the
3914 /// objective is suppressed (cannot activate or complete) while ANY listed
3915 /// flag is set for the player — the dual of `requires_flags`.
3916 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3917 forbids_flags: Vec<FlagId>,
3918 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3919 /// must hold for this gate to be open. The third field of the one gate,
3920 /// carried by every gate consumer — never by the verb that first wanted
3921 /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3922 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3923 requires_state: Vec<StateCompare>,
3924 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3925 /// bot should traverse sneaking (sprint disabled). Emitted into
3926 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3927 /// hint; no datapack effect.
3928 #[serde(default, skip_serializing_if = "is_false")]
3929 stealth: bool,
3930 /// What this objective does to the story (DSL v0.8, spec-0025; required
3931 /// at 0.8.0, `DW0481`).
3932 #[serde(default, skip_serializing_if = "Option::is_none")]
3933 happening: Option<Happening>,
3934 },
3935 /// Completed by reaching an anchor once prerequisites are met.
3936 ReachAnchor {
3937 /// Objective id.
3938 id: ObjectiveId,
3939 /// Short player-facing objective name (v0.3, optional).
3940 #[serde(default, skip_serializing_if = "Option::is_none")]
3941 title: Option<String>,
3942 /// One-line location/direction hint (v0.3, optional).
3943 #[serde(default, skip_serializing_if = "Option::is_none")]
3944 hint: Option<String>,
3945 /// The anchor to reach.
3946 anchor: AnchorId,
3947 /// Completion radius (blocks).
3948 radius: u32,
3949 /// Prerequisite objectives (intra-quest ordering).
3950 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3951 after: Vec<ObjectiveId>,
3952 /// Flags that must be set before this objective activates (v0.3).
3953 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3954 requires_flags: Vec<FlagId>,
3955 /// Negative flag gate (DSL v0.6): the
3956 /// objective is suppressed (cannot activate or complete) while ANY listed
3957 /// flag is set for the player — the dual of `requires_flags`.
3958 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3959 forbids_flags: Vec<FlagId>,
3960 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
3961 /// must hold for this gate to be open. The third field of the one gate,
3962 /// carried by every gate consumer — never by the verb that first wanted
3963 /// it. Default empty, so a pre-0.10 campaign is byte-identical.
3964 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3965 requires_state: Vec<StateCompare>,
3966 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
3967 /// bot should traverse sneaking (sprint disabled). Emitted into
3968 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
3969 /// hint; no datapack effect.
3970 #[serde(default, skip_serializing_if = "is_false")]
3971 stealth: bool,
3972 /// What this objective does to the story (DSL v0.8, spec-0025; required
3973 /// at 0.8.0, `DW0481`).
3974 #[serde(default, skip_serializing_if = "Option::is_none")]
3975 happening: Option<Happening>,
3976 },
3977 /// Completed when the referenced wave is fully slain (v0.3).
3978 Kill {
3979 /// Objective id.
3980 id: ObjectiveId,
3981 /// Short player-facing objective name (v0.3, optional).
3982 #[serde(default, skip_serializing_if = "Option::is_none")]
3983 title: Option<String>,
3984 /// One-line location/direction hint (v0.3, optional).
3985 #[serde(default, skip_serializing_if = "Option::is_none")]
3986 hint: Option<String>,
3987 /// The wave (stage-5 `waves` ref) whose mobs must be slain.
3988 wave: WaveId,
3989 /// Prerequisite objectives.
3990 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3991 after: Vec<ObjectiveId>,
3992 /// Flags that must be set before this objective activates (v0.3).
3993 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3994 requires_flags: Vec<FlagId>,
3995 /// Negative flag gate (DSL v0.6): the
3996 /// objective is suppressed (cannot activate or complete) while ANY listed
3997 /// flag is set for the player — the dual of `requires_flags`.
3998 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3999 forbids_flags: Vec<FlagId>,
4000 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4001 /// must hold for this gate to be open. The third field of the one gate,
4002 /// carried by every gate consumer — never by the verb that first wanted
4003 /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4004 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4005 requires_state: Vec<StateCompare>,
4006 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4007 /// bot should traverse sneaking (sprint disabled). Emitted into
4008 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4009 /// hint; no datapack effect.
4010 #[serde(default, skip_serializing_if = "is_false")]
4011 stealth: bool,
4012 /// What this objective does to the story (DSL v0.8, spec-0025; required
4013 /// at 0.8.0, `DW0481`).
4014 #[serde(default, skip_serializing_if = "Option::is_none")]
4015 happening: Option<Happening>,
4016 },
4017 /// Completed when `count` of `item` have been collected (v0.3).
4018 ///
4019 /// The items are provided in a container: the compiler's own chest at
4020 /// `anchor` by default, or — since DSL v0.8 — the prefab's existing
4021 /// chest/barrel at [`Objective::Collect::container`], optionally carrying an
4022 /// [`Objective::Collect::item_name`] and padded to read full with
4023 /// [`Objective::Collect::fill_count`].
4024 Collect {
4025 /// Objective id.
4026 id: ObjectiveId,
4027 /// Short player-facing objective name (v0.3, optional).
4028 #[serde(default, skip_serializing_if = "Option::is_none")]
4029 title: Option<String>,
4030 /// One-line location/direction hint (v0.3, optional).
4031 #[serde(default, skip_serializing_if = "Option::is_none")]
4032 hint: Option<String>,
4033 /// Vanilla item id to collect (validated against the registry).
4034 item: String,
4035 /// How many are required.
4036 count: u32,
4037 /// The anchor items are provided at (chest / pickup).
4038 anchor: AnchorId,
4039 /// **Adopt the container the prefab already placed**: the anchor whose
4040 /// assembled-world cell holds
4041 /// a `chest` / `trapped_chest` / `barrel` this collect fills instead of
4042 /// conjuring its own chest at [`Objective::Collect::anchor`].
4043 ///
4044 /// Same division of labour a `loot` entry and a trap's dispenser already
4045 /// keep with the prefab: furniture belongs in the piece. A beach camp's
4046 /// barrel is scenery the player has been walking past since minute one —
4047 /// having the compiler `setblock` a *second*, floating chest beside it to
4048 /// hold the quest item is exactly the downstream workaround the no-hack
4049 /// rule forbids. A `container` whose cell holds no container is a build
4050 /// error (`DW0438`), never a silent fill into a wall.
4051 ///
4052 /// The critical-path step's position follows the container (the bot opens
4053 /// *this* block), and no chest is placed at `anchor` when it is set.
4054 #[serde(default, skip_serializing_if = "Option::is_none")]
4055 container: Option<AnchorId>,
4056 /// **The item comes off a body, not out of a box**: the wave whose
4057 /// declared `drops[]` yield
4058 /// this objective's item. No container is placed — not the compiler's own
4059 /// chest at `anchor`, not a prefab one — and `container` is therefore
4060 /// mutually exclusive with it (`DW0100`-adjacent; `DW0492`).
4061 ///
4062 /// This is what makes "kill the boss → pick up its key → open the door"
4063 /// a *proved* chain rather than an authoring intention. The compiler
4064 /// requires (a) that the named wave really declares an `{item}` drop of
4065 /// this item (`DW0492`), and (b) that a `kill` objective for that wave
4066 /// precedes this collect in the objective graph (`DW0493`). The existing
4067 /// flow machinery then carries the ordering the rest of the way: the
4068 /// door's `requires_flags` hangs off this collect exactly as it would off
4069 /// a chest one.
4070 ///
4071 /// **Waves only.** An actor's death is not observable by any objective —
4072 /// there is no vanilla-side signal the flow machinery could consume — so
4073 /// an actor-gated collect would be an unprovable claim, and per the
4074 /// no-hack doctrine it is excluded rather than approximated. An actor may
4075 /// still declare `drops[]`; those drops just cannot gate a quest.
4076 #[serde(default, skip_serializing_if = "Option::is_none")]
4077 dropped_by: Option<WaveId>,
4078 /// Display name for the collected item (DSL v0.8), emitted as the vanilla `custom_name` item component.
4079 ///
4080 /// A quest item is a *named thing* in the story ("Cheese", "Tide
4081 /// Ledger"), and a player who opens the barrel must read that name — an
4082 /// unnamed `minecraft:pumpkin_pie` says nothing about what the quest asked
4083 /// for. Player-visible, so it enters the l10n string inventory
4084 /// (`obj.<quest>.<obj>.item_name`) and translates like any other line.
4085 ///
4086 /// Naming changes nothing about adjudication: the completion advancement
4087 /// and the per-tick held check both match on the ITEM ID, which a named
4088 /// stack still carries.
4089 #[serde(default, skip_serializing_if = "Option::is_none")]
4090 item_name: Option<String>,
4091 /// Padding stacks that make the container **read full**. Default `0` =
4092 /// the single required
4093 /// stack and nothing else.
4094 ///
4095 /// A barrel of cheese that opens on one lonely wheel reads as a bug, and
4096 /// vanilla's notion of "full" is *occupied slots*, not stack size — so
4097 /// this counts SLOTS: the objective's own stack lands in `container.0` and
4098 /// each padding stack repeats it in `container.1`, `container.2`, … Slot
4099 /// assignment is positional and total, the same determinism story `loot`
4100 /// tells (ADR-0006): no RNG, no loot tables, nothing to reseed.
4101 ///
4102 /// The padding is the same item, so taking the whole barrel still
4103 /// completes the objective and never over- or under-counts it.
4104 #[serde(default, skip_serializing_if = "is_zero")]
4105 fill_count: u32,
4106 /// Prerequisite objectives.
4107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4108 after: Vec<ObjectiveId>,
4109 /// Flags that must be set before this objective activates (v0.3).
4110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4111 requires_flags: Vec<FlagId>,
4112 /// Negative flag gate (DSL v0.6): the
4113 /// objective is suppressed (cannot activate or complete) while ANY listed
4114 /// flag is set for the player — the dual of `requires_flags`.
4115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4116 forbids_flags: Vec<FlagId>,
4117 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4118 /// must hold for this gate to be open. The third field of the one gate,
4119 /// carried by every gate consumer — never by the verb that first wanted
4120 /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4122 requires_state: Vec<StateCompare>,
4123 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4124 /// bot should traverse sneaking (sprint disabled). Emitted into
4125 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4126 /// hint; no datapack effect.
4127 #[serde(default, skip_serializing_if = "is_false")]
4128 stealth: bool,
4129 /// What this objective does to the story (DSL v0.8, spec-0025; required
4130 /// at 0.8.0, `DW0481`).
4131 #[serde(default, skip_serializing_if = "Option::is_none")]
4132 happening: Option<Happening>,
4133 },
4134 /// Completed by interacting with an entity at `anchor`; if `requires_item` is
4135 /// set, the item must be **held in the main hand** (v0.3; held semantics since
4136 /// DSL v0.7 — see [`Objective::Interact::requires_item`]).
4137 Interact {
4138 /// Objective id.
4139 id: ObjectiveId,
4140 /// Short player-facing objective name (v0.3, optional).
4141 #[serde(default, skip_serializing_if = "Option::is_none")]
4142 title: Option<String>,
4143 /// One-line location/direction hint (v0.3, optional).
4144 #[serde(default, skip_serializing_if = "Option::is_none")]
4145 hint: Option<String>,
4146 /// The anchor the interaction entity stands at.
4147 anchor: AnchorId,
4148 /// Item the player must be **holding in the main hand** for the
4149 /// interaction to complete (optional).
4150 ///
4151 /// Held, not merely possessed: presenting the
4152 /// item IS the action — a player who right-clicks a sleeping giant with a
4153 /// sharpened stake buried in their backpack has not stabbed anything.
4154 /// Before this ruling the gate read the whole inventory, which made every
4155 /// `requires_item` interaction fire the moment the item was picked up
4156 /// anywhere, whatever the player was actually doing with their hands.
4157 #[serde(default, skip_serializing_if = "Option::is_none")]
4158 requires_item: Option<String>,
4159 /// Diegetic feedback for a click that arrives without the required item in
4160 /// hand (DSL v0.7): narrated to that player in
4161 /// chat instead of the silence the gate used to answer with. Requires
4162 /// `requires_item` (`DW0437`).
4163 ///
4164 /// Only fires while the objective is genuinely open — same activation gate
4165 /// as the affordance itself — so a finished or not-yet-active interaction
4166 /// stays quiet.
4167 #[serde(default, skip_serializing_if = "Option::is_none")]
4168 missing_item_hint: Option<String>,
4169 /// Prop block that IS the interaction affordance (DSL v0.4, spec-0008
4170 /// §2): the compiler `setblock`s it at the anchor on activation (exactly
4171 /// as `collect` uses a real chest). Omitted = the glowing-lantern
4172 /// hologram marker (the v0.3 fallback).
4173 #[serde(default, skip_serializing_if = "Option::is_none")]
4174 prop: Option<Prop>,
4175 /// Prerequisite objectives.
4176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4177 after: Vec<ObjectiveId>,
4178 /// Flags that must be set before this objective activates (v0.3).
4179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4180 requires_flags: Vec<FlagId>,
4181 /// Negative flag gate (DSL v0.6): the
4182 /// objective is suppressed (cannot activate or complete) while ANY listed
4183 /// flag is set for the player — the dual of `requires_flags`.
4184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4185 forbids_flags: Vec<FlagId>,
4186 /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison
4187 /// must hold for this gate to be open. The third field of the one gate,
4188 /// carried by every gate consumer — never by the verb that first wanted
4189 /// it. Default empty, so a pre-0.10 campaign is byte-identical.
4190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4191 requires_state: Vec<StateCompare>,
4192 /// Bot stealth hint (DSL v0.4): mark this leg as one the critical-path
4193 /// bot should traverse sneaking (sprint disabled). Emitted into
4194 /// `critical-path.json` as `sneak: true` on the step. Purely a harness
4195 /// hint; no datapack effect.
4196 #[serde(default, skip_serializing_if = "is_false")]
4197 stealth: bool,
4198 /// What this objective does to the story (DSL v0.8, spec-0025; required
4199 /// at 0.8.0, `DW0481`).
4200 #[serde(default, skip_serializing_if = "Option::is_none")]
4201 happening: Option<Happening>,
4202 },
4203}
4204
4205/// A prop block for an `interact` objective (DSL v0.4). The block is the
4206/// affordance the player interacts with; its id is validated against the pinned
4207/// 1.21.11 block registry (`DW0193`).
4208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4209#[serde(deny_unknown_fields)]
4210pub struct Prop {
4211 /// Vanilla block id (e.g. `minecraft:lever`).
4212 pub block: String,
4213}
4214
4215// ---------------------------------------------------------------------------
4216// Stage 5 — scripted actors (DSL v0.6, spec-0014)
4217// ---------------------------------------------------------------------------
4218
4219/// A scripted stage actor (DSL v0.6, spec-0014): a NoAI/Silent/no-loot puppet,
4220/// distinct from a stage-2 [`Npc`] (no dialogue, any mob type). Emitted with tag
4221/// `dw_actor_<id>`, `Invulnerable` unless `vulnerable` (a damageable puppet stays
4222/// knockback-immune — the tower-defense creep). `skin` re-dresses it as a
4223/// `minecraft:mannequin`, exactly as a stage-2 NPC skin. The puppet is summoned by
4224/// a `spawn-actor` effect (not at load), moved by `move-actor`, and can be replaced
4225/// by a real-AI twin with `unleash-actor`.
4226#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4227#[serde(deny_unknown_fields)]
4228pub struct Actor {
4229 /// Unique actor id (`actor/<kebab>`).
4230 pub id: ActorId,
4231 /// The vanilla entity to puppet, e.g. `minecraft:warden`. Validated against the
4232 /// pinned 1.21.11 entity registry (`DW0173`).
4233 pub entity: String,
4234 /// Optional custom name shown above the puppet.
4235 #[serde(default, skip_serializing_if = "Option::is_none")]
4236 pub name: Option<String>,
4237 /// Optional player-model skin (mannequin), as a stage-2 NPC (`DW0190`).
4238 #[serde(default, skip_serializing_if = "Option::is_none")]
4239 pub skin: Option<NpcSkin>,
4240 /// The anchor the puppet is summoned on (resolved across areas, like an
4241 /// `open-gate` / `move-npc` destination).
4242 pub anchor: AnchorId,
4243 /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
4244 /// `[0, 0, 0]`): the puppet stands at the [`Mark`] the two fields spell, so
4245 /// a rank of bodies is one anchor and an offset apiece.
4246 #[serde(default, skip_serializing_if = "is_zero3")]
4247 pub offset: [i32; 3],
4248 /// Initial facing (default `south`). The puppet spawns yawed this way.
4249 #[serde(default, skip_serializing_if = "Option::is_none")]
4250 pub facing: Option<Facing>,
4251 /// If `true`, the puppet is damageable (a tower-defense creep) but stays
4252 /// knockback-immune; default `false` (fully `Invulnerable`).
4253 #[serde(default, skip_serializing_if = "is_false")]
4254 pub vulnerable: bool,
4255 /// Gear the actor wears and holds, in the same shape a wave mob uses
4256 /// ([`MobEquipment`]). Emitted into BOTH the staged puppet and the
4257 /// unleashed twin, so the dormant elite the player has been circling is
4258 /// visibly the same armoured thing that stands up. Drop chances are zero —
4259 /// wave gear and actor gear are never farmable (no-grind constitution).
4260 #[serde(default, skip_serializing_if = "Option::is_none")]
4261 pub equipment: Option<MobEquipment>,
4262 /// Attribute overrides, in the same shape a wave mob uses ([`MobAttributes`],
4263 /// the v0.4 surface — one type, one rule set, so the two surfaces cannot
4264 /// drift). Emitted into BOTH the staged puppet and the unleashed twin, so the
4265 /// elite the party fights is the elite the author tuned; without it an actor
4266 /// was stuck at vanilla base values while every wave mob could be tuned,
4267 /// which is what blocked elite authoring. A `vulnerable` actor's
4268 /// knockback-immunity is emitted first and is not authorable.
4269 #[serde(default, skip_serializing_if = "Option::is_none")]
4270 pub attributes: Option<MobAttributes>,
4271 /// How hard this actor's fight is *meant* to be (DSL v0.8, spec-0023) — the
4272 /// same [`EncounterTier`] vocabulary a [`Wave`] declares. Absent =
4273 /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.8 campaign.
4274 ///
4275 /// A wave is not the only shape an elite takes. The set-piece souls fight —
4276 /// the armoured thing kneeling among the graves that stands up when you hit
4277 /// it — is an **actor**: staged by `spawn-actor`, given AI by
4278 /// `unleash-actor`, killed by hand rather than by a `kill` objective. Before
4279 /// this field nothing anywhere stated what such a fight was billed as.
4280 ///
4281 /// Like the wave field this is a **declaration, not a knob**: the compiler
4282 /// never scales an actor from it, and emission is unchanged whichever tier is
4283 /// declared. Its readers are the health-bar advisory (`DW0912`) and the drop
4284 /// rule — only a billed fight leaves anything behind.
4285 #[serde(default, skip_serializing_if = "Option::is_none")]
4286 pub tier: Option<EncounterTier>,
4287 /// A health bar over this actor's fight (DSL v0.31, spec-0073) — the same
4288 /// [`HealthBar`](crate::healthbar::HealthBar) a [`Wave`] declares. It reads the
4289 /// bodies whose health can move: the unleashed twin, or the puppet itself when
4290 /// the actor is `vulnerable`. A bar on an actor that is neither is `DW0909`.
4291 /// Absent = no bar, byte-identical.
4292 #[serde(default, skip_serializing_if = "Option::is_none")]
4293 pub health_bar: Option<crate::healthbar::HealthBar>,
4294 /// What this actor leaves behind when a player kills it. Only an
4295 /// `elite`/`boss` actor may
4296 /// declare it (`DW0491`). Emitted into BOTH the staged puppet and the
4297 /// unleashed twin, exactly as `equipment` is — the drop belongs to the body,
4298 /// not to one of its two lifecycles. A `despawn-actor` strips the
4299 /// declaration off the body before removing it, so re-caging an elite (a
4300 /// souls re-seat) never scatters its axe.
4301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4302 pub drops: Vec<MobDrop>,
4303 /// What this body can do when it moves (DSL v0.11, spec-0034) — the same
4304 /// [`BodyTraversal`] a stage-2 [`Npc`] carries, because traversal belongs to
4305 /// the body and not to the stage that declares it. Absent = the class the
4306 /// compiler derives from `entity` (or from `minecraft:mannequin` when `skin`
4307 /// is set).
4308 #[serde(default, skip_serializing_if = "Option::is_none")]
4309 pub traversal: Option<BodyTraversal>,
4310 /// What happens each time a player is credited with killing this actor's
4311 /// body (spec-0074) — effect root R9, the same [`OnKill`] a
4312 /// wave declares. Absent = no bundle, and the actor's emission is
4313 /// byte-identical.
4314 #[serde(default, skip_serializing_if = "Option::is_none")]
4315 pub on_kill: Option<OnKill>,
4316}
4317
4318/// A cardinal facing keyword (DSL v0.6). Emitted as the puppet's spawn yaw
4319/// (MC: yaw 0 = +z/south).
4320#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4321#[serde(rename_all = "kebab-case")]
4322pub enum Facing {
4323 /// Facing +z (yaw 0) — the default.
4324 South,
4325 /// Facing -z (yaw 180).
4326 North,
4327 /// Facing -x (yaw 90).
4328 West,
4329 /// Facing +x (yaw 270).
4330 East,
4331}
4332
4333impl Facing {
4334 /// The kebab token (`south` / `north` / `west` / `east`).
4335 pub fn token(self) -> &'static str {
4336 match self {
4337 Facing::South => "south",
4338 Facing::North => "north",
4339 Facing::West => "west",
4340 Facing::East => "east",
4341 }
4342 }
4343}
4344
4345/// How a `despawn-actor` removes its puppet (DSL v0.6).
4346#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4347#[serde(rename_all = "kebab-case")]
4348pub enum DespawnStyle {
4349 /// The body leaves unseen: no death animation, red flash or death particles
4350 /// where it stood. It is moved under the world and removed there.
4351 Vanish,
4352 /// The body dies where it stands, with the vanilla death animation (a death
4353 /// the player is meant to watch).
4354 Kill,
4355}
4356
4357impl DespawnStyle {
4358 /// The kebab token (`vanish` / `kill`).
4359 pub fn token(self) -> &'static str {
4360 match self {
4361 DespawnStyle::Vanish => "vanish",
4362 DespawnStyle::Kill => "kill",
4363 }
4364 }
4365}
4366
4367/// One step of a [`Verb::Sequence`] (DSL v0.6): a group of effects fired at
4368/// an exact tick offset from the sequence's start.
4369#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4370#[serde(deny_unknown_fields)]
4371pub struct SequenceStep {
4372 /// Tick offset from the sequence start at which `effects` fire.
4373 pub at_ticks: u32,
4374 /// The effects fired at `at_ticks`. Any stage-5 effect except a nested
4375 /// `sequence` (rejected with `DW0329`).
4376 pub effects: Vec<QuestEffect>,
4377}
4378
4379impl Objective {
4380 /// This objective's id.
4381 pub fn id(&self) -> &ObjectiveId {
4382 match self {
4383 Objective::TalkTo { id, .. }
4384 | Objective::ReachAnchor { id, .. }
4385 | Objective::Kill { id, .. }
4386 | Objective::Collect { id, .. }
4387 | Objective::Interact { id, .. } => id,
4388 }
4389 }
4390
4391 /// This objective's prerequisites.
4392 pub fn after(&self) -> &[ObjectiveId] {
4393 match self {
4394 Objective::TalkTo { after, .. }
4395 | Objective::ReachAnchor { after, .. }
4396 | Objective::Kill { after, .. }
4397 | Objective::Collect { after, .. }
4398 | Objective::Interact { after, .. } => after,
4399 }
4400 }
4401
4402 /// The short player-facing objective title (v0.3, optional).
4403 pub fn title(&self) -> Option<&str> {
4404 match self {
4405 Objective::TalkTo { title, .. }
4406 | Objective::ReachAnchor { title, .. }
4407 | Objective::Kill { title, .. }
4408 | Objective::Collect { title, .. }
4409 | Objective::Interact { title, .. } => title.as_deref(),
4410 }
4411 }
4412
4413 /// The one-line location/direction hint (v0.3, optional).
4414 pub fn hint(&self) -> Option<&str> {
4415 match self {
4416 Objective::TalkTo { hint, .. }
4417 | Objective::ReachAnchor { hint, .. }
4418 | Objective::Kill { hint, .. }
4419 | Objective::Collect { hint, .. }
4420 | Objective::Interact { hint, .. } => hint.as_deref(),
4421 }
4422 }
4423
4424 /// Mutable access to the optional player-facing title (i18n localization).
4425 pub fn title_mut(&mut self) -> &mut Option<String> {
4426 match self {
4427 Objective::TalkTo { title, .. }
4428 | Objective::ReachAnchor { title, .. }
4429 | Objective::Kill { title, .. }
4430 | Objective::Collect { title, .. }
4431 | Objective::Interact { title, .. } => title,
4432 }
4433 }
4434
4435 /// Mutable access to the optional one-line hint (i18n localization).
4436 pub fn hint_mut(&mut self) -> &mut Option<String> {
4437 match self {
4438 Objective::TalkTo { hint, .. }
4439 | Objective::ReachAnchor { hint, .. }
4440 | Objective::Kill { hint, .. }
4441 | Objective::Collect { hint, .. }
4442 | Objective::Interact { hint, .. } => hint,
4443 }
4444 }
4445
4446 /// The flags that must be set before this objective activates (v0.3).
4447 pub fn requires_flags(&self) -> &[FlagId] {
4448 match self {
4449 Objective::TalkTo { requires_flags, .. }
4450 | Objective::ReachAnchor { requires_flags, .. }
4451 | Objective::Kill { requires_flags, .. }
4452 | Objective::Collect { requires_flags, .. }
4453 | Objective::Interact { requires_flags, .. } => requires_flags,
4454 }
4455 }
4456
4457 /// The negative flag gate (DSL v0.6): flags whose being set **suppresses**
4458 /// this objective. The dual of [`Objective::requires_flags`].
4459 pub fn forbids_flags(&self) -> &[FlagId] {
4460 match self {
4461 Objective::TalkTo { forbids_flags, .. }
4462 | Objective::ReachAnchor { forbids_flags, .. }
4463 | Objective::Kill { forbids_flags, .. }
4464 | Objective::Collect { forbids_flags, .. }
4465 | Objective::Interact { forbids_flags, .. } => forbids_flags,
4466 }
4467 }
4468
4469 /// The numeric gate terms (DSL v0.10, spec-0031): comparisons that must hold
4470 /// before this objective activates. See [`StateCompare`].
4471 pub fn requires_state(&self) -> &[StateCompare] {
4472 match self {
4473 Objective::TalkTo { requires_state, .. }
4474 | Objective::ReachAnchor { requires_state, .. }
4475 | Objective::Kill { requires_state, .. }
4476 | Objective::Collect { requires_state, .. }
4477 | Objective::Interact { requires_state, .. } => requires_state,
4478 }
4479 }
4480
4481 /// What this objective does to the story (DSL v0.8, spec-0025).
4482 pub fn happening(&self) -> Option<&Happening> {
4483 match self {
4484 Objective::TalkTo { happening, .. }
4485 | Objective::ReachAnchor { happening, .. }
4486 | Objective::Kill { happening, .. }
4487 | Objective::Collect { happening, .. }
4488 | Objective::Interact { happening, .. } => happening.as_ref(),
4489 }
4490 }
4491
4492 /// The bot stealth hint (DSL v0.4): traverse this leg sneaking.
4493 pub fn stealth(&self) -> bool {
4494 match self {
4495 Objective::TalkTo { stealth, .. }
4496 | Objective::ReachAnchor { stealth, .. }
4497 | Objective::Kill { stealth, .. }
4498 | Objective::Collect { stealth, .. }
4499 | Objective::Interact { stealth, .. } => *stealth,
4500 }
4501 }
4502
4503 /// The container this objective ADOPTS (DSL v0.8), if it is a `collect` that
4504 /// declares one: the anchor whose prefab-placed chest/barrel it fills instead
4505 /// of conjuring its own chest. `None` on every other objective and on a
4506 /// `collect` that keeps the compiler-placed chest.
4507 pub fn collect_container(&self) -> Option<&AnchorId> {
4508 match self {
4509 Objective::Collect { container, .. } => container.as_ref(),
4510 _ => None,
4511 }
4512 }
4513
4514 /// The wave whose declared drops provide this objective's item (DSL v0.9),
4515 /// if it is a `collect` that declares one. `None` on every other objective
4516 /// and on a `collect` fed by a container.
4517 pub fn collect_dropped_by(&self) -> Option<&WaveId> {
4518 match self {
4519 Objective::Collect { dropped_by, .. } => dropped_by.as_ref(),
4520 _ => None,
4521 }
4522 }
4523
4524 /// The padding-stack count of a `collect` (DSL v0.8); `0` for every other
4525 /// objective and for a `collect` that fills the single required stack only.
4526 pub fn collect_fill_count(&self) -> u32 {
4527 match self {
4528 Objective::Collect { fill_count, .. } => *fill_count,
4529 _ => 0,
4530 }
4531 }
4532
4533 /// The `interact` prop block (DSL v0.4), if this is an `interact` with a prop.
4534 pub fn prop(&self) -> Option<&Prop> {
4535 match self {
4536 Objective::Interact { prop, .. } => prop.as_ref(),
4537 _ => None,
4538 }
4539 }
4540
4541 /// The kebab type tag.
4542 pub fn kind(&self) -> &'static str {
4543 match self {
4544 Objective::TalkTo { .. } => "talk-to",
4545 Objective::ReachAnchor { .. } => "reach-anchor",
4546 Objective::Kill { .. } => "kill",
4547 Objective::Collect { .. } => "collect",
4548 Objective::Interact { .. } => "interact",
4549 }
4550 }
4551
4552 /// The v0.3 verb name if this objective is one of the verbs introduced in
4553 /// DSL v0.3 (`kill`/`collect`/`interact`). These validate in v0.3 campaigns
4554 ///.
4555 pub fn v03_verb(&self) -> Option<&'static str> {
4556 match self {
4557 Objective::Kill { .. } => Some("kill"),
4558 Objective::Collect { .. } => Some("collect"),
4559 Objective::Interact { .. } => Some("interact"),
4560 Objective::TalkTo { .. } | Objective::ReachAnchor { .. } => None,
4561 }
4562 }
4563}
4564
4565/// The condition under which an effect fires, as one object.
4566///
4567/// The three axes of [`crate::gate::Gate`] in their declared form: the flags that
4568/// must be set, the flags that must not be, and the numeric comparisons that must
4569/// hold. Declared once and carried by [`QuestEffect::when`], so every verb is
4570/// gatable on exactly the same terms and a fourth axis is one field here.
4571#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
4572#[serde(deny_unknown_fields)]
4573pub struct Guard {
4574 /// Flags that must ALL be set (per party) for the effect to fire. Emission
4575 /// wraps the effect's commands in `execute if score #party dw.f_<flag> matches 1`.
4576 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4577 pub requires_flags: Vec<FlagId>,
4578 /// Flags whose being set SUPPRESSES the effect — the dual of `requires_flags`,
4579 /// emitted as `execute unless score #party dw.f_<flag> matches 1`.
4580 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4581 pub forbids_flags: Vec<FlagId>,
4582 /// Numeric comparisons that must ALL hold (spec-0031), emitted as
4583 /// `execute if score <holder> dw.s_<state> matches <range>`.
4584 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4585 pub requires_state: Vec<StateCompare>,
4586}
4587
4588/// An effect fired by quest progress: **one guard, one story note, one verb.**
4589///
4590/// The guard is a property of an effect, not of the verb that first wanted one, so
4591/// it is declared once in [`Guard`] and every verb carries it — including the
4592/// staging and souls vocabulary (`spawn-actor`, `move-actor`, `set-checkpoint`,
4593/// `bonfire`, `begin-stealth`, `sequence`, …) that could not be branch-gated while
4594/// the fields lived on the variants. A gate that can never open on a
4595/// `campaign-complete` is caught where it belongs, by the completability proof.
4596///
4597/// `verb` is `#[serde(flatten)]`, so the JSON is unchanged in shape apart from the
4598/// guard moving under `when`: `{"type": "open-gate", "anchor": "…", "when":
4599/// {"requires_flags": ["…"]}}`. [`Verb`] keeps `deny_unknown_fields`, which is what
4600/// the flattened deserializer applies to everything the outer struct did not claim
4601/// — an author's typo is still `DW0100`.
4602#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4603pub struct QuestEffect {
4604 /// When this effect fires. `None` is the always-open gate.
4605 #[serde(default, skip_serializing_if = "Option::is_none")]
4606 pub when: Option<Guard>,
4607 /// What this beat does to the story (spec-0025) — validation metadata with no
4608 /// emission of its own.
4609 #[serde(default, skip_serializing_if = "Option::is_none")]
4610 pub happening: Option<Happening>,
4611 /// What the effect does.
4612 #[serde(flatten)]
4613 pub verb: Verb,
4614}
4615
4616impl From<Verb> for QuestEffect {
4617 /// An unguarded effect with no story note — the shape a compiler-synthesized
4618 /// beat and most tests want.
4619 fn from(verb: Verb) -> Self {
4620 QuestEffect {
4621 when: None,
4622 happening: None,
4623 verb,
4624 }
4625 }
4626}
4627
4628/// What an effect does, without the guard: the closed set of verbs.
4629#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
4630#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
4631pub enum Verb {
4632 /// Opens a prefab-declared gate (one-way).
4633 OpenGate {
4634 /// The gate anchor to open.
4635 anchor: AnchorId,
4636 },
4637 /// Seals a prefab-declared gate — the physical dual of `open-gate` (DSL v0.6):
4638 /// fills the gate anchor's region with the block the anchor declares (e.g. the
4639 /// boulder's `minecraft:basalt`), turning an opened threshold back into a wall.
4640 /// The declared fill block is prefab metadata; a gate anchor with no `block` is
4641 /// rejected (`DW0343`). The completability model treats the region as **solid**
4642 /// from the point in the quest DAG where this fires (mirroring how `open-gate`'s
4643 /// clearing is modelled) — a critical path that must cross a gate after it seals
4644 /// fails the DW0311 reachability proof.
4645 CloseGate {
4646 /// The gate anchor to seal.
4647 anchor: AnchorId,
4648 /// What the seal *says* when a player right-clicks it (DSL v0.8). A sealed gate is a wall the party will walk back to
4649 /// and press: the compiler answers that press on the actionbar. Absent, the
4650 /// compiler's canonical English is baked in (`The way is sealed.`) exactly
4651 /// as `world.boundary.message` does; authored, the line is l10n-inventoried
4652 /// under `<effect-key>.sealed_hint` and translates like every other
4653 /// player-visible string.
4654 ///
4655 /// Unlike `happening`, this **does** print in the hand-written `Debug`
4656 /// below when present — it changes emission, so two otherwise-identical
4657 /// sequences that differ only in their seal's answer are different content.
4658 #[serde(default, skip_serializing_if = "Option::is_none")]
4659 sealed_hint: Option<String>,
4660 },
4661 /// Marks the campaign complete (final advancement + credits). Terminal — not
4662 /// flag-gatable (gating the campaign's own completion is a deadlock footgun),
4663 /// so this variant carries no `requires_flags`.
4664 CampaignComplete {
4665 /// Which ENDING this is (DSL v0.8, spec-0025).
4666 /// A campaign with more than one `campaign-complete` has more than one
4667 /// ending, and a branch that runs to an ending names it here — so the
4668 /// terminality proof (`DW0482`) can state *which* ending a branch reached
4669 /// instead of merely that something ended. There is no separate `endings`
4670 /// section: the set of endings is exactly the set named here, the same
4671 /// rule flags follow.
4672 #[serde(default, skip_serializing_if = "Option::is_none")]
4673 ending: Option<EndingId>,
4674 },
4675 /// Gives the party an item (v0.3; party-wide since v0.6/spec-0018).
4676 GiveItem {
4677 /// Vanilla item id to give (validated against the registry).
4678 item: String,
4679 /// How many to give.
4680 count: u32,
4681 /// Optional display name (DSL v0.4), matching [`KitItem::name`].
4682 #[serde(default, skip_serializing_if = "Option::is_none")]
4683 name: Option<String>,
4684 /// Who receives it (DSL v0.6, spec-0018). Absent = [`Carrier::All`] — every
4685 /// party member. `one` hands a single copy to the player whose action fired
4686 /// the effect; it is rejected in a scheduler-only bundle (`DW0371`), which
4687 /// has no acting player.
4688 #[serde(default, skip_serializing_if = "Option::is_none")]
4689 carrier: Option<Carrier>,
4690 /// Enchantments on the given stack (`{"minecraft:sharpness": 2}`) —
4691 /// the field a `loot` stack and an equipped piece carry, under the same
4692 /// checks (`DW0433`/`DW0434`) and written by the same rule
4693 /// ([`enchantment_component`]): on a `minecraft:enchanted_book` they are
4694 /// the book's stored enchantments, the ones an anvil applies.
4695 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
4696 enchantments: BTreeMap<String, u32>,
4697 },
4698 /// Sets a campaign flag, enabling flag-gated objectives (v0.3).
4699 SetFlag {
4700 /// The flag to set.
4701 flag: FlagId,
4702 },
4703 /// Writes a declared datum to an absolute value (DSL v0.10, spec-0031).
4704 SetState {
4705 /// The datum to write (stage-5 `state` ref).
4706 state: StateId,
4707 /// The value to write.
4708 value: i32,
4709 },
4710 /// Moves a declared datum by a signed amount (DSL v0.10, spec-0031).
4711 ///
4712 /// **Signed on purpose.** A purse that a shop debits and a stake that a death
4713 /// forfeits are the same operation with the sign flipped; a separate
4714 /// `subtract-state` would be a second verb for one mechanism, and the first
4715 /// campaign to need "add a negative" would have to choose between them.
4716 AddState {
4717 /// The datum to move (stage-5 `state` ref).
4718 state: StateId,
4719 /// How far to move it. Negative counts down.
4720 amount: i32,
4721 },
4722 /// Leaves a declared [`Stake`] behind for the acting player (DSL v0.10,
4723 /// spec-0032): forfeit the declared share of its datum, and place a
4724 /// collectable marker at the compile-time anchor for where they are.
4725 ///
4726 /// **Nothing about this verb says "death".** It is written in `on_death`
4727 /// because that is where a souls-shaped delve wants it, but the mechanism is
4728 /// "leave a recoverable cache where the acting player stands", and the effect
4729 /// carries the ordinary gate so any root may run it. What *is* death-specific
4730 /// — that the corpse stands on the death position, so the placement lookup has
4731 /// a position to key on — is a property of the `on_death` root, not of this
4732 /// verb.
4733 DropStake {
4734 /// The stake (stage-5 `stakes` ref) to leave.
4735 stake: StakeId,
4736 },
4737 /// Returns a declared datum to its declared `initial` (DSL v0.10,
4738 /// spec-0031) — the verb `FlagId` has never had.
4739 ClearState {
4740 /// The datum to clear (stage-5 `state` ref).
4741 state: StateId,
4742 },
4743 /// Spawns a stage-5 wave's mobs at its anchor (v0.3).
4744 SpawnWave {
4745 /// The wave (stage-5 `waves` ref) to spawn.
4746 wave: WaveId,
4747 },
4748 /// Narrates a player-visible line (DSL v0.4, spec-0008 §3). `text` enters the
4749 /// l10n key inventory like any player-visible string.
4750 Narrate {
4751 /// The line shown to the player.
4752 text: String,
4753 /// Presentation channel (default `chat`).
4754 #[serde(default, skip_serializing_if = "Option::is_none")]
4755 style: Option<NarrateStyle>,
4756 /// Optional sound id played alongside the line.
4757 #[serde(default, skip_serializing_if = "Option::is_none")]
4758 sound: Option<String>,
4759 },
4760 /// Sets a block at an anchor (DSL v0.4, spec-0008 §2). General form of a prop
4761 /// placement. Block id validated against the pinned 1.21.11 block registry;
4762 /// a vanilla blockstate suffix (`minecraft:grindstone[face=floor]`) is
4763 /// accepted and passed through verbatim (DSL v0.6).
4764 SetBlock {
4765 /// The anchor to place the block at.
4766 anchor: AnchorId,
4767 /// Vanilla block id to place.
4768 block: String,
4769 },
4770 /// **Fill a declared region with a block** at runtime (DSL v0.10, spec-0031).
4771 ///
4772 /// The general spelling of the capability `open-gate` / `close-gate` carried
4773 /// privately: a region, filled or cleared, from a point in the quest DAG.
4774 /// `close-gate` is this verb with the region and the block read off a prefab
4775 /// gate anchor instead of authored; `set-block` is the one-cell case at a
4776 /// point anchor. All three lower through one emission
4777 /// (`emit::fill_region_command`) and are modelled by one completability rule
4778 /// (`plan::RegionEvent`), so a third consumer inherits the proof instead of
4779 /// re-deriving it.
4780 ///
4781 /// From the DAG point at which this fires, the completability model treats the
4782 /// filled cells as whatever the **block** makes them. A full-cube block leaves
4783 /// them **solid**, exactly as a `close-gate` seal is: a critical path that must
4784 /// cross the region afterwards fails `DW0311`. `minecraft:water` /
4785 /// `minecraft:lava` leave them **flooded** — impassable and never floor, because
4786 /// nothing stands on a fluid — and because a fill carries no `replace` filter it
4787 /// takes away whatever floor was in the box, so a forced leg that needed that
4788 /// footing fails `DW0544`.
4789 FillRegion {
4790 /// The volume to fill, as an anchor-centred box (`anchor ± extent`).
4791 ///
4792 /// Deliberately the existing [`StealthZone`] — the engine's one
4793 /// anchor-centred box object class, already shared by `damage-players`'s
4794 /// `in` filter, `collapse`'s `region_anchor`, a `volley` kill zone and a
4795 /// `lethal_volumes[]` region, and resolved through the single
4796 /// `Plan::zone_box`. A private twin with the same two fields would be
4797 /// `tools/ci/check-capability-ownership.py` check C by construction.
4798 ///
4799 /// An anchor-centred box rather than a prefab `region` anchor for the
4800 /// reason `collapse` states: the assembled model deletes every gate-region
4801 /// anchor's cells, so a slab declared that way would already be gone.
4802 region: StealthZone,
4803 /// The block the region is filled with (validated against the pinned
4804 /// 1.21.11 block registry, `DW0193`).
4805 block: String,
4806 },
4807 /// **Clear a declared region to air** at runtime (DSL v0.10, spec-0031) — the
4808 /// physical dual of [`Verb::FillRegion`], and the general spelling of
4809 /// what `open-gate` does to a gate anchor's region.
4810 ///
4811 /// The completability model treats the cleared cells as **passable** from the
4812 /// DAG point at which this fires, with one exception it states out loud: a
4813 /// cleared cell the model already floods stays impassable, because clearing a
4814 /// block does not remove water (`nav::World::with_cleared`).
4815 ClearRegion {
4816 /// The volume to clear, as an anchor-centred box (`anchor ± extent`) —
4817 /// the same object class [`Verb::FillRegion`] fills.
4818 region: StealthZone,
4819 },
4820 /// **Opens a placed piece's contingent way** (DSL v0.12, spec-0042 §2.4): the
4821 /// broken flight a beat repairs, the bridge a beat lowers, the rubble a beat
4822 /// clears.
4823 ///
4824 /// A piece's spatial contract may declare a traversal edge whose crossability
4825 /// depends on a named region — `laid` (empty as built, opening fills it) or
4826 /// `cleared` (built solid, opening voids it). The prefab checker proves the
4827 /// piece is severed as shipped and joined once that region is opened; this is
4828 /// the verb that opens it, and it is the only one, because a way is the object
4829 /// and opening it is the operation.
4830 ///
4831 /// **There is no region, no block and no sign on this effect, and that is the
4832 /// design rather than an omission.** All three are read from the piece's own
4833 /// exported metadata (`spatial_contract.edges[].way`), so the effect and the
4834 /// building cannot disagree about what a way is — two authorities plus an
4835 /// equality check is the defect this shape avoids, not a variant of the fix
4836 /// (spec-0042 AC8). What the campaign decides is *when*.
4837 ///
4838 /// Completability: the way is **shut until this fires**, and from the DAG
4839 /// point at which it fires the region is solid-and-footing (`laid`) or
4840 /// passable (`cleared`) — the same [`Verb::FillRegion`] /
4841 /// [`Verb::ClearRegion`] model, fed from metadata instead of from an
4842 /// authored box, so this verb inherits the forced-footing rule (`DW0546`)
4843 /// rather than restating it. Required content standing beyond a way that no
4844 /// forced opening precedes is `DW0548`, which names the way, the effect and
4845 /// the element.
4846 OpenWay {
4847 /// The placed piece whose way this opens (`prefab/<name>`).
4848 ///
4849 /// A piece, not an anchor: the way's cells are the contract's, not a gate
4850 /// anchor's, and a piece placed twice has two ways. The reference must
4851 /// name exactly one placement; naming none or several is `DW0547`.
4852 piece: PrefabId,
4853 /// The way's region name, as the piece's contract exports it
4854 /// (`spatial_contract.edges[].way.region`).
4855 way: String,
4856 },
4857 /// Despawns an NPC and its interaction hitbox (DSL v0.4, spec-0008 §5). The
4858 /// NPC leaves unseen: no death animation, red flash or death particles.
4859 DespawnNpc {
4860 /// The NPC (stage-2 ref) to remove.
4861 npc: NpcId,
4862 },
4863 /// Moves an NPC (and its interaction hitbox in lockstep) to an anchor (DSL
4864 /// v0.4, spec-0008 §5 + addendum). The compiler plans a **collision-safe walked
4865 /// path** by A* over the solved voxel grid and emits per-tick teleport
4866 /// waypoints along it, so the NPC never clips a wall and walks up to (not into)
4867 /// a solid affordance. An unroutable move is a compile error (`DW0307`).
4868 MoveNpc {
4869 /// The NPC (stage-2 ref) to move.
4870 npc: NpcId,
4871 /// The destination mark: an anchor and an optional offset (spec-0066).
4872 to: Mark,
4873 /// Optional travel speed in blocks/tick (defaults to ~0.15).
4874 #[serde(default, skip_serializing_if = "Option::is_none")]
4875 speed: Option<f64>,
4876 /// Effects fired once the NPC arrives at the destination cell — exact
4877 /// parity with [`Verb::MoveActor`]
4878 /// `on_arrive`: same arrival detection (the walk driver's final tick), same
4879 /// execution context, and every deep effect walker recurses into it via
4880 /// [`QuestEffect::nested_effect_lists`]. This is what lets content gate a
4881 /// beat on walk *completion* instead of fire-and-forgetting the walk (e.g.
4882 /// `on_arrive: [set-flag]` so a cutscene waits for the NPC to reach its
4883 /// mark).
4884 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4885 on_arrive: Vec<QuestEffect>,
4886 },
4887 /// Plays a scripted camera cutscene (DSL v0.4 addendum). Per player: save
4888 /// gamemode+position, spectator, then dolly two co-located cameras along a
4889 /// straight-line lerp between waypoints and alternate `spectate` between them
4890 /// each tick (the two-camera bounce; the same-entity re-`spectate` is a server
4891 /// no-op and is never emitted), and restore on completion. The compiler
4892 /// validates the dolly path passes only through non-solid blocks — cameras
4893 /// fly but must not clip a solid (`DW0308`).
4894 ///
4895 /// Camera **aim** (DSL v0.6): with `look_at`, every dolly camera is rotated at
4896 /// emission to face that world point from its own position, so the shot keeps
4897 /// its subject framed for the whole move; without it, the camera faces along
4898 /// the direction of travel (the segment it is currently traversing).
4899 ///
4900 /// **Shape** — a cutscene is a list of [`CameraShot`]s played back-to-back
4901 /// inside one save/restore bracket (hard cut between shots). Two accepted,
4902 /// mutually exclusive spellings, both normalized by
4903 /// [`QuestEffect::cutscene_shots`]:
4904 /// - multi-shot (DSL v0.6): `{"shots": [{path, seconds, look_at?}, …]}`;
4905 /// - single-shot (DSL v0.4): `{"path": […], "seconds": n, "look_at"?: …}` —
4906 /// exactly equivalent to a one-entry `shots` list.
4907 ///
4908 /// Mixing or omitting both is `DW0199`.
4909 Cutscene {
4910 /// Multi-shot form (DSL v0.6): the ordered shot list. Mutually exclusive
4911 /// with the single-shot `path`/`seconds` fields (`DW0199`).
4912 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4913 shots: Vec<CameraShot>,
4914 /// Single-shot form (DSL v0.4): ordered camera waypoints (straight-line
4915 /// lerp between them).
4916 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4917 path: Vec<Mark>,
4918 /// Single-shot form (DSL v0.4): shot duration in seconds.
4919 #[serde(default, skip_serializing_if = "Option::is_none")]
4920 seconds: Option<u32>,
4921 /// Single-shot form (DSL v0.6): the subject the camera keeps framed.
4922 /// Absent = face along the direction of travel.
4923 #[serde(default, skip_serializing_if = "Option::is_none")]
4924 look_at: Option<Mark>,
4925 },
4926 /// Cuts the dimension-global world time to a new state (DSL v0.5, spec-0010).
4927 /// Instantaneous (vanilla has no gradual transition); the state persists
4928 /// because the daylight cycle is frozen by sealing.
4929 SetTime {
4930 /// The time state to cut to.
4931 time: WorldTime,
4932 },
4933 /// Cuts the dimension-global weather to a new state (DSL v0.5, spec-0010).
4934 /// Instantaneous; persists because the weather cycle is frozen by sealing.
4935 SetWeather {
4936 /// The weather state to cut to.
4937 weather: WorldWeather,
4938 },
4939 /// Plays a vanilla sound event, positionally or per-player (DSL v0.6,
4940 /// spec-0014). `sound` is validated against the vendored pinned-1.21.11
4941 /// sound-event registry (`DW0326` unknown). `at` selects where the sound
4942 /// originates (default: each player's own position); `volume`/`pitch` map to
4943 /// the `playsound` command's trailing args (pitch clamps to 0.0..=2.0 in
4944 /// vanilla).
4945 PlaySound {
4946 /// The vanilla sound-event id (`minecraft:` prefix optional).
4947 sound: String,
4948 /// Where the sound plays from (default: `players`).
4949 #[serde(default, skip_serializing_if = "Option::is_none")]
4950 at: Option<SoundAt>,
4951 /// Playback volume (vanilla default 1.0; > 1.0 only extends audible range).
4952 #[serde(default, skip_serializing_if = "Option::is_none")]
4953 volume: Option<f64>,
4954 /// Playback pitch (vanilla 0.0..=2.0; default 1.0).
4955 #[serde(default, skip_serializing_if = "Option::is_none")]
4956 pitch: Option<f64>,
4957 },
4958 /// Deals damage to the acting player(s) (DSL v0.6): the real consequence a
4959 /// stealth `on_caught` or a souls-style beat needs — vanilla's `/damage`
4960 /// primitive. Runs in the effect's `as @a` / `as @s` context, so `@s` is each
4961 /// acting player: at top level it damages every player once; inside a stealth
4962 /// `on_caught` it damages the caught player (the "caught → death → respawn at
4963 /// checkpoint" beat). `amount` is in **half-hearts** (1 HP each); an amount ≥ 40
4964 /// is lethal through golden apples / absorption. `within` (JSON `in`) narrows to
4965 /// acting players standing inside an anchor-centred box (the same box model as a
4966 /// stealth zone), keeping the per-`@s` semantics. `damage_type` is the damage
4967 /// type — a curated set of vanilla types that all respect `keepInventory` and do
4968 /// **not** bypass totems (no `out_of_world`/`generic_kill`); default `generic`.
4969 /// (The field is `damage_type`, not `type`, because the effect enum is
4970 /// internally tagged on `type`.)
4971 DamagePlayers {
4972 /// Damage dealt, in half-hearts (1 = 1 HP; ≥ 40 is effectively lethal).
4973 amount: u32,
4974 /// Optional spatial filter: only damage an acting player inside this
4975 /// anchor-centred box (`anchor ± extent`). Absent = every acting player.
4976 #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
4977 within: Option<StealthZone>,
4978 /// The damage type (default [`DamageKind::Generic`]).
4979 #[serde(default, skip_serializing_if = "Option::is_none")]
4980 damage_type: Option<DamageKind>,
4981 },
4982 /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012). Emits
4983 /// `spawnpoint @a` at the anchor cell and mirrors the coords into
4984 /// `storage dw:cp pos`. Party-wide and monotonic by quest order (a later
4985 /// `set-checkpoint` always replaces an earlier one). The compiler proves the
4986 /// cell is standable (`DW0316`) and that the remaining critical path stays
4987 /// reachable from it (`DW0315`).
4988 SetCheckpoint {
4989 /// The prefab checkpoint anchor the party respawns at.
4990 anchor: AnchorId,
4991 /// Per-player effects re-run each time a player respawns while this
4992 /// checkpoint is the active one — scene reset (e.g. re-caging an
4993 /// unleashed actor). Emitted idempotently in declared order; empty = no
4994 /// hook. Respawn is detected via the vanilla `deathCount` criterion.
4995 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4996 on_respawn: Vec<QuestEffect>,
4997 },
4998 /// Places a **bonfire** rest point (DSL v0.6, spec-0016 §1) — the sibling of
4999 /// [`Verb::SetCheckpoint`] for souls-mode pacing. The effect *arms*
5000 /// the rest affordance (a `minecraft:interaction` the player right-clicks at
5001 /// the anchor, the campfire prop being prefab dressing); the checkpoint moves
5002 /// only **when the party actually rests**. Resting fires `on_rest` — the
5003 /// scene reset that makes retry cheap: re-arming traps, re-seating waves
5004 /// declared `respawns_on_rest`, restoring actor postures. Death respawns the
5005 /// party at the last-rested bonfire and runs the **same** `on_rest` bundle,
5006 /// so the world's answer to a death and to a rest is identical (spec-0016:
5007 /// death is an investment, never a tax).
5008 ///
5009 /// Proofs are inherited from the checkpoint machinery: the anchor must be
5010 /// standable (`DW0316`) and must not strand the party (`DW0315`), rooted at
5011 /// the beat that arms the bonfire (the earliest moment a rest can happen).
5012 Bonfire {
5013 /// The prefab anchor the rest affordance stands at, and the cell the
5014 /// party respawns at once rested.
5015 anchor: AnchorId,
5016 /// Effects re-run on every rest **and** on every respawn at this bonfire
5017 /// — the scene reset. Emitted in declared order and expected to be
5018 /// idempotent (the same contract as `set-checkpoint`'s `on_respawn`).
5019 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5020 on_rest: Vec<QuestEffect>,
5021 /// Title of the two-option rest dialog (DSL v0.8).
5022 /// Absent = the compiler's canonical English `Bonfire`,
5023 /// baked at emit time (the `world.boundary.message` precedent): an
5024 /// authored line is inventoried and translates like every other
5025 /// player-visible string.
5026 #[serde(default, skip_serializing_if = "Option::is_none")]
5027 prompt: Option<String>,
5028 /// Label of the **rest and save** button. Absent = `Rest and save`.
5029 /// A dialog button is a fixed-width caption, so keep it to ~20 Latin /
5030 /// ~12 Han characters (skill *Writing craft* §C) — a wider label scrolls.
5031 #[serde(default, skip_serializing_if = "Option::is_none")]
5032 rest_label: Option<String>,
5033 /// Label of the **save only** button. Absent = `Save only`.
5034 #[serde(default, skip_serializing_if = "Option::is_none")]
5035 save_label: Option<String>,
5036 },
5037 /// Begins a stealth beat (DSL v0.6, spec-0014):
5038 /// zone presence alone = hidden — no sneak requirement, which collides with
5039 /// the spectator cutscene camera. While active, every player must be
5040 /// inside some `zone` each tick; a player outside every zone for
5041 /// `grace_ticks` fires `on_caught` (typically a kill → checkpoint respawn).
5042 /// Zone membership is read from the player's position. The compiler proves
5043 /// each zone is standable and reachable from the activating beat (`DW0327`).
5044 BeginStealth {
5045 /// The "shadow" regions, each an anchor-centred box (see [`StealthZone`]).
5046 zones: Vec<StealthZone>,
5047 /// Per-player effects fired when a player is caught (out of every zone
5048 /// for `grace_ticks`). Empty = no consequence.
5049 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5050 on_caught: Vec<QuestEffect>,
5051 /// Ticks a player may be exposed before `on_caught` fires (default 20).
5052 #[serde(default = "default_grace_ticks")]
5053 grace_ticks: u32,
5054 },
5055 /// Ends the active stealth beat (DSL v0.6, spec-0014). No-op if none active.
5056 EndStealth,
5057 /// Summons a `deferred` stage-2 NPC (body + interaction hitbox + name display)
5058 /// at its declared anchor (DSL v0.6) — the dual of `despawn-npc`, and the
5059 /// scripted entrance a staged character needs. Idempotent: spawning an NPC
5060 /// already in the world is a no-op. Only meaningful for an NPC declared
5061 /// `deferred: true`; a non-deferred NPC is already in the world from init.
5062 SpawnNpc {
5063 /// The NPC (stage-2 ref) to summon.
5064 npc: NpcId,
5065 },
5066 // --- DSL v0.6 actor staging effects (spec-0014) ---
5067 /// Summons a stage-5 actor's puppet at its anchor (DSL v0.6). Idempotent: a
5068 /// spawn of an already-present actor is a no-op (re-caging after `unleash`).
5069 SpawnActor {
5070 /// The actor (stage-5 `actors` ref) to summon.
5071 actor: ActorId,
5072 },
5073 /// Removes an actor's puppet (DSL v0.6). `kill` plays the vanilla death
5074 /// animation (cutscene deaths); `vanish` is silent removal.
5075 DespawnActor {
5076 /// The actor (stage-5 `actors` ref) to remove.
5077 actor: ActorId,
5078 /// How the puppet is removed.
5079 style: DespawnStyle,
5080 },
5081 /// Walks an actor's puppet to an anchor by A*-planned per-tick teleport over
5082 /// the assembled model, using the actor's hitbox footprint, yawed along the
5083 /// path tangent (DSL v0.6). Concurrent movers are allowed (a herded
5084 /// flock is N synchronized `move-actor`s). Unroutable → `DW0325`. `on_arrive`
5085 /// effects fire once the puppet reaches the destination cell.
5086 MoveActor {
5087 /// The actor (stage-5 `actors` ref) to move.
5088 actor: ActorId,
5089 /// The destination mark: an anchor and an optional offset (spec-0066).
5090 to: Mark,
5091 /// Optional travel speed in blocks/tick (defaults to ~0.15).
5092 #[serde(default, skip_serializing_if = "Option::is_none")]
5093 speed: Option<f64>,
5094 /// Effects fired once the puppet arrives at the destination cell.
5095 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5096 on_arrive: Vec<QuestEffect>,
5097 },
5098 /// Replaces an actor's puppet with a real-AI twin of the same type / position /
5099 /// name / attributes / tag (DSL v0.6) — the "attack the idle giant → real
5100 /// fight" beat. Re-caging is `despawn-actor` + `spawn-actor` (idempotent), not a
5101 /// special verb.
5102 UnleashActor {
5103 /// The actor (stage-5 `actors` ref) to unleash.
5104 actor: ActorId,
5105 },
5106 /// A deterministic timeline (DSL v0.6): one schedule chain firing effect groups
5107 /// at exact tick offsets. Effects are any in the stage-5 set except a nested
5108 /// `sequence` (rejected with `DW0329`).
5109 Sequence {
5110 /// Timeline steps; each fires its `effects` at `at_ticks` from the start.
5111 steps: Vec<SequenceStep>,
5112 },
5113 /// **Saturating projectile volley** (DSL v0.6, spec-0022): command-summoned
5114 /// projectiles with real velocity vectors, fired from a gallery slot into a
5115 /// declared kill zone.
5116 ///
5117 /// The contract is **saturation, not sniping**.
5118 /// Every salvo puts one projectile on the trajectory to **every standable
5119 /// cell of `kill_zone`**, plus one aimed at the triggering player's
5120 /// fire-time position (which punishes standing still). A player therefore
5121 /// cannot dodge a volley by strafing — escaping means *leaving the zone*, a
5122 /// decision rather than a lucky step. Coverage is proven at compile time:
5123 /// `from_anchor` must have clear line of fire to every standable kill-zone
5124 /// cell (`DW0442`), so "the gallery slot can actually hit a player anywhere
5125 /// on the stairs" is a build-time fact, not a hope.
5126 ///
5127 /// Projectiles are summoned `NoGravity` so the flown path is exactly the
5128 /// straight segment the coverage proof checks — proof and runtime share one
5129 /// geometry. Drag scales speed but not direction, so the line is preserved.
5130 Volley {
5131 /// Projectile entity id (default `minecraft:arrow`; validated against
5132 /// the pinned 1.21.11 registry, `DW0441`).
5133 #[serde(default, skip_serializing_if = "Option::is_none")]
5134 projectile: Option<String>,
5135 /// The gallery slot the volley is fired FROM — a point anchor. Its cell
5136 /// must be clear (a projectile spawned inside a wall never leaves it).
5137 from_anchor: AnchorId,
5138 /// The zone the volley must blanket, as an anchor-centred box
5139 /// (`anchor ± extent`) — the same shape `damage-players`'s `in` and
5140 /// `begin-stealth`'s `zones` use. Every standable cell in it receives
5141 /// fire each salvo.
5142 ///
5143 /// Deliberately NOT a bare prefab `region` anchor: the assembled model
5144 /// clears every gate-region anchor's cells unconditionally, so
5145 /// describing a zone that way would delete the geometry it names.
5146 kill_zone: StealthZone,
5147 /// How many rounds the pattern repeats (default 3, `DW0443` bounds it).
5148 #[serde(default, skip_serializing_if = "Option::is_none")]
5149 salvos: Option<u32>,
5150 /// Ticks between salvos (default 10, `DW0443` bounds it).
5151 #[serde(default, skip_serializing_if = "Option::is_none")]
5152 interval: Option<u32>,
5153 },
5154 /// **Ceiling collapse** (DSL v0.6, spec-0022): delete a region's blocks and
5155 /// drop them as `falling_block` entities — the buried-alive trap redstone
5156 /// cannot express at all.
5157 ///
5158 /// The post-collapse world is **modeled, not guessed**: the compiler clears
5159 /// the region, settles every dropped column through the existing gravity
5160 /// model (spec-0010), optionally paves the landing surface with
5161 /// `then_floor`, and re-runs the critical-path completability proof against
5162 /// that mutated world (`DW0445`). A collapse that buries the only route is a
5163 /// build error, exactly as a `shortcut` seal that strands the party is.
5164 Collapse {
5165 /// The volume whose blocks fall, as an anchor-centred box
5166 /// (`anchor ± extent`). Must hold blocks and sit above standable footing
5167 /// (`DW0444`) — a collapse over nothing is scenery, not a trap.
5168 ///
5169 /// An anchor-centred box rather than a prefab `region` anchor for a
5170 /// load-bearing reason: the assembled model deletes every gate-region
5171 /// anchor's cells, so a ceiling slab declared that way would already be
5172 /// gone before the trap ever fired.
5173 region_anchor: StealthZone,
5174 /// The block the falling entities are made of (default
5175 /// `minecraft:gravel`; validated against the pinned registry, `DW0441`).
5176 #[serde(default, skip_serializing_if = "Option::is_none")]
5177 falling_block: Option<String>,
5178 /// Optional block the landing surface is paved with once the debris
5179 /// settles — the authored post-collapse floor the completability proof
5180 /// reasons over.
5181 #[serde(default, skip_serializing_if = "Option::is_none")]
5182 then_floor: Option<String>,
5183 },
5184 /// Grants a vanilla **status effect** for a stated duration (DSL v0.10,
5185 /// spec-0031). The engine has emitted status effects since v0.6 — the
5186 /// night-vision area mitigation is a self-rescheduling, region-scoped
5187 /// `effect give` — and exposed none, so an author who wanted blindness for a
5188 /// lift ride, slowness in deep water or regeneration at a shrine had no
5189 /// surface at all. This is that surface, over the whole pinned 1.21.11
5190 /// `mob_effect` registry (`DW0192` rejects an id outside it).
5191 ///
5192 /// **A grant carries a duration, and there is no way to spell one that does
5193 /// not.** Vanilla's `infinite` keyword is deliberately absent from this
5194 /// surface: an effect whose only removal is a later command is an effect the
5195 /// player keeps forever whenever that command does not run — a logout, a
5196 /// crash, a chain interrupted by a death. A duration expires on its own, with
5197 /// no cooperation from anything. `seconds` is therefore required and bounded
5198 /// (1..=[`MAX_EFFECT_SECONDS`], `DW0541`), and the *pattern* that reintroduces
5199 /// the same hazard — pairing a grant with a `clear-effect` that removes it
5200 /// while it is still live — is `DW0540`.
5201 ///
5202 /// `in` narrows to players inside an anchor-centred box, the same
5203 /// [`StealthZone`] a `begin-stealth` zone, a `damage-players` filter and a
5204 /// `lethal_volumes[]` region use, resolved through the one `Plan::zone_box`.
5205 /// It is what makes "blind whoever is riding the car" expressible without
5206 /// blinding the whole party.
5207 GiveEffect {
5208 /// Vanilla status-effect id (e.g. `minecraft:blindness`), validated
5209 /// against the pinned 1.21.11 registry (`DW0192`).
5210 effect: String,
5211 /// Duration in seconds. Required, `1..=`[`MAX_EFFECT_SECONDS`]
5212 /// (`DW0541`) — see the variant docs for why there is no infinite form.
5213 seconds: u32,
5214 /// Amplifier (0 = level I), `0..=`[`MAX_POTION_AMPLIFIER`] (`DW0541`).
5215 /// Absent = 0.
5216 #[serde(default, skip_serializing_if = "Option::is_none")]
5217 amplifier: Option<u32>,
5218 /// Suppress the swirling particles (vanilla's `hideParticles`). Absent =
5219 /// `false`, vanilla's own default.
5220 #[serde(default, skip_serializing_if = "Option::is_none")]
5221 hide_particles: Option<bool>,
5222 /// Optional spatial filter: only grant to a player inside this
5223 /// anchor-centred box (`anchor ± extent`). Absent = every player the
5224 /// effect's audience addresses.
5225 #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
5226 within: Option<StealthZone>,
5227 },
5228 /// Removes a status effect (DSL v0.10, spec-0031) — vanilla's `effect clear`.
5229 ///
5230 /// This is **not** how a `give-effect` is supposed to end: a duration is. It
5231 /// exists for the effects the engine did not grant — a potion the player
5232 /// drank, a `wither` a mob applied, the whole set at a bonfire — which is why
5233 /// `effect` is optional (absent = clear everything, exactly as vanilla's
5234 /// `effect clear <targets>` does). Pairing it with a live grant of the same
5235 /// effect in the same bundle is `DW0540`.
5236 ClearEffect {
5237 /// The status-effect id to remove (`DW0192`). **Absent clears every
5238 /// effect**, matching `effect clear <targets>` with no id.
5239 #[serde(default, skip_serializing_if = "Option::is_none")]
5240 effect: Option<String>,
5241 /// Optional spatial filter, identical in shape and meaning to
5242 /// [`Verb::GiveEffect`]'s.
5243 #[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
5244 within: Option<StealthZone>,
5245 },
5246 /// Teleports **everything inside a declared volume** to an anchor (DSL v0.10,
5247 /// spec-0031).
5248 ///
5249 /// **The selector is a region, never a block.** "Whoever is standing on this
5250 /// block" has three different answers for a player half a foot over the edge,
5251 /// a player mid-jump and a player sneaking on the lip; a volume has one, and
5252 /// it is the same one every tick. `from` is the anchor-centred
5253 /// [`StealthZone`] box the rest of the engine already uses.
5254 ///
5255 /// **The selection is total over bodies.** Emission is a single `tp
5256 /// @e[<box>,tag=!dw_fixture] <cell>` with no `type=`, no `limit=` and no
5257 /// `sort=` — every body in the volume moves, and
5258 /// `crates/delvec/tests/v10_teleport.rs` asserts that from the emitted
5259 /// selector rather than from anyone's memory. A machinery-**type** exemption
5260 /// of the kind a `lethal_volumes[]` entry must carry was considered and
5261 /// **rejected**: an NPC is a body plus a co-located `minecraft:interaction`
5262 /// hitbox, so exempting `minecraft:interaction` — as the lethal volume does —
5263 /// would teleport the speaker and leave its dialogue box behind. Everyone on
5264 /// the car travels, players and entities
5265 /// alike; totality over bodies is how that is true.
5266 ///
5267 /// The one narrowing is a **class the engine's own furniture declares about
5268 /// itself**: `dw_fixture` means *my position IS engine state*. A bonfire's
5269 /// hitbox, a shortcut lever and a recovery stake's marker are places, not
5270 /// passengers, and carrying one does not move a thing — it rewrites a fact
5271 /// (for a stake, the ledger holds the marker's coordinates, and the next tick
5272 /// retires a marker nobody has a wager at). Places whose cell is known at
5273 /// compile time are refused outright instead, because the author can move
5274 /// them (`DW0542`); places the runtime puts down are excluded by the selector,
5275 /// because nobody can (`DW0545`). Nothing an author writes carries either tag,
5276 /// and no campaign JSON can turn either off.
5277 ///
5278 /// **A teleport is not a rescue.** Accumulated fall distance carries across
5279 /// one unchanged and is charged in full at the destination — measured Δ
5280 /// exactly `0.0000` in 46/46 trials on the pinned 1.21.11, including
5281 /// teleports 143 and 157 blocks straight *up*, with landing damage
5282 /// `floor(fall_distance) − 3` (`docs/notes/death-and-teleport-spike.md` §3).
5283 /// A platform that arrives under a falling player past ~20 blocks of fall is
5284 /// the surface they die on. The compiler does not try to reset the counter:
5285 /// what *does* reset it was explicitly not measured, and inventing a
5286 /// mechanism from recall is the folklore this project forbids.
5287 Teleport {
5288 /// The volume whose contents are moved, as an anchor-centred box
5289 /// (`anchor ± extent`) — the same shape a `begin-stealth` zone, a
5290 /// `damage-players` `in` filter and a `lethal_volumes[]` region take.
5291 ///
5292 /// Deliberately NOT a bare prefab `region` anchor, for the reason
5293 /// [`Verb::Collapse`] records: the assembled model clears every
5294 /// gate-region anchor's cells, so a volume described that way would
5295 /// delete the geometry it names.
5296 from: StealthZone,
5297 /// The destination mark (spec-0066). Resolved to a literal cell at build
5298 /// time, so the emitted `tp` carries absolute coordinates and no runtime
5299 /// search.
5300 to: Mark,
5301 },
5302 /// Fires a **firework rocket** from a mark (DSL v0.29, spec-0068).
5303 ///
5304 /// One effect at a point, the member of the same class as [`Verb::PlaySound`]
5305 /// — a one-shot thing that happens where the campaign says, beside the sound
5306 /// that goes with it. A display of many rockets is a [`Verb::Sequence`] of
5307 /// these, not a verb with timing of its own.
5308 ///
5309 /// # The burst height is a stated number, not a roll
5310 ///
5311 /// The emitter writes the entity's `LifeTime`
5312 /// ([`crate::firework::lifetime_ticks`]) rather than leaving it to the game,
5313 /// which randomises it at launch: two runs of one datapack would otherwise
5314 /// burst at two heights and nothing could be proven about where the burst is.
5315 /// Fixed at the floor of the game's range, the burst stands
5316 /// [`crate::firework::burst_height`] blocks over the mark.
5317 ///
5318 /// # A burst hurts, so the compiler asks where it is
5319 ///
5320 /// A build refuses a rocket whose column to that height is roofed, and one
5321 /// whose burst lies within [`crate::firework::BLAST_RADIUS`] blocks of a
5322 /// place the campaign posts a body (`DW0899`). Players are **not** posted:
5323 /// a player standing level with a burst takes up to
5324 /// [`crate::firework::worst_damage_hp`] HP, under a full body's twenty, and
5325 /// that is a hazard a player can see coming.
5326 Firework {
5327 /// The mark the rocket is launched from — the cell's centre, at the
5328 /// mark's own plane.
5329 at: Mark,
5330 /// Flight duration, 1–3 (the three the game crafts). Absent =
5331 /// [`crate::firework::MIN_FLIGHT`].
5332 #[serde(default, skip_serializing_if = "Option::is_none")]
5333 #[schemars(range(min = 1, max = 3))]
5334 flight: Option<u8>,
5335 /// One to seven bursts, in the order the component carries them.
5336 #[schemars(length(min = 1, max = 7))]
5337 explosions: Vec<FireworkExplosion>,
5338 },
5339}
5340
5341/// Default `grace_ticks` for [`Verb::BeginStealth`] (spec-0014).
5342fn default_grace_ticks() -> u32 {
5343 20
5344}
5345
5346/// Default projectile for [`Verb::Volley`] (spec-0022).
5347pub const DEFAULT_VOLLEY_PROJECTILE: &str = "minecraft:arrow";
5348/// Default salvo count for [`Verb::Volley`] (spec-0022).
5349pub const DEFAULT_VOLLEY_SALVOS: u32 = 3;
5350/// Default ticks between salvos for [`Verb::Volley`] (spec-0022).
5351pub const DEFAULT_VOLLEY_INTERVAL: u32 = 10;
5352/// Largest admissible `salvos` — beyond this a volley is an entity-count
5353/// hazard rather than a trap (`DW0443`).
5354pub const MAX_VOLLEY_SALVOS: u32 = 16;
5355/// Largest admissible `interval` in ticks (`DW0443`): 10 seconds. A volley
5356/// slower than this is no longer one event the player reads as a trap.
5357pub const MAX_VOLLEY_INTERVAL: u32 = 200;
5358/// Default falling block for [`Verb::Collapse`] (spec-0022).
5359pub const DEFAULT_COLLAPSE_FALLING_BLOCK: &str = "minecraft:gravel";
5360
5361/// A stealth "shadow" region (DSL v0.6, spec-0014): an axis-aligned box centred
5362/// on `anchor`, extending `extent` blocks along each axis (so the box spans
5363/// `anchor ± extent`). Presented in-world via dark cells but judged purely by
5364/// region membership, so the check is deterministic and provable.
5365#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5366#[serde(deny_unknown_fields)]
5367pub struct StealthZone {
5368 /// The prefab anchor at the centre of the zone box.
5369 pub anchor: AnchorId,
5370 /// Half-extents `[x, y, z]` in blocks from the anchor (each component ≥ 0);
5371 /// the zone AABB is `[anchor - extent, anchor + extent]`.
5372 pub extent: [u32; 3],
5373}
5374
5375/// Where a [`Verb::PlaySound`] originates (DSL v0.6, spec-0014). A sound
5376/// plays at fixed coordinates or at each listener's own position; the compiler
5377/// resolves no position for a live actor, so the `actor` variant is accepted by
5378/// the schema and rejected with `DW0335`.
5379#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5380#[serde(tag = "at", rename_all = "kebab-case", deny_unknown_fields)]
5381pub enum SoundAt {
5382 /// Play the sound positioned at a resolved anchor, audible to all players.
5383 Anchor {
5384 /// The anchor the sound plays from.
5385 anchor: AnchorId,
5386 /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
5387 /// `[0, 0, 0]`): the sound plays at the [`Mark`] the two fields spell.
5388 #[serde(default, skip_serializing_if = "is_zero3")]
5389 offset: [i32; 3],
5390 },
5391 /// Play the sound at each player's own position (the default).
5392 Players,
5393 /// Play the sound at a scripted actor's position (rejected — `DW0335`; no
5394 /// actor position resolves at emission).
5395 Actor {
5396 /// The actor id (stage-5 `actors[]`).
5397 actor: String,
5398 },
5399}
5400
5401/// The damage type of a [`Verb::DamagePlayers`] effect (DSL v0.6). A
5402/// **curated** subset of the vanilla 1.21.11 damage-type registry: every variant
5403/// respects the `keepInventory` death flow (a gamerule, so all deaths do) and does
5404/// **not** bypass a totem of undying — the totem-bypassing `out_of_world` /
5405/// `generic_kill` types are deliberately excluded, so a scripted consequence can
5406/// never silently void a player's held totem. Modelled as an enum (not a free
5407/// string) so an unknown type is a schema rejection (`DW0100`) and needs no separate
5408/// registry / diagnostic. `generic` is the default: command damage that respects
5409/// totems + absorption but ignores armor, so a scripted hit lands regardless of gear.
5410#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5411#[serde(rename_all = "kebab-case")]
5412pub enum DamageKind {
5413 /// `minecraft:generic` — armor-ignoring command damage (default).
5414 Generic,
5415 /// `minecraft:magic` — magical damage.
5416 Magic,
5417 /// `minecraft:wither` — the wither/withering effect's damage type.
5418 Wither,
5419 /// `minecraft:on_fire` — burning damage.
5420 Fire,
5421 /// `minecraft:drown` — drowning damage.
5422 Drown,
5423 /// `minecraft:freeze` — powder-snow freezing damage.
5424 Freeze,
5425 /// `minecraft:fall` — fall damage.
5426 Fall,
5427 /// `minecraft:lightning_bolt` — a lightning strike's damage type.
5428 LightningBolt,
5429 /// `minecraft:explosion` — a (non-player) explosion's damage type.
5430 Explosion,
5431}
5432
5433impl DamageKind {
5434 /// The vanilla `minecraft:` damage-type id emitted to `/damage`.
5435 pub fn id(self) -> &'static str {
5436 match self {
5437 DamageKind::Generic => "minecraft:generic",
5438 DamageKind::Magic => "minecraft:magic",
5439 DamageKind::Wither => "minecraft:wither",
5440 DamageKind::Fire => "minecraft:on_fire",
5441 DamageKind::Drown => "minecraft:drown",
5442 DamageKind::Freeze => "minecraft:freeze",
5443 DamageKind::Fall => "minecraft:fall",
5444 DamageKind::LightningBolt => "minecraft:lightning_bolt",
5445 DamageKind::Explosion => "minecraft:explosion",
5446 }
5447 }
5448}
5449
5450/// A stage-5 **lethal volume** (DSL v0.10, spec-0031): a declared box that kills
5451/// whatever enters it, and states — in the campaign's own words — what killed it.
5452///
5453/// # A mechanism, not a fiction
5454///
5455/// The commissioning case was a cliff whose fall must be fatal, but nothing here
5456/// knows what a cliff is: a lava pit, an acid pool, an out-of-bounds plane and the
5457/// bottom of a lift shaft are the same declaration, differently dressed and
5458/// differently worded. The alternative considered and **rejected** for the cliff
5459/// was making the world's horizon void so the fall kills anyway — that changes
5460/// approved art to obtain a behaviour, and it serves exactly one fiction.
5461///
5462/// # It is geometry, so the completability proof owns it
5463///
5464/// A volume that kills is, for a route, a volume that cannot be crossed. The
5465/// compiler models its cells as impassable in the same navigation world every
5466/// other reachability proof runs on, exactly as a `close-gate`'s sealed region is
5467/// modelled solid — so a forced path that has no way to an objective except
5468/// through a lethal volume is a build failure (`DW0510`) naming the volume, never
5469/// a shipped delve that kills the player on the critical path. A respawn seat
5470/// inside one is the death loop that failure mode ends in, and is its own
5471/// error (`DW0511`).
5472///
5473/// # It rides the death edge that already exists
5474///
5475/// The kill is a `/damage`, exactly like `damage-players`, a trap payload or a
5476/// timed gate's crush. Everything downstream — the vanilla `deathCount` edge
5477/// (`dw.deaths` / `dw.death_ack`), the checkpoint re-seat (`cp_respawn_check`),
5478/// `keep_inventory` — sees an ordinary death and needs no second detector.
5479#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5480#[serde(deny_unknown_fields)]
5481pub struct LethalVolume {
5482 /// Unique lethal-volume id (`lethal/<kebab>`).
5483 pub id: LethalVolumeId,
5484 /// The volume: an anchor-centred box (`anchor ± extent`).
5485 ///
5486 /// **Deliberately the existing zone type**, not a second struct with the same
5487 /// two fields. `StealthZone` is already the engine's anchor-centred box object
5488 /// class — `damage-players`'s `in` filter reuses it, and the compiler resolves
5489 /// every one of them through the single `Plan::zone_box`. A private twin here
5490 /// would be `tools/ci/check-capability-ownership.py` check C by construction, and
5491 /// would fork the resolution the very next time a box grew a capability.
5492 pub region: StealthZone,
5493 /// What this volume says when it kills — a player-visible line, inventoried
5494 /// under `lethal.<id>.message` and translated like every other one.
5495 ///
5496 /// Required, and deliberately so: a volume with no words is a player who dies
5497 /// with no idea why, and there is no compiler-owned default that could be
5498 /// right for a cliff, a lava pit and an acid pool at once.
5499 pub message: String,
5500 /// The damage type the kill is dealt with (default [`DamageKind::Generic`]).
5501 ///
5502 /// This is what words vanilla's own broadcast — `fall` says the party member
5503 /// fell from a high place, `on_fire` that they burnt to a crisp — while
5504 /// [`Self::message`] says what the *place* was. The curated enum is shared
5505 /// with `damage-players`, so a lethal volume can no more void a held totem
5506 /// than a scripted hit can.
5507 #[serde(default, skip_serializing_if = "Option::is_none")]
5508 pub damage_type: Option<DamageKind>,
5509 /// **The blocks that show a player this floor kills** (spec-0062 §3).
5510 ///
5511 /// A lava surface, a magma floor, a bed of spikes, a burning strip: the
5512 /// danger is level with the footing because the block *is* the signal. This
5513 /// is where a volume says so, and it is a claim about the assembled bytes
5514 /// rather than a word that switches a rule off — `DW0891` checks it per
5515 /// caught cell against the block under or in that cell, refuses a listed
5516 /// block no caught cell bears out, and refuses at validation any id vanilla
5517 /// does not hurt a body with.
5518 ///
5519 /// Empty is the ordinary case and means the ordinary thing: this volume
5520 /// catches no floor the party walks, because it sits at the bottom of a pit
5521 /// or a course under a lake's surface. It does **not** exempt a cell from
5522 /// the walk graph — a visible hazard is still a hazard, and the router
5523 /// refuses every cell of the keep-out either way.
5524 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5525 pub shown_by: Vec<String>,
5526}
5527
5528// ---------------------------------------------------------------------------
5529// Stage 5 — trade and the recovery stake (DSL v0.10, spec-0032)
5530// ---------------------------------------------------------------------------
5531
5532/// A stage-5 **shop** (DSL v0.10, spec-0032): an interaction point that opens a
5533/// list of offers, each of which is a gate and a bundle of effects.
5534///
5535/// # It is the rest flow with different buttons, and that is the whole design
5536///
5537/// A bonfire is already an interaction entity, a player-interaction advancement
5538/// that supplies the acting player, a `minecraft:multi_action` dialog, buttons
5539/// that run `/trigger`, and tick dispatch. A shop is that same hardware with an
5540/// authored button list, so nothing here is new machinery — it is the machinery
5541/// spec-0016 §1 shipped, made author-visible.
5542///
5543/// # There is no price field, and that is deliberate
5544///
5545/// A price is *"may this happen yet?"*, which is the question
5546/// [`Gate`](crate::gate::Gate) answers for six other object classes. An offer is
5547/// therefore the **seventh gate consumer**: it carries `requires_flags`,
5548/// `forbids_flags` and `requires_state`, and the numeric comparison it needs is
5549/// the one spec-0031 put in the gate rather than in the verb that first asked.
5550/// A `price` field would be a second comparison surface for one meaning, which is
5551/// the defect CLAUDE.md names first.
5552///
5553/// # Villager `Offers` is excluded
5554///
5555/// Three independent reasons, any one sufficient (spec-0032): a vanilla trade
5556/// cost can only ever be an item, never a scoreboard value; right-click on a
5557/// villager body is already allocated to dialogue; and the data-driven trade
5558/// registry post-dates the pinned 1.21.11.
5559#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5560#[serde(deny_unknown_fields)]
5561pub struct Shop {
5562 /// Unique shop id (`shop/<kebab>`).
5563 pub id: ShopId,
5564 /// The prefab anchor the shop's interaction point stands on.
5565 pub anchor: AnchorId,
5566 /// The dialog's title line. Player-visible, inventoried under
5567 /// `shop.<id>.title`.
5568 pub title: String,
5569 /// The item the visible marker renders as (default `minecraft:emerald`).
5570 /// Cosmetic only: the marker is a `minecraft:item_display`, so it never
5571 /// occupies the cell or obstructs the hitbox.
5572 #[serde(default, skip_serializing_if = "Option::is_none")]
5573 pub marker_item: Option<String>,
5574 /// The offers, in declaration order — which is button order, and the order
5575 /// the `/trigger` routing values are assigned in.
5576 pub offers: Vec<ShopOffer>,
5577}
5578
5579/// One button in a [`Shop`]: a gate, a label, and the effects choosing it runs.
5580///
5581/// **Effect root R8.** The bundle hangs off an object with runtime machinery of
5582/// its own (an advancement, a dialog, a trigger objective and a tick dispatch),
5583/// which is spec-0031's stated rule for adding a root rather than desugaring.
5584///
5585/// **Refusal is authored, not a field.** An offer whose own gate is closed is not
5586/// shown at all; an offer that should be *shown and refuse* leaves its own gate
5587/// open and gates its effects instead — the purchase behind `at-least <price>`,
5588/// the apology behind `at-most <price − 1>`. Both are the ordinary per-effect
5589/// gate every effect already carries, so the engine adds no `refused` field and
5590/// no second way to say one thing.
5591#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5592#[serde(deny_unknown_fields)]
5593pub struct ShopOffer {
5594 /// The button's caption. Player-visible, inventoried under
5595 /// `shop.<id>.offer.<i>.label`, and width-checked like any dialog button.
5596 pub label: String,
5597 /// The button's hover tooltip — the natural home for a price in words.
5598 /// Player-visible, inventoried under `shop.<id>.offer.<i>.tooltip`.
5599 #[serde(default, skip_serializing_if = "Option::is_none")]
5600 pub tooltip: Option<String>,
5601 /// Flags that must all be set for this offer to be shown (the one gate).
5602 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5603 pub requires_flags: Vec<FlagId>,
5604 /// Flags whose being set hides this offer (the one gate).
5605 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5606 pub forbids_flags: Vec<FlagId>,
5607 /// Numeric gate terms (the one gate) — **this is where a price lives**.
5608 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5609 pub requires_state: Vec<StateCompare>,
5610 /// What choosing this offer does. Effect root R8; runs with the choosing
5611 /// player as the acting player, so a `player`-scoped datum is writable here.
5612 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5613 pub effects: Vec<QuestEffect>,
5614}
5615
5616impl ShopOffer {
5617 /// This offer's whole gate, as one value (DSL v0.10) — see
5618 /// [`crate::gate::Gate`].
5619 pub fn gate(&self) -> crate::gate::Gate<'_> {
5620 crate::gate::Gate::of(
5621 &self.requires_flags,
5622 &self.forbids_flags,
5623 &self.requires_state,
5624 )
5625 }
5626}
5627
5628/// How much of a datum a death forfeits into a [`Stake`] (DSL v0.10, spec-0032).
5629///
5630/// A creator who wants **no death cost at all** picks [`Forfeit::None`]; the
5631/// stake then still marks where they fell, which is the memorial case.
5632#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5633#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5634pub enum Forfeit {
5635 /// The whole balance.
5636 All,
5637 /// A percentage of the balance, rounded toward zero (integer arithmetic —
5638 /// ADR-0006 forbids anything a second implementation could round differently).
5639 Proportion {
5640 /// 0–100.
5641 percent: u32,
5642 },
5643 /// A fixed amount, capped at the balance so a purse can never go negative.
5644 Fixed {
5645 /// The amount.
5646 amount: i32,
5647 },
5648 /// Nothing is taken. The stake is a marker, not a wager.
5649 None,
5650}
5651
5652/// What a death does when the player already holds the maximum number of live
5653/// stakes (DSL v0.10, spec-0032).
5654#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5655#[serde(rename_all = "kebab-case")]
5656pub enum OnFull {
5657 /// Retire the player's oldest live stake — its contents are lost — and place a
5658 /// new one. With `max_live: 1` this is the souls behaviour.
5659 Replace,
5660 /// Leave the existing stakes alone: the new death places nothing and forfeits
5661 /// nothing. One wager at a time.
5662 Keep,
5663}
5664
5665impl OnFull {
5666 /// The wire token (`replace` / `keep`).
5667 pub fn token(self) -> &'static str {
5668 match self {
5669 OnFull::Replace => "replace",
5670 OnFull::Keep => "keep",
5671 }
5672 }
5673}
5674
5675/// Who may collect a stake that is not theirs (DSL v0.10, spec-0032).
5676#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5677#[serde(rename_all = "kebab-case")]
5678pub enum CollectBy {
5679 /// Only the player who left it. The default.
5680 Owner,
5681 /// Anyone standing at it. The whole of every live stake at that place is
5682 /// transferred to the collector.
5683 Anyone,
5684}
5685
5686impl CollectBy {
5687 /// The wire token (`owner` / `anyone`).
5688 pub fn token(self) -> &'static str {
5689 match self {
5690 CollectBy::Owner => "owner",
5691 CollectBy::Anyone => "anyone",
5692 }
5693 }
5694}
5695
5696/// A stage-5 **recovery stake** (DSL v0.10, spec-0032): what a death leaves
5697/// behind, and the one chance to get it back.
5698///
5699/// # A mechanism, not a genre
5700///
5701/// "Souls" is one setting of this declaration and nothing here knows the word.
5702/// The mechanism is *a death forfeits some of a declared datum into a collectable
5703/// marker at a computed place, and collecting it returns exactly what was taken*.
5704/// A campaign with no death cost, a campaign with a permanent memorial at every
5705/// death site, and a campaign with the souls loop are three configurations, not
5706/// three engines.
5707///
5708/// # Where it lands is a compile-time answer
5709///
5710/// The owner's rule: *the anchor is the point, on the walkable path
5711/// from the respawn point in force at the moment of death to the death point
5712/// under the quest state in force at that moment, that minimises distance to the
5713/// death point.* Every term already has an owner in the compiler — walkability is
5714/// the navigation world the completability proof runs on, quest-state passability
5715/// is the DAG-indexed sealing `close-gate` established, and the respawn point in
5716/// force is engine state. So it is a table computed at build time and a lookup at
5717/// run time: **no runtime search, no nondeterminism.**
5718///
5719/// # It is hardware, so it inherits the hardware rules
5720///
5721/// The marker is an invisible `minecraft:interaction` for the hitbox plus a
5722/// glowing `minecraft:item_display` for the rendering — the same pair every other
5723/// affordance in the engine uses, and therefore the same invisible-affordance
5724/// (`DW0420`) and hardware-erasure (`DW0421`) proofs. It is deliberately **not**
5725/// a dropped item entity: an item despawns after 6000 ticks, burns in lava, sinks
5726/// in the void, and can be picked up by anyone.
5727#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
5728#[serde(deny_unknown_fields)]
5729pub struct Stake {
5730 /// Unique stake id (`stake/<kebab>`).
5731 pub id: StakeId,
5732 /// The datum a death forfeits into this stake, and a collection returns.
5733 /// Must be declared in the stage-5 `state` list, and must be `player`-scoped:
5734 /// a stake is a personal wager, and a party-shared purse would turn one
5735 /// player's death into everyone's penalty.
5736 pub state: StateId,
5737 /// How much of [`Self::state`] a death takes. Default: the whole balance.
5738 #[serde(default, skip_serializing_if = "Option::is_none")]
5739 pub forfeit: Option<Forfeit>,
5740 /// How many live stakes one player may hold at once. Default 1; `0` means a
5741 /// death never places one (and never forfeits anything), which is the
5742 /// "no death cost" configuration.
5743 #[serde(default, skip_serializing_if = "Option::is_none")]
5744 pub max_live: Option<u32>,
5745 /// What a death does when the player is already at [`Self::max_live`].
5746 /// Default [`OnFull::Replace`].
5747 #[serde(default, skip_serializing_if = "Option::is_none")]
5748 pub on_full: Option<OnFull>,
5749 /// Who may collect. Default [`CollectBy::Owner`].
5750 #[serde(default, skip_serializing_if = "Option::is_none")]
5751 pub collect_by: Option<CollectBy>,
5752 /// The line a collection says, on the action bar. Player-visible, inventoried
5753 /// under `stake.<id>.collected`.
5754 pub collected_message: String,
5755 /// The item the glowing marker renders as (default `minecraft:soul_lantern`).
5756 #[serde(default, skip_serializing_if = "Option::is_none")]
5757 pub marker_item: Option<String>,
5758}
5759
5760impl Stake {
5761 /// The forfeit rule, with the documented default applied.
5762 pub fn forfeit(&self) -> Forfeit {
5763 self.forfeit.unwrap_or(Forfeit::All)
5764 }
5765
5766 /// How many live stakes one player may hold, with the documented default.
5767 pub fn max_live(&self) -> u32 {
5768 self.max_live.unwrap_or(1)
5769 }
5770
5771 /// The at-capacity rule, with the documented default.
5772 pub fn on_full(&self) -> OnFull {
5773 self.on_full.unwrap_or(OnFull::Replace)
5774 }
5775
5776 /// The collection rule, with the documented default.
5777 pub fn collect_by(&self) -> CollectBy {
5778 self.collect_by.unwrap_or(CollectBy::Owner)
5779 }
5780
5781 /// The item the marker renders as, with the documented default.
5782 pub fn marker_item(&self) -> &str {
5783 self.marker_item
5784 .as_deref()
5785 .unwrap_or("minecraft:soul_lantern")
5786 }
5787}
5788
5789/// The presentation channel for a [`Verb::Narrate`] (DSL v0.4).
5790#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5791#[serde(rename_all = "kebab-case")]
5792pub enum NarrateStyle {
5793 /// A chat line (default).
5794 Chat,
5795 /// A large on-screen title.
5796 Title,
5797 /// An on-screen subtitle.
5798 Subtitle,
5799 /// An "art" title rendered through the delve's custom resource-pack pixel-banner
5800 /// font (`delve:art`) so endings can flash blocky all-caps text (DSL v0.6,
5801 /// spec-0014). Text is checked at compile time against the font's glyph inventory
5802 /// (`DW0328`); characters outside it (e.g. non-Latin script) are rejected. It
5803 /// renders in the vanilla title slot, so it is width-checked like any title
5804 /// (`DW0330`) — roughly 15 glyphs fit on screen.
5805 Art,
5806 /// The **actionbar** — the one-line strip above the hotbar (DSL v0.11).
5807 ///
5808 /// This is the channel a *reply* uses: it does not interrupt, it does not
5809 /// stack, and it is overwritten by the next one. Every reply the compiler
5810 /// itself writes has always used it — a sealed gate's answer, a checkpoint
5811 /// return, the lobby's party count — but `narrate` could not reach it, which
5812 /// is the mechanical reason `close-gate.sealed_hint` could not have been an
5813 /// ordinary `narrate` even had someone tried (capability-ownership audit
5814 /// finding 3b). A channel is a property of the message, not of the verb that
5815 /// first wanted it.
5816 ///
5817 /// Unlike a title it is never width-checked: vanilla truncates nothing and
5818 /// draws it at GUI width, and a reply is a fragment rather than a banner.
5819 Actionbar,
5820}
5821
5822impl NarrateStyle {
5823 /// The kebab tag (`chat` / `title` / `subtitle` / `art` / `actionbar`).
5824 pub fn token(self) -> &'static str {
5825 match self {
5826 NarrateStyle::Chat => "chat",
5827 NarrateStyle::Title => "title",
5828 NarrateStyle::Subtitle => "subtitle",
5829 NarrateStyle::Art => "art",
5830 NarrateStyle::Actionbar => "actionbar",
5831 }
5832 }
5833}
5834
5835/// **A mark** (spec-0066): an anchor and an integer block offset from it, the
5836/// one declaration of a point the campaign places relative to a piece.
5837///
5838/// Its cell is the anchor's resolved cell plus `offset`, in world axes after
5839/// the piece's placement. It is what a body stands on ([`Npc::offset`],
5840/// [`Actor::offset`]), where a walk ends ([`Verb::MoveNpc`], [`Verb::MoveActor`],
5841/// [`Verb::Teleport`]), where the cast ledger says a body is
5842/// ([`CastPlace::Mark`]), where a sound plays ([`SoundAt::Anchor`]), and every
5843/// camera position in a shot: a dolly waypoint (`path`), an aim target
5844/// (`look_at`) and an anchor subject (`subject`). The roles are fields; the
5845/// type is one.
5846///
5847/// A mark's cell lies inside the placed piece its anchor belongs to (`DW0897`):
5848/// an offset says *where beside this place*, never *which place*.
5849#[derive(
5850 Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
5851)]
5852#[serde(deny_unknown_fields)]
5853pub struct Mark {
5854 /// The anchor the mark is relative to.
5855 pub anchor: AnchorId,
5856 /// Integer `[x, y, z]` block offset from the anchor (default `[0, 0, 0]`).
5857 #[serde(default, skip_serializing_if = "is_zero3")]
5858 pub offset: [i32; 3],
5859}
5860
5861impl Mark {
5862 /// A mark at `anchor` with no offset.
5863 pub fn at(anchor: AnchorId) -> Self {
5864 Mark {
5865 anchor,
5866 offset: [0, 0, 0],
5867 }
5868 }
5869
5870 /// Whether the offset is non-zero.
5871 pub fn is_offset(&self) -> bool {
5872 !is_zero3(&self.offset)
5873 }
5874
5875 /// The mark's cell, given the cell its anchor resolved to.
5876 pub fn cell(&self, anchor_cell: [i32; 3]) -> [i32; 3] {
5877 offset_cell(anchor_cell, self.offset)
5878 }
5879
5880 /// The mark as a diagnostic spells it: the anchor alone at a zero offset,
5881 /// else `anchor + [x, y, z]`.
5882 pub fn display(&self) -> String {
5883 if self.is_offset() {
5884 format!(
5885 "{} + [{}, {}, {}]",
5886 self.anchor, self.offset[0], self.offset[1], self.offset[2]
5887 )
5888 } else {
5889 self.anchor.as_str().to_string()
5890 }
5891 }
5892}
5893
5894/// `cell + offset`, componentwise: the one arithmetic a [`Mark`] adds.
5895pub fn offset_cell(cell: [i32; 3], offset: [i32; 3]) -> [i32; 3] {
5896 [
5897 cell[0] + offset[0],
5898 cell[1] + offset[1],
5899 cell[2] + offset[2],
5900 ]
5901}
5902
5903/// One shot of a [`Verb::Cutscene`] (DSL v0.6): a camera dolly with its
5904/// own duration and optional subject. A cutscene plays its shots back-to-back —
5905/// a hard cut between them — inside a single gamemode/position save-restore
5906/// bracket, so a wide establishing move can be followed by an interior close-up
5907/// without the players ever leaving the cinematic.
5908#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5909#[serde(deny_unknown_fields)]
5910pub struct CameraShot {
5911 /// Ordered camera waypoints (straight-line lerp between them). A one-waypoint
5912 /// path is a static shot. Required without `shot_style`; with one, optional —
5913 /// an explicit `path` always overrides the style's expanded dolly.
5914 #[serde(default, skip_serializing_if = "Vec::is_empty")]
5915 pub path: Vec<Mark>,
5916 /// This shot's duration in seconds. Required without `shot_style`; with one,
5917 /// optional — the style's default duration applies (see
5918 /// [`ShotStyle::default_seconds`]), and an explicit value always overrides.
5919 #[serde(default, skip_serializing_if = "Option::is_none")]
5920 pub seconds: Option<u32>,
5921 /// Optional subject the camera keeps framed for this shot. Absent = face
5922 /// along the direction of travel (or, under a `shot_style`, the style's own
5923 /// aim at its `subject`). An explicit `look_at` always overrides a style's
5924 /// aim.
5925 #[serde(default, skip_serializing_if = "Option::is_none")]
5926 pub look_at: Option<Mark>,
5927 /// Shot-style preset (DSL v0.6, spec-0015 shot-grammar library): the
5928 /// compiler expands the style deterministically into a camera dolly +
5929 /// per-keyframe aim from the `subject`'s resolved geometry. Requires
5930 /// `subject`; `path`/`look_at`/`seconds` remain legal and always override
5931 /// the corresponding expanded part (`DW0348` polices the combinations).
5932 #[serde(default, skip_serializing_if = "Option::is_none")]
5933 pub shot_style: Option<ShotStyle>,
5934 /// The styled shot's subject — what the shot is *about*. Required with
5935 /// `shot_style`, rejected without one (`DW0348`).
5936 #[serde(default, skip_serializing_if = "Option::is_none")]
5937 pub subject: Option<CameraSubject>,
5938 /// `two-shot` only: the second framed subject (`DW0348` elsewhere).
5939 #[serde(default, skip_serializing_if = "Option::is_none")]
5940 pub subject_b: Option<CameraSubject>,
5941 /// Styled shots only: the style's characteristic camera distance in blocks
5942 /// (its *start* distance for the dolly styles). Default per style — see the
5943 /// `shot_style` table in `docs/reference/compiler.md`. Clamped range 1..=48
5944 /// (`DW0348`).
5945 #[serde(default, skip_serializing_if = "Option::is_none")]
5946 pub dist: Option<f64>,
5947 /// `orbit-arc` only: the sweep in degrees, 45..=120 (dossier range),
5948 /// default 90 (`DW0348` elsewhere or out of range).
5949 #[serde(default, skip_serializing_if = "Option::is_none")]
5950 pub degrees: Option<f64>,
5951 /// Styled shots only: placement bearing in degrees — where the camera sits
5952 /// (or starts) relative to the subject, measured like a Minecraft yaw
5953 /// *from* the subject: `0` puts the camera south of the subject (+Z), `90`
5954 /// west (−X), `-90` east (+X), `180` north (−Z). Default `0`. For
5955 /// `side-track` the bearing picks which side of the subject's travel the
5956 /// camera runs abeam (`0` = right of travel, `180` = left).
5957 #[serde(default, skip_serializing_if = "Option::is_none")]
5958 pub bearing: Option<f64>,
5959}
5960
5961/// A `shot_style` preset (DSL v0.6): the dossier's 9-template library
5962/// (`docs/notes/camera-dossier.md` §2), each expanded deterministically by the
5963/// compiler into a dolly + aim from the subject's geometry. Camera "lens feel"
5964/// is **distance only** — vanilla has no in-game FOV control.
5965#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5966#[serde(rename_all = "kebab-case")]
5967pub enum ShotStyle {
5968 /// Fully static close framing of a prop/detail. The beat that always looks
5969 /// right.
5970 Insert,
5971 /// Static position; only the aim turns as the subject (ideally moving)
5972 /// passes. Rockstar's roadside "ground view".
5973 LockedOff,
5974 /// Straight dolly toward the subject along the view axis; medium → close.
5975 PushIn,
5976 /// Reverse of `push-in`: close → wide, revealing context.
5977 PullBackReveal,
5978 /// High and far, descending and closing on the subject. First sight of an
5979 /// area.
5980 EstablishingCrane,
5981 /// Constant-radius, constant-height arc around the subject.
5982 OrbitArc,
5983 /// Parallel dolly abeam a **moving** subject at constant offset — needs a
5984 /// compiler-known subject path (`DW0349`).
5985 SideTrack,
5986 /// Static placement solved so **both** subjects land on opposite thirds
5987 /// (Toric-space construction). Needs `subject_b`.
5988 TwoShot,
5989 /// Low, close, trailing a **moving** subject near ground level — needs a
5990 /// compiler-known subject path (`DW0349`).
5991 LowFollow,
5992}
5993
5994impl ShotStyle {
5995 /// The kebab token (diagnostics, digests).
5996 pub fn token(self) -> &'static str {
5997 match self {
5998 ShotStyle::Insert => "insert",
5999 ShotStyle::LockedOff => "locked-off",
6000 ShotStyle::PushIn => "push-in",
6001 ShotStyle::PullBackReveal => "pull-back-reveal",
6002 ShotStyle::EstablishingCrane => "establishing-crane",
6003 ShotStyle::OrbitArc => "orbit-arc",
6004 ShotStyle::SideTrack => "side-track",
6005 ShotStyle::TwoShot => "two-shot",
6006 ShotStyle::LowFollow => "low-follow",
6007 }
6008 }
6009
6010 /// Default shot duration in seconds, from the dossier's per-style duration
6011 /// ranges (§2, anchored on the film-editing ASL literature) — the value an
6012 /// omitted `seconds` resolves to.
6013 pub fn default_seconds(self) -> u32 {
6014 match self {
6015 ShotStyle::Insert => 2,
6016 ShotStyle::LockedOff => 6,
6017 ShotStyle::PushIn => 4,
6018 ShotStyle::PullBackReveal => 6,
6019 ShotStyle::EstablishingCrane => 8,
6020 ShotStyle::OrbitArc => 8,
6021 ShotStyle::SideTrack => 8,
6022 ShotStyle::TwoShot => 5,
6023 ShotStyle::LowFollow => 5,
6024 }
6025 }
6026
6027 /// `true` for the styles whose subject must be *moving* on a compiler-known
6028 /// path (`move-npc` / `move-actor` in the same effect group or sequence) —
6029 /// `side-track` and `low-follow` (`DW0349` otherwise).
6030 pub fn needs_moving_subject(self) -> bool {
6031 matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
6032 }
6033}
6034
6035/// A styled shot's subject (DSL v0.6): the world thing the shot frames — a
6036/// prefab anchor point, a stage-2 NPC, or a stage-5 actor — plus an integer
6037/// block offset. For `npc`/`actor` subjects the aim point is the entity's cell
6038/// **plus one block up** (torso height, so a close shot does not frame feet)
6039/// before `offset` is applied; an `anchor` subject aims at the block centre
6040/// exactly like a [`CameraTarget`].
6041/// Each variant's payload is a **named struct** carrying `deny_unknown_fields`
6042/// — serde has no variant-level `deny_unknown_fields`, so an untagged
6043/// enum with inline struct variants silently *ignores* any key it does not
6044/// recognise: `{"npc": …, "ofset": [0,1,0]}` would deserialize happily with the
6045/// offset dropped, and `{"anchor": …, "npc": …}` would quietly match `Anchor`
6046/// and discard the NPC. Lifting each variant into its own type keeps the repo-wide
6047/// deny-unknown rule for both serde and the published JSON Schema
6048/// (`additionalProperties: false`): a mistyped shot subject fails the schema
6049/// instead of rendering a shot pointed somewhere the author never asked for.
6050#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6051#[serde(untagged)]
6052pub enum CameraSubject {
6053 /// A fixed world point: prefab anchor + offset.
6054 Anchor(Mark),
6055 /// A stage-2 NPC — moving if a `move-npc` for it runs in the same effect
6056 /// group / sequence, else static at its declared (or spawn) anchor.
6057 Npc(NpcSubject),
6058 /// A stage-5 actor — moving if a `move-actor` for it runs in the same
6059 /// effect group / sequence, else static at its declared anchor.
6060 Actor(ActorSubject),
6061}
6062
6063/// A [`CameraSubject::Npc`] payload: a stage-2 NPC.
6064#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6065#[serde(deny_unknown_fields)]
6066pub struct NpcSubject {
6067 /// The NPC (stage-2 ref).
6068 pub npc: NpcId,
6069 /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
6070 #[serde(default, skip_serializing_if = "is_zero3")]
6071 pub offset: [i32; 3],
6072}
6073
6074/// A [`CameraSubject::Actor`] payload: a stage-5 actor.
6075#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
6076#[serde(deny_unknown_fields)]
6077pub struct ActorSubject {
6078 /// The actor (stage-5 `actors` ref).
6079 pub actor: ActorId,
6080 /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
6081 #[serde(default, skip_serializing_if = "is_zero3")]
6082 pub offset: [i32; 3],
6083}
6084
6085impl CameraSubject {
6086 /// The subject's integer offset, whichever variant.
6087 pub fn offset(&self) -> [i32; 3] {
6088 match self {
6089 CameraSubject::Anchor(s) => s.offset,
6090 CameraSubject::Npc(s) => s.offset,
6091 CameraSubject::Actor(s) => s.offset,
6092 }
6093 }
6094
6095 /// A short canonical rendering for digests/diagnostics.
6096 pub fn canon(&self) -> String {
6097 let (kind, id, o) = match self {
6098 CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
6099 CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
6100 CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
6101 };
6102 format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
6103 }
6104}
6105
6106/// `Debug` is hand-written because it is a **stable content-key rendering**:
6107/// the compiler's `payload_verb_key` (FNV over a verb's own `{:?}`) names the
6108/// generated `volley_`, `collapse_` and `teleport_` functions from it, so a shot
6109/// that uses none of the v0.6 style fields must render byte-identically to the
6110/// pre-style struct (`seconds` prints its inner value; absent style fields print
6111/// nothing) — otherwise a purely additive schema change would silently churn the
6112/// content key of every payload that carries a cutscene.
6113impl std::fmt::Debug for CameraShot {
6114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6115 let mut d = f.debug_struct("CameraShot");
6116 d.field("path", &self.path);
6117 match self.seconds {
6118 Some(v) => d.field("seconds", &v),
6119 None => d.field("seconds", &self.seconds),
6120 };
6121 d.field("look_at", &self.look_at);
6122 if self.shot_style.is_some() {
6123 d.field("shot_style", &self.shot_style);
6124 }
6125 if self.subject.is_some() {
6126 d.field("subject", &self.subject);
6127 }
6128 if self.subject_b.is_some() {
6129 d.field("subject_b", &self.subject_b);
6130 }
6131 if self.dist.is_some() {
6132 d.field("dist", &self.dist);
6133 }
6134 if self.degrees.is_some() {
6135 d.field("degrees", &self.degrees);
6136 }
6137 if self.bearing.is_some() {
6138 d.field("bearing", &self.bearing);
6139 }
6140 d.finish()
6141 }
6142}
6143
6144impl CameraShot {
6145 /// The shot's resolved duration in seconds: explicit `seconds`, else the
6146 /// style default, else `1` (a shape-invalid shot — `DW0199` reports it; the
6147 /// fallback only keeps downstream passes total).
6148 pub fn resolved_seconds(&self) -> u32 {
6149 self.seconds
6150 .or(self.shot_style.map(ShotStyle::default_seconds))
6151 .unwrap_or(1)
6152 }
6153}
6154
6155/// serde `skip_serializing_if` helper: skip a `[0, 0, 0]` offset.
6156fn is_zero3(v: &[i32; 3]) -> bool {
6157 *v == [0, 0, 0]
6158}
6159
6160impl Verb {
6161 /// The kebab-case `type` tag this verb serializes as — the verb a
6162 /// diagnostic should name so the author can find it in the JSON.
6163 pub fn tag(&self) -> &'static str {
6164 match self {
6165 Verb::OpenGate { .. } => "open-gate",
6166 Verb::CloseGate { .. } => "close-gate",
6167 Verb::CampaignComplete { .. } => "campaign-complete",
6168 Verb::GiveItem { .. } => "give-item",
6169 Verb::SetFlag { .. } => "set-flag",
6170 Verb::SetState { .. } => "set-state",
6171 Verb::AddState { .. } => "add-state",
6172 Verb::ClearState { .. } => "clear-state",
6173 Verb::DropStake { .. } => "drop-stake",
6174 Verb::SpawnWave { .. } => "spawn-wave",
6175 Verb::Narrate { .. } => "narrate",
6176 Verb::SetBlock { .. } => "set-block",
6177 Verb::FillRegion { .. } => "fill-region",
6178 Verb::ClearRegion { .. } => "clear-region",
6179 Verb::OpenWay { .. } => "open-way",
6180 Verb::DespawnNpc { .. } => "despawn-npc",
6181 Verb::MoveNpc { .. } => "move-npc",
6182 Verb::Cutscene { .. } => "cutscene",
6183 Verb::SetTime { .. } => "set-time",
6184 Verb::SetWeather { .. } => "set-weather",
6185 Verb::PlaySound { .. } => "play-sound",
6186 Verb::DamagePlayers { .. } => "damage-players",
6187 Verb::SetCheckpoint { .. } => "set-checkpoint",
6188 Verb::Bonfire { .. } => "bonfire",
6189 Verb::BeginStealth { .. } => "begin-stealth",
6190 Verb::EndStealth => "end-stealth",
6191 Verb::SpawnNpc { .. } => "spawn-npc",
6192 Verb::SpawnActor { .. } => "spawn-actor",
6193 Verb::DespawnActor { .. } => "despawn-actor",
6194 Verb::MoveActor { .. } => "move-actor",
6195 Verb::UnleashActor { .. } => "unleash-actor",
6196 Verb::Sequence { .. } => "sequence",
6197 Verb::Volley { .. } => "volley",
6198 Verb::Collapse { .. } => "collapse",
6199 Verb::GiveEffect { .. } => "give-effect",
6200 Verb::ClearEffect { .. } => "clear-effect",
6201 Verb::Teleport { .. } => "teleport",
6202 Verb::Firework { .. } => "firework",
6203 }
6204 }
6205}
6206
6207impl QuestEffect {
6208 /// The gate anchor if this is `open-gate`.
6209 pub fn open_gate_anchor(&self) -> Option<&AnchorId> {
6210 match &self.verb {
6211 Verb::OpenGate { anchor, .. } => Some(anchor),
6212 _ => None,
6213 }
6214 }
6215
6216 /// The gate anchor if this is `close-gate` (DSL v0.6).
6217 pub fn close_gate_anchor(&self) -> Option<&AnchorId> {
6218 match &self.verb {
6219 Verb::CloseGate { anchor, .. } => Some(anchor),
6220 _ => None,
6221 }
6222 }
6223
6224 /// The authored `sealed_hint` if this is a `close-gate` that declares one (DSL
6225 /// v0.8). `None` for every other effect **and** for a `close-gate` that takes
6226 /// the compiler's canonical English seal line.
6227 pub fn close_gate_sealed_hint(&self) -> Option<&str> {
6228 match &self.verb {
6229 Verb::CloseGate { sealed_hint, .. } => sealed_hint.as_deref(),
6230 _ => None,
6231 }
6232 }
6233
6234 /// The wave id if this is `spawn-wave` (v0.3).
6235 pub fn spawn_wave(&self) -> Option<&WaveId> {
6236 match &self.verb {
6237 Verb::SpawnWave { wave, .. } => Some(wave),
6238 _ => None,
6239 }
6240 }
6241
6242 /// The flag id if this is `set-flag` (v0.3).
6243 pub fn set_flag(&self) -> Option<&FlagId> {
6244 match &self.verb {
6245 Verb::SetFlag { flag, .. } => Some(flag),
6246 _ => None,
6247 }
6248 }
6249
6250 /// The item id if this is `give-item` (v0.3).
6251 pub fn give_item(&self) -> Option<&str> {
6252 match &self.verb {
6253 Verb::GiveItem { item, .. } => Some(item),
6254 _ => None,
6255 }
6256 }
6257
6258 /// `true` if this is a `give-item` carrying a v0.4 display `name`.
6259 pub fn give_item_named(&self) -> bool {
6260 matches!(self.verb, Verb::GiveItem { name: Some(_), .. })
6261 }
6262
6263 /// The declared `carrier` if this is a `give-item` that states one (v0.6,
6264 /// spec-0018). `None` for any other effect **and** for a `give-item` that
6265 /// leaves it absent — absent reads as [`Carrier::All`], and the distinction
6266 /// matters only for the pre-0.6 reserved-field gate.
6267 pub fn give_carrier(&self) -> Option<Carrier> {
6268 match &self.verb {
6269 Verb::GiveItem { carrier, .. } => *carrier,
6270 _ => None,
6271 }
6272 }
6273
6274 /// Does this `give-item` hand a single copy to the acting player (v0.6,
6275 /// spec-0018)? `false` for every other effect and for the party-wide default.
6276 pub fn gives_to_one(&self) -> bool {
6277 matches!(self.give_carrier(), Some(Carrier::One))
6278 }
6279
6280 /// The `set-block` block id if this is a v0.4 `set-block` effect.
6281 pub fn set_block(&self) -> Option<(&AnchorId, &str)> {
6282 match &self.verb {
6283 Verb::SetBlock { anchor, block, .. } => Some((anchor, block.as_str())),
6284 _ => None,
6285 }
6286 }
6287
6288 /// The NPC id if this is a v0.4 `despawn-npc` effect.
6289 pub fn despawn_npc(&self) -> Option<&NpcId> {
6290 match &self.verb {
6291 Verb::DespawnNpc { npc, .. } => Some(npc),
6292 _ => None,
6293 }
6294 }
6295
6296 /// `(npc, to)` if this is a v0.4 `move-npc` effect.
6297 pub fn move_npc(&self) -> Option<(&NpcId, &Mark)> {
6298 match &self.verb {
6299 Verb::MoveNpc { npc, to, .. } => Some((npc, to)),
6300 _ => None,
6301 }
6302 }
6303
6304 /// The v0.3 effect name if this effect is one introduced in DSL v0.3
6305 /// (`give-item`/`set-flag`/`spawn-wave`). These validate in v0.3 campaigns
6306 ///.
6307 pub fn v03_effect(&self) -> Option<&'static str> {
6308 match &self.verb {
6309 Verb::GiveItem { .. } => Some("give-item"),
6310 Verb::SetFlag { .. } => Some("set-flag"),
6311 Verb::SpawnWave { .. } => Some("spawn-wave"),
6312 Verb::OpenGate { .. }
6313 | Verb::CloseGate { .. }
6314 | Verb::CampaignComplete { .. } => None,
6315 // v0.4 effects report via `v04_effect`; v0.5 via `v05_effect`; they
6316 // are not v0.3 verbs.
6317 Verb::Narrate { .. }
6318 | Verb::SetBlock { .. }
6319 | Verb::DespawnNpc { .. }
6320 | Verb::MoveNpc { .. }
6321 | Verb::Cutscene { .. }
6322 | Verb::SetTime { .. }
6323 | Verb::SetWeather { .. }
6324 | Verb::PlaySound { .. }
6325 | Verb::DamagePlayers { .. }
6326 | Verb::SetCheckpoint { .. }
6327 | Verb::Bonfire { .. }
6328 | Verb::BeginStealth { .. }
6329 | Verb::EndStealth
6330 | Verb::SpawnActor { .. }
6331 | Verb::DespawnActor { .. }
6332 | Verb::MoveActor { .. }
6333 | Verb::UnleashActor { .. }
6334 | Verb::SpawnNpc { .. }
6335 | Verb::Sequence { .. }
6336 // spec-0022 trap-payload verbs are v0.6 — they report via `v06_effect`.
6337 | Verb::Volley { .. }
6338 | Verb::Collapse { .. }
6339 // spec-0031's state verbs, region writes, status effects and teleport,
6340 // and spec-0032's `drop-stake`, are all v0.10 — they report via
6341 // `v10_effect`.
6342 | Verb::SetState { .. }
6343 | Verb::AddState { .. }
6344 | Verb::ClearState { .. }
6345 | Verb::FillRegion { .. }
6346 | Verb::ClearRegion { .. }
6347 // spec-0042's `open-way` is v0.12 — it reports via `v12_effect`.
6348 | Verb::OpenWay { .. }
6349 | Verb::GiveEffect { .. }
6350 | Verb::ClearEffect { .. }
6351 | Verb::Teleport { .. }
6352 // spec-0068's `firework` is v0.29.
6353 | Verb::Firework { .. }
6354 | Verb::DropStake { .. } => None,
6355 }
6356 }
6357
6358 /// The v0.4 effect name if this effect is one introduced in DSL v0.4
6359 /// (`narrate`/`set-block`/`despawn-npc`/`move-npc`/`cutscene`). These validate
6360 /// in v0.4 campaigns.
6361 pub fn v04_effect(&self) -> Option<&'static str> {
6362 match &self.verb {
6363 Verb::Narrate { .. } => Some("narrate"),
6364 Verb::SetBlock { .. } => Some("set-block"),
6365 Verb::DespawnNpc { .. } => Some("despawn-npc"),
6366 Verb::MoveNpc { .. } => Some("move-npc"),
6367 Verb::Cutscene { .. } => Some("cutscene"),
6368 _ => None,
6369 }
6370 }
6371
6372 /// The v0.5 effect name if this effect is one introduced in DSL v0.5
6373 /// (`set-time`/`set-weather`, spec-0010).
6374 pub fn v05_effect(&self) -> Option<&'static str> {
6375 match &self.verb {
6376 Verb::SetTime { .. } => Some("set-time"),
6377 Verb::SetWeather { .. } => Some("set-weather"),
6378 _ => None,
6379 }
6380 }
6381
6382 /// The v0.6 effect name if this effect is one introduced in DSL v0.6
6383 /// (`set-checkpoint`, spec-0012; `begin-stealth`/`end-stealth`, spec-0014;
6384 /// `play-sound`, spec-0014; the scripted-actor staging verbs
6385 /// `spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`/`sequence`,
6386 /// spec-0014). These validate in v0.6 campaigns
6387 /// earlier. (The `narrate` `art` style is a v0.6 addition to an existing verb
6388 /// — see [`QuestEffect::narrate_art`] — not a new effect.)
6389 pub fn v06_effect(&self) -> Option<&'static str> {
6390 match &self.verb {
6391 Verb::CloseGate { .. } => Some("close-gate"),
6392 Verb::SetCheckpoint { .. } => Some("set-checkpoint"),
6393 Verb::Bonfire { .. } => Some("bonfire"),
6394 Verb::BeginStealth { .. } => Some("begin-stealth"),
6395 Verb::EndStealth => Some("end-stealth"),
6396 Verb::PlaySound { .. } => Some("play-sound"),
6397 Verb::DamagePlayers { .. } => Some("damage-players"),
6398 Verb::SpawnActor { .. } => Some("spawn-actor"),
6399 Verb::DespawnActor { .. } => Some("despawn-actor"),
6400 Verb::MoveActor { .. } => Some("move-actor"),
6401 Verb::UnleashActor { .. } => Some("unleash-actor"),
6402 Verb::Sequence { .. } => Some("sequence"),
6403 Verb::SpawnNpc { .. } => Some("spawn-npc"),
6404 // spec-0022 trap-payload verbs — v0.6 surface, reserved earlier.
6405 Verb::Volley { .. } => Some("volley"),
6406 Verb::Collapse { .. } => Some("collapse"),
6407 _ => None,
6408 }
6409 }
6410
6411 /// The v0.10 effect name if this effect is one introduced in DSL v0.10
6412 /// (`set-state`/`add-state`/`clear-state` and the region writes
6413 /// `fill-region`/`clear-region`, spec-0031). These validate in v0.10
6414 /// campaigns.
6415 pub fn v10_effect(&self) -> Option<&'static str> {
6416 match &self.verb {
6417 Verb::SetState { .. } => Some("set-state"),
6418 Verb::AddState { .. } => Some("add-state"),
6419 Verb::ClearState { .. } => Some("clear-state"),
6420 Verb::FillRegion { .. } => Some("fill-region"),
6421 Verb::ClearRegion { .. } => Some("clear-region"),
6422 Verb::GiveEffect { .. } => Some("give-effect"),
6423 Verb::ClearEffect { .. } => Some("clear-effect"),
6424 Verb::Teleport { .. } => Some("teleport"),
6425 Verb::DropStake { .. } => Some("drop-stake"),
6426 _ => None,
6427 }
6428 }
6429
6430 /// The v0.12 effect name if this effect is one introduced in DSL v0.12
6431 /// (`open-way`, spec-0042).
6432 pub fn v12_effect(&self) -> Option<&'static str> {
6433 match &self.verb {
6434 Verb::OpenWay { .. } => Some("open-way"),
6435 _ => None,
6436 }
6437 }
6438
6439 /// **The way this effect opens** (DSL v0.12, spec-0042): the placed piece and
6440 /// the name of one way that piece's spatial contract exports.
6441 ///
6442 /// The third spelling of the one region write, beside
6443 /// [`QuestEffect::region_write`] (the author's own box) and
6444 /// [`QuestEffect::gate_region_write`] (a gate anchor's box). It answers with a
6445 /// *reference* and never with geometry, because the geometry is not the
6446 /// campaign's to state: the cells, the block and the direction all live in the
6447 /// piece's metadata, and the compiler resolves them there
6448 /// (`compiler::ways`). A region-shaped accessor here would be the second
6449 /// authority this surface exists to avoid.
6450 pub fn way_write(&self) -> Option<(&PrefabId, &str)> {
6451 match &self.verb {
6452 Verb::OpenWay { piece, way, .. } => Some((piece, way.as_str())),
6453 _ => None,
6454 }
6455 }
6456
6457 /// **The one region write**, whichever verb spelled it (DSL v0.10,
6458 /// spec-0031): the box to write and the block to write it with — `None` for
6459 /// the block meaning *clear to air*.
6460 ///
6461 /// This is the accessor the capability belongs to. It answers for
6462 /// `fill-region` / `clear-region`, which name their own box; `open-gate` /
6463 /// `close-gate` are the same operation over a box a prefab gate anchor
6464 /// declares, so they answer through
6465 /// [`QuestEffect::gate_region_write`](Self::gate_region_write) — the anchor
6466 /// is theirs, the *operation* is shared.
6467 pub fn region_write(&self) -> Option<(&StealthZone, Option<&str>)> {
6468 match &self.verb {
6469 Verb::FillRegion { region, block, .. } => Some((region, Some(block.as_str()))),
6470 Verb::ClearRegion { region, .. } => Some((region, None)),
6471 _ => None,
6472 }
6473 }
6474
6475 /// The gate anchor this effect writes, and whether the write **fills** it:
6476 /// `Some((anchor, true))` for `close-gate`, `Some((anchor, false))` for
6477 /// `open-gate`, `None` for everything else.
6478 ///
6479 /// The gate half of [`QuestEffect::region_write`]: same operation, but the box
6480 /// and the fill block come from the prefab's gate anchor rather than from the
6481 /// author. Everything that reasons about runtime region writes reads both
6482 /// accessors and nothing else.
6483 pub fn gate_region_write(&self) -> Option<(&AnchorId, bool)> {
6484 match &self.verb {
6485 Verb::CloseGate { anchor, .. } => Some((anchor, true)),
6486 Verb::OpenGate { anchor, .. } => Some((anchor, false)),
6487 _ => None,
6488 }
6489 }
6490
6491 /// `(projectile, from_anchor, kill_zone, salvos, interval)` if this is a
6492 /// `volley` (spec-0022), with the documented defaults already applied.
6493 pub fn volley(&self) -> Option<(&str, &AnchorId, &StealthZone, u32, u32)> {
6494 match &self.verb {
6495 Verb::Volley {
6496 projectile,
6497 from_anchor,
6498 kill_zone,
6499 salvos,
6500 interval,
6501 ..
6502 } => Some((
6503 projectile.as_deref().unwrap_or(DEFAULT_VOLLEY_PROJECTILE),
6504 from_anchor,
6505 kill_zone,
6506 salvos.unwrap_or(DEFAULT_VOLLEY_SALVOS),
6507 interval.unwrap_or(DEFAULT_VOLLEY_INTERVAL),
6508 )),
6509 _ => None,
6510 }
6511 }
6512
6513 /// `(region_anchor, falling_block, then_floor)` if this is a `collapse`
6514 /// (spec-0022), with the documented default already applied.
6515 pub fn collapse(&self) -> Option<(&StealthZone, &str, Option<&str>)> {
6516 match &self.verb {
6517 Verb::Collapse {
6518 region_anchor,
6519 falling_block,
6520 then_floor,
6521 ..
6522 } => Some((
6523 region_anchor,
6524 falling_block
6525 .as_deref()
6526 .unwrap_or(DEFAULT_COLLAPSE_FALLING_BLOCK),
6527 then_floor.as_deref(),
6528 )),
6529 _ => None,
6530 }
6531 }
6532
6533 /// The NPC id if this is a v0.6 `spawn-npc` effect (the dual of
6534 /// [`QuestEffect::despawn_npc`]).
6535 pub fn spawn_npc(&self) -> Option<&NpcId> {
6536 match &self.verb {
6537 Verb::SpawnNpc { npc, .. } => Some(npc),
6538 _ => None,
6539 }
6540 }
6541
6542 /// **The body this effect puts into the world**, by id, for every body class
6543 /// alike ([`BodyRef`]).
6544 ///
6545 /// `spawn-npc` and `spawn-actor` are the two, and they are answered in one
6546 /// place so a rule about a body's lifetime quantifies over bodies rather
6547 /// than over the verb that first needed it. A body's OTHER entry — standing
6548 /// on its mark from world init — is not an effect at all and is
6549 /// [`BodyRef::at_world_init`].
6550 ///
6551 /// `unleash-actor` is deliberately not an entry: it puts no new body on the
6552 /// mark, it replaces the one already standing there (see [`Self::body_exit`]).
6553 pub fn body_entry(&self) -> Option<&str> {
6554 match &self.verb {
6555 Verb::SpawnNpc { npc, .. } => Some(npc.as_str()),
6556 Verb::SpawnActor { actor, .. } => Some(actor.as_str()),
6557 _ => None,
6558 }
6559 }
6560
6561 /// **The body this effect takes out of the world**, by id, for every body
6562 /// class alike.
6563 ///
6564 /// `despawn-npc` and `despawn-actor` remove the body outright.
6565 /// `unleash-actor` is the third: it kills the staged puppet and stands a
6566 /// real-AI twin in its place, and from that moment the compiler makes no
6567 /// claim about where that body is or whether it is still alive — the twin
6568 /// walks, fights and dies under vanilla AI. Answering all three here is what
6569 /// keeps "can this body still be standing?" from being decided one verb at a
6570 /// time.
6571 ///
6572 /// Deliberately NOT an exit: `move-npc` / `move-actor`. A walked body is
6573 /// still in the world, and its declared mark is still the cell the engine
6574 /// summoned it onto — a mark two live bodies share is shared whether or not
6575 /// one of them has since walked off it.
6576 pub fn body_exit(&self) -> Option<&str> {
6577 match &self.verb {
6578 Verb::DespawnNpc { npc, .. } => Some(npc.as_str()),
6579 Verb::DespawnActor { actor, .. } | Verb::UnleashActor { actor, .. } => {
6580 Some(actor.as_str())
6581 }
6582 _ => None,
6583 }
6584 }
6585
6586 /// `(anchor, on_respawn)` if this is a v0.6 `set-checkpoint` effect.
6587 pub fn set_checkpoint(&self) -> Option<(&AnchorId, &[QuestEffect])> {
6588 match &self.verb {
6589 Verb::SetCheckpoint { anchor, on_respawn } => Some((anchor, on_respawn.as_slice())),
6590 _ => None,
6591 }
6592 }
6593
6594 /// `(anchor, on_rest)` if this is a `bonfire` effect (spec-0016 §1).
6595 pub fn bonfire(&self) -> Option<(&AnchorId, &[QuestEffect])> {
6596 match &self.verb {
6597 Verb::Bonfire {
6598 anchor, on_rest, ..
6599 } => Some((anchor, on_rest.as_slice())),
6600 _ => None,
6601 }
6602 }
6603
6604 /// The bonfire's authored rest-dialog strings, each `None` when unauthored
6605 /// (the compiler then bakes its canonical English). `None` for every other
6606 /// effect (spec-0016 §1).
6607 pub fn bonfire_labels(&self) -> Option<BonfireLabels<'_>> {
6608 match &self.verb {
6609 Verb::Bonfire {
6610 prompt,
6611 rest_label,
6612 save_label,
6613 ..
6614 } => Some(BonfireLabels {
6615 prompt: prompt.as_deref(),
6616 rest_label: rest_label.as_deref(),
6617 save_label: save_label.as_deref(),
6618 }),
6619 _ => None,
6620 }
6621 }
6622
6623 /// The `within` filter zone if this is a v0.6 `damage-players` effect that
6624 /// declares one (the `in` spatial scope). `None` for an unscoped
6625 /// `damage-players` and for every other effect.
6626 pub fn damage_within(&self) -> Option<&StealthZone> {
6627 match &self.verb {
6628 Verb::DamagePlayers { within, .. } => within.as_ref(),
6629 _ => None,
6630 }
6631 }
6632
6633 /// `(zones, on_caught, grace_ticks)` if this is a v0.6 `begin-stealth` effect.
6634 pub fn begin_stealth(&self) -> Option<(&[StealthZone], &[QuestEffect], u32)> {
6635 match &self.verb {
6636 Verb::BeginStealth {
6637 zones,
6638 on_caught,
6639 grace_ticks,
6640 } => Some((zones.as_slice(), on_caught.as_slice(), *grace_ticks)),
6641 _ => None,
6642 }
6643 }
6644
6645 /// The target time if this is a v0.5 `set-time` effect.
6646 pub fn set_time(&self) -> Option<WorldTime> {
6647 match &self.verb {
6648 Verb::SetTime { time, .. } => Some(*time),
6649 _ => None,
6650 }
6651 }
6652
6653 /// The target weather if this is a v0.5 `set-weather` effect.
6654 pub fn set_weather(&self) -> Option<WorldWeather> {
6655 match &self.verb {
6656 Verb::SetWeather { weather, .. } => Some(*weather),
6657 _ => None,
6658 }
6659 }
6660
6661 /// The effect lists nested one level inside this effect (DSL v0.6): a
6662 /// `sequence`'s per-step effects (in step order), a `set-checkpoint`'s
6663 /// `on_respawn`, a `begin-stealth`'s `on_caught`, and a `move-actor`'s /
6664 /// `move-npc`'s `on_arrive`. Empty for a leaf effect.
6665 ///
6666 /// This is the **single authority** on effect nesting. Every deep traversal —
6667 /// the flag/wave producer scans, the checkpoint/stealth collector, the l10n
6668 /// string inventory, and emission — walks the tree through it (see
6669 /// [`Self::visit_deep`]), so a new nesting site is picked up everywhere at
6670 /// once and no walker can silently miss a list (the class of bug where a
6671 /// `set-flag`/`set-checkpoint` nested in a `sequence` was skipped).
6672 pub fn nested_effect_lists(&self) -> Vec<&[QuestEffect]> {
6673 match &self.verb {
6674 Verb::Sequence { steps } => steps.iter().map(|s| s.effects.as_slice()).collect(),
6675 Verb::SetCheckpoint { on_respawn, .. } => vec![on_respawn.as_slice()],
6676 Verb::Bonfire { on_rest, .. } => vec![on_rest.as_slice()],
6677 Verb::BeginStealth { on_caught, .. } => vec![on_caught.as_slice()],
6678 Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
6679 vec![on_arrive.as_slice()]
6680 }
6681 _ => Vec::new(),
6682 }
6683 }
6684
6685 /// Visit `self` and every transitively nested effect (depth-first, pre-order),
6686 /// descending through [`Self::nested_effect_lists`].
6687 pub fn visit_deep<'a>(&'a self, f: &mut dyn FnMut(&'a QuestEffect)) {
6688 f(self);
6689 for list in self.nested_effect_lists() {
6690 for e in list {
6691 e.visit_deep(f);
6692 }
6693 }
6694 }
6695
6696 /// Each nested effect list ([`Self::nested_effect_lists`]) paired with the
6697 /// **stable key segment** used to derive child l10n keys / diagnostic paths, and
6698 /// exposed mutably so the localization pass can rewrite nested player-visible
6699 /// strings in place. Segments: `seq.<step>` for each sequence step (step index),
6700 /// `respawn` for `set-checkpoint.on_respawn`, `caught` for
6701 /// `begin-stealth.on_caught`, `arrive` for `move-actor.on_arrive`. Kept in
6702 /// lockstep with `nested_effect_lists` (same lists, same order) — the position-
6703 /// derived segments make every derived key deterministic and stable across
6704 /// builds (ADR-0006 byte-identity).
6705 pub fn nested_effect_lists_keyed_mut(&mut self) -> Vec<(String, &mut [QuestEffect])> {
6706 match &mut self.verb {
6707 Verb::Sequence { steps } => steps
6708 .iter_mut()
6709 .enumerate()
6710 .map(|(s, st)| (format!("seq.{s}"), st.effects.as_mut_slice()))
6711 .collect(),
6712 Verb::SetCheckpoint { on_respawn, .. } => {
6713 vec![("respawn".to_string(), on_respawn.as_mut_slice())]
6714 }
6715 Verb::Bonfire { on_rest, .. } => {
6716 vec![("rest".to_string(), on_rest.as_mut_slice())]
6717 }
6718 Verb::BeginStealth { on_caught, .. } => {
6719 vec![("caught".to_string(), on_caught.as_mut_slice())]
6720 }
6721 Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
6722 vec![("arrive".to_string(), on_arrive.as_mut_slice())]
6723 }
6724 _ => Vec::new(),
6725 }
6726 }
6727
6728 /// Immutable sibling of [`Self::nested_effect_lists_keyed_mut`] that additionally
6729 /// exposes the **JSON-pointer path segment** for each nested list, so a deep
6730 /// consumer scan (sound/art/give/wave refs) can report a precise diagnostic path
6731 /// *and* the matching l10n key. Each entry is `(path_seg, key_seg, list)`; the
6732 /// caller appends the per-effect index — `/{j}` to the path, `.{j}` to the key.
6733 /// Kept in lockstep with `nested_effect_lists` / `nested_effect_lists_keyed_mut`
6734 /// (same lists, same order): the l10n key segments match
6735 /// `nested_effect_lists_keyed_mut` exactly (`seq.<step>`/`respawn`/`caught`/
6736 /// `arrive`), and the path segments name the real fields
6737 /// (`steps/<step>/effects`, `on_respawn`, `on_caught`, `on_arrive`).
6738 pub fn nested_effect_lists_labeled(&self) -> Vec<(String, String, &[QuestEffect])> {
6739 match &self.verb {
6740 Verb::Sequence { steps } => steps
6741 .iter()
6742 .enumerate()
6743 .map(|(s, st)| {
6744 (
6745 format!("steps/{s}/effects"),
6746 format!("seq.{s}"),
6747 st.effects.as_slice(),
6748 )
6749 })
6750 .collect(),
6751 Verb::SetCheckpoint { on_respawn, .. } => vec![(
6752 "on_respawn".to_string(),
6753 "respawn".to_string(),
6754 on_respawn.as_slice(),
6755 )],
6756 Verb::Bonfire { on_rest, .. } => vec![(
6757 "on_rest".to_string(),
6758 "rest".to_string(),
6759 on_rest.as_slice(),
6760 )],
6761 Verb::BeginStealth { on_caught, .. } => vec![(
6762 "on_caught".to_string(),
6763 "caught".to_string(),
6764 on_caught.as_slice(),
6765 )],
6766 Verb::MoveActor { on_arrive, .. } | Verb::MoveNpc { on_arrive, .. } => {
6767 vec![(
6768 "on_arrive".to_string(),
6769 "arrive".to_string(),
6770 on_arrive.as_slice(),
6771 )]
6772 }
6773 _ => Vec::new(),
6774 }
6775 }
6776
6777 /// Every world anchor this effect names **at this node** — the single
6778 /// authority on the anchor-bearing effect surface, the referential sibling of
6779 /// [`Self::nested_effect_lists`]. Each entry is `(json_path_suffix, anchor)`,
6780 /// where the suffix is appended to the effect's own JSON pointer
6781 /// (`anchor`, `to/anchor`, `in/anchor`, `zones/<i>/anchor`, `at/anchor`,
6782 /// `shots/<i>/path/<j>/anchor`, …).
6783 ///
6784 /// Not recursive: pair it with [`Self::visit_deep`] to sweep a whole effect
6785 /// tree. Every consumer that must resolve an anchor — the DSL's `DW0142`
6786 /// reference scan, the compiler's build-time resolution seal (`DW0355`) —
6787 /// goes through here, so a new anchor-bearing variant (or a new anchor field
6788 /// on an existing one) is picked up by all of them at once. That closes the
6789 /// silent-drop class of bug: an anchor-bearing effect whose anchor is typo'd
6790 /// used to emit *nothing* (the emitter's `for … if name == anchor` loop simply
6791 /// found no match) while every shallow validator looked straight past it.
6792 ///
6793 /// # The third element is the SHAPE the site demands
6794 ///
6795 /// A reference is not only a name: `close-gate` fills and clears a region and
6796 /// `bonfire` seats a body, and until spec-0052 those two sat in one match arm
6797 /// with nothing recording the difference. The demand rides with the reference
6798 /// so that a new anchor-bearing variant cannot be added without stating what
6799 /// it does with the anchor — the same reason this is one authority and not
6800 /// three walks.
6801 ///
6802 /// **A demand is stated only where the shape is structurally required**, and
6803 /// the line is drawn where this engine's own `DW0845` already draws it — *a
6804 /// region is not a place to stand*:
6805 ///
6806 /// * [`StationKind::Gate`] where the verb cannot function without a region
6807 /// that seals and clears: the two gate verbs. A point is not a gate, and
6808 /// `gate_region_block_any` finds nothing for one.
6809 /// * [`StationKind::Point`] where a **body is put**: a checkpoint seat, a
6810 /// bonfire, a `move-npc`/`move-actor` destination, a `teleport`
6811 /// destination. A body cannot stand inside bars.
6812 /// * `None` wherever a reference merely names a **location** — every
6813 /// anchor-centred volume's centre, every camera field, a `set-block`, a
6814 /// `play-sound`. A gate region's own corner is a perfectly good answer
6815 /// there, and refusing it would refuse correct content.
6816 ///
6817 /// That last case is not a gap left for later. An earlier draft of this rule
6818 /// demanded a point at every non-gate site, and the gallery refused to
6819 /// compile: `trigger/east-door-wrong-side` is a `use` trigger sitting on the
6820 /// very `anchor/seam-…` of a barred door so that it can say "the east door
6821 /// does not open from this side". A check that resolves against a smaller
6822 /// world than the campaign has refuses CONTENT, which is the lesson `DW0343`
6823 /// carries three files away.
6824 pub fn anchor_refs(&self) -> Vec<(String, &AnchorId, Option<StationKind>)> {
6825 // Every camera field is a point: a shot flies through cells and looks at
6826 // one.
6827 /// `(suffix, anchor, kind)` for a shot's own anchor-bearing fields, under `base`.
6828 fn shot_refs<'a>(
6829 base: &str,
6830 shot: &'a CameraShot,
6831 ) -> Vec<(String, &'a AnchorId, Option<StationKind>)> {
6832 let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shot
6833 .path
6834 .iter()
6835 .enumerate()
6836 .map(|(j, w)| (format!("{base}path/{j}/anchor"), &w.anchor, None))
6837 .collect();
6838 if let Some(t) = &shot.look_at {
6839 out.push((format!("{base}look_at/anchor"), &t.anchor, None));
6840 }
6841 for (field, subject) in [("subject", &shot.subject), ("subject_b", &shot.subject_b)] {
6842 if let Some(CameraSubject::Anchor(s)) = subject {
6843 out.push((format!("{base}{field}/anchor"), &s.anchor, None));
6844 }
6845 }
6846 out
6847 }
6848 match &self.verb {
6849 // The two gate verbs address a REGION that seals and clears; the
6850 // three below them seat or write at a cell. They shared an arm until
6851 // the demand had somewhere to be written down.
6852 Verb::OpenGate { anchor, .. } | Verb::CloseGate { anchor, .. } => {
6853 vec![("anchor".to_string(), anchor, Some(StationKind::Gate))]
6854 }
6855 // A checkpoint and a bonfire are **respawn seats** — a body is put
6856 // there, so a region is not one. `set-block` writes a block at a
6857 // cell, which names a location and seats nothing.
6858 Verb::SetCheckpoint { anchor, .. } | Verb::Bonfire { anchor, .. } => {
6859 vec![("anchor".to_string(), anchor, Some(StationKind::Point))]
6860 }
6861 Verb::SetBlock { anchor, .. } => {
6862 vec![("anchor".to_string(), anchor, None)]
6863 }
6864 Verb::MoveNpc { to, .. } | Verb::MoveActor { to, .. } => {
6865 vec![(
6866 "to/anchor".to_string(),
6867 &to.anchor,
6868 Some(StationKind::Point),
6869 )]
6870 }
6871 // The `in` filter is one capability on three verbs, so it registers
6872 // once: `damage-players` (v0.6) and the v0.10 status-effect pair.
6873 Verb::DamagePlayers {
6874 within: Some(zone), ..
6875 }
6876 | Verb::GiveEffect {
6877 within: Some(zone), ..
6878 }
6879 | Verb::ClearEffect {
6880 within: Some(zone), ..
6881 } => vec![("in/anchor".to_string(), &zone.anchor, None)],
6882 // Both of a `teleport`'s anchors are load-bearing — the source volume
6883 // decides WHAT moves and the destination decides WHERE — so a typo in
6884 // either is a dangling reference (`DW0142`), never a silently
6885 // zero-cell volume or a dropped command.
6886 Verb::Teleport { from, to, .. } => vec![
6887 ("from/anchor".to_string(), &from.anchor, None),
6888 (
6889 "to/anchor".to_string(),
6890 &to.anchor,
6891 Some(StationKind::Point),
6892 ),
6893 ],
6894 Verb::BeginStealth { zones, .. } => zones
6895 .iter()
6896 .enumerate()
6897 .map(|(i, z)| (format!("zones/{i}/anchor"), &z.anchor, None))
6898 .collect(),
6899 Verb::PlaySound {
6900 at: Some(SoundAt::Anchor { anchor, .. }),
6901 ..
6902 } => vec![("at/anchor".to_string(), anchor, None)],
6903 // A firework is launched from a point and seats nothing, so it names
6904 // a location in the same shape `play-sound` does.
6905 Verb::Firework { at, .. } => vec![("at/anchor".to_string(), &at.anchor, None)],
6906 // spec-0022 trap-payload verbs. Both anchors of a `volley` are
6907 // load-bearing for the coverage proof, so both register here — a
6908 // typo'd `kill_zone` must be a dangling-reference error, never a
6909 // silently zero-cell (and therefore vacuously "covered") volley.
6910 Verb::Volley {
6911 from_anchor,
6912 kill_zone,
6913 ..
6914 } => vec![
6915 ("from_anchor".to_string(), from_anchor, None),
6916 ("kill_zone/anchor".to_string(), &kill_zone.anchor, None),
6917 ],
6918 Verb::Collapse { region_anchor, .. } => {
6919 vec![(
6920 "region_anchor/anchor".to_string(),
6921 ®ion_anchor.anchor,
6922 None,
6923 )]
6924 }
6925 // The v0.10 region writes: the box's anchor is load-bearing for both
6926 // the emission and the completability model, so a typo'd one must be a
6927 // dangling-reference error (`DW0142`/`DW0355`), never a silently
6928 // unwritten — and therefore vacuously proven — region.
6929 Verb::FillRegion { region, .. } | Verb::ClearRegion { region, .. } => {
6930 vec![("region/anchor".to_string(), ®ion.anchor, None)]
6931 }
6932 // Both cutscene spellings (`DW0199` polices mixing them): the v0.6
6933 // multi-shot list, or the v0.4 single-shot fields flattened at the
6934 // effect's own level.
6935 Verb::Cutscene {
6936 shots,
6937 path,
6938 look_at,
6939 ..
6940 } => {
6941 let mut out: Vec<(String, &AnchorId, Option<StationKind>)> = shots
6942 .iter()
6943 .enumerate()
6944 .flat_map(|(i, s)| shot_refs(&format!("shots/{i}/"), s))
6945 .collect();
6946 out.extend(
6947 path.iter()
6948 .enumerate()
6949 .map(|(j, w)| (format!("path/{j}/anchor"), &w.anchor, None)),
6950 );
6951 if let Some(t) = look_at {
6952 out.push(("look_at/anchor".to_string(), &t.anchor, None));
6953 }
6954 out
6955 }
6956 _ => Vec::new(),
6957 }
6958 }
6959
6960 /// **Every object of a subject kind this effect names at this node** — the
6961 /// `npc/`, `actor/`, `wave/` and `anchor/` ids [`Happening::subject`] may
6962 /// name, in a fixed order, deduplicated (spec-0071 §3).
6963 ///
6964 /// The bodies come from [`Self::body_entry`] / [`Self::body_exit`] and the
6965 /// move verbs; the anchors come from [`Self::anchor_refs`], the single
6966 /// authority on the anchor-bearing surface, so a verb that gains an anchor
6967 /// gains it here too. Not recursive: a `sequence`'s steps each answer for
6968 /// themselves.
6969 ///
6970 /// This is a question about the **object class** a beat can be about, not
6971 /// about a list of verbs somebody maintains — which is why `unleash-actor`
6972 /// (one actor), `set-block` (one anchor) and `fill-region` (one anchor)
6973 /// answer it without being named anywhere, and why `move-actor` (an actor
6974 /// AND a destination anchor) and `teleport` (two anchors) answer with two.
6975 pub fn subject_objects(&self) -> Vec<&str> {
6976 fn add<'a>(id: &'a str, out: &mut Vec<&'a str>) {
6977 if !out.contains(&id) {
6978 out.push(id);
6979 }
6980 }
6981 let mut out: Vec<&str> = Vec::new();
6982 match &self.verb {
6983 Verb::SpawnNpc { npc, .. }
6984 | Verb::DespawnNpc { npc, .. }
6985 | Verb::MoveNpc { npc, .. } => add(npc.as_str(), &mut out),
6986 Verb::SpawnWave { wave, .. } => add(wave.as_str(), &mut out),
6987 _ => {}
6988 }
6989 if let Some(actor) = self.actor_ref() {
6990 add(actor.as_str(), &mut out);
6991 }
6992 for (_, anchor, _) in self.anchor_refs() {
6993 add(anchor.as_str(), &mut out);
6994 }
6995 out
6996 }
6997
6998 /// **What this effect's `happening` is about**: the subject the branch
6999 /// chronicle records and the contradiction proof (`DW0485`) reasons over
7000 /// (spec-0071 §3).
7001 ///
7002 /// A stated [`Happening::subject`] always wins — the caller knows more. An
7003 /// absent one resolves to the effect's own object when it has exactly one
7004 /// ([`Self::subject_objects`]), because the beat that opens a gate is about
7005 /// that gate and the id is otherwise typed twice, two keys apart. An effect
7006 /// with several objects, or none, resolves nothing: naming one of them would
7007 /// be the compiler guessing which.
7008 ///
7009 /// **The one derivation.** The namespace check (`dsl::validate`) and the
7010 /// chronicle writer (`delvec::compiler::branch`) both read the subject
7011 /// through here, so a beat cannot be about one thing for the proof and
7012 /// another for the account a reader is handed.
7013 pub fn happening_subject(&self) -> Option<HappeningSubject<'_>> {
7014 let h = self.happening.as_ref()?;
7015 if let Some(stated) = h.subject.as_deref() {
7016 return Some(HappeningSubject {
7017 id: stated,
7018 derived: false,
7019 });
7020 }
7021 let objects = self.subject_objects();
7022 match objects.as_slice() {
7023 [only] => Some(HappeningSubject {
7024 id: only,
7025 derived: true,
7026 }),
7027 _ => None,
7028 }
7029 }
7030
7031 /// The `cutscene` camera subject if this is a single-shot `cutscene` carrying
7032 /// the v0.6 `look_at` field.
7033 pub fn cutscene_look_at(&self) -> Option<&Mark> {
7034 match &self.verb {
7035 Verb::Cutscene { look_at, .. } => look_at.as_ref(),
7036 _ => None,
7037 }
7038 }
7039
7040 /// `true` if this is a `cutscene` written in the v0.6 multi-shot form
7041 ///.
7042 pub fn cutscene_multi_shot(&self) -> bool {
7043 matches!(&self.verb, Verb::Cutscene { shots, .. } if !shots.is_empty())
7044 }
7045
7046 /// The normalized shot list of a `cutscene`, whichever spelling was used: the
7047 /// v0.6 `shots` list as-is, or the v0.4 `path`/`seconds`/`look_at` fields as a
7048 /// single shot. `None` for a non-cutscene effect; an empty list for a cutscene
7049 /// whose shape is invalid (`DW0199` reports that).
7050 pub fn cutscene_shots(&self) -> Option<Vec<CameraShot>> {
7051 match &self.verb {
7052 Verb::Cutscene {
7053 shots,
7054 path,
7055 seconds,
7056 look_at,
7057 ..
7058 } => {
7059 if !shots.is_empty() {
7060 return Some(shots.clone());
7061 }
7062 match seconds {
7063 Some(secs) => Some(vec![CameraShot {
7064 path: path.clone(),
7065 seconds: Some(*secs),
7066 look_at: look_at.clone(),
7067 shot_style: None,
7068 subject: None,
7069 subject_b: None,
7070 dist: None,
7071 degrees: None,
7072 bearing: None,
7073 }]),
7074 None => Some(Vec::new()),
7075 }
7076 }
7077 _ => None,
7078 }
7079 }
7080
7081 /// `true` if this is a `narrate` carrying the `art` style (glyph-checked
7082 /// `DW0328`).
7083 pub fn narrate_art(&self) -> bool {
7084 matches!(
7085 &self.verb,
7086 Verb::Narrate {
7087 style: Some(NarrateStyle::Art),
7088 ..
7089 }
7090 )
7091 }
7092
7093 /// The `narrate` line's text if this is a `narrate` with the `art` style.
7094 pub fn narrate_art_text(&self) -> Option<&str> {
7095 match &self.verb {
7096 Verb::Narrate {
7097 text,
7098 style: Some(NarrateStyle::Art),
7099 ..
7100 } => Some(text.as_str()),
7101 _ => None,
7102 }
7103 }
7104
7105 /// The `narrate` line's style and text if this is a `narrate` rendered **on
7106 /// screen** — `title`, `subtitle` or `art` — rather than in chat. These are the
7107 /// styles vanilla draws centred and unwrapped, so their rendered width is
7108 /// length-checked against the screen (`DW0330`); `chat` scrolls and wraps, so it
7109 /// is exempt.
7110 pub fn narrate_on_screen(&self) -> Option<(NarrateStyle, &str)> {
7111 match &self.verb {
7112 Verb::Narrate {
7113 text,
7114 style: Some(s),
7115 ..
7116 } if matches!(
7117 s,
7118 NarrateStyle::Title | NarrateStyle::Subtitle | NarrateStyle::Art
7119 ) =>
7120 {
7121 Some((*s, text.as_str()))
7122 }
7123 _ => None,
7124 }
7125 }
7126
7127 /// Every vanilla sound-event id this effect references, for registry
7128 /// validation (`DW0326`): a `play-sound`'s `sound`, and a `narrate`'s optional
7129 /// `sound`. Returns `(subpath, id)` pairs where `subpath` locates the field
7130 /// within the effect (e.g. `sound`).
7131 pub fn sound_refs(&self) -> Vec<(&'static str, &str)> {
7132 match &self.verb {
7133 Verb::PlaySound { sound, .. } => vec![("sound", sound.as_str())],
7134 Verb::Narrate { sound: Some(s), .. } => vec![("sound", s.as_str())],
7135 _ => Vec::new(),
7136 }
7137 }
7138
7139 /// The `play-sound` `at: actor` id, if this effect is a `play-sound`
7140 /// targeting an actor (rejected `DW0335`).
7141 pub fn play_sound_actor(&self) -> Option<&str> {
7142 match &self.verb {
7143 Verb::PlaySound {
7144 at: Some(SoundAt::Actor { actor }),
7145 ..
7146 } => Some(actor.as_str()),
7147 _ => None,
7148 }
7149 }
7150
7151 /// The per-effect flag gate: flags that must ALL be set (per party) for this
7152 /// effect to fire. Empty for an ungated effect. Read off the one [`Guard`], so
7153 /// **every** verb answers it — the staging and souls vocabulary included.
7154 pub fn requires_flags(&self) -> &[FlagId] {
7155 self.when.as_ref().map_or(&[][..], |g| &g.requires_flags)
7156 }
7157
7158 /// The per-effect **negative** flag gate: flags whose being set suppresses this
7159 /// effect — the dual of [`QuestEffect::requires_flags`], on the same guard.
7160 pub fn forbids_flags(&self) -> &[FlagId] {
7161 self.when.as_ref().map_or(&[][..], |g| &g.forbids_flags)
7162 }
7163
7164 /// The numeric gate terms (spec-0031) — the third axis of the same guard, so
7165 /// "which verbs are gatable" has exactly one answer.
7166 pub fn requires_state(&self) -> &[StateCompare] {
7167 self.when.as_ref().map_or(&[][..], |g| &g.requires_state)
7168 }
7169
7170 /// The datum this effect writes and how, if it is one of the DSL v0.10 state
7171 /// verbs (`set-state` / `add-state` / `clear-state`).
7172 pub fn writes_state(&self) -> Option<(&StateId, StateWrite)> {
7173 match &self.verb {
7174 Verb::SetState { state, value, .. } => Some((state, StateWrite::Set(*value))),
7175 Verb::AddState { state, amount, .. } => Some((state, StateWrite::Add(*amount))),
7176 Verb::ClearState { state, .. } => Some((state, StateWrite::Clear)),
7177 _ => None,
7178 }
7179 }
7180
7181 /// The `on_arrive` bundle if this is a `move-npc` carrying one (DSL v0.6;
7182 /// parity with `move-actor`). `None` for a bare `move-npc` and every other
7183 /// effect.
7184 pub fn move_npc_on_arrive(&self) -> Option<&[QuestEffect]> {
7185 match &self.verb {
7186 Verb::MoveNpc { on_arrive, .. } if !on_arrive.is_empty() => Some(on_arrive.as_slice()),
7187 _ => None,
7188 }
7189 }
7190
7191 /// The actor id this effect targets, if it is one of the actor staging effects
7192 /// (`spawn-actor`/`despawn-actor`/`move-actor`/`unleash-actor`). `sequence` has
7193 /// no single actor (its nested effects each carry their own).
7194 pub fn actor_ref(&self) -> Option<&ActorId> {
7195 match &self.verb {
7196 Verb::SpawnActor { actor, .. }
7197 | Verb::DespawnActor { actor, .. }
7198 | Verb::MoveActor { actor, .. }
7199 | Verb::UnleashActor { actor, .. } => Some(actor),
7200 _ => None,
7201 }
7202 }
7203
7204 /// Every vanilla **status-effect** id this effect names, for registry
7205 /// validation (`DW0192`) — the sibling of [`Self::sound_refs`] and the single
7206 /// authority on the status-effect-bearing verb surface. `(subpath, id)`
7207 /// pairs; empty for a `clear-effect` that names none (which clears all).
7208 pub fn status_effect_refs(&self) -> Vec<(&'static str, &str)> {
7209 match &self.verb {
7210 Verb::GiveEffect { effect, .. } => vec![("effect", effect.as_str())],
7211 Verb::ClearEffect {
7212 effect: Some(e), ..
7213 } => vec![("effect", e.as_str())],
7214 _ => Vec::new(),
7215 }
7216 }
7217
7218 /// `(effect, seconds, amplifier, hide_particles, in)` if this is a
7219 /// `give-effect` (DSL v0.10), with the documented defaults already applied.
7220 pub fn give_effect(&self) -> Option<(&str, u32, u32, bool, Option<&StealthZone>)> {
7221 match &self.verb {
7222 Verb::GiveEffect {
7223 effect,
7224 seconds,
7225 amplifier,
7226 hide_particles,
7227 within,
7228 ..
7229 } => Some((
7230 effect.as_str(),
7231 *seconds,
7232 amplifier.unwrap_or(0),
7233 hide_particles.unwrap_or(false),
7234 within.as_ref(),
7235 )),
7236 _ => None,
7237 }
7238 }
7239
7240 /// `(effect, in)` if this is a `clear-effect` (DSL v0.10). The effect is
7241 /// `None` for the clear-everything form, exactly as vanilla spells it.
7242 pub fn clear_effect(&self) -> Option<(Option<&str>, Option<&StealthZone>)> {
7243 match &self.verb {
7244 Verb::ClearEffect { effect, within, .. } => Some((effect.as_deref(), within.as_ref())),
7245 _ => None,
7246 }
7247 }
7248
7249 /// `(from, to)` if this is a `teleport` (DSL v0.10): the source volume and
7250 /// the destination mark.
7251 pub fn teleport(&self) -> Option<(&StealthZone, &Mark)> {
7252 match &self.verb {
7253 Verb::Teleport { from, to, .. } => Some((from, to)),
7254 _ => None,
7255 }
7256 }
7257}
7258
7259// ---------------------------------------------------------------------------
7260// Stage 7 — world-edits (the map editor edit script, DSL v0.6, spec-0017)
7261// ---------------------------------------------------------------------------
7262
7263/// Stage 7 payload (optional; DSL v0.6, spec-0017): the map-editor edit script.
7264///
7265/// The artifact of record for L3 world detailing: an ordered list of edit
7266/// batches the compiler replays deterministically **after** world assembly.
7267/// The world files are never truth — same DSL + same edits + same seed →
7268/// byte-identical world (ADR-0006). A campaign without a `world-edits.json`
7269/// builds byte-identically to one from before this stage existed.
7270#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7271#[serde(deny_unknown_fields)]
7272pub struct WorldEditsContent {
7273 /// Ordered edit batches, replayed in order. After every batch the post-edit
7274 /// invariants re-prove (walkability, sealing + relight, boundary safety),
7275 /// so each batch is a valid, snapshot-reviewable world state.
7276 pub batches: Vec<EditBatch>,
7277}
7278
7279/// One edit batch: an ordered group of edit verbs applied to a single area,
7280/// checked and snapshot-rendered as a unit.
7281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7282#[serde(deny_unknown_fields)]
7283pub struct EditBatch {
7284 /// Batch id (`batch/<kebab>`), unique in the script. Also the batch's
7285 /// snapshot name and seed-stream label — renaming a batch deliberately
7286 /// reseeds its noise.
7287 pub id: EditBatchId,
7288 /// The stage-1 area this batch edits. Frames and regions resolve against
7289 /// this area's placed pieces and anchors.
7290 pub area: AreaId,
7291 /// Authoring context (why this batch exists). Machine-ignored; **excluded**
7292 /// from l10n like `theme`/`premise`.
7293 #[serde(default, skip_serializing_if = "Option::is_none")]
7294 pub note: Option<String>,
7295 /// Ordered edit verbs. `select` defines named regions; later verbs in the
7296 /// same batch refer back to them (strictly backward, like every DSL ref).
7297 pub edits: Vec<WorldEdit>,
7298}
7299
7300/// One edit verb (spec-0017 L3). Every verb operates on named regions and every
7301/// seeded verb derives its noise stream from the campaign seed + its script
7302/// position (`edits/<batch>/<index>`) — no wall clock, no unseeded RNG.
7303#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7304#[serde(tag = "verb", rename_all = "kebab-case", deny_unknown_fields)]
7305pub enum WorldEdit {
7306 /// Define a named region (`region/<kebab>`) for later verbs in this batch.
7307 Select {
7308 /// The region name being defined (unique within the batch).
7309 name: RegionId,
7310 /// The region's shape (box, surface band, palette match, or a
7311 /// composition of earlier regions).
7312 shape: RegionShape,
7313 },
7314 /// Fill every cell of a region from a seeded palette recipe (value-noise
7315 /// keyed, so picks cluster into patches — never a uniform fill).
7316 Fill {
7317 /// The region (an earlier `select` in this batch) to fill.
7318 region: RegionId,
7319 /// The palette recipe to fill with.
7320 recipe: PaletteRecipe,
7321 },
7322 /// Like `fill`, but only rewrites cells whose current block matches one of
7323 /// `matching` (base ids; blockstate suffixes are ignored when matching).
7324 Replace {
7325 /// The region (an earlier `select` in this batch) to edit.
7326 region: RegionId,
7327 /// Base block ids to rewrite (e.g. `["minecraft:stone"]`).
7328 matching: Vec<String>,
7329 /// The palette recipe to rewrite them with.
7330 recipe: PaletteRecipe,
7331 },
7332 /// Clear a region to air. Sealing-aware: the carved region re-enters the
7333 /// sealing + relight passes and every walkability invariant re-proves.
7334 Carve {
7335 /// The region (an earlier `select` in this batch) to clear.
7336 region: RegionId,
7337 },
7338 /// Reshape terrain surface within a region (raise / lower / smooth).
7339 Morph {
7340 /// The region (an earlier `select` in this batch) whose columns to
7341 /// reshape. The region defines the footprint and where each column's
7342 /// surface is read (the highest occupied cell in the region's y-range);
7343 /// `raise`/`smooth` may add cells above the region's top — reshaping
7344 /// upward is the point — while removal only touches region cells.
7345 region: RegionId,
7346 /// The surface operation.
7347 op: MorphOp,
7348 },
7349 /// Seeded dressing scatter (spec-0017): drop weighted single-block
7350 /// dressing (flora, rocks, props) onto standable cells of a region —
7351 /// air cells with an occupied cell directly below — honoring keep-clear
7352 /// envelopes (`avoid`). Per-cell white-noise density gate (dressing wants
7353 /// speckle, not the fill verbs' clustered patches), deterministic from the
7354 /// campaign seed + script position.
7355 Scatter {
7356 /// The region (an earlier `select` in this batch) to dress.
7357 region: RegionId,
7358 /// Weighted dressing blocks (blockstate suffixes allowed).
7359 items: Vec<PaletteBlock>,
7360 /// Per-candidate placement probability in `(0, 1]`.
7361 density: f64,
7362 /// Keep-clear envelopes: earlier regions whose cells (and columns —
7363 /// matched by `(x, z)`) never receive dressing.
7364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
7365 avoid: Vec<RegionId>,
7366 /// Optional minimum spacing: when set, candidates are taken in
7367 /// descending noise order and one is rejected while another accepted
7368 /// candidate is closer than this on **both** horizontal axes (the
7369 /// generators' spread rule).
7370 #[serde(default, skip_serializing_if = "Option::is_none")]
7371 spacing: Option<u32>,
7372 /// Optional cap on how many items are placed (highest-noise first).
7373 #[serde(default, skip_serializing_if = "Option::is_none")]
7374 limit: Option<u32>,
7375 },
7376 /// Structural flora (spec-0017): plant hand-shaped trees via the
7377 /// lean-or-grow canopy rules — a canopy that would reach a
7378 /// keep-clear (`avoid`) column first leans one block away from it; if that
7379 /// still covers the corridor the tree grows tall instead, arching its
7380 /// whole canopy 3 blocks above the trunk's floor. No leaf is ever sliced.
7381 Plant {
7382 /// The region (an earlier `select` in this batch) to plant in. Trunk
7383 /// cells are standable region cells (air over an occupied cell).
7384 region: RegionId,
7385 /// The tree species (canopy shape rules are per-species).
7386 tree: TreeKind,
7387 /// How many trees to plant (≥ 1; highest-noise candidates first).
7388 count: u32,
7389 /// Keep-clear envelopes: trunks never stand in these columns and
7390 /// canopies lean/grow to clear them.
7391 #[serde(default, skip_serializing_if = "Vec::is_empty")]
7392 avoid: Vec<RegionId>,
7393 /// Minimum trunk spacing (reject when closer on BOTH axes; default 4).
7394 #[serde(default, skip_serializing_if = "Option::is_none")]
7395 spacing: Option<u32>,
7396 },
7397 /// Stamp a prefab fragment (spec-0017): copy a library prefab's
7398 /// non-air cells into the world at a frame-resolved position. The fragment
7399 /// is a first-class library prefab — its provenance/license metadata is
7400 /// recorded and validated exactly like any placed prefab (ADR-0013);
7401 /// nothing outside the library can be stamped.
7402 Fragment {
7403 /// The library prefab to stamp.
7404 prefab: PrefabId,
7405 /// The frame `at` resolves in.
7406 frame: EditFrame,
7407 /// Where the fragment's local `(0, 0, 0)` lands (frame coordinates).
7408 at: [i32; 3],
7409 /// Placement rotation (default `none`), the same quarter-turn set as
7410 /// `/place template`.
7411 #[serde(default, skip_serializing_if = "Option::is_none")]
7412 rotation: Option<FragmentRotation>,
7413 },
7414 /// Explicit region relight (spec-0017, spec-0010 machinery): run the
7415 /// deterministic fixture-placement pass over ONE region and bake the
7416 /// resulting fixtures into the edit script's writes — authorial control of
7417 /// where fixtures land, instead of the whole-area pass's greedy siting.
7418 /// (The whole-area relight still re-proves after every batch either way.)
7419 Relight {
7420 /// The region (an earlier `select` in this batch) to relight: its
7421 /// reachable walkable cells are brought to `min_light`.
7422 region: RegionId,
7423 /// Fixture override; default = the area's declared `lighting.fixture`.
7424 #[serde(default, skip_serializing_if = "Option::is_none")]
7425 fixture: Option<Fixture>,
7426 /// Target light override (1..=14); default = the area's declared
7427 /// `lighting.min_light`. Required (with `fixture`) when the area
7428 /// declares no `lighting`.
7429 #[serde(default, skip_serializing_if = "Option::is_none")]
7430 min_light: Option<u8>,
7431 },
7432 /// L2 massing (spec-0017): replace a placed piece with another
7433 /// library prefab that re-mates every currently-mated socket at its exact
7434 /// world pose (any rotation; overlap-checked). Applied at **plan** time —
7435 /// the whole downstream ladder (anchors, gate reachability, assembly,
7436 /// relight, nav, L3 detailing) re-runs over the massaged layout. Massing
7437 /// verbs live in massing-only batches, ordered before every detailing
7438 /// batch.
7439 SwapPiece {
7440 /// The piece's placement index in its area's solved layout (0-based,
7441 /// entry first).
7442 piece: u32,
7443 /// The prefab the indexed piece must currently be (drift guard).
7444 prefab: PrefabId,
7445 /// The library prefab to swap in.
7446 with: PrefabId,
7447 },
7448 /// L2 massing (spec-0017): attach a new piece at a specific **open**
7449 /// (unmated) socket of an existing piece — the targeted form of the
7450 /// solver's frontier attach. The socket opens (its seal becomes a
7451 /// passage); the new piece's other sockets seal.
7452 InsertPiece {
7453 /// The host piece's placement index.
7454 at_piece: u32,
7455 /// The prefab the host piece must currently be (drift guard).
7456 prefab: PrefabId,
7457 /// The host's connector index (prefab metadata `connectors` order).
7458 socket: u32,
7459 /// The library prefab to attach.
7460 insert: PrefabId,
7461 },
7462 /// L2 massing (spec-0017): remove a **leaf** piece (exactly one
7463 /// mated socket; never the entry piece). The neighbour's socket unmates
7464 /// and re-seals. Removal shifts later placement indices — order removals
7465 /// before other index-referencing massing verbs.
7466 RemovePiece {
7467 /// The piece's placement index.
7468 piece: u32,
7469 /// The prefab the indexed piece must currently be (drift guard).
7470 prefab: PrefabId,
7471 },
7472 /// L2 massing (spec-0017): override one socket's seal — `open`
7473 /// clears the opening to a passage, `sealed` walls it up — independent of
7474 /// its mated state (sealing a mated doorway makes a wall between joined
7475 /// pieces; opening an unmated exterior socket exposes the outside, which
7476 /// the boundary-safety proof then judges).
7477 RewireSocket {
7478 /// The piece's placement index.
7479 piece: u32,
7480 /// The prefab the indexed piece must currently be (drift guard).
7481 prefab: PrefabId,
7482 /// The connector index (prefab metadata `connectors` order).
7483 socket: u32,
7484 /// The socket's new state.
7485 state: SocketState,
7486 },
7487 /// L2 massing (spec-0017): re-pick this piece from its area pool's
7488 /// compatible members (weighted, seeded from the campaign seed + this
7489 /// verb's script position — moving the verb deliberately re-rolls). The
7490 /// current prefab is excluded, so a reseed always changes the piece or
7491 /// errors loudly.
7492 ReseedPiece {
7493 /// The piece's placement index.
7494 piece: u32,
7495 /// The prefab the indexed piece must currently be (drift guard).
7496 prefab: PrefabId,
7497 },
7498}
7499
7500/// A socket seal state for `rewire-socket` (spec-0017).
7501#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7502#[serde(rename_all = "kebab-case")]
7503pub enum SocketState {
7504 /// The opening is cleared to a passage.
7505 Open,
7506 /// The opening is walled up.
7507 Sealed,
7508}
7509
7510/// A tree species for the `plant` verb (spec-0017). One species per
7511/// canopy-rule implementation; the shipped rule set is the lean-or-grow oak.
7512#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7513#[serde(rename_all = "kebab-case")]
7514pub enum TreeKind {
7515 /// Small hand-shaped oak (3–4 logs, 5-wide leaf ball) with the
7516 /// lean-or-grow corridor rules.
7517 Oak,
7518}
7519
7520/// A `fragment` stamp rotation (spec-0017) — the `/place template`
7521/// quarter-turn set.
7522#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7523#[serde(rename_all = "kebab-case")]
7524pub enum FragmentRotation {
7525 /// No rotation.
7526 None,
7527 /// 90° clockwise.
7528 Clockwise90,
7529 /// 180°.
7530 Clockwise180,
7531 /// 90° counterclockwise.
7532 Counterclockwise90,
7533}
7534
7535/// A `select` verb's shape (spec-0017): primitive shapes resolve in a declared
7536/// frame; compositions combine earlier regions of the same batch.
7537#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7538#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7539pub enum RegionShape {
7540 /// An inclusive axis-aligned box, `min`/`max` in the declared frame.
7541 Box {
7542 /// The coordinate frame `min`/`max` resolve in.
7543 frame: EditFrame,
7544 /// Inclusive minimum corner (frame coordinates).
7545 min: [i32; 3],
7546 /// Inclusive maximum corner (frame coordinates; each axis ≥ `min`).
7547 max: [i32; 3],
7548 },
7549 /// The band of cells at `from..=to` blocks relative to each column's
7550 /// terrain surface (the highest non-air cell of the column) within an
7551 /// earlier region. `from: 1, to: 3` is the 3 cells of air-space above the
7552 /// surface; `from: 0, to: 0` is the surface cells themselves; negative
7553 /// offsets reach below the surface.
7554 SurfaceBand {
7555 /// The earlier region whose columns are scanned.
7556 over: RegionId,
7557 /// Inclusive band start, relative to each column's surface y.
7558 from: i32,
7559 /// Inclusive band end (≥ `from`), relative to each column's surface y.
7560 to: i32,
7561 },
7562 /// The cells of an earlier region whose current block matches one of
7563 /// `blocks` (base ids; blockstate suffixes ignored when matching).
7564 PaletteMatch {
7565 /// The earlier region to filter.
7566 within: RegionId,
7567 /// Base block ids to match (e.g. `["minecraft:grass_block"]`).
7568 blocks: Vec<String>,
7569 },
7570 /// The union of earlier regions.
7571 Union {
7572 /// The earlier regions to unite (≥ 2).
7573 of: Vec<RegionId>,
7574 },
7575 /// The intersection of earlier regions.
7576 Intersect {
7577 /// The earlier regions to intersect (≥ 2).
7578 of: Vec<RegionId>,
7579 },
7580 /// An earlier region minus other earlier regions.
7581 Subtract {
7582 /// The earlier region to start from.
7583 base: RegionId,
7584 /// The earlier regions to remove from it (≥ 1).
7585 remove: Vec<RegionId>,
7586 },
7587}
7588
7589/// The coordinate frame a primitive [`RegionShape`] resolves in (spec-0017):
7590/// piece-local or anchor-relative — never raw world coordinates, so an edit
7591/// script survives a layout's world placement moving.
7592#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7593#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7594pub enum EditFrame {
7595 /// The local frame of a placed piece of the batch's area: `[0, 0, 0]` is
7596 /// the piece's structure origin, axes as authored (the compiler applies
7597 /// the piece's placed rotation).
7598 PieceLocal {
7599 /// The piece's placement index in the area's solved layout (0-based,
7600 /// entry piece first — the order `delvec snapshot`'s manifest lists).
7601 piece: u32,
7602 /// The prefab the indexed piece must be (a drift guard: if a re-solve
7603 /// changed the layout, the mismatch is a loud compile error, never a
7604 /// silently misplaced edit).
7605 prefab: PrefabId,
7606 },
7607 /// Relative to a resolved anchor of the batch's area: `[0, 0, 0]` is the
7608 /// anchor cell, axes world-aligned.
7609 AnchorRelative {
7610 /// The anchor (prefab metadata, resolved by the compiler).
7611 anchor: AnchorId,
7612 },
7613}
7614
7615/// A surface operation for the `morph` verb (spec-0017).
7616#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7617#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7618pub enum MorphOp {
7619 /// Raise each column's surface by up to `by` blocks, drawing the added
7620 /// cells from `recipe` (value-noise keyed per cell, so a raised band reads
7621 /// as natural strata, not an extruded slab).
7622 Raise {
7623 /// How many blocks to raise each column (≥ 1).
7624 by: u32,
7625 /// The palette recipe for the added cells.
7626 recipe: PaletteRecipe,
7627 },
7628 /// Lower each column's surface by up to `by` blocks (carving the topmost
7629 /// solid cells to air).
7630 Lower {
7631 /// How many blocks to lower each column (≥ 1).
7632 by: u32,
7633 },
7634 /// Relax each column's surface toward the mean of its cardinal neighbours
7635 /// (one block per pass), turning steps into slopes. Added cells draw from
7636 /// `recipe`; removed cells carve to air. Deterministic double-buffered
7637 /// passes in fixed scan order.
7638 Smooth {
7639 /// Relaxation passes (≥ 1).
7640 passes: u32,
7641 /// The palette recipe for cells a pass adds.
7642 recipe: PaletteRecipe,
7643 },
7644}
7645
7646/// A seeded palette recipe (spec-0017): weighted blocks picked per cell by a
7647/// smooth value-noise sample, the island/cave generators' proven primitive —
7648/// picks cluster into strata/patches instead of per-cell speckle, and a
7649/// single-entry recipe is the degenerate (discouraged) uniform case.
7650#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7651#[serde(deny_unknown_fields)]
7652pub struct PaletteRecipe {
7653 /// Weighted palette entries (≥ 1; ≥ 2 for any visible surface).
7654 pub blocks: Vec<PaletteBlock>,
7655 /// Noise frequency in blocks⁻¹ (default `0.35` — patches a few blocks
7656 /// across). Larger = smaller patches. Must be finite and > 0.
7657 #[serde(default, skip_serializing_if = "Option::is_none")]
7658 pub scale: Option<f64>,
7659}
7660
7661/// One weighted entry of a [`PaletteRecipe`].
7662#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7663#[serde(deny_unknown_fields)]
7664pub struct PaletteBlock {
7665 /// Vanilla block id (validated against the pinned 1.21.11 registry), with
7666 /// an optional verbatim blockstate suffix (`minecraft:oak_leaves[persistent=true]`).
7667 pub block: String,
7668 /// Relative weight (finite, > 0).
7669 pub weight: f64,
7670}
7671
7672/// Where in the campaign one effect sits — the attribution every per-branch
7673/// proof and the branch chronicle need (spec-0025).
7674#[derive(Clone, Debug, PartialEq, Eq)]
7675pub enum EffectSite {
7676 /// A quest's `on_objective_complete[<objective>]` bundle.
7677 Objective {
7678 /// The owning quest.
7679 quest: String,
7680 /// The objective whose completion fires the bundle.
7681 objective: String,
7682 },
7683 /// A quest's `on_complete` bundle.
7684 QuestComplete {
7685 /// The owning quest.
7686 quest: String,
7687 },
7688 /// An environment trigger's `effects` bundle — ambient, no DAG position.
7689 Trigger {
7690 /// The trigger id.
7691 trigger: String,
7692 },
7693 /// A trap's spec-0022 `payload` bundle — ambient, no DAG position.
7694 Trap {
7695 /// The trap id.
7696 trap: String,
7697 },
7698 /// A **dialogue option's** `set-checkpoint` `on_respawn` bundle — ambient, no
7699 /// DAG position, and the only site that does not live in the quests stage.
7700 ///
7701 /// This variant did not exist until the effect-root sweep, and its absence was
7702 /// load-bearing: `EffectSite` had no way to *represent* a dialogue-hosted
7703 /// bundle, so the four proofs that walk [`for_each_campaign_effect`]
7704 /// (`combat::actor_beats`, `validate::difficulty_checks`,
7705 /// `daylight::fightable_actor`, `nav::actor_fights`) could not have seen root 5
7706 /// even if their authors had thought of it. Widening the type is what let the
7707 /// walk widen.
7708 DialogueRespawn {
7709 /// The NPC whose dialogue tree hosts the option.
7710 npc: String,
7711 /// The node the option sits under.
7712 node: String,
7713 },
7714 /// A `shortcuts[].on_unlock` bundle (spec-0016 §2) — ambient, no DAG
7715 /// position, and the sixth root: representable here only since spec-0031, for
7716 /// exactly the reason [`EffectSite::DialogueRespawn`] records above.
7717 ShortcutUnlock {
7718 /// The shortcut id.
7719 shortcut: String,
7720 },
7721 /// A `shops[].offers[].effects` bundle (DSL v0.10, spec-0032) — ambient, no
7722 /// DAG position: nobody is forced to buy anything.
7723 ShopOffer {
7724 /// The shop id.
7725 shop: String,
7726 /// The offer's index within that shop, which is also its button order.
7727 offer: usize,
7728 },
7729 /// The campaign's `on_death` bundle (spec-0031) — ambient, no DAG position,
7730 /// and no owning object: there is one per campaign.
7731 OnDeath,
7732 /// A wave's or an actor's `on_kill` bundle (spec-0074) — ambient, no DAG
7733 /// position: nobody is forced to be credited with a kill.
7734 OnKill {
7735 /// The fight's id (`wave/<kebab>` or `actor/<kebab>`).
7736 fight: String,
7737 },
7738}
7739
7740impl EffectSite {
7741 /// The quest this site belongs to, if it has a DAG position at all.
7742 ///
7743 /// **This `Option` is the capability, and it is on the enum rather than on the
7744 /// variants that happen to have a quest.** Only two of the eight sites name a
7745 /// quest, because only two of the eight roots HAVE a DAG position: an ambient
7746 /// root — a trigger, a trap payload, a dialogue `on_respawn`, a shortcut's
7747 /// `on_unlock`, the campaign's `on_death`, a shop offer — fires at a moment no
7748 /// static model can order, and inventing a quest for one would be exactly the
7749 /// over-attribution the completability model must not make. Asking the question
7750 /// of every variant and getting an honest `None` is the lift;
7751 /// `tools/ci/check-capability-ownership.py` check D asked for it while the field
7752 /// was still cross-cutting, and spec-0032's eighth site took it below that
7753 /// threshold, so the reasoning lives here now rather than in an exemption.
7754 pub fn quest(&self) -> Option<&str> {
7755 match self {
7756 EffectSite::Objective { quest, .. } | EffectSite::QuestComplete { quest } => {
7757 Some(quest)
7758 }
7759 EffectSite::Trigger { .. }
7760 | EffectSite::Trap { .. }
7761 | EffectSite::DialogueRespawn { .. }
7762 | EffectSite::ShortcutUnlock { .. }
7763 | EffectSite::ShopOffer { .. }
7764 | EffectSite::OnDeath
7765 | EffectSite::OnKill { .. } => None,
7766 }
7767 }
7768}
7769
7770/// Visit **every** effect the compiler can lower — at every one of the five
7771/// effect roots, top-level and transitively nested — in a fixed deterministic
7772/// order, invoking `f(json_pointer, site, effect)`.
7773///
7774/// The roots come from [`crate::effects::for_each_effect_root`], the single
7775/// enumeration; nesting is descended through the single
7776/// [`QuestEffect::nested_effect_lists_labeled`] authority. Neither axis is
7777/// enumerated here, which is the point: this walk used to hand-list four of the
7778/// five roots (it had no `EffectSite` variant for the fifth), so every proof
7779/// defined in terms of it inherited that blind spot.
7780pub fn for_each_campaign_effect<'a>(
7781 c: &'a crate::envelope::Campaign,
7782 f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
7783) {
7784 crate::effects::for_each_effect_root(c, &mut |root, list| {
7785 let site = match root.owner {
7786 crate::effects::EffectRootOwner::ObjectiveComplete { quest, objective } => {
7787 EffectSite::Objective {
7788 quest: quest.id.as_str().to_string(),
7789 objective: objective.to_string(),
7790 }
7791 }
7792 crate::effects::EffectRootOwner::QuestComplete { quest } => EffectSite::QuestComplete {
7793 quest: quest.id.as_str().to_string(),
7794 },
7795 crate::effects::EffectRootOwner::Trigger(t) => EffectSite::Trigger {
7796 trigger: t.id.as_str().to_string(),
7797 },
7798 crate::effects::EffectRootOwner::TrapPayload(t) => EffectSite::Trap {
7799 trap: t.id.as_str().to_string(),
7800 },
7801 crate::effects::EffectRootOwner::DialogueRespawn => {
7802 // The npc and node are in the root's path; parse them back rather
7803 // than widening the root walk's owner for one consumer.
7804 let seg = |n: usize| -> String {
7805 root.path.split('/').nth(n).unwrap_or_default().to_string()
7806 };
7807 EffectSite::DialogueRespawn {
7808 npc: seg(3),
7809 node: seg(5),
7810 }
7811 }
7812 crate::effects::EffectRootOwner::ShortcutUnlock(s) => EffectSite::ShortcutUnlock {
7813 shortcut: s.id.as_str().to_string(),
7814 },
7815 crate::effects::EffectRootOwner::OnDeath => EffectSite::OnDeath,
7816 crate::effects::EffectRootOwner::ShopOffer(h) => EffectSite::ShopOffer {
7817 shop: h.id.as_str().to_string(),
7818 // The offer index is in the root's path
7819 // (`/content/shops/<h>/offers/<i>/effects`), parsed back rather
7820 // than widening the owner for one consumer — the same call the
7821 // dialogue arm above makes. Segment 5 is the index: segment 4 is
7822 // the literal `offers`, which parses as nothing and reported
7823 // every offer as the shop's first.
7824 offer: root
7825 .path
7826 .split('/')
7827 .nth(5)
7828 .and_then(|n| n.parse().ok())
7829 .unwrap_or(0),
7830 },
7831 crate::effects::EffectRootOwner::OnKill(f) => EffectSite::OnKill {
7832 fight: f.id().to_string(),
7833 },
7834 };
7835 for (i, eff) in list.iter().enumerate() {
7836 campaign_effect_deep(eff, &format!("{}/{i}", root.path), &site, f);
7837 }
7838 });
7839}
7840
7841fn campaign_effect_deep<'a>(
7842 eff: &'a QuestEffect,
7843 path: &str,
7844 site: &EffectSite,
7845 f: &mut dyn FnMut(&str, &EffectSite, &'a QuestEffect),
7846) {
7847 f(path, site, eff);
7848 for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
7849 for (j, inner) in list.iter().enumerate() {
7850 campaign_effect_deep(inner, &format!("{path}/{pseg}/{j}"), site, f);
7851 }
7852 }
7853}
7854
7855// ---------------------------------------------------------------------------
7856// The spine authority (`QuestPlanContent::spine`)
7857// ---------------------------------------------------------------------------
7858
7859#[cfg(test)]
7860mod spine_tests {
7861 use super::QuestPlanContent;
7862
7863 /// Build a plan from `(id, deps)` pairs plus a finale. JSON rather than a
7864 /// struct literal on purpose: a field added to `PlannedQuest` later must not
7865 /// red these tests for a reason that has nothing to do with the spine.
7866 fn plan(finale: &str, quests: &[(&str, &[&str])]) -> QuestPlanContent {
7867 let quests: Vec<serde_json::Value> = quests
7868 .iter()
7869 .map(|(id, deps)| {
7870 serde_json::json!({
7871 "id": id,
7872 "goal": "g",
7873 "area": "area/keep",
7874 "npcs": [],
7875 "depends_on": deps,
7876 "mandatory": true,
7877 "act": 1,
7878 })
7879 })
7880 .collect();
7881 serde_json::from_value(serde_json::json!({
7882 "finale": finale,
7883 "quests": quests,
7884 }))
7885 .expect("plan fixture parses")
7886 }
7887
7888 fn sorted(p: &QuestPlanContent) -> Vec<String> {
7889 p.spine().into_iter().map(str::to_owned).collect()
7890 }
7891
7892 #[test]
7893 fn a_chain_is_wholly_spine() {
7894 let p = plan(
7895 "quest/c",
7896 &[
7897 ("quest/a", &[]),
7898 ("quest/b", &["quest/a"]),
7899 ("quest/c", &["quest/b"]),
7900 ],
7901 );
7902 assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c"]);
7903 }
7904
7905 #[test]
7906 fn a_quest_the_finale_does_not_depend_on_is_off_the_spine() {
7907 // Exactly the `DW0132` shape: the plan does not converge, and the spine
7908 // is the half that does. The authority answers, it does not refuse — the
7909 // refusal is `validate`'s, built on this answer.
7910 let p = plan("quest/end", &[("quest/end", &[]), ("quest/side-trip", &[])]);
7911 assert_eq!(sorted(&p), ["quest/end"]);
7912 }
7913
7914 #[test]
7915 fn a_diamond_counts_the_join_once() {
7916 let p = plan(
7917 "quest/d",
7918 &[
7919 ("quest/a", &[]),
7920 ("quest/b", &["quest/a"]),
7921 ("quest/c", &["quest/a"]),
7922 ("quest/d", &["quest/b", "quest/c"]),
7923 ],
7924 );
7925 assert_eq!(sorted(&p), ["quest/a", "quest/b", "quest/c", "quest/d"]);
7926 }
7927
7928 #[test]
7929 fn a_cycle_terminates_and_yields_a_set() {
7930 // `DW0130` refuses this plan, but the authority is asked before that
7931 // verdict is known (the layout binding prints on an erroring campaign),
7932 // so it must terminate rather than hang.
7933 let p = plan(
7934 "quest/b",
7935 &[("quest/a", &["quest/b"]), ("quest/b", &["quest/a"])],
7936 );
7937 assert_eq!(sorted(&p), ["quest/a", "quest/b"]);
7938 }
7939
7940 #[test]
7941 fn a_dangling_dependency_is_reported_and_expands_no_further() {
7942 // The deliberate difference between the two derivations this function
7943 // replaced. `validate` pruned undeclared ids before walking; the
7944 // authority does not, because pruning them would make the set disagree
7945 // with the document, and naming an id the plan does not declare is
7946 // `DW0112`'s finding rather than the spine's.
7947 let p = plan("quest/end", &[("quest/end", &["quest/ghost"])]);
7948 assert_eq!(sorted(&p), ["quest/end", "quest/ghost"]);
7949 }
7950
7951 #[test]
7952 fn an_undeclared_finale_is_the_whole_spine() {
7953 // `DW0131`'s shape. The answer is honest about the document: nothing the
7954 // plan declares is on the spine of a finale it never declared.
7955 let p = plan(
7956 "quest/ghost",
7957 &[("quest/a", &[]), ("quest/b", &["quest/a"])],
7958 );
7959 assert_eq!(sorted(&p), ["quest/ghost"]);
7960 }
7961}
7962
7963// ---------------------------------------------------------------------------
7964// The derived happening subject (spec-0071 §3)
7965// ---------------------------------------------------------------------------
7966
7967#[cfg(test)]
7968mod happening_subject_tests {
7969 use super::QuestEffect;
7970
7971 /// An effect with a `happening` that states no subject.
7972 fn beat(verb: serde_json::Value) -> QuestEffect {
7973 let mut v = verb;
7974 v["happening"] = serde_json::json!({ "verb": "opens", "text": "It gives." });
7975 serde_json::from_value(v).expect("effect fixture parses")
7976 }
7977
7978 fn subject(v: serde_json::Value) -> Option<String> {
7979 beat(v).happening_subject().map(|s| {
7980 assert!(s.derived, "the fixture states no subject");
7981 s.id.to_string()
7982 })
7983 }
7984
7985 /// **The census, over every verb the DSL declares.** Which verbs resolve a
7986 /// subject is a fact about the objects an effect names, so the answer is
7987 /// computed here rather than listed anywhere: the table below is held to the
7988 /// variant set of the exported schema, and a thirty-ninth verb reds this
7989 /// test until somebody answers for it.
7990 ///
7991 /// Run with `--nocapture` to print the census a document quotes.
7992 #[test]
7993 fn every_verb_answers_the_census() {
7994 // One minimal instance per verb, in the schema's own order.
7995 let table: Vec<(&str, serde_json::Value, Option<&str>)> = vec![
7996 (
7997 "open-gate",
7998 serde_json::json!({"type":"open-gate","anchor":"anchor/door"}),
7999 Some("anchor/door"),
8000 ),
8001 (
8002 "close-gate",
8003 serde_json::json!({"type":"close-gate","anchor":"anchor/door"}),
8004 Some("anchor/door"),
8005 ),
8006 (
8007 "campaign-complete",
8008 serde_json::json!({"type":"campaign-complete"}),
8009 None,
8010 ),
8011 (
8012 "give-item",
8013 serde_json::json!({"type":"give-item","item":"minecraft:bread","count":1}),
8014 None,
8015 ),
8016 (
8017 "set-flag",
8018 serde_json::json!({"type":"set-flag","flag":"flag/heard"}),
8019 None,
8020 ),
8021 (
8022 "set-state",
8023 serde_json::json!({"type":"set-state","state":"state/purse","value":1}),
8024 None,
8025 ),
8026 (
8027 "add-state",
8028 serde_json::json!({"type":"add-state","state":"state/purse","amount":-1}),
8029 None,
8030 ),
8031 (
8032 "drop-stake",
8033 serde_json::json!({"type":"drop-stake","stake":"stake/purse"}),
8034 None,
8035 ),
8036 (
8037 "clear-state",
8038 serde_json::json!({"type":"clear-state","state":"state/purse"}),
8039 None,
8040 ),
8041 (
8042 "spawn-wave",
8043 serde_json::json!({"type":"spawn-wave","wave":"wave/muster"}),
8044 Some("wave/muster"),
8045 ),
8046 (
8047 "narrate",
8048 serde_json::json!({"type":"narrate","text":"The hall answers."}),
8049 None,
8050 ),
8051 (
8052 "set-block",
8053 serde_json::json!({"type":"set-block","anchor":"anchor/altar","block":"minecraft:stone"}),
8054 Some("anchor/altar"),
8055 ),
8056 (
8057 "fill-region",
8058 serde_json::json!({"type":"fill-region","region":{"anchor":"anchor/pit","extent":[2,2,2]},"block":"minecraft:water"}),
8059 Some("anchor/pit"),
8060 ),
8061 (
8062 "clear-region",
8063 serde_json::json!({"type":"clear-region","region":{"anchor":"anchor/pit","extent":[2,2,2]}}),
8064 Some("anchor/pit"),
8065 ),
8066 (
8067 "open-way",
8068 serde_json::json!({"type":"open-way","piece":"prefab/span","way":"gap"}),
8069 None,
8070 ),
8071 (
8072 "despawn-npc",
8073 serde_json::json!({"type":"despawn-npc","npc":"npc/keeper"}),
8074 Some("npc/keeper"),
8075 ),
8076 (
8077 "move-npc",
8078 serde_json::json!({"type":"move-npc","npc":"npc/keeper","to":{"anchor":"anchor/door"}}),
8079 None,
8080 ),
8081 (
8082 "cutscene",
8083 serde_json::json!({"type":"cutscene","path":[{"anchor":"anchor/hall"},{"anchor":"anchor/door"}],"seconds":4}),
8084 None,
8085 ),
8086 (
8087 "set-time",
8088 serde_json::json!({"type":"set-time","time":"noon"}),
8089 None,
8090 ),
8091 (
8092 "set-weather",
8093 serde_json::json!({"type":"set-weather","weather":"clear"}),
8094 None,
8095 ),
8096 (
8097 "play-sound",
8098 serde_json::json!({"type":"play-sound","sound":"minecraft:block.bell.use","at":{"at":"anchor","anchor":"anchor/bell"}}),
8099 Some("anchor/bell"),
8100 ),
8101 (
8102 "damage-players",
8103 serde_json::json!({"type":"damage-players","amount":2}),
8104 None,
8105 ),
8106 (
8107 "set-checkpoint",
8108 serde_json::json!({"type":"set-checkpoint","anchor":"anchor/seat"}),
8109 Some("anchor/seat"),
8110 ),
8111 (
8112 "bonfire",
8113 serde_json::json!({"type":"bonfire","anchor":"anchor/seat"}),
8114 Some("anchor/seat"),
8115 ),
8116 (
8117 "begin-stealth",
8118 serde_json::json!({"type":"begin-stealth","zones":[{"anchor":"anchor/yard","extent":[4,2,4]}]}),
8119 Some("anchor/yard"),
8120 ),
8121 (
8122 "end-stealth",
8123 serde_json::json!({"type":"end-stealth"}),
8124 None,
8125 ),
8126 (
8127 "spawn-npc",
8128 serde_json::json!({"type":"spawn-npc","npc":"npc/keeper"}),
8129 Some("npc/keeper"),
8130 ),
8131 (
8132 "spawn-actor",
8133 serde_json::json!({"type":"spawn-actor","actor":"actor/giant"}),
8134 Some("actor/giant"),
8135 ),
8136 (
8137 "despawn-actor",
8138 serde_json::json!({"type":"despawn-actor","actor":"actor/giant","style":"vanish"}),
8139 Some("actor/giant"),
8140 ),
8141 (
8142 "move-actor",
8143 serde_json::json!({"type":"move-actor","actor":"actor/giant","to":{"anchor":"anchor/door"}}),
8144 None,
8145 ),
8146 (
8147 "unleash-actor",
8148 serde_json::json!({"type":"unleash-actor","actor":"actor/giant"}),
8149 Some("actor/giant"),
8150 ),
8151 (
8152 "sequence",
8153 serde_json::json!({"type":"sequence","steps":[]}),
8154 None,
8155 ),
8156 (
8157 "volley",
8158 serde_json::json!({"type":"volley","from_anchor":"anchor/slot","kill_zone":{"anchor":"anchor/lane","extent":[3,2,3]}}),
8159 None,
8160 ),
8161 (
8162 "collapse",
8163 serde_json::json!({"type":"collapse","region_anchor":{"anchor":"anchor/roof","extent":[3,1,3]}}),
8164 Some("anchor/roof"),
8165 ),
8166 (
8167 "give-effect",
8168 serde_json::json!({"type":"give-effect","effect":"minecraft:speed","seconds":10}),
8169 None,
8170 ),
8171 (
8172 "clear-effect",
8173 serde_json::json!({"type":"clear-effect"}),
8174 None,
8175 ),
8176 (
8177 "teleport",
8178 serde_json::json!({"type":"teleport","from":{"anchor":"anchor/hall","extent":[4,2,4]},"to":{"anchor":"anchor/door"}}),
8179 None,
8180 ),
8181 (
8182 "firework",
8183 serde_json::json!({"type":"firework","at":{"anchor":"anchor/court"},"explosions":[{"shape":"star","colors":["#ffd700"]}]}),
8184 Some("anchor/court"),
8185 ),
8186 ];
8187 // The binding: the table answers for every verb the schema declares, and
8188 // for no name the schema does not.
8189 let schema = crate::schema::stage_schema(crate::envelope::Stage::Quests);
8190 let declared: std::collections::BTreeSet<String> = schema["$defs"]["QuestEffect"]["oneOf"]
8191 .as_array()
8192 .expect("QuestEffect is a tagged union")
8193 .iter()
8194 .map(|b| {
8195 b["properties"]["type"]["const"]
8196 .as_str()
8197 .expect("a tag")
8198 .to_string()
8199 })
8200 .collect();
8201 let answered: std::collections::BTreeSet<String> =
8202 table.iter().map(|(t, _, _)| (*t).to_string()).collect();
8203 assert_eq!(
8204 answered, declared,
8205 "the census answers for exactly the verbs the DSL declares"
8206 );
8207 for (tag, effect, want) in &table {
8208 let got = subject(effect.clone());
8209 assert_eq!(
8210 got.as_deref(),
8211 *want,
8212 "`{tag}` resolves {want:?}; it names {:?}",
8213 beat(effect.clone()).subject_objects()
8214 );
8215 println!(
8216 "| `{tag}` | {} | {} |",
8217 want.unwrap_or("—"),
8218 beat(effect.clone()).subject_objects().join(", ")
8219 );
8220 }
8221 }
8222
8223 /// **Some verbs answer per instance, not per verb**, because the object is
8224 /// in an optional field: an `in` filter, a second stealth zone, a camera
8225 /// path of one waypoint. The rule is over the objects an effect names, so
8226 /// the same verb resolves in one document and not in another — and that is
8227 /// the answer, not a gap in it.
8228 #[test]
8229 fn a_verb_whose_object_is_optional_answers_per_instance() {
8230 for (what, effect, want) in [
8231 (
8232 "damage-players with an `in` filter",
8233 serde_json::json!({"type":"damage-players","amount":2,"in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
8234 Some("anchor/lane"),
8235 ),
8236 (
8237 "give-effect with an `in` filter",
8238 serde_json::json!({"type":"give-effect","effect":"minecraft:speed","seconds":10,"in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
8239 Some("anchor/lane"),
8240 ),
8241 (
8242 "clear-effect with an `in` filter",
8243 serde_json::json!({"type":"clear-effect","in":{"anchor":"anchor/lane","extent":[3,2,3]}}),
8244 Some("anchor/lane"),
8245 ),
8246 (
8247 "play-sound with no `at`",
8248 serde_json::json!({"type":"play-sound","sound":"minecraft:block.bell.use"}),
8249 None,
8250 ),
8251 (
8252 "begin-stealth over two zones",
8253 serde_json::json!({"type":"begin-stealth","zones":[
8254 {"anchor":"anchor/yard","extent":[4,2,4]},
8255 {"anchor":"anchor/lane","extent":[4,2,4]}]}),
8256 None,
8257 ),
8258 (
8259 "a cutscene whose path names one cell",
8260 serde_json::json!({"type":"cutscene","path":[{"anchor":"anchor/hall"}],"seconds":4}),
8261 Some("anchor/hall"),
8262 ),
8263 ] {
8264 assert_eq!(subject(effect.clone()).as_deref(), want, "{what}");
8265 }
8266 }
8267
8268 /// The four the spec illustrates, beside the ones the rule covers without
8269 /// naming them.
8270 #[test]
8271 fn an_effect_with_one_object_is_about_that_object() {
8272 for (effect, want) in [
8273 (
8274 serde_json::json!({ "type": "open-gate", "anchor": "anchor/door" }),
8275 "anchor/door",
8276 ),
8277 (
8278 serde_json::json!({ "type": "close-gate", "anchor": "anchor/door" }),
8279 "anchor/door",
8280 ),
8281 (
8282 serde_json::json!({ "type": "spawn-actor", "actor": "actor/giant" }),
8283 "actor/giant",
8284 ),
8285 (
8286 serde_json::json!({ "type": "despawn-actor", "actor": "actor/giant", "style": "vanish" }),
8287 "actor/giant",
8288 ),
8289 (
8290 serde_json::json!({ "type": "unleash-actor", "actor": "actor/giant" }),
8291 "actor/giant",
8292 ),
8293 (
8294 serde_json::json!({ "type": "spawn-wave", "wave": "wave/muster" }),
8295 "wave/muster",
8296 ),
8297 (
8298 serde_json::json!({ "type": "spawn-npc", "npc": "npc/keeper" }),
8299 "npc/keeper",
8300 ),
8301 (
8302 serde_json::json!({ "type": "despawn-npc", "npc": "npc/keeper" }),
8303 "npc/keeper",
8304 ),
8305 (
8306 serde_json::json!({ "type": "set-block", "anchor": "anchor/altar", "block": "minecraft:stone" }),
8307 "anchor/altar",
8308 ),
8309 ] {
8310 assert_eq!(subject(effect.clone()).as_deref(), Some(want), "{effect}");
8311 }
8312 }
8313
8314 /// Several objects, or none, resolve nothing: naming one of them would be
8315 /// the compiler guessing which the beat is about. A `move-actor` walks an
8316 /// actor to an anchor and names both.
8317 #[test]
8318 fn an_effect_with_no_single_object_resolves_nothing() {
8319 for effect in [
8320 serde_json::json!({ "type": "move-actor", "actor": "actor/giant", "to": { "anchor": "anchor/door" } }),
8321 serde_json::json!({ "type": "move-npc", "npc": "npc/keeper", "to": { "anchor": "anchor/door" } }),
8322 serde_json::json!({ "type": "teleport", "from": { "anchor": "anchor/hall", "extent": [2, 2, 2] }, "to": { "anchor": "anchor/door" } }),
8323 serde_json::json!({ "type": "narrate", "text": "The hall answers." }),
8324 serde_json::json!({ "type": "set-flag", "flag": "flag/heard" }),
8325 ] {
8326 assert_eq!(subject(effect.clone()), None, "{effect}");
8327 }
8328 }
8329
8330 /// A stated subject wins, and says so.
8331 #[test]
8332 fn a_stated_subject_is_not_derived() {
8333 let e: QuestEffect = serde_json::from_value(serde_json::json!({
8334 "type": "open-gate",
8335 "anchor": "anchor/door",
8336 "happening": { "verb": "opens", "text": "He lifts it.", "subject": "npc/keeper" }
8337 }))
8338 .unwrap();
8339 let s = e.happening_subject().expect("a stated subject");
8340 assert_eq!((s.id, s.derived), ("npc/keeper", false));
8341 }
8342
8343 /// No `happening`, no subject: the derivation is about a beat, not about an
8344 /// effect.
8345 #[test]
8346 fn an_effect_with_no_happening_has_no_subject() {
8347 let e: QuestEffect = serde_json::from_value(
8348 serde_json::json!({ "type": "open-gate", "anchor": "anchor/door" }),
8349 )
8350 .unwrap();
8351 assert!(e.happening_subject().is_none());
8352 assert_eq!(e.subject_objects(), vec!["anchor/door"]);
8353 }
8354}