Skip to main content

delvewright_dsl/
cutscene.rs

1//! Cutscenes: the camera shots a cutscene is made of and whether the party is shown.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::serde_fields::is_zero3;
7use crate::{ActorId, Mark, NpcId};
8
9/// Whether a `cutscene` shows the party's bodies (spec-0095).
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "kebab-case")]
12pub enum CutsceneParty {
13    /// Each player in play is shown by a stand-in where they stood: a
14    /// `minecraft:mannequin` wearing their own profile (skin) and a copy of
15    /// their armour and held items, removed when the cutscene ends.
16    #[default]
17    Present,
18    /// No stand-ins: the bodies leave the scene for the cutscene's length.
19    Absent,
20}
21
22impl CutsceneParty {
23    /// The kebab token (`present` / `absent`).
24    pub fn token(self) -> &'static str {
25        match self {
26            CutsceneParty::Present => "present",
27            CutsceneParty::Absent => "absent",
28        }
29    }
30}
31
32/// One shot of a [`Verb::Cutscene`] (DSL v0.6): a camera dolly with its
33/// own duration and optional subject. A cutscene plays its shots back-to-back —
34/// a hard cut between them — inside a single gamemode/position save-restore
35/// bracket, so a wide establishing move can be followed by an interior close-up
36/// without the players ever leaving the cinematic.
37#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
38#[serde(deny_unknown_fields)]
39pub struct CameraShot {
40    /// Ordered camera waypoints (straight-line lerp between them). A one-waypoint
41    /// path is a static shot. Required without `shot_style`; with one, optional —
42    /// an explicit `path` always overrides the style's expanded dolly.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub path: Vec<Mark>,
45    /// This shot's duration in seconds. Required without `shot_style`; with one,
46    /// optional — the style's default duration applies (see
47    /// [`ShotStyle::default_seconds`]), and an explicit value always overrides.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub seconds: Option<u32>,
50    /// Optional subject the camera keeps framed for this shot. Absent = face
51    /// along the direction of travel (or, under a `shot_style`, the style's own
52    /// aim at its `subject`). An explicit `look_at` always overrides a style's
53    /// aim.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub look_at: Option<Mark>,
56    /// Shot-style preset (DSL v0.6, spec-0015 shot-grammar library): the
57    /// compiler expands the style deterministically into a camera dolly +
58    /// per-keyframe aim from the `subject`'s resolved geometry. Requires
59    /// `subject`; `path`/`look_at`/`seconds` remain legal and always override
60    /// the corresponding expanded part (`DW0348` polices the combinations).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub shot_style: Option<ShotStyle>,
63    /// The styled shot's subject — what the shot is *about*. Required with
64    /// `shot_style`, rejected without one (`DW0348`).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub subject: Option<CameraSubject>,
67    /// `two-shot` only: the second framed subject (`DW0348` elsewhere).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub subject_b: Option<CameraSubject>,
70    /// Styled shots only: the style's characteristic camera distance in blocks
71    /// (its *start* distance for the dolly styles). Default per style — see the
72    /// `shot_style` table in `docs/reference/compiler.md`. Clamped range 1..=48
73    /// (`DW0348`).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub dist: Option<f64>,
76    /// `orbit-arc` only: the sweep in degrees, 45..=120 (dossier range),
77    /// default 90 (`DW0348` elsewhere or out of range).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub degrees: Option<f64>,
80    /// Styled shots only: placement bearing in degrees — where the camera sits
81    /// (or starts) relative to the subject, measured like a Minecraft yaw
82    /// *from* the subject: `0` puts the camera south of the subject (+Z), `90`
83    /// west (−X), `-90` east (+X), `180` north (−Z). Default `0`. For
84    /// `side-track` the bearing picks which side of the subject's travel the
85    /// camera runs abeam (`0` = right of travel, `180` = left).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub bearing: Option<f64>,
88}
89
90/// A `shot_style` preset (DSL v0.6): the dossier's 9-template library
91/// (`docs/notes/camera-dossier.md` §2), each expanded deterministically by the
92/// compiler into a dolly + aim from the subject's geometry. Camera "lens feel"
93/// is **distance only** — vanilla has no in-game FOV control.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95#[serde(rename_all = "kebab-case")]
96pub enum ShotStyle {
97    /// Fully static close framing of a prop/detail. The beat that always looks
98    /// right.
99    Insert,
100    /// Static position; only the aim turns as the subject (ideally moving)
101    /// passes. Rockstar's roadside "ground view".
102    LockedOff,
103    /// Straight dolly toward the subject along the view axis; medium → close.
104    PushIn,
105    /// Reverse of `push-in`: close → wide, revealing context.
106    PullBackReveal,
107    /// High and far, descending and closing on the subject. First sight of an
108    /// area.
109    EstablishingCrane,
110    /// Constant-radius, constant-height arc around the subject.
111    OrbitArc,
112    /// Parallel dolly abeam a **moving** subject at constant offset — needs a
113    /// compiler-known subject path (`DW0349`).
114    SideTrack,
115    /// Static placement solved so **both** subjects land on opposite thirds
116    /// (Toric-space construction). Needs `subject_b`.
117    TwoShot,
118    /// Low, close, trailing a **moving** subject near ground level — needs a
119    /// compiler-known subject path (`DW0349`).
120    LowFollow,
121}
122
123impl ShotStyle {
124    /// The kebab token (diagnostics, digests).
125    pub fn token(self) -> &'static str {
126        match self {
127            ShotStyle::Insert => "insert",
128            ShotStyle::LockedOff => "locked-off",
129            ShotStyle::PushIn => "push-in",
130            ShotStyle::PullBackReveal => "pull-back-reveal",
131            ShotStyle::EstablishingCrane => "establishing-crane",
132            ShotStyle::OrbitArc => "orbit-arc",
133            ShotStyle::SideTrack => "side-track",
134            ShotStyle::TwoShot => "two-shot",
135            ShotStyle::LowFollow => "low-follow",
136        }
137    }
138
139    /// Default shot duration in seconds, from the dossier's per-style duration
140    /// ranges (§2, anchored on the film-editing ASL literature) — the value an
141    /// omitted `seconds` resolves to.
142    pub fn default_seconds(self) -> u32 {
143        match self {
144            ShotStyle::Insert => 2,
145            ShotStyle::LockedOff => 6,
146            ShotStyle::PushIn => 4,
147            ShotStyle::PullBackReveal => 6,
148            ShotStyle::EstablishingCrane => 8,
149            ShotStyle::OrbitArc => 8,
150            ShotStyle::SideTrack => 8,
151            ShotStyle::TwoShot => 5,
152            ShotStyle::LowFollow => 5,
153        }
154    }
155
156    /// `true` for the styles whose subject must be *moving* on a compiler-known
157    /// path (`move-npc` / `move-actor` in the same effect group or sequence) —
158    /// `side-track` and `low-follow` (`DW0349` otherwise).
159    pub fn needs_moving_subject(self) -> bool {
160        matches!(self, ShotStyle::SideTrack | ShotStyle::LowFollow)
161    }
162}
163
164/// A styled shot's subject (DSL v0.6): the world thing the shot frames — a
165/// prefab anchor point, a stage-2 NPC, or a stage-5 actor — plus an integer
166/// block offset. For `npc`/`actor` subjects the aim point is the entity's cell
167/// **plus one block up** (torso height, so a close shot does not frame feet)
168/// before `offset` is applied; an `anchor` subject aims at the block centre
169/// exactly like a [`CameraTarget`].
170/// Each variant's payload is a **named struct** carrying `deny_unknown_fields`
171/// — serde has no variant-level `deny_unknown_fields`, so an untagged
172/// enum with inline struct variants silently *ignores* any key it does not
173/// recognise: `{"npc": …, "ofset": [0,1,0]}` would deserialize happily with the
174/// offset dropped, and `{"anchor": …, "npc": …}` would quietly match `Anchor`
175/// and discard the NPC. Lifting each variant into its own type keeps the repo-wide
176/// deny-unknown rule for both serde and the published JSON Schema
177/// (`additionalProperties: false`): a mistyped shot subject fails the schema
178/// instead of rendering a shot pointed somewhere the author never asked for.
179#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
180#[serde(untagged)]
181pub enum CameraSubject {
182    /// A fixed world point: prefab anchor + offset.
183    Anchor(Mark),
184    /// A stage-2 NPC — moving if a `move-npc` for it runs in the same effect
185    /// group / sequence, else static at its declared (or spawn) anchor.
186    Npc(NpcSubject),
187    /// A stage-5 actor — moving if a `move-actor` for it runs in the same
188    /// effect group / sequence, else static at its declared anchor.
189    Actor(ActorSubject),
190}
191
192/// A [`CameraSubject::Npc`] payload: a stage-2 NPC.
193#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
194#[serde(deny_unknown_fields)]
195pub struct NpcSubject {
196    /// The NPC (stage-2 ref).
197    pub npc: NpcId,
198    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
199    #[serde(default, skip_serializing_if = "is_zero3")]
200    pub offset: [i32; 3],
201}
202
203/// A [`CameraSubject::Actor`] payload: a stage-5 actor.
204#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
205#[serde(deny_unknown_fields)]
206pub struct ActorSubject {
207    /// The actor (stage-5 `actors` ref).
208    pub actor: ActorId,
209    /// Integer `[x, y, z]` block offset (default `[0, 0, 0]`).
210    #[serde(default, skip_serializing_if = "is_zero3")]
211    pub offset: [i32; 3],
212}
213
214impl CameraSubject {
215    /// The subject's integer offset, whichever variant.
216    pub fn offset(&self) -> [i32; 3] {
217        match self {
218            CameraSubject::Anchor(s) => s.offset,
219            CameraSubject::Npc(s) => s.offset,
220            CameraSubject::Actor(s) => s.offset,
221        }
222    }
223
224    /// A short canonical rendering for digests/diagnostics.
225    pub fn canon(&self) -> String {
226        let (kind, id, o) = match self {
227            CameraSubject::Anchor(s) => ("a", s.anchor.as_str(), &s.offset),
228            CameraSubject::Npc(s) => ("n", s.npc.as_str(), &s.offset),
229            CameraSubject::Actor(s) => ("c", s.actor.as_str(), &s.offset),
230        };
231        format!("{kind}:{id}@{},{},{}", o[0], o[1], o[2])
232    }
233}
234
235/// `Debug` is hand-written because it is a **stable content-key rendering**:
236/// the compiler's `payload_verb_key` (FNV over a verb's own `{:?}`) names the
237/// generated `volley_`, `collapse_` and `teleport_` functions from it, so a shot
238/// that uses none of the v0.6 style fields must render byte-identically to the
239/// pre-style struct (`seconds` prints its inner value; absent style fields print
240/// nothing) — otherwise a purely additive schema change would silently churn the
241/// content key of every payload that carries a cutscene.
242impl std::fmt::Debug for CameraShot {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        let mut d = f.debug_struct("CameraShot");
245        d.field("path", &self.path);
246        match self.seconds {
247            Some(v) => d.field("seconds", &v),
248            None => d.field("seconds", &self.seconds),
249        };
250        d.field("look_at", &self.look_at);
251        if self.shot_style.is_some() {
252            d.field("shot_style", &self.shot_style);
253        }
254        if self.subject.is_some() {
255            d.field("subject", &self.subject);
256        }
257        if self.subject_b.is_some() {
258            d.field("subject_b", &self.subject_b);
259        }
260        if self.dist.is_some() {
261            d.field("dist", &self.dist);
262        }
263        if self.degrees.is_some() {
264            d.field("degrees", &self.degrees);
265        }
266        if self.bearing.is_some() {
267            d.field("bearing", &self.bearing);
268        }
269        d.finish()
270    }
271}
272
273impl CameraShot {
274    /// The shot's resolved duration in seconds: explicit `seconds`, else the
275    /// style default, else `1` (a shape-invalid shot — `DW0199` reports it; the
276    /// fallback only keeps downstream passes total).
277    pub fn resolved_seconds(&self) -> u32 {
278        self.seconds
279            .or(self.shot_style.map(ShotStyle::default_seconds))
280            .unwrap_or(1)
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
286// ---------------------------------------------------------------------------
287
288use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
289use crate::envelope::Campaign;
290use crate::{QuestEffect, Verb};
291use std::collections::BTreeSet;
292
293crate::dw_code! {
294    /// (v0.6) A `cutscene` effect's shape is invalid: it mixes the multi-shot
295    /// `shots` list with the single-shot `path`/`seconds` fields, gives neither,
296    /// or declares a shot with an empty camera `path`. A cutscene must resolve to
297    /// at least one shot, and every shot to at least one camera position.
298    pub const CUTSCENE_SHAPE: DwCode = DwCode::new("DW0199", ExitTier::Build);
299}
300
301crate::dw_code! {
302    /// (v0.6) A `shot_style` declaration is semantically invalid (spec-0015 shot
303    /// grammar): a styled shot with no `subject`; style-only fields (`subject`,
304    /// `subject_b`, `dist`, `degrees`, `bearing`) on an unstyled shot; a
305    /// `subject_b` on a style other than `two-shot` (or a `two-shot` without
306    /// one); `degrees` off `orbit-arc` or outside `45..=120`; `dist` outside
307    /// `1..=48`; or `bearing` outside `-360..=360`. Validation-tier (exit 1).
308    pub const SHOT_STYLE_INVALID: DwCode = DwCode::new("DW0348", ExitTier::Build);
309}
310
311crate::dw_code! {
312    /// (v0.6) A `side-track` / `low-follow` shot whose subject has no
313    /// compiler-known motion: those styles dolly *with* a moving subject, so the
314    /// subject must be an NPC/actor with a matching `move-npc`/`move-actor` in
315    /// the same effect group or the same `sequence` timeline (an `anchor`
316    /// subject can never move). Validation-tier (exit 1). Use `locked-off` /
317    /// `push-in` for a static subject instead.
318    pub const SHOT_SUBJECT_UNMOVED: DwCode = DwCode::new("DW0349", ExitTier::Build);
319}
320
321/// `cutscene` shape (`DW0199`): a cutscene is written either multi-shot
322/// (`shots: [...]`, DSL v0.6) or single-shot (`path` + `seconds`, DSL v0.4) —
323/// never both, never neither — and every resolved shot needs at least one camera
324/// waypoint. The two spellings normalize to the same shot list
325/// ([`QuestEffect::cutscene_shots`]), so this is the one place the shape is
326/// policed; emission may then assume a non-empty, well-formed list.
327pub(crate) fn check_cutscene_shape(eff: &QuestEffect, base_path: &str, d: &mut Vec<Diagnostic>) {
328    let Verb::Cutscene {
329        shots,
330        path,
331        seconds,
332        ..
333    } = &eff.verb
334    else {
335        return;
336    };
337    let single = !path.is_empty() || seconds.is_some();
338    let err = |d: &mut Vec<Diagnostic>, field: &str, msg: String| {
339        d.push(Diagnostic::error(
340            CUTSCENE_SHAPE,
341            "quests",
342            format!("{base_path}/{field}"),
343            msg,
344        ));
345    };
346    match (!shots.is_empty(), single) {
347        (true, true) => err(
348            d,
349            "shots",
350            "`cutscene` mixes the multi-shot `shots` list with the single-shot \
351             `path`/`seconds` fields — use one form: move the single-shot fields into a `shots` \
352             entry, or drop `shots`"
353                .to_string(),
354        ),
355        (false, false) => err(
356            d,
357            "shots",
358            "`cutscene` declares no shot — give a `shots` list of \
359             `{path, seconds, look_at?}` (multi-shot), or a single-shot `path` + `seconds`"
360                .to_string(),
361        ),
362        (false, true) if seconds.is_none() => err(
363            d,
364            "seconds",
365            "single-shot `cutscene` is missing `seconds` — every shot needs a duration".to_string(),
366        ),
367        _ => {}
368    }
369    for (i, shot) in shots.iter().enumerate() {
370        // A `shot_style` supplies both a default dolly and a default duration
371        // (spec-0015), so `path`/`seconds` become optional overrides on a
372        // styled shot; the style's own shape is policed by `DW0348`/`DW0349`.
373        if shot.shot_style.is_some() {
374            continue;
375        }
376        if shot.path.is_empty() {
377            err(
378                d,
379                &format!("shots/{i}/path"),
380                "`cutscene` shot has an empty camera `path` — give at least one waypoint (one \
381                 waypoint is a static shot, two or more is a dolly), or use a `shot_style`"
382                    .to_string(),
383            );
384        }
385        if shot.seconds.is_none() {
386            err(
387                d,
388                &format!("shots/{i}/seconds"),
389                "`cutscene` shot is missing `seconds` — every unstyled shot needs an explicit \
390                 duration (a `shot_style` would supply a default)"
391                    .to_string(),
392            );
393        }
394    }
395    if shots.is_empty() && single && path.is_empty() {
396        err(
397            d,
398            "path",
399            "single-shot `cutscene` has an empty camera `path` — give at least one waypoint (one \
400             waypoint is a static shot, two or more is a dolly)"
401                .to_string(),
402        );
403    }
404}
405
406/// Shot-style semantics (DSL v0.6, spec-0015 shot grammar): `DW0348` for
407/// invalid style/param combinations, `DW0112` for a subject referencing an
408/// unknown npc/actor, and `DW0349` for a `side-track`/`low-follow` whose
409/// subject provably cannot move.
410///
411/// The moving-subject scope mirrors the compiler's expansion resolution
412/// exactly: a subject "moves" if a matching `move-npc`/`move-actor` runs in the
413/// **same effect list**, or anywhere in the **same `sequence` timeline**
414/// (including the list that launched the sequence). Nested reaction lists
415/// (`on_arrive`/`on_caught`/`on_respawn`) start a fresh scope — their firing
416/// time is unknowable statically, so motion outside them is never assumed.
417pub(crate) fn cutscene_style_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
418    use crate::{CameraSubject, ShotStyle};
419    let quests = &c.quests.content;
420    let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
421    let npc_ids = &npc_ids;
422    let actor_ids: BTreeSet<&str> = quests.actors.iter().map(|a| a.id.as_str()).collect();
423
424    /// The sibling moves visible to a cutscene: `(is_actor, id)`.
425    fn moves_in(list: &[QuestEffect], scope: &mut Vec<(bool, String)>) {
426        for e in list {
427            match &e.verb {
428                Verb::MoveNpc { npc, .. } => scope.push((false, npc.to_string())),
429                Verb::MoveActor { actor, .. } => scope.push((true, actor.to_string())),
430                Verb::Sequence { steps } => {
431                    // A sequence launched from this list shares its timeline.
432                    for st in steps {
433                        moves_in(&st.effects, scope);
434                    }
435                }
436                _ => {}
437            }
438        }
439    }
440
441    fn subject_check(
442        sub: &CameraSubject,
443        field: &str,
444        path: &str,
445        npc_ids: &BTreeSet<&str>,
446        actor_ids: &BTreeSet<&str>,
447        d: &mut Vec<Diagnostic>,
448    ) {
449        let (unknown, kind, id) = match sub {
450            CameraSubject::Anchor(_) => return,
451            CameraSubject::Npc(s) => (!npc_ids.contains(s.npc.as_str()), "npc", s.npc.as_str()),
452            CameraSubject::Actor(s) => (
453                !actor_ids.contains(s.actor.as_str()),
454                "actor",
455                s.actor.as_str(),
456            ),
457        };
458        if unknown {
459            d.push(Diagnostic::error(
460                codes::DANGLING_REF,
461                "quests",
462                format!("{path}/{field}"),
463                format!(
464                    "shot `{field}` references unknown {kind} `{id}` — declare it (stage 2 npcs / \
465                     stage-5 `actors`) or correct the reference"
466                ),
467            ));
468        }
469    }
470
471    fn check_shot_list(
472        eff: &QuestEffect,
473        scope: &[(bool, String)],
474        path: &str,
475        npc_ids: &BTreeSet<&str>,
476        actor_ids: &BTreeSet<&str>,
477        d: &mut Vec<Diagnostic>,
478    ) {
479        let Verb::Cutscene { shots, .. } = &eff.verb else {
480            return;
481        };
482        for (i, shot) in shots.iter().enumerate() {
483            let spath = format!("{path}/shots/{i}");
484            let err = |d: &mut Vec<Diagnostic>, field: &str, msg: String| {
485                d.push(Diagnostic::error(
486                    SHOT_STYLE_INVALID,
487                    "quests",
488                    format!("{spath}/{field}"),
489                    msg,
490                ));
491            };
492            let Some(style) = shot.shot_style else {
493                for (field, present) in [
494                    ("subject", shot.subject.is_some()),
495                    ("subject_b", shot.subject_b.is_some()),
496                    ("dist", shot.dist.is_some()),
497                    ("degrees", shot.degrees.is_some()),
498                    ("bearing", shot.bearing.is_some()),
499                ] {
500                    if present {
501                        err(
502                            d,
503                            field,
504                            format!(
505                                "`{field}` is a `shot_style` parameter but this shot declares no \
506                                 `shot_style` — add one, or drop the field"
507                            ),
508                        );
509                    }
510                }
511                continue;
512            };
513            let token = style.token();
514            match &shot.subject {
515                None => err(
516                    d,
517                    "subject",
518                    format!(
519                        "`shot_style: {token}` needs a `subject` — the anchor, npc, or actor the \
520                         shot frames"
521                    ),
522                ),
523                Some(sub) => subject_check(sub, "subject", &spath, npc_ids, actor_ids, d),
524            }
525            match (&shot.subject_b, style == ShotStyle::TwoShot) {
526                (None, true) => err(
527                    d,
528                    "subject_b",
529                    "`shot_style: two-shot` frames two subjects — give `subject_b`".to_string(),
530                ),
531                (Some(_), false) => err(
532                    d,
533                    "subject_b",
534                    format!("`subject_b` is only meaningful on `two-shot`, not `{token}`"),
535                ),
536                (Some(sub), true) => subject_check(sub, "subject_b", &spath, npc_ids, actor_ids, d),
537                (None, false) => {}
538            }
539            if let Some(g) = shot.degrees {
540                if style != ShotStyle::OrbitArc {
541                    err(
542                        d,
543                        "degrees",
544                        format!("`degrees` is only meaningful on `orbit-arc`, not `{token}`"),
545                    );
546                } else if !(45.0..=120.0).contains(&g) {
547                    err(
548                        d,
549                        "degrees",
550                        format!(
551                            "`orbit-arc` sweep `{g}` is outside `45..=120` degrees (the dossier's \
552                             readable-orbit range)"
553                        ),
554                    );
555                }
556            }
557            if let Some(dist) = shot.dist
558                && !(1.0..=48.0).contains(&dist)
559            {
560                err(
561                    d,
562                    "dist",
563                    format!("`dist` `{dist}` is outside the sane `1..=48` block range"),
564                );
565            }
566            if let Some(b) = shot.bearing
567                && !(-360.0..=360.0).contains(&b)
568            {
569                err(
570                    d,
571                    "bearing",
572                    format!("`bearing` `{b}` is outside `-360..=360` degrees"),
573                );
574            }
575            if style.needs_moving_subject() {
576                let moved = match &shot.subject {
577                    Some(CameraSubject::Npc(s)) => {
578                        scope.iter().any(|(a, id)| !a && id == s.npc.as_str())
579                    }
580                    Some(CameraSubject::Actor(s)) => {
581                        scope.iter().any(|(a, id)| *a && id == s.actor.as_str())
582                    }
583                    // An anchor can never move; a missing subject already got DW0348.
584                    Some(CameraSubject::Anchor(_)) => false,
585                    None => true,
586                };
587                if !moved {
588                    d.push(Diagnostic::error(
589                        SHOT_SUBJECT_UNMOVED,
590                        "quests",
591                        format!("{spath}/subject"),
592                        format!(
593                            "`shot_style: {token}` dollies with a MOVING subject, but this \
594                             subject has no matching `move-npc`/`move-actor` in the same effect \
595                             group or sequence — add the move alongside the cutscene, or use a \
596                             static style (`locked-off`, `push-in`)"
597                        ),
598                    ));
599                }
600            }
601        }
602    }
603
604    /// Walk an effect list with its move scope; recurse into nested lists.
605    fn walk_list(
606        list: &[QuestEffect],
607        outer_scope: &[(bool, String)],
608        path: &str,
609        npc_ids: &BTreeSet<&str>,
610        actor_ids: &BTreeSet<&str>,
611        d: &mut Vec<Diagnostic>,
612    ) {
613        let mut scope = outer_scope.to_vec();
614        moves_in(list, &mut scope);
615        for (j, e) in list.iter().enumerate() {
616            let epath = format!("{path}/{j}");
617            check_shot_list(e, &scope, &epath, npc_ids, actor_ids, d);
618            for (pseg, _kseg, inner) in e.nested_effect_lists_labeled() {
619                // A sequence step shares this timeline's scope; reaction lists
620                // (`on_arrive`/`on_caught`/`on_respawn`) fire at an unknowable
621                // time and start fresh.
622                let inherited: &[(bool, String)] = if matches!(&e.verb, Verb::Sequence { .. }) {
623                    &scope
624                } else {
625                    &[]
626                };
627                walk_list(
628                    inner,
629                    inherited,
630                    &format!("{epath}/{pseg}"),
631                    npc_ids,
632                    actor_ids,
633                    d,
634                );
635            }
636        }
637    }
638
639    for (i, q) in quests.quests.iter().enumerate() {
640        for (key, effs) in &q.on_objective_complete {
641            walk_list(
642                effs,
643                &[],
644                &format!("/content/quests/{i}/on_objective_complete/{key}"),
645                npc_ids,
646                &actor_ids,
647                d,
648            );
649        }
650        walk_list(
651            &q.on_complete,
652            &[],
653            &format!("/content/quests/{i}/on_complete"),
654            npc_ids,
655            &actor_ids,
656            d,
657        );
658    }
659    for (i, t) in quests.triggers.iter().enumerate() {
660        walk_list(
661            &t.effects,
662            &[],
663            &format!("/content/triggers/{i}/effects"),
664            npc_ids,
665            &actor_ids,
666            d,
667        );
668    }
669}