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}