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}