delvewright_dsl/actor.rs
1//! Stage 5 — scripted actors (DSL v0.6, spec-0014): bodies the story moves.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::serde_fields::{is_false, is_zero3};
7use crate::{
8 ActorId, AnchorId, BodyTraversal, BodyWatch, EncounterTier, MobAttributes, MobDrop,
9 MobEquipment, NpcSkin, OnKill, QuestEffect,
10};
11
12#[cfg(doc)]
13use crate::{Mark, Npc, Wave};
14
15/// A scripted stage actor (DSL v0.6, spec-0014): a NoAI/Silent/no-loot puppet,
16/// distinct from a stage-2 [`Npc`] (no dialogue, any mob type). Emitted with tag
17/// `dw_actor_<id>`, `Invulnerable` unless `vulnerable` (a damageable puppet stays
18/// knockback-immune — the tower-defense creep). `skin` re-dresses it as a
19/// `minecraft:mannequin`, exactly as a stage-2 NPC skin. The puppet is summoned by
20/// a `spawn-actor` effect (not at load), moved by `move-actor`, and can be replaced
21/// by a real-AI twin with `unleash-actor`.
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
23#[serde(deny_unknown_fields)]
24pub struct Actor {
25 /// Unique actor id (`actor/<kebab>`).
26 pub id: ActorId,
27 /// The vanilla entity to puppet, e.g. `minecraft:warden`. Validated against the
28 /// pinned 1.21.11 entity registry (`DW0173`).
29 pub entity: String,
30 /// Optional custom name shown above the puppet.
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub name: Option<String>,
33 /// Optional player-model skin (mannequin), as a stage-2 NPC (`DW0190`).
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub skin: Option<NpcSkin>,
36 /// The anchor the puppet is summoned on (resolved across areas, like an
37 /// `open-gate` / `move-npc` destination).
38 pub anchor: AnchorId,
39 /// Integer `[x, y, z]` block offset from `anchor` (spec-0066, default
40 /// `[0, 0, 0]`): the puppet stands at the [`Mark`] the two fields spell, so
41 /// a rank of bodies is one anchor and an offset apiece.
42 #[serde(default, skip_serializing_if = "is_zero3")]
43 pub offset: [i32; 3],
44 /// Initial facing (default `south`). The puppet spawns yawed this way.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub facing: Option<Facing>,
47 /// If `true`, the puppet is damageable (a tower-defense creep) but stays
48 /// knockback-immune; default `false` (fully `Invulnerable`).
49 #[serde(default, skip_serializing_if = "is_false")]
50 pub vulnerable: bool,
51 /// Gear the actor wears and holds, in the same shape a wave mob uses
52 /// ([`MobEquipment`]). Emitted into BOTH the staged puppet and the
53 /// unleashed twin, so the dormant elite the player has been circling is
54 /// visibly the same armoured thing that stands up. Drop chances are zero —
55 /// wave gear and actor gear are never farmable (no-grind constitution).
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub equipment: Option<MobEquipment>,
58 /// Attribute overrides, in the same shape a wave mob uses ([`MobAttributes`],
59 /// the v0.4 surface — one type, one rule set, so the two surfaces cannot
60 /// drift). Emitted into BOTH the staged puppet and the unleashed twin, so the
61 /// elite the party fights is the elite the author tuned; without it an actor
62 /// was stuck at vanilla base values while every wave mob could be tuned,
63 /// which is what blocked elite authoring. A `vulnerable` actor's
64 /// knockback-immunity is emitted first and is not authorable.
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub attributes: Option<MobAttributes>,
67 /// How hard this actor's fight is *meant* to be (DSL v0.8, spec-0023) — the
68 /// same [`EncounterTier`] vocabulary a [`Wave`] declares. Absent =
69 /// [`EncounterTier::Ordinary`], byte-identical to every pre-0.8 campaign.
70 ///
71 /// A wave is not the only shape an elite takes. The set-piece souls fight —
72 /// the armoured thing kneeling among the graves that stands up when you hit
73 /// it — is an **actor**: staged by `spawn-actor`, given AI by
74 /// `unleash-actor`, killed by hand rather than by a `kill` objective. Before
75 /// this field nothing anywhere stated what such a fight was billed as.
76 ///
77 /// Like the wave field this is a **declaration, not a knob**: the compiler
78 /// never scales an actor from it, and emission is unchanged whichever tier is
79 /// declared. Its readers are the health-bar advisory (`DW0912`) and the drop
80 /// rule — only a billed fight leaves anything behind.
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub tier: Option<EncounterTier>,
83 /// A health bar over this actor's fight (DSL v0.31, spec-0073) — the same
84 /// [`HealthBar`](crate::healthbar::HealthBar) a [`Wave`] declares. It reads the
85 /// bodies whose health can move: the unleashed twin, or the puppet itself when
86 /// the actor is `vulnerable`. A bar on an actor that is neither is `DW0909`.
87 /// Absent = no bar, byte-identical.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub health_bar: Option<crate::healthbar::HealthBar>,
90 /// What this actor leaves behind when a player kills it. Only an
91 /// `elite`/`boss` actor may
92 /// declare it (`DW0491`). Emitted into BOTH the staged puppet and the
93 /// unleashed twin, exactly as `equipment` is — the drop belongs to the body,
94 /// not to one of its two lifecycles. A `despawn-actor` strips the
95 /// declaration off the body before removing it, so re-caging an elite (a
96 /// souls re-seat) never scatters its axe.
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub drops: Vec<MobDrop>,
99 /// What this body can do when it moves (DSL v0.11, spec-0034) — the same
100 /// [`BodyTraversal`] a stage-2 [`Npc`] carries, because traversal belongs to
101 /// the body and not to the stage that declares it. Absent = the class the
102 /// compiler derives from `entity` (or from `minecraft:mannequin` when `skin`
103 /// is set).
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub traversal: Option<BodyTraversal>,
106 /// Turn to face a player in reach (spec-0101) — the same [`BodyWatch`] a
107 /// stage-2 [`Npc`] carries. Watches the puppet only: an unleashed twin's
108 /// facing is its own AI's. Absent = byte-identical.
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub watch: Option<BodyWatch>,
111 /// What happens each time a player is credited with killing this actor's
112 /// body (spec-0074) — effect root R9, the same [`OnKill`] a
113 /// wave declares. Absent = no bundle, and the actor's emission is
114 /// byte-identical.
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub on_kill: Option<OnKill>,
117}
118
119/// A cardinal facing keyword (DSL v0.6). Emitted as the puppet's spawn yaw
120/// (MC: yaw 0 = +z/south).
121#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
122#[serde(rename_all = "kebab-case")]
123pub enum Facing {
124 /// Facing +z (yaw 0) — the default.
125 South,
126 /// Facing -z (yaw 180).
127 North,
128 /// Facing -x (yaw 90).
129 West,
130 /// Facing +x (yaw 270).
131 East,
132}
133
134impl Facing {
135 /// The kebab token (`south` / `north` / `west` / `east`).
136 pub fn token(self) -> &'static str {
137 match self {
138 Facing::South => "south",
139 Facing::North => "north",
140 Facing::West => "west",
141 Facing::East => "east",
142 }
143 }
144}
145
146/// How a `despawn-actor` removes its puppet (DSL v0.6).
147#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
148#[serde(rename_all = "kebab-case")]
149pub enum DespawnStyle {
150 /// The body leaves unseen: no death animation, red flash or death particles
151 /// where it stood. It is moved under the world and removed there.
152 Vanish,
153 /// The body dies where it stands, with the vanilla death animation (a death
154 /// the player is meant to watch).
155 Kill,
156}
157
158impl DespawnStyle {
159 /// The kebab token (`vanish` / `kill`).
160 pub fn token(self) -> &'static str {
161 match self {
162 DespawnStyle::Vanish => "vanish",
163 DespawnStyle::Kill => "kill",
164 }
165 }
166}
167
168/// One step of a [`Verb::Sequence`] (DSL v0.6): a group of effects fired at
169/// an exact tick offset from the sequence's start.
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
171#[serde(deny_unknown_fields)]
172pub struct SequenceStep {
173 /// Tick offset from the sequence start at which `effects` fire.
174 pub at_ticks: u32,
175 /// The effects fired at `at_ticks`. Any stage-5 effect except a nested
176 /// `sequence` (rejected with `DW0329`).
177 pub effects: Vec<QuestEffect>,
178}
179
180// ---------------------------------------------------------------------------
181// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
182// ---------------------------------------------------------------------------
183
184use crate::Verb;
185use crate::diagnostic::{Diagnostic, codes};
186use crate::envelope::Campaign;
187use crate::ids::is_kebab;
188use crate::quest::check::check_no_nested_sequence;
189use crate::registry::{AnchorRegistry, EntityRegistry, ItemRegistry};
190use crate::validate::{AnchorProviders, station_kind_diag};
191use crate::wave::check_equipment;
192use std::collections::BTreeSet;
193
194/// Recursively visit every effect in `effs`, descending into every nested effect
195/// list ([`QuestEffect::nested_effect_lists`]: `sequence` steps, `set-checkpoint`
196/// `on_respawn`, `begin-stealth` `on_caught`, `move-actor` / `move-npc`
197/// `on_arrive`).
198fn walk_effects_deep(effs: &[QuestEffect], f: &mut dyn FnMut(&QuestEffect)) {
199 for e in effs {
200 e.visit_deep(f);
201 }
202}
203
204/// Actor declarations (spec-0014): a known entity id, a well-formed and distinct
205/// skin, a spawn anchor some area provides — and every actor staging effect's
206/// reference (`DW0112`), every `move-actor` destination anchor (`DW0142`) and the
207/// no-nested-`sequence` rule (`DW0329`) at every depth of a quest's or a
208/// trigger's bundles.
209pub(crate) fn actor_checks(
210 c: &Campaign,
211 anchors: &dyn AnchorRegistry,
212 entities: &dyn EntityRegistry,
213 d: &mut Vec<Diagnostic>,
214) {
215 let quests = &c.quests.content;
216 let declared: BTreeSet<&str> = quests.actors.iter().map(|a| a.id.as_str()).collect();
217
218 // Anchor names provided by single-prefab areas (pool areas resolve anchors in
219 // the compiler, so their presence defers the check — mirroring `DW0142`'s
220 // single-prefab-only scope; never a false positive).
221 let providers = AnchorProviders::build(c, anchors);
222
223 // Actor declarations: entity id, skin, spawn anchor.
224 for (i, a) in quests.actors.iter().enumerate() {
225 if !entities.contains(&a.entity) {
226 d.push(Diagnostic::error(
227 codes::ENTITY_UNKNOWN,
228 "quests",
229 format!("/content/actors/{i}/entity"),
230 format!(
231 "actor entity `{}` is not a known 1.21.11 entity id — use a valid namespaced \
232 entity id (e.g. `minecraft:warden`)",
233 a.entity
234 ),
235 ));
236 }
237 if let Some(skin) = &a.skin
238 && !is_kebab(&skin.texture_id)
239 {
240 d.push(Diagnostic::error(
241 codes::SKIN_INVALID,
242 "quests",
243 format!("/content/actors/{i}/skin/texture_id"),
244 format!(
245 "actor skin `texture_id` `{}` is malformed — it must be a bare kebab token \
246 (e.g. `giant-idle`), matching the `skins/<texture_id>.png` filename",
247 skin.texture_id
248 ),
249 ));
250 }
251 if let Some(f) = station_kind_diag(
252 &providers,
253 a.anchor.as_str(),
254 crate::layout::StationKind::Point,
255 "an actor's station",
256 "quests",
257 format!("/content/actors/{i}/anchor"),
258 ) {
259 d.push(f);
260 }
261 if !providers.resolvable(a.anchor.as_str()) {
262 d.push(Diagnostic::error(
263 codes::ANCHOR_UNRESOLVED,
264 "quests",
265 format!("/content/actors/{i}/anchor"),
266 format!(
267 "actor anchor `{}` is not provided by any area's prefab — {}",
268 a.anchor,
269 providers.anchor_remedy(
270 "use an anchor a prefab exposes, or bind a prefab/pool that carries it"
271 ),
272 ),
273 ));
274 }
275 }
276
277 // Effect-level: actor references (DW0112), move-actor destination anchors
278 // (DW0142), and the no-nested-sequence rule (DW0329). Deep-walk so effects
279 // nested in a `sequence` / `move-actor` `on_arrive` are covered.
280 let mut groups: Vec<(String, &[QuestEffect])> = Vec::new();
281 for (i, q) in quests.quests.iter().enumerate() {
282 for (key, effs) in &q.on_objective_complete {
283 groups.push((
284 format!("/content/quests/{i}/on_objective_complete/{key}"),
285 effs.as_slice(),
286 ));
287 }
288 groups.push((
289 format!("/content/quests/{i}/on_complete"),
290 q.on_complete.as_slice(),
291 ));
292 }
293 for (i, t) in quests.triggers.iter().enumerate() {
294 groups.push((
295 format!("/content/triggers/{i}/effects"),
296 t.effects.as_slice(),
297 ));
298 }
299 for (path, effs) in &groups {
300 let mut visit = |e: &QuestEffect| {
301 if let Some(actor) = e.actor_ref()
302 && !declared.contains(actor.as_str())
303 {
304 d.push(Diagnostic::error(
305 codes::DANGLING_REF,
306 "quests",
307 path.clone(),
308 format!(
309 "actor staging effect references unknown actor `{actor}` — declare it in \
310 the stage-5 `actors` list, or fix the reference"
311 ),
312 ));
313 }
314 if let Verb::MoveActor { to, .. } = &e.verb
315 && let Some(f) = station_kind_diag(
316 &providers,
317 to.anchor.as_str(),
318 crate::layout::StationKind::Point,
319 "a `move-actor` destination",
320 "quests",
321 path.clone(),
322 )
323 {
324 d.push(f);
325 } else if let Verb::MoveActor { to, .. } = &e.verb
326 && !providers.resolvable(to.anchor.as_str())
327 {
328 d.push(Diagnostic::error(
329 codes::ANCHOR_UNRESOLVED,
330 "quests",
331 path.clone(),
332 format!(
333 "move-actor destination anchor `{}` is not provided by any \
334 area's prefab — {}",
335 to.anchor,
336 providers.anchor_remedy("use an anchor a prefab exposes"),
337 ),
338 ));
339 }
340 };
341 walk_effects_deep(effs, &mut visit);
342 check_no_nested_sequence(effs, path, d);
343 }
344}
345
346/// Actor `equipment` (spec-0021): item ids and enchantments, by the wave mob's
347/// rule ([`crate::wave::check_equipment`]).
348pub(crate) fn actor_equipment_checks(
349 c: &Campaign,
350 items: &dyn ItemRegistry,
351 d: &mut Vec<Diagnostic>,
352) {
353 let quests = &c.quests.content;
354 // Actor `equipment` (spec-0021): the same shape, the same registries, the
355 // same diagnostics as a wave mob's — one surface, one rule set.
356 for (i, a) in quests.actors.iter().enumerate() {
357 let Some(eq) = &a.equipment else { continue };
358 check_equipment(
359 eq,
360 "actor",
361 &format!("/content/actors/{i}/equipment"),
362 items,
363 d,
364 );
365 }
366}
367
368/// `DW0110` over the scripted-actor ids.
369pub(crate) fn actor_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
370 for (i, a) in c.quests.content.actors.iter().enumerate() {
371 crate::ids::id_syntax!(d, a.id, "quests", format!("/content/actors/{i}/id"));
372 }
373}
374
375/// `DW0111` over the scripted-actor ids: unique within the stage-5 actors
376/// namespace (DSL v0.6).
377pub(crate) fn actor_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
378 crate::ids::dup_check(
379 c.quests
380 .content
381 .actors
382 .iter()
383 .enumerate()
384 .map(|(i, a)| (a.id.as_str(), format!("/content/actors/{i}/id"))),
385 "quests",
386 "actor",
387 d,
388 );
389}