delvewright_dsl/assembly.rs
1//! Stage 5 — assemblies (spec-0082): a fixed thing that can be hit and hits back.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::serde_fields::is_zero3;
7use crate::{AssemblyId, Facing, Mark, QuestEffect, RigId, StealthZone};
8
9/// A fixed thing that can be hit and hits back (spec-0082): an object built of
10/// display entities standing at a [`Mark`], moving through the clips of a
11/// library [`rig`](crate::rig), struck in melee through an optional
12/// `minecraft:interaction` hitbox, and striking a player who stands in its
13/// arming region.
14///
15/// It is not a fight class and never dies: no health, equipment, traversal,
16/// health bar or kill credit. A hit count is an ordinary `state` datum a
17/// `strike-assembly` trigger adds to, and what happens at a count is an effect
18/// behind the ordinary gate.
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
20#[serde(deny_unknown_fields)]
21pub struct Assembly {
22 /// Unique assembly id (`assembly/<kebab>`).
23 pub id: AssemblyId,
24 /// The library rig (`rig/<name>`, resolved to `rigs/<name>/rig.json`
25 /// beside the prefab library) whose parts and clips this assembly is.
26 pub rig: RigId,
27 /// Where the rig's origin stands: an anchor and an optional offset. The
28 /// rig's origin is the mark cell's centre at its floor plane.
29 pub at: Mark,
30 /// Which way the rig's `+z` front faces (default `south`). Applied by the
31 /// compiler to every frame, so the emitted entities stand at yaw 0.
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub facing: Option<Facing>,
34 /// The clip playing from spawn. Absent: the parts stand in the rig's rest
35 /// pose and no clip plays until a `play-clip`.
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub initial: Option<String>,
38 /// The `minecraft:interaction` a player strikes. Absent: the assembly
39 /// cannot be struck, and a `strike-assembly` on it is refused (`DW0936`).
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub hitbox: Option<AssemblyHitbox>,
42 /// The blows it deals. Absent: it never strikes.
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub strikes: Option<AssemblyStrikes>,
45}
46
47impl Assembly {
48 /// The declared facing, `south` when absent.
49 pub fn facing(&self) -> Facing {
50 self.facing.unwrap_or(Facing::South)
51 }
52}
53
54/// An assembly's hitbox (spec-0082 §3.2): a `minecraft:interaction` of
55/// `width × height` whose bottom centre is the mark's cell centre plus
56/// `offset`. Melee only: an arrow passes through an interaction.
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct AssemblyHitbox {
60 /// Width in blocks (`0 < width <= 6`, `DW0936`).
61 pub width: f64,
62 /// Height in blocks (`0 < height <= 22`, `DW0936`).
63 pub height: f64,
64 /// Integer `[x, y, z]` block offset of the box's bottom centre from the
65 /// mark (default `[0, 0, 0]`).
66 #[serde(default, skip_serializing_if = "is_zero3")]
67 pub offset: [i32; 3],
68}
69
70/// An assembly's strike pattern (spec-0082 §3.2).
71///
72/// The pattern runs, repeating from its first step, on every tick on which
73/// some player's body is in `while_in`, and stops at the end of the step in
74/// flight when nobody is.
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct AssemblyStrikes {
78 /// The arming region: an anchor-centred box. Every landing box lies inside
79 /// it (`DW0938`), so a player who never entered it is never struck.
80 pub while_in: StealthZone,
81 /// The steps, in order.
82 pub pattern: Vec<StrikeStep>,
83 /// Aim (spec-0082 §5.7). Absent: every blow lands where its `on_land`
84 /// boxes say. Present: at the start of every wind-up the assembly turns to
85 /// the one of its declared facings nearest the bearing of the nearest
86 /// player in `while_in`, and every `damage-players` box in `on_land` turns
87 /// with it.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub aim: Option<StrikeAim>,
90}
91
92/// An aimed strike pattern's facings (spec-0082 §5.7): `facings` turns
93/// evenly spaced round the vertical axis through the mark, the first being the
94/// assembly's declared `facing`. Every `on_land` box is written for that first
95/// facing; the compiler turns it to each of the others and proves every facing
96/// a player in `while_in` can draw (`DW0938`, judged per facing). Which facing
97/// a blow takes is chosen at run time among the proven ones; nothing about
98/// where it lands is computed there.
99#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
100#[serde(deny_unknown_fields)]
101pub struct StrikeAim {
102 /// How many facings, evenly spaced: 4 is a quarter turn apart, 8 an eighth,
103 /// 16 a sixteenth. At least 1 (1 is the declared facing alone).
104 pub facings: std::num::NonZeroU32,
105}
106
107/// One step of a strike pattern: wind up, hold, strike, land.
108///
109/// How long the wind-up is and how hard the blow lands are the creator's
110/// judgement, by spec-0016's standing ruling: no telegraph rule and no
111/// one-shot rule.
112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
113#[serde(deny_unknown_fields)]
114pub struct StrikeStep {
115 /// The clip played first.
116 pub windup: String,
117 /// Ticks the windup's last frame is held before the strike clip starts.
118 pub hold: u32,
119 /// The clip the blow is.
120 pub strike: String,
121 /// The step's pace: the keyframe cadence, in ticks per frame (1–20, the
122 /// rig's own bounds), its wind-up and strike clips play at. Absent: each
123 /// clip's own. The wind-up lasts `1 + (frames - 1) × cadence` ticks, then
124 /// `hold`; the blow lands one cadence after the strike's last frame.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub ticks_per_frame: Option<u32>,
127 /// A lock (spec-0094): at the start of the wind-up the step picks one
128 /// player in `lock.within` by `lock.pick`, reads the cell their feet stand
129 /// in, turns the assembly to it and strikes it with whichever of `strike`
130 /// and `lock.reaches` the compiler proved comes down there. The blow's area
131 /// is then the cells that clip comes down on, derived by the compiler: a
132 /// `damage-players` in a locked step declares no `in` (`DW0969`). Absent:
133 /// the blow lands where its `on_land` boxes say.
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub lock: Option<StrikeLock>,
136 /// Effects run, with no acting player, on the tick a client has drawn the
137 /// strike clip's last frame whole (one cadence after it is applied). A step
138 /// with none is a feint.
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub on_land: Vec<QuestEffect>,
141}
142
143/// A strike step's lock (spec-0094 §3.1): the step strikes where one player
144/// stands, chosen when its wind-up begins.
145///
146/// A display entity cannot bend live to a point: its pose is a keyframe a rig
147/// precomputed. So a lock is two run-time choices among things the compiler
148/// proved — a **turn** of the whole assembly about its mark (any yaw: a `tp` of
149/// the root turns every riding part with it), and a **pose**, the first of
150/// `strike` then `reaches` whose last frame, at that turn, comes down on the
151/// locked cell with its whole blow inside `while_in`. Every standable cell of
152/// `within` owes such a pose, or the build refuses naming the cells (`DW0968`).
153#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields)]
155pub struct StrikeLock {
156 /// The region a target is chosen in: an anchor-centred box. The step winds
157 /// up only while some player's body is in it, and every blow it can deal
158 /// lies inside `while_in` (`DW0968`).
159 pub within: StealthZone,
160 /// Which player in `within` the step locks onto.
161 pub pick: LockPick,
162 /// Further strike clips, beyond the step's `strike`, the lock may choose
163 /// among — a limb's blows at other reaches. Tried after `strike`, in the
164 /// order written; the first that comes down on the locked cell is played.
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub reaches: Vec<String>,
167}
168
169/// Which player a locked strike chooses (spec-0094 §3.1): vanilla's own
170/// selector orders, measured from the assembly's mark.
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172#[serde(rename_all = "kebab-case")]
173pub enum LockPick {
174 /// The player nearest the mark (`sort=nearest`).
175 Nearest,
176 /// The player furthest from the mark (`sort=furthest`).
177 Furthest,
178 /// A player chosen at random (`sort=random`).
179 Random,
180}
181
182impl LockPick {
183 /// The `sort=` value of the selector that makes the choice.
184 pub fn sort(self) -> &'static str {
185 match self {
186 LockPick::Nearest => "nearest",
187 LockPick::Furthest => "furthest",
188 LockPick::Random => "random",
189 }
190 }
191}
192
193// ---------------------------------------------------------------------------
194// Validation
195// ---------------------------------------------------------------------------
196
197use std::collections::BTreeMap;
198
199use crate::Verb;
200use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
201use crate::envelope::Campaign;
202use crate::registry::AnchorRegistry;
203use crate::validate::{AnchorProviders, station_kind_diag};
204
205crate::dw_code! {
206 /// (spec-0082 §5.1, §5.5) **An assembly's rig cannot be emitted as
207 /// declared.** The library holds no `rigs/<name>/rig.json` for the
208 /// assembly's `rig`, or the file does not parse, or it breaks a structural
209 /// rule (no part, an unknown block, a clip with no frame, a frame short a
210 /// part, a cadence outside `1..=20`, a non-finite transform, a zero scale);
211 /// or an `initial`, a strike step's `windup`/`strike`, or a `play-clip`
212 /// names a clip the rig lacks — the message lists the rig's clips.
213 /// Validation-tier (exit 1). Prescription: regenerate the rig with its
214 /// generator, or name a clip the rig declares (`delvec rig describe`
215 /// prints them).
216 pub const ASSEMBLY_RIG: DwCode = DwCode::new("DW0935", ExitTier::Build);
217}
218
219crate::dw_code! {
220 /// (spec-0094 §5.2) **A locked strike's blow is declared where the lock
221 /// derives it.** A locked step's blow lands on the cells its chosen
222 /// clip comes down on at the locked turn, so a `damage-players` at the
223 /// top of its `on_land` that declares an `in` box, any `damage-players`
224 /// nested inside another effect's list there (it cannot be moved with
225 /// the lock), and a locked step in a pattern that also declares `aim`
226 /// (two rules choosing one turn) are refused, each naming the field.
227 /// Validation-tier (exit 1). Prescription: drop the `in`, lift the
228 /// `damage-players` to the top of `on_land`, or drop `aim` or `lock`.
229 pub const ASSEMBLY_LOCK_SHAPE: DwCode = DwCode::new("DW0969", ExitTier::Build);
230}
231
232crate::dw_code! {
233 /// (spec-0094 §3.3) **An `arm-strikes` names an assembly that never
234 /// strikes.** The assembly declares no `strikes`, so there is no pattern
235 /// to re-arm and the beat does nothing. Validation-tier (exit 1).
236 /// Prescription: give the assembly a `strikes` pattern, or drop the
237 /// effect.
238 pub const ASSEMBLY_ARM_NOTHING: DwCode = DwCode::new("DW0970", ExitTier::Build);
239}
240
241/// spec-0082: **assemblies, their rigs, and every reference to one.**
242///
243/// * Each assembly's `rig` resolves in the library and passes the rig's
244/// structural rules ([`crate::rig::check`]); its `initial` and every strike
245/// step's `windup`/`strike` name clips the rig declares (`DW0935`). A
246/// registry that is not the whole library answers
247/// [`crate::rig::RigLookup::Unknown`] and nothing is refused on its word.
248/// * Its mark's anchor, and its arming region's, are provided by some area
249/// (`DW0142`), and the mark is a point station.
250/// * Every `spawn-assembly` / `despawn-assembly` / `play-clip`, at every depth
251/// of every effect root, names a declared assembly (`DW0112`), and a
252/// `play-clip` names a clip its rig declares (`DW0935`).
253/// * Every `strike-assembly` trigger names a declared assembly (`DW0112`).
254///
255/// The hitbox's bounds, its reach and where a blow lands are judged at build
256/// time, where cells exist (`DW0936`–`DW0938`, `compiler::assembly`).
257pub(crate) fn assembly_checks(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
258 use crate::rig::RigLookup;
259 let quests = &c.quests.content;
260 if quests.assemblies.is_empty()
261 && !quests
262 .triggers
263 .iter()
264 .any(|t| t.on.assembly_target().is_some())
265 {
266 // Still walk the effects: a verb naming an assembly in a campaign that
267 // declares none is a dangling reference.
268 let mut any = false;
269 crate::for_each_campaign_effect(c, &mut |_, _, e| {
270 any |= assembly_verb(e).is_some();
271 });
272 if !any {
273 return;
274 }
275 }
276 let providers = AnchorProviders::build(c, anchors);
277 // The rig each declared assembly resolved to, for the clip checks below.
278 let mut rigs: BTreeMap<&str, Option<&crate::rig::Rig>> = BTreeMap::new();
279 let clip_list = |r: &crate::rig::Rig| -> String {
280 let names = r.clip_names();
281 if names.is_empty() {
282 "none".to_string()
283 } else {
284 names
285 .iter()
286 .map(|n| format!("`{n}`"))
287 .collect::<Vec<_>>()
288 .join(", ")
289 }
290 };
291 for (i, a) in quests.assemblies.iter().enumerate() {
292 let at = format!("/content/assemblies/{i}");
293 let resolved = match anchors.rig(&a.rig) {
294 RigLookup::Unknown => None,
295 RigLookup::Missing => {
296 d.push(Diagnostic::error(
297 ASSEMBLY_RIG,
298 "quests",
299 format!("{at}/rig"),
300 format!(
301 "assembly `{}` names rig `{}`, and the library holds no `{}/{}/{}` — a \
302 rig is a file a generator writes beside the prefab library, never \
303 campaign JSON. Run the generator that writes it, or name a rig the \
304 library holds",
305 a.id,
306 a.rig,
307 crate::rig::RIGS_DIR,
308 crate::l10n::local_id(a.rig.as_str()),
309 crate::rig::RIG_FILE,
310 ),
311 ));
312 None
313 }
314 RigLookup::Malformed(e) => {
315 d.push(Diagnostic::error(
316 ASSEMBLY_RIG,
317 "quests",
318 format!("{at}/rig"),
319 format!(
320 "assembly `{}` names rig `{}`, whose `{}` does not parse as a rig \
321 document: {e}. Regenerate it with the generator that wrote it",
322 a.id,
323 a.rig,
324 crate::rig::RIG_FILE,
325 ),
326 ));
327 None
328 }
329 RigLookup::Found(r) => {
330 let issues = crate::rig::check(r);
331 for issue in &issues {
332 d.push(Diagnostic::error(
333 ASSEMBLY_RIG,
334 "quests",
335 format!("{at}/rig"),
336 format!(
337 "assembly `{}` names rig `{}`, which breaks a rig rule at `{}`: {}. \
338 Regenerate the rig with its generator",
339 a.id, a.rig, issue.field, issue.message
340 ),
341 ));
342 }
343 if issues.is_empty() { Some(r) } else { None }
344 }
345 };
346 rigs.insert(a.id.as_str(), resolved);
347 if let Some(r) = resolved {
348 let mut need = |clip: &str, path: String, role: &str| {
349 if r.clips.contains_key(clip) {
350 return;
351 }
352 d.push(Diagnostic::error(
353 ASSEMBLY_RIG,
354 "quests",
355 path,
356 format!(
357 "assembly `{}` asks for clip `{clip}` as its {role}, and rig `{}` declares \
358 no such clip. Its clips are: {}",
359 a.id,
360 a.rig,
361 clip_list(r)
362 ),
363 ));
364 };
365 if let Some(initial) = &a.initial {
366 need(initial, format!("{at}/initial"), "`initial`");
367 }
368 let mut paced: Vec<Diagnostic> = Vec::new();
369 if let Some(s) = &a.strikes {
370 for (j, step) in s.pattern.iter().enumerate() {
371 need(
372 &step.windup,
373 format!("{at}/strikes/pattern/{j}/windup"),
374 "strike step's `windup`",
375 );
376 need(
377 &step.strike,
378 format!("{at}/strikes/pattern/{j}/strike"),
379 "strike step's `strike`",
380 );
381 if let Some(lock) = &step.lock {
382 for (k, reach) in lock.reaches.iter().enumerate() {
383 need(
384 reach,
385 format!("{at}/strikes/pattern/{j}/lock/reaches/{k}"),
386 "locked strike step's `reaches`",
387 );
388 }
389 }
390 if let Some(t) = step.ticks_per_frame
391 && !(crate::rig::MIN_TICKS_PER_FRAME..=crate::rig::MAX_TICKS_PER_FRAME)
392 .contains(&t)
393 {
394 paced.push(Diagnostic::error(
395 ASSEMBLY_RIG,
396 "quests",
397 format!("{at}/strikes/pattern/{j}/ticks_per_frame"),
398 format!(
399 "assembly `{}`'s strike step {j} plays its clips at {t} tick(s) per \
400 frame. A keyframe cadence is {} to {} — the bounds every rig clip \
401 is held to. Choose a cadence in that range, or drop \
402 `ticks_per_frame` to play each clip at its own",
403 a.id,
404 crate::rig::MIN_TICKS_PER_FRAME,
405 crate::rig::MAX_TICKS_PER_FRAME
406 ),
407 ));
408 }
409 }
410 }
411 d.extend(paced);
412 }
413 if let Some(f) = station_kind_diag(
414 &providers,
415 a.at.anchor.as_str(),
416 crate::layout::StationKind::Point,
417 "an assembly's mark",
418 "quests",
419 format!("{at}/at/anchor"),
420 ) {
421 d.push(f);
422 } else if !providers.resolvable(a.at.anchor.as_str()) {
423 d.push(Diagnostic::error(
424 codes::ANCHOR_UNRESOLVED,
425 "quests",
426 format!("{at}/at/anchor"),
427 format!(
428 "assembly `{}` stands at anchor `{}`, which no area's prefab provides — {}",
429 a.id,
430 a.at.anchor,
431 providers.anchor_remedy(
432 "use an anchor a prefab exposes, or bind a prefab/pool that carries it"
433 ),
434 ),
435 ));
436 }
437 if let Some(s) = &a.strikes {
438 lock_shape_checks(a, i, s, d);
439 for (j, step) in s.pattern.iter().enumerate() {
440 let Some(lock) = &step.lock else { continue };
441 if providers.resolvable(lock.within.anchor.as_str()) {
442 continue;
443 }
444 d.push(Diagnostic::error(
445 codes::ANCHOR_UNRESOLVED,
446 "quests",
447 format!("{at}/strikes/pattern/{j}/lock/within/anchor"),
448 format!(
449 "assembly `{}`'s strike step {j} locks onto a player in a region centred \
450 on anchor `{}`, which no area's prefab provides — {}",
451 a.id,
452 lock.within.anchor,
453 providers.anchor_remedy(
454 "use an anchor a prefab exposes, or bind a prefab/pool that carries it"
455 ),
456 ),
457 ));
458 }
459 }
460 if let Some(s) = &a.strikes
461 && !providers.resolvable(s.while_in.anchor.as_str())
462 {
463 d.push(Diagnostic::error(
464 codes::ANCHOR_UNRESOLVED,
465 "quests",
466 format!("{at}/strikes/while_in/anchor"),
467 format!(
468 "assembly `{}`'s arming region is centred on anchor `{}`, which no area's \
469 prefab provides — {}",
470 a.id,
471 s.while_in.anchor,
472 providers.anchor_remedy(
473 "use an anchor a prefab exposes, or bind a prefab/pool that carries it"
474 ),
475 ),
476 ));
477 }
478 }
479 // Which declared assemblies strike, for `arm-strikes` (`DW0970`).
480 let strikes: BTreeMap<&str, bool> = quests
481 .assemblies
482 .iter()
483 .map(|a| (a.id.as_str(), a.strikes.is_some()))
484 .collect();
485 // Every verb that names an assembly, at every depth of every root.
486 crate::for_each_campaign_effect(c, &mut |path, _site, e| {
487 let Some((assembly, clip)) = assembly_verb(e) else {
488 return;
489 };
490 if matches!(e.verb, Verb::ArmStrikes { .. }) && strikes.get(assembly) == Some(&false) {
491 d.push(Diagnostic::error(
492 ASSEMBLY_ARM_NOTHING,
493 "quests",
494 format!("{path}/assembly"),
495 format!(
496 "`arm-strikes` re-arms assembly `{assembly}`'s strike pattern, and the \
497 assembly declares no `strikes` — there is no pattern to re-arm, so the beat \
498 does nothing. Give the assembly a `strikes` pattern, or drop the effect"
499 ),
500 ));
501 }
502 let Some(resolved) = rigs.get(assembly) else {
503 d.push(Diagnostic::error(
504 codes::DANGLING_REF,
505 "quests",
506 path.to_string(),
507 format!(
508 "`{}` names assembly `{assembly}`, which the stage-5 `assemblies` list does \
509 not declare — declare it, or fix the reference",
510 e.verb.tag()
511 ),
512 ));
513 return;
514 };
515 if let (Some(clip), Some(r)) = (clip, resolved)
516 && !r.clips.contains_key(clip)
517 {
518 d.push(Diagnostic::error(
519 ASSEMBLY_RIG,
520 "quests",
521 format!("{path}/clip"),
522 format!(
523 "`play-clip` asks assembly `{assembly}` for clip `{clip}`, and its rig \
524 declares no such clip. Its clips are: {}",
525 clip_list(r)
526 ),
527 ));
528 }
529 });
530 for (i, t) in quests.triggers.iter().enumerate() {
531 if let Some(m) = t.on.assembly_target()
532 && !rigs.contains_key(m.as_str())
533 {
534 d.push(Diagnostic::error(
535 codes::DANGLING_REF,
536 "quests",
537 format!("/content/triggers/{i}/on/assembly"),
538 format!(
539 "`strike-assembly` trigger `{}` targets assembly `{m}`, which the stage-5 \
540 `assemblies` list does not declare — use a declared assembly id",
541 t.id
542 ),
543 ));
544 }
545 }
546}
547
548/// The assembly a verb names, with the clip a `play-clip` asks for.
549fn assembly_verb(e: &QuestEffect) -> Option<(&str, Option<&str>)> {
550 match &e.verb {
551 Verb::SpawnAssembly { assembly } | Verb::DespawnAssembly { assembly } => {
552 Some((assembly.as_str(), None))
553 }
554 Verb::PlayClip { assembly, clip } => Some((assembly.as_str(), Some(clip.as_str()))),
555 Verb::ArmStrikes { assembly } => Some((assembly.as_str(), None)),
556 _ => None,
557 }
558}
559
560/// spec-0094 §5.2 (`DW0969`): **a locked step's blow is the lock's to place.**
561/// A locked step lands on the cells its chosen clip comes down on at the turn
562/// it locked to, so a box an author writes cannot be where the blow lands: a
563/// top-level `damage-players` in its `on_land` declares no `in`, no
564/// `damage-players` stands inside another effect's list there, and a pattern
565/// that turns by `aim` has no locked step.
566fn lock_shape_checks(
567 a: &crate::Assembly,
568 i: usize,
569 s: &crate::AssemblyStrikes,
570 d: &mut Vec<Diagnostic>,
571) {
572 fn nested_damage(effs: &[QuestEffect], path: &str, out: &mut Vec<String>) {
573 for (k, e) in effs.iter().enumerate() {
574 let here = format!("{path}/{k}");
575 if matches!(e.verb, Verb::DamagePlayers { .. }) {
576 out.push(here.clone());
577 }
578 for (seg, _, list) in e.nested_effect_lists_labeled() {
579 nested_damage(list, &format!("{here}/{seg}"), out);
580 }
581 }
582 }
583 let at = format!("/content/assemblies/{i}/strikes");
584 for (j, step) in s.pattern.iter().enumerate() {
585 if step.lock.is_none() {
586 continue;
587 }
588 let here = format!("{at}/pattern/{j}");
589 if s.aim.is_some() {
590 d.push(Diagnostic::error(
591 ASSEMBLY_LOCK_SHAPE,
592 "quests",
593 format!("{here}/lock"),
594 format!(
595 "assembly `{}`'s strike pattern turns by `aim`, and its step {j} declares a \
596 `lock` — two rules choosing the one turn the assembly strikes from. A locked \
597 step turns to the cell it locks onto; an aimed pattern turns to one of its \
598 facings. Drop `aim` from the pattern, or drop `lock` from the step",
599 a.id
600 ),
601 ));
602 }
603 for (k, e) in step.on_land.iter().enumerate() {
604 let p = format!("{here}/on_land/{k}");
605 if matches!(e.verb, Verb::DamagePlayers { .. }) && e.damage_within().is_some() {
606 d.push(Diagnostic::error(
607 ASSEMBLY_LOCK_SHAPE,
608 "quests",
609 format!("{p}/in"),
610 format!(
611 "assembly `{}`'s strike step {j} locks onto a player, and its \
612 `damage-players` ({p}) declares an `in` box. A locked blow lands on the \
613 cells its clip comes down on at the turn it locked to — the compiler \
614 derives that area for every cell it can lock, so a written box is a \
615 second, fixed answer that is wrong at every other cell. Drop the `in`",
616 a.id
617 ),
618 ));
619 }
620 let mut deep = Vec::new();
621 for (seg, _, list) in e.nested_effect_lists_labeled() {
622 nested_damage(list, &format!("{p}/{seg}"), &mut deep);
623 }
624 for q in deep {
625 d.push(Diagnostic::error(
626 ASSEMBLY_LOCK_SHAPE,
627 "quests",
628 q.clone(),
629 format!(
630 "assembly `{}`'s strike step {j} locks onto a player, and a \
631 `damage-players` ({q}) stands inside another effect's list. Only a blow \
632 at the top of a locked step's `on_land` is moved to the cells the clip \
633 comes down on; this one would land nowhere the lock chose. Move it to the \
634 top of `on_land` (a `when` on it is kept)",
635 a.id
636 ),
637 ));
638 }
639 }
640 }
641}
642
643/// `DW0110` over each assembly's id and the rig it names.
644pub(crate) fn assembly_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
645 for (i, a) in c.quests.content.assemblies.iter().enumerate() {
646 crate::ids::id_syntax!(d, a.id, "quests", format!("/content/assemblies/{i}/id"));
647 crate::ids::id_syntax!(d, a.rig, "quests", format!("/content/assemblies/{i}/rig"));
648 }
649}
650
651/// `DW0111` over the assembly ids: unique within the stage-5 assemblies
652/// namespace (spec-0082).
653pub(crate) fn assembly_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
654 crate::ids::dup_check(
655 c.quests
656 .content
657 .assemblies
658 .iter()
659 .enumerate()
660 .map(|(i, a)| (a.id.as_str(), format!("/content/assemblies/{i}/id"))),
661 "quests",
662 "assembly",
663 d,
664 );
665}