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}