Skip to main content

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}