Skip to main content

delvewright_dsl/
body.rs

1//! Body traversal — the declaration every body that moves carries (DSL v0.11,
2//! spec-0034), and the one enumeration of the campaign's bodies.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::{Actor, AreaId, ClassId, Mark, Npc, NpcSkin};
8
9#[cfg(doc)]
10use crate::WaveMob;
11
12/// How a body gets around.
13///
14/// The compiler DERIVES this from the entity id for every body (spiders climb,
15/// ghasts fly, `#minecraft:aquatic` swims, and everything else — including every
16/// id the table has never heard of — is [`Locomotion::Ground`], the checked
17/// class). [`BodyTraversal`] is the author's side of the same vocabulary: one
18/// enum, so a declaration and a derivation can never mean different things.
19///
20/// The vocabulary lives in this crate rather than in the compiler because it is
21/// now DSL surface; the compiler re-exports it and owns the derivation table
22/// (`compiler::traversal`).
23#[derive(
24    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
25)]
26#[serde(rename_all = "kebab-case")]
27pub enum Locomotion {
28    /// Walks, steps and jumps — the default and the CHECKED class.
29    Ground,
30    /// Climbs sheer vertical surfaces (vanilla's `Spider` class).
31    Climber,
32    /// Leaves the ground under its own power.
33    Flier,
34    /// A member of vanilla's `#minecraft:aquatic` tag. A ledger classification
35    /// that exempts nothing, which is why it may not be **declared**
36    /// (`DW0455`) — see [`BodyTraversal`].
37    Aquatic,
38}
39
40impl Locomotion {
41    /// The stable kebab token this class is written and reported under.
42    pub fn token(self) -> &'static str {
43        match self {
44            Locomotion::Ground => "ground",
45            Locomotion::Climber => "climber",
46            Locomotion::Flier => "flier",
47            Locomotion::Aquatic => "aquatic",
48        }
49    }
50
51    /// Every class, in ledger order — so a report can never silently drop a row
52    /// when a class is added.
53    pub const ALL: [Locomotion; 4] = [
54        Locomotion::Ground,
55        Locomotion::Climber,
56        Locomotion::Flier,
57        Locomotion::Aquatic,
58    ];
59}
60
61/// What a body can do when it moves, **declared by the author** (DSL v0.11,
62/// spec-0034).
63///
64/// Carried by every object class in the DSL that has a body and a position and
65/// is walked by a compiler-emitted route — the stage-2 [`Npc`] and the stage-5
66/// [`Actor`]. It is deliberately one shared type on both rather than a field
67/// per consumer: traversal is a property of a body that moves, not of the verb
68/// that first needed it (CLAUDE.md), and a second bespoke field would be the
69/// defect rather than the fix.
70///
71/// **A declaration is a claim the build holds you to, never an opt-out.** The
72/// compiler compares the verdicts this body earns under the declared class
73/// against the ones it earns under its species' derived class; a declaration
74/// that changes no verdict is inert and is `DW0454`. So declaring `climber` on
75/// a sheep is only accepted where that sheep's route really does go over a
76/// barrier line — the exception is authored and proven, instead of happening by
77/// accident and merely rendering.
78///
79/// **What is deliberately NOT here: `opens_gates`.** Passing a closed fence gate
80/// is a right-click, a scripted walk is a compiler-emitted `tp` polyline whose
81/// puppet performs no interaction at all, and no runtime verb changes a fence
82/// gate's block state. Declaring it would not make it true, so the error tier
83/// (`DW0452`) has no authorable exemption and a declaration can never reach it.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(deny_unknown_fields)]
86pub struct BodyTraversal {
87    /// How this body gets around, overriding what its entity id implies.
88    pub locomotion: Locomotion,
89}
90
91/// **A still body that turns to face a player** (spec-0101) — the declaration
92/// both body classes carry, as one type, because facing belongs to the body and
93/// not to the verb that first wanted it turned.
94///
95/// The run-time turn is `execute … anchored eyes run rotate <body> facing
96/// entity <player> eyes` (the body's eyes to the player's eyes), re-issued
97/// from the root `tick` by one generated function (`watch_tick`): every tick a
98/// player [`Self::who`] names stands within [`Self::within`] blocks of the
99/// body's feet, the body faces that player's eyes with its own, body and head
100/// together; with nobody in reach it keeps its last facing; while a `move-npc` /
101/// `move-actor` walks it, the walk owns the yaw and the watch resumes on the tick
102/// after the arrival.
103///
104/// Both fields are required: whom the figure watches and how far it sees are the
105/// creator's, so neither has a default.
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107#[serde(deny_unknown_fields)]
108pub struct BodyWatch {
109    /// Whom the body faces: `"nearest"` (the nearest player), or
110    /// `{ "class": "class/<id>" }` (the nearest player wearing that class).
111    pub who: WatchWho,
112    /// How far the body sees, in whole blocks `>= 1`, measured as the game
113    /// measures an entity selector's `distance`: from the body's feet to the
114    /// player's.
115    pub within: std::num::NonZeroU32,
116}
117
118/// Whom a watching body faces ([`BodyWatch::who`]): the bare keyword
119/// `"nearest"`, or a class filter. Untagged, and each variant's payload is its
120/// own type, so a mistyped key fails the schema instead of matching the wrong
121/// arm (see `CameraSubject`).
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123#[serde(untagged)]
124pub enum WatchWho {
125    /// `"nearest"`: the nearest player, whoever they are.
126    Nearest(WatchNearest),
127    /// `{ "class": "class/<id>" }`: the nearest player who took that class.
128    Class(WatchClass),
129}
130
131impl WatchWho {
132    /// The class this watch is filtered to, if any.
133    pub fn class(&self) -> Option<&ClassId> {
134        match self {
135            WatchWho::Nearest(_) => None,
136            WatchWho::Class(c) => Some(&c.class),
137        }
138    }
139
140    /// The stable token a report writes this under: `nearest`, or the class id.
141    pub fn token(&self) -> String {
142        match self {
143            WatchWho::Nearest(_) => "nearest".to_string(),
144            WatchWho::Class(c) => c.class.as_str().to_string(),
145        }
146    }
147}
148
149/// The keyword form of [`WatchWho`].
150#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151#[serde(rename_all = "kebab-case")]
152pub enum WatchNearest {
153    /// The nearest player.
154    Nearest,
155}
156
157/// The class-filter form of [`WatchWho`].
158#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
159#[serde(deny_unknown_fields)]
160pub struct WatchClass {
161    /// The class (stage-3 ref) whose players this body watches.
162    pub class: ClassId,
163}
164
165/// One object class that has a **body the compiler stages**: a declared
166/// position, a declared species, and a compiler-emitted route.
167///
168/// A sum type rather than a flattened tuple, so **adding a body class is a
169/// compile error at every consumer** until each one says what it does with it.
170/// The alternative — each rule walking the classes it happens to remember — is
171/// the defect class CLAUDE.md names: a hand-rolled walk that
172/// enumerated three of five effect roots.
173///
174/// **Keyed to the object, not to the verb that first needed it.** This type was
175/// introduced for [`BodyTraversal`] and read, for a while, as "one consumer of
176/// `BodyTraversal`" — which is why its only enumeration
177/// ([`body_traversal_sites`]) was filtered to bodies declaring a traversal, and
178/// why the second property a body carries, its [`NpcSkin`], had no enumeration
179/// at all: the bake walked the stage-2 npc list by hand, and an actor's skin was
180/// emitted into the summon, never baked into the pack, and never refused.
181/// A body's properties belong to the body. [`body_sites`] is the unfiltered
182/// walk; a per-property enumeration is a *filter* over it, never a second walk.
183///
184/// Deliberately NOT a member: [`WaveMob`]. A wave mob has a body and a position,
185/// but it is driven by **native vanilla AI**, never by a compiler-emitted route,
186/// so the compiler makes no claim about the moves it makes and a locomotion
187/// declaration on it could change no verdict. It declares no `skin` either — the
188/// exclusion holds for both properties, and the schema says so
189/// (`crates/dsl/tests/body_skin_sites.rs`). It becomes a member the day the lane
190/// proof reasons about how its bodies move — and it joins here, through this
191/// same type, rather than through a field of its own.
192#[derive(Clone, Copy, Debug)]
193pub enum BodyRef<'a> {
194    /// A stage-2 NPC, walked by `move-npc`.
195    Npc(&'a Npc),
196    /// A stage-5 scripted actor, walked by `move-actor`.
197    Actor(&'a Actor),
198}
199
200impl<'a> BodyRef<'a> {
201    /// The declaring stage's wire name (`npcs` / `quests`) — also the stage
202    /// whose `dsl_version` fences this body's declaration.
203    pub fn stage(self) -> &'static str {
204        match self {
205            BodyRef::Npc(_) => "npcs",
206            BodyRef::Actor(_) => "quests",
207        }
208    }
209
210    /// The body's declared id.
211    pub fn id(self) -> &'a str {
212        match self {
213            BodyRef::Npc(n) => n.id.as_str(),
214            BodyRef::Actor(a) => a.id.as_str(),
215        }
216    }
217
218    /// The entity id written on the body. **Not necessarily the body that
219    /// ships**: a `skin` re-dresses it as a `minecraft:mannequin` — see
220    /// [`Self::worn_entity`].
221    pub fn declared_entity(self) -> &'a str {
222        match self {
223            BodyRef::Npc(n) => n.base_entity.as_str(),
224            BodyRef::Actor(a) => a.entity.as_str(),
225        }
226    }
227
228    /// **The entity id the body ships as**: `minecraft:mannequin` when it
229    /// declares a `skin`, else the declared entity. The one authority for that
230    /// rule — the compiler's geometric proofs (`nav::npc_body_entity`,
231    /// `nav::actor_body_entity`) and the equipment fit rule (`DW0898`) all read
232    /// it.
233    pub fn worn_entity(self) -> &'a str {
234        match self.skin() {
235            Some(_) => "minecraft:mannequin",
236            None => self.declared_entity(),
237        }
238    }
239
240    /// **The mark this body is placed on** — the anchor and offset the engine
241    /// summons it at, for every class alike (spec-0066).
242    ///
243    /// A body's placement is a property of the body, not of the stage list that
244    /// happens to declare it: a mark is a cell, and a cell holds one body. The
245    /// rule that reads this ([`crate::compiler`]'s `DW0896`, via
246    /// [`body_sites`]) therefore quantifies over npcs and actors in one pass
247    /// rather than over `actors[]`, which is where the seven-men-one-anchor
248    /// muster came from.
249    pub fn mark(self) -> Mark {
250        let (anchor, offset) = match self {
251            BodyRef::Npc(n) => (&n.anchor, n.offset),
252            BodyRef::Actor(a) => (&a.anchor, a.offset),
253        };
254        Mark {
255            anchor: anchor.clone(),
256            offset,
257        }
258    }
259
260    /// The area whose anchor table resolves [`Self::mark`]'s anchor first, when this
261    /// class declares one.
262    ///
263    /// A stage-2 npc names its area and is resolved inside it; a stage-5 actor
264    /// names none and is resolved across every placed piece, exactly as an
265    /// `open-gate` / `move-actor` destination is. Stated here so the resolution
266    /// rule is one rule over both classes and not a per-call-site habit.
267    pub fn area(self) -> Option<&'a AreaId> {
268        match self {
269            BodyRef::Npc(n) => Some(&n.area),
270            BodyRef::Actor(_) => None,
271        }
272    }
273
274    /// Whether this body stands on its mark from **world init**, with no effect
275    /// having to fire.
276    ///
277    /// A stage-2 npc does unless it is `deferred`; a stage-5 actor never does —
278    /// a puppet exists only from the `spawn-actor` that summons it, which is why
279    /// an actor no `spawn-actor` names never exists at all.
280    pub fn at_world_init(self) -> bool {
281        match self {
282            BodyRef::Npc(n) => !n.deferred,
283            BodyRef::Actor(_) => false,
284        }
285    }
286
287    /// Whether a **player** can end this body's life.
288    ///
289    /// An npc body is emitted `Invulnerable:1b` unconditionally, so nothing a
290    /// player does removes it; an actor's puppet is `Invulnerable` unless it
291    /// declares [`Actor::vulnerable`]. A body a player can kill is one whose
292    /// lifetime the compiler cannot bound, which is the whole of what this
293    /// answers.
294    pub fn killable_by_players(self) -> bool {
295        match self {
296            BodyRef::Npc(_) => false,
297            BodyRef::Actor(a) => a.vulnerable,
298        }
299    }
300
301    /// This body's traversal declaration, if it carries one.
302    pub fn traversal(self) -> Option<&'a BodyTraversal> {
303        match self {
304            BodyRef::Npc(n) => n.traversal.as_ref(),
305            BodyRef::Actor(a) => a.traversal.as_ref(),
306        }
307    }
308
309    /// This body's skin declaration, if it carries one.
310    ///
311    /// A skinned body of **either** class ships as a `minecraft:mannequin`
312    /// whose `profile.texture` resolves to `delvewright:npc/<campaign_id>/<texture_id>`,
313    /// so either one owes the same `skins/<texture_id>.png` under the same refusal
314    /// (`DW0309`). Answering it here is what stops the bake from being a
315    /// property of one class.
316    pub fn skin(self) -> Option<&'a NpcSkin> {
317        match self {
318            BodyRef::Npc(n) => n.skin.as_ref(),
319            BodyRef::Actor(a) => a.skin.as_ref(),
320        }
321    }
322
323    /// This body's watch declaration (spec-0101), if it carries one.
324    pub fn watch(self) -> Option<&'a BodyWatch> {
325        match self {
326            BodyRef::Npc(n) => n.watch.as_ref(),
327            BodyRef::Actor(a) => a.watch.as_ref(),
328        }
329    }
330
331    /// This class's name in the JSON Schema export (`delvec schema --stage all`).
332    ///
333    /// The join between the closed Rust set and the schema, which is the only
334    /// authority on *which object classes declare what*. `body_skin_sites.rs`
335    /// compares the two.
336    pub fn class(self) -> &'static str {
337        match self {
338            BodyRef::Npc(_) => "Npc",
339            BodyRef::Actor(_) => "Actor",
340        }
341    }
342
343    /// Every body class, by schema name. The closed set, stated once.
344    pub const ALL_CLASSES: [&'static str; 2] = ["Npc", "Actor"];
345}
346
347/// A staged body declaration, with the JSON pointer at the declaration itself.
348///
349/// The pointer is at the OBJECT (`/content/npcs/3`), not at any one of its
350/// fields: a per-property site appends its own field name. A pointer built per
351/// property is how two walks of one population start disagreeing about where a
352/// thing was declared.
353#[derive(Clone, Debug)]
354pub struct BodySite<'a> {
355    /// Which object class declared it, and the object itself.
356    pub body: BodyRef<'a>,
357    /// JSON pointer at the declaration, for a diagnostic path.
358    pub path: String,
359}
360
361/// **Every** body the campaign declares, in stage order: stage-2 npcs in
362/// declaration order, then stage-5 actors in declaration order.
363///
364/// The one walk of the campaign's bodies. A rule about a property a body carries
365/// is a *filter* over this ([`body_traversal_sites`], [`body_skin_sites`]) — never
366/// a second traversal, and never a hand-written loop over one stage's list,
367/// which is exactly how an actor's skin came to be emitted but never baked.
368pub fn body_sites(c: &crate::envelope::Campaign) -> Vec<BodySite<'_>> {
369    let mut out: Vec<BodySite<'_>> = Vec::new();
370    for (i, n) in c.npcs.content.npcs.iter().enumerate() {
371        out.push(BodySite {
372            body: BodyRef::Npc(n),
373            path: format!("/content/npcs/{i}"),
374        });
375    }
376    for (i, a) in c.quests.content.actors.iter().enumerate() {
377        out.push(BodySite {
378            body: BodyRef::Actor(a),
379            path: format!("/content/actors/{i}"),
380        });
381    }
382    out
383}
384
385/// A body that carries a [`BodyTraversal`] declaration, with the JSON pointer at
386/// it.
387#[derive(Clone, Debug)]
388pub struct BodyTraversalSite<'a> {
389    /// Which object class declared it, and the object itself.
390    pub body: BodyRef<'a>,
391    /// JSON pointer at the `traversal` field, for a diagnostic path.
392    pub path: String,
393    /// The declaration.
394    pub traversal: &'a BodyTraversal,
395}
396
397/// Every body in the campaign that DECLARES a traversal, in stage order.
398///
399/// The one enumeration of the declaration's consumers, shared by the DSL's value
400/// check (`DW0455`) and by the compiler's proof (`DW0454`), so "which object
401/// classes carry this" is answered in exactly one place.
402pub fn body_traversal_sites(c: &crate::envelope::Campaign) -> Vec<BodyTraversalSite<'_>> {
403    body_sites(c)
404        .into_iter()
405        .filter_map(|s| {
406            s.body.traversal().map(|t| BodyTraversalSite {
407                body: s.body,
408                path: format!("{}/traversal", s.path),
409                traversal: t,
410            })
411        })
412        .collect()
413}
414
415/// A body that carries an [`NpcSkin`] declaration, with the JSON pointer at it.
416#[derive(Clone, Debug)]
417pub struct BodySkinSite<'a> {
418    /// Which object class declared it, and the object itself.
419    pub body: BodyRef<'a>,
420    /// JSON pointer at the `skin` field, for a diagnostic path.
421    pub path: String,
422    /// The declaration.
423    pub skin: &'a NpcSkin,
424}
425
426/// Every body in the campaign that DECLARES a skin, in stage order.
427///
428/// The one enumeration of what the resource-pack bake must serve and what
429/// `DW0309` must refuse, for every class alike — a skinned npc and a skinned
430/// actor are the same fact about two bodies.
431///
432/// **Declarations, not textures.** Two bodies may name one `texture_id` (an npc
433/// and the puppet that plays it), and the caller decides what that means; the
434/// bake reads each file once.
435pub fn body_skin_sites(c: &crate::envelope::Campaign) -> Vec<BodySkinSite<'_>> {
436    body_sites(c)
437        .into_iter()
438        .filter_map(|s| {
439            s.body.skin().map(|k| BodySkinSite {
440                body: s.body,
441                path: format!("{}/skin", s.path),
442                skin: k,
443            })
444        })
445        .collect()
446}
447
448/// A body that carries a [`BodyWatch`] declaration, with the JSON pointer at it.
449#[derive(Clone, Debug)]
450pub struct BodyWatchSite<'a> {
451    /// Which object class declared it, and the object itself.
452    pub body: BodyRef<'a>,
453    /// JSON pointer at the `watch` field, for a diagnostic path.
454    pub path: String,
455    /// The declaration.
456    pub watch: &'a BodyWatch,
457}
458
459/// Every body in the campaign that DECLARES a watch, in stage order (spec-0101).
460///
461/// The one enumeration of the watchers, shared by the class refusal
462/// (`DW0996`), the drawability proof (`DW0997`), the emitter (`watch_tick`, the
463/// summon tag, the walk-driver yield), the generated PackTests and the bot's
464/// record — a filter over [`body_sites`], so a third body class is a compile
465/// error at every consumer until it says what it does with a watch.
466pub fn body_watch_sites(c: &crate::envelope::Campaign) -> Vec<BodyWatchSite<'_>> {
467    body_sites(c)
468        .into_iter()
469        .filter_map(|s| {
470            s.body.watch().map(|w| BodyWatchSite {
471                body: s.body,
472                path: format!("{}/watch", s.path),
473                watch: w,
474            })
475        })
476        .collect()
477}
478
479/// The **mutable mirror** of [`body_skin_sites`]: every skin declaration in the
480/// campaign, in the identical order, exposed mutably so one pass can rewrite what
481/// every emitter will read ([`crate::l10n::namespace_skin_textures`]).
482///
483/// It carries no [`BodyRef`] and no pointer, because a rewrite needs neither and a
484/// borrow of the whole body would forbid the field it is there to change. What it
485/// does owe is the **same population**: a body class that declares a skin and is
486/// missing here would keep an un-namespaced texture and collide with every other
487/// delve, silently. `body_skin_sites_mut_is_the_same_walk`
488/// (`crates/dsl/tests/body_skin_sites.rs`) pins that over a campaign carrying one
489/// body of every class in [`BodyRef::ALL_CLASSES`] — the closed set the schema
490/// export is compared against in the same file, so a new body class turns that
491/// coverage red and both walks are visited together.
492pub fn body_skins_mut(c: &mut crate::envelope::Campaign) -> Vec<&mut NpcSkin> {
493    let mut out: Vec<&mut NpcSkin> = Vec::new();
494    out.extend(
495        c.npcs
496            .content
497            .npcs
498            .iter_mut()
499            .filter_map(|n| n.skin.as_mut()),
500    );
501    out.extend(
502        c.quests
503            .content
504            .actors
505            .iter_mut()
506            .filter_map(|a| a.skin.as_mut()),
507    );
508    out
509}
510
511// ---------------------------------------------------------------------------
512// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
513// ---------------------------------------------------------------------------
514
515use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
516use crate::envelope::Campaign;
517
518crate::dw_code! {
519    /// (v0.11, spec-0034) **A declared locomotion the engine cannot hold the
520    /// body to** — today exactly one value, `aquatic`.
521    ///
522    /// The declaration surface exists so an author can claim a capability and
523    /// have the claim PROVEN. `aquatic` is the one
524    /// class that carries no exemption and governs no rule: it is a ledger
525    /// label the compiler derives from vanilla's own `#minecraft:aquatic` tag.
526    /// Declaring it could therefore never change a verdict, so it would always
527    /// land in `DW0454` — and a value whose only possible outcome is another
528    /// diagnostic is a trap, not a surface.
529    ///
530    /// The gap it names, stated rather than left to folklore (CLAUDE.md's
531    /// no-hack rule): the compiler routes **every** body on standable ground,
532    /// and `flooded` cells are impassable and never floor for every body. There
533    /// is no water-traversal model for a declaration to feed, so there is
534    /// nothing to hold an aquatic claim to. When routing grows one, this
535    /// refusal is what has to be deleted to enable the value.
536    ///
537    /// Error tier, raised in `validate_campaign_with`, so the run ends at the
538    /// validation tier (exit 1). Prescription: remove the declaration — a body whose
539    /// route crosses water is governed by the flooded-cell rules already, and
540    /// the derived aquatic class still reaches the binding ledger.
541    pub const TRAVERSAL_UNPROVABLE: DwCode = DwCode::new("DW0455", ExitTier::Build);
542}
543
544/// DSL v0.11 (spec-0034): a declared locomotion the engine cannot hold the body
545/// to is refused at declaration time (`DW0455`).
546///
547/// Today that is exactly `aquatic`, and the reason is structural rather than a
548/// taste call: `aquatic` carries no exemption and governs no rule, so declaring
549/// it could never change a verdict — it would land in `DW0454` every time. A
550/// value whose only outcome is another diagnostic is a trap, so it is refused
551/// here with the gap named.
552pub(crate) fn body_traversal_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
553    for site in body_traversal_sites(c) {
554        if site.traversal.locomotion != Locomotion::Aquatic {
555            continue;
556        }
557        let (stage, path, id) = (site.body.stage(), &site.path, site.body.id());
558        d.push(Diagnostic::error(
559            TRAVERSAL_UNPROVABLE,
560            stage,
561            format!("{path}/locomotion"),
562            format!(
563                "`{id}` declares `locomotion: aquatic`, which the compiler cannot hold it to. \
564                 `aquatic` is the one class that carries no exemption and governs no rule — it is \
565                 a ledger label derived from vanilla's own `#minecraft:aquatic` tag — so the \
566                 declaration could never change a verdict and would be reported inert (`DW0454`). \
567                 The gap, stated rather than left to folklore: routing has ONE reachability model, \
568                 standable ground, and water-flooded cells are impassable and never floor for \
569                 EVERY body, so there is nothing for an aquatic claim to feed. Prescription: \
570                 remove the declaration — a route that crosses water is already governed by the \
571                 flooded-cell rules, and a body vanilla itself calls aquatic still reaches the \
572                 traversal proof's binding ledger under its derived class."
573            ),
574        ));
575    }
576}
577
578crate::dw_code! {
579    /// (spec-0101) **A watch for a class nobody plays**: a body's
580    /// `watch.who.class` names a class stage 3 does not declare.
581    ///
582    /// The watch line filters its player by the class tag the class apply puts
583    /// on whoever takes it (`dw_class_<c>`). A class stage 3 never declares is a
584    /// class nobody can take, so no player can ever wear its tag and the body
585    /// could never turn. Judged over the whole campaign — a stage-2 body may
586    /// name a class the creator writes at stage 3, exactly as `DW0197` judges a
587    /// stage-2 body against stage 5. Prescription: name a declared class, or
588    /// watch `"nearest"`.
589    pub const WATCH_CLASS_UNDECLARED: DwCode = DwCode::new("DW0996", ExitTier::Build);
590}
591
592/// spec-0101: a watch that names a class stage 3 does not declare is refused
593/// (`DW0996`), naming the body and the class. Empty for a campaign whose bodies
594/// declare no class watch.
595pub(crate) fn body_watch_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
596    let declared: std::collections::BTreeSet<&str> = c
597        .classes
598        .content
599        .classes
600        .iter()
601        .map(|k| k.id.as_str())
602        .collect();
603    for site in body_watch_sites(c) {
604        let Some(class) = site.watch.who.class() else {
605            continue;
606        };
607        if declared.contains(class.as_str()) {
608            continue;
609        }
610        let (stage, path, id) = (site.body.stage(), &site.path, site.body.id());
611        let known = if declared.is_empty() {
612            "none".to_string()
613        } else {
614            format!(
615                "`{}`",
616                declared.iter().copied().collect::<Vec<_>>().join("`, `")
617            )
618        };
619        d.push(Diagnostic::error(
620            WATCH_CLASS_UNDECLARED,
621            stage,
622            format!("{path}/who/class"),
623            format!(
624                "`{id}` watches the nearest player of `{class}`, a class stage 3 does not \
625                 declare — nobody can take it, so no player ever wears its tag and the body \
626                 could never turn. The declared classes: {known}. Name one of them, or \
627                 watch `\"nearest\"`."
628            ),
629        ));
630    }
631}