Skip to main content

delvewright_dsl/quest/
verb.rs

1//! The verb of an effect: what one effect does when it fires.
2
3use std::collections::BTreeMap;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::serde_fields::is_zero3;
9use crate::{
10    ActorId, AnchorId, AssemblyId, AtmosphereId, CameraShot, Carrier, CutsceneParty, DamageKind,
11    DespawnStyle, EndingId, FireworkExplosion, FlagId, Mark, NpcId, PrefabId, QuestEffect,
12    SequenceStep, StakeId, StateId, StealthZone, WaveId, WorldTime, WorldWeather,
13};
14
15#[cfg(doc)]
16use crate::{KitItem, MAX_EFFECT_SECONDS, MAX_POTION_AMPLIFIER, Stake, enchantment_component};
17
18/// What an effect does, without the guard: the closed set of verbs.
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
20#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
21pub enum Verb {
22    /// Opens a prefab-declared gate (one-way).
23    OpenGate {
24        /// The gate anchor to open.
25        anchor: AnchorId,
26    },
27    /// Seals a prefab-declared gate — the physical dual of `open-gate` (DSL v0.6):
28    /// fills the gate anchor's region with the block the anchor declares (e.g. the
29    /// boulder's `minecraft:basalt`), turning an opened threshold back into a wall.
30    /// The declared fill block is prefab metadata; a gate anchor with no `block` is
31    /// rejected (`DW0343`). The completability model treats the region as **solid**
32    /// from the point in the quest DAG where this fires (mirroring how `open-gate`'s
33    /// clearing is modelled) — a critical path that must cross a gate after it seals
34    /// fails the DW0311 reachability proof.
35    CloseGate {
36        /// The gate anchor to seal.
37        anchor: AnchorId,
38        /// 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
39        /// and press: the compiler answers that press on the actionbar. Absent, the
40        /// compiler's canonical English is baked in (`The way is sealed.`) exactly
41        /// as `world.boundary.message` does; authored, the line is l10n-inventoried
42        /// under `<effect-key>.sealed_hint` and translates like every other
43        /// player-visible string.
44        ///
45        /// Unlike `happening`, this **does** print in the hand-written `Debug`
46        /// below when present — it changes emission, so two otherwise-identical
47        /// sequences that differ only in their seal's answer are different content.
48        #[serde(default, skip_serializing_if = "Option::is_none")]
49        sealed_hint: Option<String>,
50    },
51    /// Marks the campaign complete (final advancement + credits). Terminal — not
52    /// flag-gatable (gating the campaign's own completion is a deadlock footgun),
53    /// so this variant carries no `requires_flags`.
54    CampaignComplete {
55        /// Which ENDING this is (DSL v0.8, spec-0025).
56        /// A campaign with more than one `campaign-complete` has more than one
57        /// ending, and a branch that runs to an ending names it here — so the
58        /// terminality proof (`DW0482`) can state *which* ending a branch reached
59        /// instead of merely that something ended. There is no separate `endings`
60        /// section: the set of endings is exactly the set named here, the same
61        /// rule flags follow.
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        ending: Option<EndingId>,
64    },
65    /// Gives the party an item (v0.3; party-wide since v0.6/spec-0018).
66    GiveItem {
67        /// Vanilla item id to give (validated against the registry).
68        item: String,
69        /// How many to give.
70        count: u32,
71        /// Optional display name (DSL v0.4), matching [`KitItem::name`].
72        #[serde(default, skip_serializing_if = "Option::is_none")]
73        name: Option<String>,
74        /// Who receives it (DSL v0.6, spec-0018). Absent = [`Carrier::All`] — every
75        /// party member. `one` hands a single copy to the player whose action fired
76        /// the effect; it is rejected in a scheduler-only bundle (`DW0371`), which
77        /// has no acting player.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        carrier: Option<Carrier>,
80        /// Enchantments on the given stack (`{"minecraft:sharpness": 2}`) —
81        /// the field a `loot` stack and an equipped piece carry, under the same
82        /// checks (`DW0433`/`DW0434`) and written by the same rule
83        /// ([`enchantment_component`]): on a `minecraft:enchanted_book` they are
84        /// the book's stored enchantments, the ones an anvil applies.
85        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
86        enchantments: BTreeMap<String, u32>,
87    },
88    /// Sets a campaign flag, enabling flag-gated objectives (v0.3).
89    SetFlag {
90        /// The flag to set.
91        flag: FlagId,
92    },
93    /// Writes a declared datum to an absolute value (DSL v0.10, spec-0031).
94    SetState {
95        /// The datum to write (stage-5 `state` ref).
96        state: StateId,
97        /// The value to write.
98        value: i32,
99    },
100    /// Moves a declared datum by a signed amount (DSL v0.10, spec-0031).
101    ///
102    /// **Signed on purpose.** A purse that a shop debits and a stake that a death
103    /// forfeits are the same operation with the sign flipped; a separate
104    /// `subtract-state` would be a second verb for one mechanism, and the first
105    /// campaign to need "add a negative" would have to choose between them.
106    AddState {
107        /// The datum to move (stage-5 `state` ref).
108        state: StateId,
109        /// How far to move it. Negative counts down.
110        amount: i32,
111    },
112    /// Leaves a declared [`Stake`] behind for the acting player (DSL v0.10,
113    /// spec-0032): forfeit the declared share of its datum, and place a
114    /// collectable marker at the compile-time anchor for where they are.
115    ///
116    /// **Nothing about this verb says "death".** It is written in `on_death`
117    /// because that is where a souls-shaped delve wants it, but the mechanism is
118    /// "leave a recoverable cache where the acting player stands", and the effect
119    /// carries the ordinary gate so any root may run it. What *is* death-specific
120    /// — that the corpse stands on the death position, so the placement lookup has
121    /// a position to key on — is a property of the `on_death` root, not of this
122    /// verb.
123    DropStake {
124        /// The stake (stage-5 `stakes` ref) to leave.
125        stake: StakeId,
126    },
127    /// Returns a declared datum to its declared `initial` (DSL v0.10,
128    /// spec-0031) — the verb `FlagId` has never had.
129    ClearState {
130        /// The datum to clear (stage-5 `state` ref).
131        state: StateId,
132    },
133    /// Spawns a stage-5 wave's mobs at its anchor (v0.3).
134    SpawnWave {
135        /// The wave (stage-5 `waves` ref) to spawn.
136        wave: WaveId,
137    },
138    /// Narrates a player-visible line (DSL v0.4, spec-0008 §3). `text` enters the
139    /// l10n key inventory like any player-visible string.
140    Narrate {
141        /// The line shown to the player.
142        text: String,
143        /// Presentation channel (default `chat`).
144        #[serde(default, skip_serializing_if = "Option::is_none")]
145        style: Option<NarrateStyle>,
146        /// Optional sound id played alongside the line.
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        sound: Option<String>,
149    },
150    /// Sets a block at an anchor (DSL v0.4, spec-0008 §2). General form of a prop
151    /// placement. Block id validated against the pinned 1.21.11 block registry;
152    /// a vanilla blockstate suffix (`minecraft:grindstone[face=floor]`) is
153    /// accepted and passed through verbatim (DSL v0.6).
154    SetBlock {
155        /// The anchor to place the block at.
156        anchor: AnchorId,
157        /// Vanilla block id to place.
158        block: String,
159    },
160    /// **Fill a declared region with a block** at runtime (DSL v0.10, spec-0031).
161    ///
162    /// The general spelling of the capability `open-gate` / `close-gate` carried
163    /// privately: a region, filled or cleared, from a point in the quest DAG.
164    /// `close-gate` is this verb with the region and the block read off a prefab
165    /// gate anchor instead of authored; `set-block` is the one-cell case at a
166    /// point anchor. All three lower through one emission
167    /// (`emit::fill_region_command`) and are modelled by one completability rule
168    /// (`plan::RegionEvent`), so a third consumer inherits the proof instead of
169    /// re-deriving it.
170    ///
171    /// From the DAG point at which this fires, the completability model treats the
172    /// filled cells as whatever the **block** makes them. A full-cube block leaves
173    /// them **solid**, exactly as a `close-gate` seal is: a critical path that must
174    /// cross the region afterwards fails `DW0311`. `minecraft:water` /
175    /// `minecraft:lava` leave them **flooded** — impassable and never floor, because
176    /// nothing stands on a fluid — and because a fill carries no `replace` filter it
177    /// takes away whatever floor was in the box, so a forced leg that needed that
178    /// footing fails `DW0544`.
179    FillRegion {
180        /// The volume to fill, as an anchor-centred box (`anchor ± extent`).
181        ///
182        /// Deliberately the existing [`StealthZone`] — the engine's one
183        /// anchor-centred box object class, already shared by `damage-players`'s
184        /// `in` filter, `collapse`'s `region_anchor`, a `volley` kill zone and a
185        /// `lethal_volumes[]` region, and resolved through the single
186        /// `Plan::zone_box`. A private twin with the same two fields would be
187        /// `tools/ci/check-capability-ownership.py` check C by construction.
188        ///
189        /// An anchor-centred box rather than a prefab `region` anchor for the
190        /// reason `collapse` states: the assembled model deletes every gate-region
191        /// anchor's cells, so a slab declared that way would already be gone.
192        region: StealthZone,
193        /// The block the region is filled with (validated against the pinned
194        /// 1.21.11 block registry, `DW0193`).
195        block: String,
196    },
197    /// **Clear a declared region to air** at runtime (DSL v0.10, spec-0031) — the
198    /// physical dual of [`Verb::FillRegion`], and the general spelling of
199    /// what `open-gate` does to a gate anchor's region.
200    ///
201    /// The completability model treats the cleared cells as **passable** from the
202    /// DAG point at which this fires, with one exception it states out loud: a
203    /// cleared cell the model already floods stays impassable, because clearing a
204    /// block does not remove water (`nav::World::with_cleared`).
205    ClearRegion {
206        /// The volume to clear, as an anchor-centred box (`anchor ± extent`) —
207        /// the same object class [`Verb::FillRegion`] fills.
208        region: StealthZone,
209    },
210    /// **Repaint a volume's sky** (spec-0080 §3.3): `/fillbiome` over the
211    /// volume with the named atmosphere's biome, while the party stands in it.
212    ///
213    /// A runtime edit of the world keyed to a volume, so it is a verb of the
214    /// physical-edit family [`Verb::FillRegion`] / [`Verb::ClearRegion`] form.
215    /// Exactly one of `region` / `place` (`DW0929`): a volume inside a place is
216    /// the creator's judgement, a whole place's bounds are a derivation the
217    /// creator never types. Painting back is this verb naming the place's own
218    /// atmosphere, or `atmosphere: null` for the horizon's biome.
219    ///
220    /// A hard cut: the client blends fog over its biome-blend radius and grass
221    /// not at all, and biome cells are 4×4×4, so the painted volume is the
222    /// enclosing 4-aligned box, up to three blocks past each face.
223    SetAtmosphere {
224        /// One of `world.atmospheres[]`, or `null` for the horizon's biome.
225        #[serde(default)]
226        atmosphere: Option<AtmosphereId>,
227        /// The volume, as an anchor-centred box (`anchor ± extent`) — the same
228        /// object class [`Verb::FillRegion`] fills, resolved through the same
229        /// `Plan::zone_box`.
230        #[serde(default, skip_serializing_if = "Option::is_none")]
231        region: Option<StealthZone>,
232        /// A whole place: an `area/…` id, or a site-plan box's `node/…`. Its
233        /// volume is the cells the place's own `atmosphere` paints at setup:
234        /// its bounds, grown as far as the client's biome blend reads.
235        #[serde(default, skip_serializing_if = "Option::is_none")]
236        place: Option<String>,
237    },
238    /// **Opens a placed piece's contingent way** (DSL v0.12, spec-0042 §2.4): the
239    /// broken flight a beat repairs, the bridge a beat lowers, the rubble a beat
240    /// clears.
241    ///
242    /// A piece's spatial contract may declare a traversal edge whose crossability
243    /// depends on a named region — `laid` (empty as built, opening fills it) or
244    /// `cleared` (built solid, opening voids it). The prefab checker proves the
245    /// piece is severed as shipped and joined once that region is opened; this is
246    /// the verb that opens it, and it is the only one, because a way is the object
247    /// and opening it is the operation.
248    ///
249    /// **There is no region, no block and no sign on this effect, and that is the
250    /// design rather than an omission.** All three are read from the piece's own
251    /// exported metadata (`spatial_contract.edges[].way`), so the effect and the
252    /// building cannot disagree about what a way is — two authorities plus an
253    /// equality check is the defect this shape avoids, not a variant of the fix
254    /// (spec-0042 AC8). What the campaign decides is *when*.
255    ///
256    /// Completability: the way is **shut until this fires**, and from the DAG
257    /// point at which it fires the region is solid-and-footing (`laid`) or
258    /// passable (`cleared`) — the same [`Verb::FillRegion`] /
259    /// [`Verb::ClearRegion`] model, fed from metadata instead of from an
260    /// authored box, so this verb inherits the forced-footing rule (`DW0546`)
261    /// rather than restating it. Required content standing beyond a way that no
262    /// forced opening precedes is `DW0548`, which names the way, the effect and
263    /// the element.
264    OpenWay {
265        /// The placed piece whose way this opens (`prefab/<name>`).
266        ///
267        /// A piece, not an anchor: the way's cells are the contract's, not a gate
268        /// anchor's, and a piece placed twice has two ways. The reference must
269        /// name exactly one placement; naming none or several is `DW0547`.
270        piece: PrefabId,
271        /// The way's region name, as the piece's contract exports it
272        /// (`spatial_contract.edges[].way.region`).
273        way: String,
274    },
275    /// Despawns an NPC and its interaction hitbox (DSL v0.4, spec-0008 §5). The
276    /// NPC leaves unseen: no death animation, red flash or death particles.
277    DespawnNpc {
278        /// The NPC (stage-2 ref) to remove.
279        npc: NpcId,
280    },
281    /// Moves an NPC (and its interaction hitbox in lockstep) to an anchor (DSL
282    /// v0.4, spec-0008 §5 + addendum). The compiler plans a **collision-safe walked
283    /// path** by A* over the solved voxel grid and emits per-tick teleport
284    /// waypoints along it, so the NPC never clips a wall and walks up to (not into)
285    /// a solid affordance. An unroutable move is a compile error (`DW0307`).
286    MoveNpc {
287        /// The NPC (stage-2 ref) to move.
288        npc: NpcId,
289        /// The destination mark: an anchor and an optional offset (spec-0066).
290        to: Mark,
291        /// Optional travel speed in blocks/tick (defaults to ~0.15).
292        #[serde(default, skip_serializing_if = "Option::is_none")]
293        speed: Option<f64>,
294        /// Effects fired once the NPC arrives at the destination cell — exact
295        /// parity with [`Verb::MoveActor`]
296        /// `on_arrive`: same arrival detection (the walk driver's final tick), same
297        /// execution context, and every deep effect walker recurses into it via
298        /// [`QuestEffect::nested_effect_lists`]. This is what lets content gate a
299        /// beat on walk *completion* instead of fire-and-forgetting the walk (e.g.
300        /// `on_arrive: [set-flag]` so a cutscene waits for the NPC to reach its
301        /// mark).
302        #[serde(default, skip_serializing_if = "Vec::is_empty")]
303        on_arrive: Vec<QuestEffect>,
304    },
305    /// Plays a scripted camera cutscene (DSL v0.4 addendum). Per player: save
306    /// gamemode+position, spectator, then dolly two co-located cameras along a
307    /// straight-line lerp between waypoints and alternate `spectate` between them
308    /// each tick (the two-camera bounce; the same-entity re-`spectate` is a server
309    /// no-op and is never emitted), and restore on completion. The compiler
310    /// validates the dolly path passes only through non-solid blocks — cameras
311    /// fly but must not clip a solid (`DW0308`).
312    ///
313    /// Camera **aim** (DSL v0.6): with `look_at`, every dolly camera is rotated at
314    /// emission to face that world point from its own position, so the shot keeps
315    /// its subject framed for the whole move; without it, the camera faces along
316    /// the direction of travel (the segment it is currently traversing).
317    ///
318    /// **Shape** — a cutscene is a list of [`CameraShot`]s played back-to-back
319    /// inside one save/restore bracket (hard cut between shots). Two accepted,
320    /// mutually exclusive spellings, both normalized by
321    /// [`QuestEffect::cutscene_shots`]:
322    /// - multi-shot (DSL v0.6): `{"shots": [{path, seconds, look_at?}, …]}`;
323    /// - single-shot (DSL v0.4): `{"path": […], "seconds": n, "look_at"?: …}` —
324    ///   exactly equivalent to a one-entry `shots` list.
325    ///
326    /// Mixing or omitting both is `DW0199`.
327    Cutscene {
328        /// Multi-shot form (DSL v0.6): the ordered shot list. Mutually exclusive
329        /// with the single-shot `path`/`seconds` fields (`DW0199`).
330        #[serde(default, skip_serializing_if = "Vec::is_empty")]
331        shots: Vec<CameraShot>,
332        /// Single-shot form (DSL v0.4): ordered camera waypoints (straight-line
333        /// lerp between them).
334        #[serde(default, skip_serializing_if = "Vec::is_empty")]
335        path: Vec<Mark>,
336        /// Single-shot form (DSL v0.4): shot duration in seconds.
337        #[serde(default, skip_serializing_if = "Option::is_none")]
338        seconds: Option<u32>,
339        /// Single-shot form (DSL v0.6): the subject the camera keeps framed.
340        /// Absent = face along the direction of travel.
341        #[serde(default, skip_serializing_if = "Option::is_none")]
342        look_at: Option<Mark>,
343        /// Whether the party's bodies stay in the scene while the camera flies
344        /// (spec-0095). Absent = `present`: every player in play is shown by a
345        /// stand-in wearing their own skin and equipment, standing where they
346        /// stood, for the cutscene's whole length. `absent` takes the bodies out
347        /// of the scene: a vision, a memory, a scene somewhere else.
348        #[serde(default, skip_serializing_if = "Option::is_none")]
349        party: Option<CutsceneParty>,
350    },
351    /// Cuts the dimension-global world time to a new state (DSL v0.5, spec-0010).
352    /// Instantaneous (vanilla has no gradual transition); the state persists
353    /// because the daylight cycle is frozen by sealing.
354    SetTime {
355        /// The time state to cut to.
356        time: WorldTime,
357    },
358    /// Cuts the dimension-global weather to a new state (DSL v0.5, spec-0010).
359    /// Instantaneous; persists because the weather cycle is frozen by sealing.
360    SetWeather {
361        /// The weather state to cut to.
362        weather: WorldWeather,
363    },
364    /// Plays a vanilla sound event, positionally or per-player (DSL v0.6,
365    /// spec-0014). `sound` is validated against the vendored pinned-1.21.11
366    /// sound-event registry (`DW0326` unknown). `at` selects where the sound
367    /// originates (default: each player's own position); `volume`/`pitch` map to
368    /// the `playsound` command's trailing args (pitch clamps to 0.0..=2.0 in
369    /// vanilla).
370    PlaySound {
371        /// The vanilla sound-event id (`minecraft:` prefix optional).
372        sound: String,
373        /// Where the sound plays from (default: `players`).
374        #[serde(default, skip_serializing_if = "Option::is_none")]
375        at: Option<SoundAt>,
376        /// Playback volume (vanilla default 1.0; > 1.0 only extends audible range).
377        #[serde(default, skip_serializing_if = "Option::is_none")]
378        volume: Option<f64>,
379        /// Playback pitch (vanilla 0.0..=2.0; default 1.0).
380        #[serde(default, skip_serializing_if = "Option::is_none")]
381        pitch: Option<f64>,
382    },
383    /// Deals damage to the acting player(s) (DSL v0.6): the real consequence a
384    /// stealth `on_caught` or a souls-style beat needs — vanilla's `/damage`
385    /// primitive. Runs in the effect's `as @a` / `as @s` context, so `@s` is each
386    /// acting player: at top level it damages every player once; inside a stealth
387    /// `on_caught` it damages the caught player (the "caught → death → respawn at
388    /// checkpoint" beat). `amount` is in **half-hearts** (1 HP each); an amount ≥ 40
389    /// is lethal through golden apples / absorption. The envelope's `in`
390    /// ([`QuestEffect::within`]) narrows to acting players standing inside an
391    /// anchor-centred box, keeping the per-`@s` semantics. `damage_type` is the damage
392    /// type — a curated set of vanilla types that all respect `keepInventory` and do
393    /// **not** bypass totems (no `out_of_world`/`generic_kill`); default `generic`.
394    /// (The field is `damage_type`, not `type`, because the effect enum is
395    /// internally tagged on `type`.)
396    DamagePlayers {
397        /// Damage dealt, in half-hearts (1 = 1 HP; ≥ 40 is effectively lethal).
398        amount: u32,
399        /// The damage type (default [`DamageKind::Generic`]).
400        #[serde(default, skip_serializing_if = "Option::is_none")]
401        damage_type: Option<DamageKind>,
402    },
403    /// Sets the party-wide respawn checkpoint (DSL v0.6, spec-0012). Emits
404    /// `spawnpoint @a` at the anchor cell and mirrors the coords into
405    /// `storage dw:cp pos`. Party-wide and monotonic by quest order (a later
406    /// `set-checkpoint` always replaces an earlier one). The compiler proves the
407    /// cell is standable (`DW0316`) and that the remaining critical path stays
408    /// reachable from it (`DW0315`).
409    SetCheckpoint {
410        /// The prefab checkpoint anchor the party respawns at.
411        anchor: AnchorId,
412        /// Per-player effects re-run each time a player respawns while this
413        /// checkpoint is the active one — scene reset (e.g. re-caging an
414        /// unleashed actor). Emitted idempotently in declared order; empty = no
415        /// hook. Respawn is detected via the vanilla `deathCount` criterion.
416        #[serde(default, skip_serializing_if = "Vec::is_empty")]
417        on_respawn: Vec<QuestEffect>,
418    },
419    /// Places a **bonfire** rest point (DSL v0.6, spec-0016 §1) — the sibling of
420    /// [`Verb::SetCheckpoint`] for souls-mode pacing. The effect *arms*
421    /// the rest affordance (a `minecraft:interaction` the player right-clicks at
422    /// the anchor, the campfire prop being prefab dressing); the checkpoint moves
423    /// only **when the party actually rests**. Resting fires `on_rest` — the
424    /// scene reset that makes retry cheap: re-arming traps, re-seating waves
425    /// declared `respawns_on_rest`, restoring actor postures. Death respawns the
426    /// party at the last-rested bonfire and runs the **same** `on_rest` bundle,
427    /// so the world's answer to a death and to a rest is identical (spec-0016:
428    /// death is an investment, never a tax).
429    ///
430    /// Proofs are inherited from the checkpoint machinery: the anchor must be
431    /// standable (`DW0316`) and must not strand the party (`DW0315`), rooted at
432    /// the beat that arms the bonfire (the earliest moment a rest can happen).
433    Bonfire {
434        /// The prefab anchor the rest affordance stands at, and the cell the
435        /// party respawns at once rested.
436        anchor: AnchorId,
437        /// Effects re-run on every rest **and** on every respawn at this bonfire
438        /// — the scene reset. Emitted in declared order and expected to be
439        /// idempotent (the same contract as `set-checkpoint`'s `on_respawn`).
440        #[serde(default, skip_serializing_if = "Vec::is_empty")]
441        on_rest: Vec<QuestEffect>,
442        /// Title of the two-option rest dialog (DSL v0.8).
443        /// Absent = the compiler's canonical English `Bonfire`,
444        /// baked at emit time (the `world.boundary.message` precedent): an
445        /// authored line is inventoried and translates like every other
446        /// player-visible string.
447        #[serde(default, skip_serializing_if = "Option::is_none")]
448        prompt: Option<String>,
449        /// Label of the **rest and save** button. Absent = `Rest and save`.
450        /// A dialog button is a fixed-width caption, so keep it to ~20 Latin /
451        /// ~12 Han characters (skill *Writing craft* §C) — a wider label scrolls.
452        #[serde(default, skip_serializing_if = "Option::is_none")]
453        rest_label: Option<String>,
454        /// Label of the **save only** button. Absent = `Save only`.
455        #[serde(default, skip_serializing_if = "Option::is_none")]
456        save_label: Option<String>,
457        /// Hover tooltip of the **rest and save** button (spec-0078) — the same
458        /// optional `tooltip` every dialog button carries, beside the label it
459        /// explains. Absent = no tooltip. Not subject to `DW0331`: a tooltip
460        /// wraps in its own hover box. Inventoried as `fx.….rest_tooltip`.
461        #[serde(default, skip_serializing_if = "Option::is_none")]
462        rest_tooltip: Option<String>,
463        /// Hover tooltip of the **save only** button (spec-0078). Absent = no
464        /// tooltip. Inventoried as `fx.….save_tooltip`.
465        #[serde(default, skip_serializing_if = "Option::is_none")]
466        save_tooltip: Option<String>,
467    },
468    /// Begins a stealth beat (DSL v0.6, spec-0014):
469    /// zone presence alone = hidden — no sneak requirement, which collides with
470    /// the spectator cutscene camera. While active, every player must be
471    /// inside some `zone` each tick; a player outside every zone for
472    /// `grace_ticks` fires `on_caught` (typically a kill → checkpoint respawn).
473    /// Zone membership is read from the player's position. The compiler proves
474    /// each zone is standable and reachable from the activating beat (`DW0327`).
475    BeginStealth {
476        /// The "shadow" regions, each an anchor-centred box (see [`StealthZone`]).
477        zones: Vec<StealthZone>,
478        /// Per-player effects fired when a player is caught (out of every zone
479        /// for `grace_ticks`). Empty = no consequence.
480        #[serde(default, skip_serializing_if = "Vec::is_empty")]
481        on_caught: Vec<QuestEffect>,
482        /// Ticks a player may be exposed before `on_caught` fires (default 20).
483        #[serde(default = "default_grace_ticks")]
484        grace_ticks: u32,
485    },
486    /// Ends the active stealth beat (DSL v0.6, spec-0014). No-op if none active.
487    EndStealth,
488    /// Summons a `deferred` stage-2 NPC (body + interaction hitbox + name display)
489    /// at its declared anchor (DSL v0.6) — the dual of `despawn-npc`, and the
490    /// scripted entrance a staged character needs. Idempotent: spawning an NPC
491    /// already in the world is a no-op. Only meaningful for an NPC declared
492    /// `deferred: true`; a non-deferred NPC is already in the world from init.
493    SpawnNpc {
494        /// The NPC (stage-2 ref) to summon.
495        npc: NpcId,
496    },
497    // --- DSL v0.6 actor staging effects (spec-0014) ---
498    /// Summons a stage-5 actor's puppet at its anchor (DSL v0.6). Idempotent: a
499    /// spawn of an already-present actor is a no-op (re-caging after `unleash`).
500    SpawnActor {
501        /// The actor (stage-5 `actors` ref) to summon.
502        actor: ActorId,
503    },
504    /// Removes an actor's puppet (DSL v0.6). `kill` plays the vanilla death
505    /// animation (cutscene deaths); `vanish` is silent removal.
506    DespawnActor {
507        /// The actor (stage-5 `actors` ref) to remove.
508        actor: ActorId,
509        /// How the puppet is removed.
510        style: DespawnStyle,
511    },
512    /// Walks an actor's puppet to an anchor by A*-planned per-tick teleport over
513    /// the assembled model, using the actor's hitbox footprint, yawed along the
514    /// path tangent (DSL v0.6). Concurrent movers are allowed (a herded
515    /// flock is N synchronized `move-actor`s). Unroutable → `DW0325`. `on_arrive`
516    /// effects fire once the puppet reaches the destination cell.
517    MoveActor {
518        /// The actor (stage-5 `actors` ref) to move.
519        actor: ActorId,
520        /// The destination mark: an anchor and an optional offset (spec-0066).
521        to: Mark,
522        /// Optional travel speed in blocks/tick (defaults to ~0.15).
523        #[serde(default, skip_serializing_if = "Option::is_none")]
524        speed: Option<f64>,
525        /// Effects fired once the puppet arrives at the destination cell.
526        #[serde(default, skip_serializing_if = "Vec::is_empty")]
527        on_arrive: Vec<QuestEffect>,
528    },
529    /// Replaces an actor's puppet with a real-AI twin of the same type / position /
530    /// name / attributes / tag (DSL v0.6) — the "attack the idle giant → real
531    /// fight" beat. Re-caging is `despawn-actor` + `spawn-actor` (idempotent), not a
532    /// special verb.
533    UnleashActor {
534        /// The actor (stage-5 `actors` ref) to unleash.
535        actor: ActorId,
536    },
537    // --- spec-0082 assembly verbs ---
538    /// Summons an assembly (spec-0082): its root, one display per rig part
539    /// riding the root, and its hitbox when declared, at its mark, playing its
540    /// `initial` clip. Idempotent: a spawn of a present assembly is a no-op.
541    SpawnAssembly {
542        /// The assembly (stage-5 `assemblies` ref) to summon.
543        assembly: AssemblyId,
544    },
545    /// Removes an assembly's root, parts and hitbox (spec-0082). They leave
546    /// unseen: a display entity has no death, so there is no `style`.
547    DespawnAssembly {
548        /// The assembly (stage-5 `assemblies` ref) to remove.
549        assembly: AssemblyId,
550    },
551    /// Switches the clip an assembly plays (spec-0082); its first frame is
552    /// applied on the next tick. While a strike step is in flight the switch
553    /// waits for the step to end, so a story beat never cuts a strike at the
554    /// frame before it lands.
555    PlayClip {
556        /// The assembly (stage-5 `assemblies` ref).
557        assembly: AssemblyId,
558        /// A clip the assembly's rig declares.
559        clip: String,
560    },
561    /// Re-arms an assembly's strike pattern (spec-0094 §3.3). A `play-clip`
562    /// stands the pattern down: the clip it plays completes and holds (or
563    /// loops) whoever stands in `while_in`, and no wind-up begins again until
564    /// this verb runs. The pattern resumes from its first step on the next tick
565    /// some player is in its arming region. Naming an assembly that declares
566    /// no `strikes` is `DW0970`.
567    ArmStrikes {
568        /// The assembly (stage-5 `assemblies` ref) whose pattern is re-armed.
569        assembly: AssemblyId,
570    },
571    /// A deterministic timeline (DSL v0.6): one schedule chain firing effect groups
572    /// at exact tick offsets. Effects are any in the stage-5 set except a nested
573    /// `sequence` (rejected with `DW0329`).
574    Sequence {
575        /// Timeline steps; each fires its `effects` at `at_ticks` from the start.
576        steps: Vec<SequenceStep>,
577    },
578    /// **Saturating projectile volley** (DSL v0.6, spec-0022): command-summoned
579    /// projectiles with real velocity vectors, fired from a gallery slot into a
580    /// declared kill zone.
581    ///
582    /// The contract is **saturation, not sniping**.
583    /// Every salvo puts one projectile on the trajectory to **every standable
584    /// cell of `kill_zone`**, plus one aimed at the triggering player's
585    /// fire-time position (which punishes standing still). A player therefore
586    /// cannot dodge a volley by strafing — escaping means *leaving the zone*, a
587    /// decision rather than a lucky step. Coverage is proven at compile time:
588    /// `from_anchor` must have clear line of fire to every standable kill-zone
589    /// cell (`DW0442`), so "the gallery slot can actually hit a player anywhere
590    /// on the stairs" is a build-time fact, not a hope.
591    ///
592    /// Projectiles are summoned `NoGravity` so the flown path is exactly the
593    /// straight segment the coverage proof checks — proof and runtime share one
594    /// geometry. Drag scales speed but not direction, so the line is preserved.
595    Volley {
596        /// Projectile entity id (default `minecraft:arrow`; validated against
597        /// the pinned 1.21.11 registry, `DW0441`).
598        #[serde(default, skip_serializing_if = "Option::is_none")]
599        projectile: Option<String>,
600        /// The gallery slot the volley is fired FROM — a point anchor. Its cell
601        /// must be clear (a projectile spawned inside a wall never leaves it).
602        from_anchor: AnchorId,
603        /// The zone the volley must blanket, as an anchor-centred box
604        /// (`anchor ± extent`) — the same shape `damage-players`'s `in` and
605        /// `begin-stealth`'s `zones` use. Every standable cell in it receives
606        /// fire each salvo.
607        ///
608        /// Deliberately NOT a bare prefab `region` anchor: the assembled model
609        /// clears every gate-region anchor's cells unconditionally, so
610        /// describing a zone that way would delete the geometry it names.
611        kill_zone: StealthZone,
612        /// How many rounds the pattern repeats (default 3, `DW0443` bounds it).
613        #[serde(default, skip_serializing_if = "Option::is_none")]
614        salvos: Option<u32>,
615        /// Ticks between salvos (default 10, `DW0443` bounds it).
616        #[serde(default, skip_serializing_if = "Option::is_none")]
617        interval: Option<u32>,
618    },
619    /// **Ceiling collapse** (DSL v0.6, spec-0022): delete a region's blocks and
620    /// drop them as `falling_block` entities — the buried-alive trap redstone
621    /// cannot express at all.
622    ///
623    /// The post-collapse world is **modeled, not guessed**: the compiler clears
624    /// the region, settles every dropped column through the existing gravity
625    /// model (spec-0010), optionally paves the landing surface with
626    /// `then_floor`, and re-runs the critical-path completability proof against
627    /// that mutated world (`DW0445`). A collapse that buries the only route is a
628    /// build error, exactly as a `shortcut` seal that strands the party is.
629    Collapse {
630        /// The volume whose blocks fall, as an anchor-centred box
631        /// (`anchor ± extent`). Must hold blocks and sit above standable footing
632        /// (`DW0444`) — a collapse over nothing is scenery, not a trap.
633        ///
634        /// An anchor-centred box rather than a prefab `region` anchor for a
635        /// load-bearing reason: the assembled model deletes every gate-region
636        /// anchor's cells, so a ceiling slab declared that way would already be
637        /// gone before the trap ever fired.
638        region_anchor: StealthZone,
639        /// The block the falling entities are made of (default
640        /// `minecraft:gravel`; validated against the pinned registry, `DW0441`).
641        #[serde(default, skip_serializing_if = "Option::is_none")]
642        falling_block: Option<String>,
643        /// Optional block the landing surface is paved with once the debris
644        /// settles — the authored post-collapse floor the completability proof
645        /// reasons over.
646        #[serde(default, skip_serializing_if = "Option::is_none")]
647        then_floor: Option<String>,
648    },
649    /// Grants a vanilla **status effect** for a stated duration (DSL v0.10,
650    /// spec-0031). The engine has emitted status effects since v0.6 — the
651    /// night-vision area mitigation is a self-rescheduling, region-scoped
652    /// `effect give` — and exposed none, so an author who wanted blindness for a
653    /// lift ride, slowness in deep water or regeneration at a shrine had no
654    /// surface at all. This is that surface, over the whole pinned 1.21.11
655    /// `mob_effect` registry (`DW0192` rejects an id outside it).
656    ///
657    /// **A grant carries a duration, and there is no way to spell one that does
658    /// not.** Vanilla's `infinite` keyword is deliberately absent from this
659    /// surface: an effect whose only removal is a later command is an effect the
660    /// player keeps forever whenever that command does not run — a logout, a
661    /// crash, a chain interrupted by a death. A duration expires on its own, with
662    /// no cooperation from anything. `seconds` is therefore required and bounded
663    /// (1..=[`MAX_EFFECT_SECONDS`], `DW0541`), and the *pattern* that reintroduces
664    /// the same hazard — pairing a grant with a `clear-effect` that removes it
665    /// while it is still live — is `DW0540`.
666    ///
667    /// The envelope's `in` ([`QuestEffect::within`]) narrows to players inside an
668    /// anchor-centred box. It is what makes "blind whoever is riding the car"
669    /// expressible without blinding the whole party. A blinding grant owes the
670    /// blind-reach proof wherever it is written (`DW0943`, spec-0085 §6).
671    GiveEffect {
672        /// Vanilla status-effect id (e.g. `minecraft:blindness`), validated
673        /// against the pinned 1.21.11 registry (`DW0192`).
674        effect: String,
675        /// Duration in seconds. Required, `1..=`[`MAX_EFFECT_SECONDS`]
676        /// (`DW0541`) — see the variant docs for why there is no infinite form.
677        seconds: u32,
678        /// Amplifier (0 = level I), `0..=`[`MAX_POTION_AMPLIFIER`] (`DW0541`).
679        /// Absent = 0.
680        #[serde(default, skip_serializing_if = "Option::is_none")]
681        amplifier: Option<u32>,
682        /// Suppress the swirling particles (vanilla's `hideParticles`). Absent =
683        /// `false`, vanilla's own default.
684        #[serde(default, skip_serializing_if = "Option::is_none")]
685        hide_particles: Option<bool>,
686    },
687    /// Removes a status effect (DSL v0.10, spec-0031) — vanilla's `effect clear`.
688    ///
689    /// This is **not** how a `give-effect` is supposed to end: a duration is. It
690    /// exists for the effects the engine did not grant — a potion the player
691    /// drank, a `wither` a mob applied, the whole set at a bonfire — which is why
692    /// `effect` is optional (absent = clear everything, exactly as vanilla's
693    /// `effect clear <targets>` does). Pairing it with a live grant of the same
694    /// effect in the same bundle is `DW0540`.
695    ClearEffect {
696        /// The status-effect id to remove (`DW0192`). **Absent clears every
697        /// effect**, matching `effect clear <targets>` with no id.
698        #[serde(default, skip_serializing_if = "Option::is_none")]
699        effect: Option<String>,
700    },
701    /// Teleports **everything inside a declared volume** to an anchor (DSL v0.10,
702    /// spec-0031).
703    ///
704    /// **The selector is a region, never a block.** "Whoever is standing on this
705    /// block" has three different answers for a player half a foot over the edge,
706    /// a player mid-jump and a player sneaking on the lip; a volume has one, and
707    /// it is the same one every tick. `from` is the anchor-centred
708    /// [`StealthZone`] box the rest of the engine already uses.
709    ///
710    /// **The selection is total over bodies.** Emission is a single `tp
711    /// @e[<box>,tag=!dw_fixture] <cell>` with no `type=`, no `limit=` and no
712    /// `sort=` — every body in the volume moves, and
713    /// `crates/delvec/tests/v10_teleport.rs` asserts that from the emitted
714    /// selector rather than from anyone's memory. A machinery-**type** exemption
715    /// of the kind a `lethal_volumes[]` entry must carry was considered and
716    /// **rejected**: an NPC is a body plus a co-located `minecraft:interaction`
717    /// hitbox, so exempting `minecraft:interaction` — as the lethal volume does —
718    /// would teleport the speaker and leave its dialogue box behind. Everyone on
719    /// the car travels, players and entities
720    /// alike; totality over bodies is how that is true.
721    ///
722    /// The one narrowing is a **class the engine's own furniture declares about
723    /// itself**: `dw_fixture` means *my position IS engine state*. A bonfire's
724    /// hitbox, a shortcut lever and a recovery stake's marker are places, not
725    /// passengers, and carrying one does not move a thing — it rewrites a fact
726    /// (for a stake, the ledger holds the marker's coordinates, and the next tick
727    /// retires a marker nobody has a wager at). Places whose cell is known at
728    /// compile time are refused outright instead, because the author can move
729    /// them (`DW0542`); places the runtime puts down are excluded by the selector,
730    /// because nobody can (`DW0545`). Nothing an author writes carries either tag,
731    /// and no campaign JSON can turn either off.
732    ///
733    /// **A teleport is not a rescue.** Accumulated fall distance carries across
734    /// one unchanged and is charged in full at the destination — measured Δ
735    /// exactly `0.0000` in 46/46 trials on the pinned 1.21.11, including
736    /// teleports 143 and 157 blocks straight *up*, with landing damage
737    /// `floor(fall_distance) − 3` (`docs/notes/death-and-teleport-spike.md` §3).
738    /// A platform that arrives under a falling player past ~20 blocks of fall is
739    /// the surface they die on. The compiler does not try to reset the counter:
740    /// what *does* reset it was explicitly not measured, and inventing a
741    /// mechanism from recall is the folklore this project forbids.
742    Teleport {
743        /// The volume whose contents are moved, as an anchor-centred box
744        /// (`anchor ± extent`) — the same shape a `begin-stealth` zone, a
745        /// `damage-players` `in` filter and a `lethal_volumes[]` region take.
746        ///
747        /// Deliberately NOT a bare prefab `region` anchor, for the reason
748        /// [`Verb::Collapse`] records: the assembled model clears every
749        /// gate-region anchor's cells, so a volume described that way would
750        /// delete the geometry it names.
751        from: StealthZone,
752        /// The destination mark (spec-0066). Resolved to a literal cell at build
753        /// time, so the emitted `tp` carries absolute coordinates and no runtime
754        /// search.
755        to: Mark,
756    },
757    /// Fires a **firework rocket** from a mark (DSL v0.29, spec-0068).
758    ///
759    /// One effect at a point, the member of the same class as [`Verb::PlaySound`]
760    /// — a one-shot thing that happens where the campaign says, beside the sound
761    /// that goes with it. A display of many rockets is a [`Verb::Sequence`] of
762    /// these, not a verb with timing of its own.
763    ///
764    /// # The burst height is a stated number, not a roll
765    ///
766    /// The emitter writes the entity's `LifeTime`
767    /// ([`crate::firework::lifetime_ticks`]) rather than leaving it to the game,
768    /// which randomises it at launch: two runs of one datapack would otherwise
769    /// burst at two heights and nothing could be proven about where the burst is.
770    /// Fixed at the floor of the game's range, the burst stands
771    /// [`crate::firework::burst_height`] blocks over the mark.
772    ///
773    /// # A burst hurts, so the compiler asks where it is
774    ///
775    /// A build refuses a rocket whose column to that height is roofed, and one
776    /// whose burst lies within [`crate::firework::BLAST_RADIUS`] blocks of a
777    /// place the campaign posts a body (`DW0899`). Players are **not** posted:
778    /// a player standing level with a burst takes up to
779    /// [`crate::firework::worst_damage_hp`] HP, under a full body's twenty, and
780    /// that is a hazard a player can see coming.
781    Firework {
782        /// The mark the rocket is launched from — the cell's centre, at the
783        /// mark's own plane.
784        at: Mark,
785        /// Flight duration, 1–3 (the three the game crafts). Absent =
786        /// [`crate::firework::MIN_FLIGHT`].
787        #[serde(default, skip_serializing_if = "Option::is_none")]
788        #[schemars(range(min = 1, max = 3))]
789        flight: Option<u8>,
790        /// One to seven bursts, in the order the component carries them.
791        #[schemars(length(min = 1, max = 7))]
792        explosions: Vec<FireworkExplosion>,
793    },
794    /// Spawns **particles** (spec-0085 §4.3) — the next one-shot point effect
795    /// after [`Verb::Firework`], at a mark or at each addressed player.
796    ///
797    /// `particle` is a vanilla particle type id validated against the pinned
798    /// registry `crates/dsl/data/particles-1.21.11.json`; an unknown id, or one
799    /// whose type **takes options** (`dust`, `block`, `item`, …), is `DW0941`.
800    ///
801    /// Emitted as one vanilla `particle` command, always in **`force`** mode,
802    /// whose viewers are the effect's audience: a particle a creator writes is
803    /// meant to be seen, and `force` is the mode the game sends 512 blocks out
804    /// and draws even at the client's Minimal particle setting. A
805    /// `minecraft:elder_guardian` at `players` is the full-screen face.
806    Particle {
807        /// The particle type id (`minecraft:` prefix optional).
808        particle: String,
809        /// Where the particles spawn: a [`Mark`], or `players` — at each
810        /// addressed player's own position.
811        at: ParticleAt,
812        /// How many (vanilla `<count>`, default 1). Zero is vanilla's spelling of
813        /// a different thing — one particle with a velocity — and is refused.
814        #[serde(default, skip_serializing_if = "Option::is_none")]
815        #[schemars(range(min = 1))]
816        count: Option<u32>,
817        /// Standard deviations `[x, y, z]` of the spawn spread, in blocks
818        /// (vanilla `<delta>`, default `[0, 0, 0]`).
819        #[serde(default, skip_serializing_if = "Option::is_none")]
820        spread: Option<[f64; 3]>,
821        /// Vanilla `<speed>` (default 0).
822        #[serde(default, skip_serializing_if = "Option::is_none")]
823        speed: Option<f64>,
824    },
825    /// Strikes a **lightning bolt** at a mark (DSL v0.36, spec-0092) — the
826    /// one-shot point effect beside [`Verb::Firework`] and [`Verb::Particle`].
827    ///
828    /// A real `minecraft:lightning_bolt`: every client in range draws the bolt
829    /// and the sky flash and hears the thunder. It stands at the mark's cell
830    /// centre on the mark's plane, so it strikes the block under the mark. A
831    /// storm of strikes is a [`Verb::Sequence`] of these.
832    ///
833    /// # A bolt hurts, so the compiler asks where it lands
834    ///
835    /// The bolt hits every living body within the reach
836    /// [`crate::lightning::REACH_HORIZONTAL`] / [`crate::lightning::REACH_BELOW`]
837    /// / [`crate::lightning::REACH_ABOVE`] states, and turns a villager into a
838    /// witch; a build refuses a strike in reach of a place the campaign posts a
839    /// body (`DW0958`), and one whose struck block the game would rewrite — a
840    /// lightning rod or weathering copper (`DW0959`). Players are **not**
841    /// posted: a player in reach takes at most
842    /// [`crate::lightning::worst_damage_hp`] HP. It lights no fire: every delve
843    /// seals `fire_spread_radius_around_player` at 0 (spec-0092 §2.3).
844    Lightning {
845        /// The mark the bolt strikes — the cell's centre, at the mark's plane.
846        at: Mark,
847    },
848}
849
850/// Where a [`Verb::Particle`] spawns (spec-0085 §4.3): a mark, or the literal
851/// `players`.
852#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
853#[serde(untagged)]
854pub enum ParticleAt {
855    /// `"players"` — at each addressed player's own position.
856    Players(PlayersKeyword),
857    /// A mark — the cell's centre at the mark's plane.
858    Mark(Mark),
859}
860
861/// The literal `players`, the one keyword [`ParticleAt`] admits.
862#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
863#[serde(rename_all = "kebab-case")]
864pub enum PlayersKeyword {
865    /// At each addressed player.
866    Players,
867}
868
869/// Default `grace_ticks` for [`Verb::BeginStealth`] (spec-0014).
870fn default_grace_ticks() -> u32 {
871    20
872}
873
874/// Default projectile for [`Verb::Volley`] (spec-0022).
875pub const DEFAULT_VOLLEY_PROJECTILE: &str = "minecraft:arrow";
876/// Default salvo count for [`Verb::Volley`] (spec-0022).
877pub const DEFAULT_VOLLEY_SALVOS: u32 = 3;
878/// Default ticks between salvos for [`Verb::Volley`] (spec-0022).
879pub const DEFAULT_VOLLEY_INTERVAL: u32 = 10;
880/// Largest admissible `salvos` — beyond this a volley is an entity-count
881/// hazard rather than a trap (`DW0443`).
882pub const MAX_VOLLEY_SALVOS: u32 = 16;
883/// Largest admissible `interval` in ticks (`DW0443`): 10 seconds. A volley
884/// slower than this is no longer one event the player reads as a trap.
885pub const MAX_VOLLEY_INTERVAL: u32 = 200;
886/// Default falling block for [`Verb::Collapse`] (spec-0022).
887pub const DEFAULT_COLLAPSE_FALLING_BLOCK: &str = "minecraft:gravel";
888
889/// Where a [`Verb::PlaySound`] originates (DSL v0.6, spec-0014). A sound
890/// plays at fixed coordinates or at each listener's own position; the compiler
891/// resolves no position for a live actor, so the `actor` variant is accepted by
892/// the schema and rejected with `DW0335`.
893#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
894#[serde(tag = "at", rename_all = "kebab-case", deny_unknown_fields)]
895pub enum SoundAt {
896    /// Play the sound positioned at a resolved anchor, audible to all players.
897    Anchor {
898        /// The anchor the sound plays from.
899        anchor: AnchorId,
900        /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
901        /// `[0, 0, 0]`): the sound plays at the [`Mark`] the two fields spell.
902        #[serde(default, skip_serializing_if = "is_zero3")]
903        offset: [i32; 3],
904    },
905    /// Play the sound at each player's own position (the default), or at
906    /// `offset` in **the listener's own frame** (spec-0085 §4.4): `+x` to the
907    /// listener's left, `+y` up, `+z` the way the listener faces, with the pitch
908    /// flattened so *behind* stays at ear height. `[0, 0, -3]` is three blocks
909    /// behind. Integer blocks, as a [`Mark`]'s offset is.
910    Players {
911        /// Integer `[x, y, z]` offset in the listener's local frame (default
912        /// `[0, 0, 0]`).
913        #[serde(default, skip_serializing_if = "is_zero3")]
914        offset: [i32; 3],
915    },
916    /// Play the sound at a scripted actor's position (rejected — `DW0335`; no
917    /// actor position resolves at emission).
918    Actor {
919        /// The actor id (stage-5 `actors[]`).
920        actor: String,
921    },
922}
923
924/// The presentation channel for a [`Verb::Narrate`] (DSL v0.4).
925#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
926#[serde(rename_all = "kebab-case")]
927pub enum NarrateStyle {
928    /// A chat line (default).
929    Chat,
930    /// A large on-screen title.
931    Title,
932    /// An on-screen subtitle.
933    Subtitle,
934    /// An "art" title rendered through the delve's custom resource-pack pixel-banner
935    /// font (`delve:art`) so endings can flash blocky all-caps text (DSL v0.6,
936    /// spec-0014). Text is checked at compile time against the font's glyph inventory
937    /// (`DW0328`); characters outside it (e.g. non-Latin script) are rejected. It
938    /// renders in the vanilla title slot, so it is width-checked like any title
939    /// (`DW0330`) — roughly 15 glyphs fit on screen.
940    Art,
941    /// The **actionbar** — the one-line strip above the hotbar (DSL v0.11).
942    ///
943    /// This is the channel a *reply* uses: it does not interrupt, it does not
944    /// stack, and it is overwritten by the next one. Every reply the compiler
945    /// itself writes has always used it — a sealed gate's answer, a checkpoint
946    /// return, the lobby's party count — but `narrate` could not reach it, which
947    /// is the mechanical reason `close-gate.sealed_hint` could not have been an
948    /// ordinary `narrate` even had someone tried (capability-ownership audit
949    /// finding 3b). A channel is a property of the message, not of the verb that
950    /// first wanted it.
951    ///
952    /// Unlike a title it is never width-checked: vanilla truncates nothing and
953    /// draws it at GUI width, and a reply is a fragment rather than a banner.
954    Actionbar,
955}
956
957impl NarrateStyle {
958    /// The kebab tag (`chat` / `title` / `subtitle` / `art` / `actionbar`).
959    pub fn token(self) -> &'static str {
960        match self {
961            NarrateStyle::Chat => "chat",
962            NarrateStyle::Title => "title",
963            NarrateStyle::Subtitle => "subtitle",
964            NarrateStyle::Art => "art",
965            NarrateStyle::Actionbar => "actionbar",
966        }
967    }
968}
969
970impl Verb {
971    /// The kebab-case `type` tag this verb serializes as — the verb a
972    /// diagnostic should name so the author can find it in the JSON.
973    pub fn tag(&self) -> &'static str {
974        match self {
975            Verb::OpenGate { .. } => "open-gate",
976            Verb::CloseGate { .. } => "close-gate",
977            Verb::CampaignComplete { .. } => "campaign-complete",
978            Verb::GiveItem { .. } => "give-item",
979            Verb::SetFlag { .. } => "set-flag",
980            Verb::SetState { .. } => "set-state",
981            Verb::AddState { .. } => "add-state",
982            Verb::ClearState { .. } => "clear-state",
983            Verb::DropStake { .. } => "drop-stake",
984            Verb::SpawnWave { .. } => "spawn-wave",
985            Verb::Narrate { .. } => "narrate",
986            Verb::SetBlock { .. } => "set-block",
987            Verb::FillRegion { .. } => "fill-region",
988            Verb::ClearRegion { .. } => "clear-region",
989            Verb::SetAtmosphere { .. } => "set-atmosphere",
990            Verb::OpenWay { .. } => "open-way",
991            Verb::DespawnNpc { .. } => "despawn-npc",
992            Verb::MoveNpc { .. } => "move-npc",
993            Verb::Cutscene { .. } => "cutscene",
994            Verb::SetTime { .. } => "set-time",
995            Verb::SetWeather { .. } => "set-weather",
996            Verb::PlaySound { .. } => "play-sound",
997            Verb::DamagePlayers { .. } => "damage-players",
998            Verb::SetCheckpoint { .. } => "set-checkpoint",
999            Verb::Bonfire { .. } => "bonfire",
1000            Verb::BeginStealth { .. } => "begin-stealth",
1001            Verb::EndStealth => "end-stealth",
1002            Verb::SpawnNpc { .. } => "spawn-npc",
1003            Verb::SpawnActor { .. } => "spawn-actor",
1004            Verb::DespawnActor { .. } => "despawn-actor",
1005            Verb::MoveActor { .. } => "move-actor",
1006            Verb::UnleashActor { .. } => "unleash-actor",
1007            Verb::Sequence { .. } => "sequence",
1008            Verb::Volley { .. } => "volley",
1009            Verb::Collapse { .. } => "collapse",
1010            Verb::GiveEffect { .. } => "give-effect",
1011            Verb::ClearEffect { .. } => "clear-effect",
1012            Verb::Teleport { .. } => "teleport",
1013            Verb::Firework { .. } => "firework",
1014            Verb::SpawnAssembly { .. } => "spawn-assembly",
1015            Verb::DespawnAssembly { .. } => "despawn-assembly",
1016            Verb::PlayClip { .. } => "play-clip",
1017            Verb::ArmStrikes { .. } => "arm-strikes",
1018            Verb::Particle { .. } => "particle",
1019            Verb::Lightning { .. } => "lightning",
1020        }
1021    }
1022
1023    /// **Whether the emitter addresses this verb to players** (spec-0085 §3.3) —
1024    /// whether its emitted commands name the effect's audience selector at all.
1025    ///
1026    /// A verb that answers `false` is a **party fact**: it fires once for the
1027    /// world (a flag, a gate, a block, a region, a wave, an actor, an NPC, a
1028    /// camera, the time, a checkpoint, a stealth beat, a timeline, a teleported
1029    /// volume, a rocket), so the envelope's `audience` and `in` have nothing to
1030    /// narrow and are refused on it (`DW0942`). A `player`-scoped state write
1031    /// answers `false` too: its holder is the acting player by declaration, never
1032    /// the audience.
1033    ///
1034    /// Exhaustive, so a new verb cannot be added without answering it; and
1035    /// `emit`'s own test binds this answer to the emitted bytes in both
1036    /// directions — every verb is emitted under two audiences, and its commands
1037    /// differ exactly when this says `true`.
1038    pub fn addresses_players(&self) -> bool {
1039        match self {
1040            Verb::GiveItem { .. }
1041            | Verb::Narrate { .. }
1042            | Verb::PlaySound { .. }
1043            | Verb::DamagePlayers { .. }
1044            | Verb::GiveEffect { .. }
1045            | Verb::ClearEffect { .. }
1046            | Verb::Particle { .. } => true,
1047            Verb::OpenGate { .. }
1048            | Verb::CloseGate { .. }
1049            | Verb::CampaignComplete { .. }
1050            | Verb::SetFlag { .. }
1051            | Verb::SetState { .. }
1052            | Verb::AddState { .. }
1053            | Verb::ClearState { .. }
1054            | Verb::DropStake { .. }
1055            | Verb::SpawnWave { .. }
1056            | Verb::SetBlock { .. }
1057            | Verb::FillRegion { .. }
1058            | Verb::ClearRegion { .. }
1059            | Verb::OpenWay { .. }
1060            | Verb::DespawnNpc { .. }
1061            | Verb::MoveNpc { .. }
1062            | Verb::Cutscene { .. }
1063            | Verb::SetTime { .. }
1064            | Verb::SetWeather { .. }
1065            | Verb::SetCheckpoint { .. }
1066            | Verb::Bonfire { .. }
1067            | Verb::BeginStealth { .. }
1068            | Verb::EndStealth
1069            | Verb::SpawnActor { .. }
1070            | Verb::DespawnActor { .. }
1071            | Verb::MoveActor { .. }
1072            | Verb::UnleashActor { .. }
1073            | Verb::SpawnNpc { .. }
1074            | Verb::Sequence { .. }
1075            | Verb::Volley { .. }
1076            | Verb::Collapse { .. }
1077            | Verb::Teleport { .. }
1078            | Verb::Firework { .. }
1079            // spec-0092: the thunder is the game's to send; the bolt is a world fact.
1080            | Verb::Lightning { .. }
1081            // spec-0080: a biome repaint is a world fact (`fillbiome`).
1082            | Verb::SetAtmosphere { .. }
1083            // spec-0082: an assembly is a world object.
1084            | Verb::SpawnAssembly { .. }
1085            | Verb::DespawnAssembly { .. }
1086            | Verb::PlayClip { .. }
1087            | Verb::ArmStrikes { .. } => false,
1088        }
1089    }
1090}