Skip to main content

delvewright_dsl/
layout.rs

1//! **Space is a graph before it is a coordinate** (spec-0049 §3, §4.2) —
2//! pipeline stages 2 and 3.
3//!
4//! Two campaign stage documents land here, and neither holds a single
5//! coordinate:
6//!
7//! * **`geometry-brief`** — the machine-readable half of the whole map's written
8//!   brief: `facts[]`, each a number with a name. Stage 4's identity checks bind
9//!   a site plan to these (spec-0049 §4.2); this round lands the document and
10//!   nothing reads it yet, which is stated rather than implied.
11//! * **`layout-graph`** — the campaign's space as a graph: places, the
12//!   connections between them, the authored critical path, and where each quest
13//!   beat happens. The topology carries the global guarantees and is checked as
14//!   an object of its own, cheaply, **before geometry exists to make it
15//!   expensive**.
16//!
17//! # What makes the ordering structural rather than prose
18//!
19//! spec-0049 §7 enumerates four inversions the design must make uncompilable.
20//! The one this module owns is *graph before mission*, and §7 is honest that it
21//! is **representable**: a graph with no `beats[]` and no gating validates
22//! against quest documents it never references. What it is not is silently
23//! green. Two teeth, both here:
24//!
25//! 1. `DW0817` states its beat binding, and a **zero** beat binding is printed
26//!    as a zero — a critical path over an unbound graph is a route through
27//!    nothing and says so.
28//! 2. `DW0818`'s **reverse** direction: the moment quests exist, every
29//!    place-bound beat must bind to a node. A graph is free to arrive before the
30//!    mission; it is not free to arrive after it and ignore it.
31//!
32//! # The gating vocabulary is deliberately NOT [`crate::gate::Gate`]
33//!
34//! An edge's [`EdgeGating`] names flags and a quest, which is what the campaign's
35//! one gate names too — so the question is owed an answer rather than a
36//! convention. A [`Gate`](crate::gate::Gate) is a **runtime** object: emission
37//! evaluates it against an acting player, and
38//! [`GateConsumer::evaluates_per_player`](crate::gate::GateConsumer) is what makes
39//! a `player`-scoped datum's readability decidable. A layout-graph edge is
40//! evaluated by nothing at run time — the graph emits no command, no function and
41//! no scoreboard — so making it a gate consumer would push a never-emitted object
42//! into machinery whose whole subject is emission, and every proof written about
43//! "the gates of this campaign" would then be reasoning about a claim instead of
44//! about a thing the server runs.
45//!
46//! It is also narrower on purpose. The closure below is **monotone** (§3.2), so a
47//! negative flag term and a numeric comparison are terms it cannot decide; a
48//! surface an author may write and no proof honours is worse than one that is not
49//! there. What an edge states is therefore a *projection* of the campaign's
50//! runtime gating into topology — and `DW0818` is what keeps it a projection
51//! rather than a second vocabulary: every flag it names must be one some effect
52//! really sets, and every quest it names must exist.
53//!
54//! Determinism (ADR-0006): every set and map here is a `BTreeSet`/`BTreeMap` and
55//! every walk is over a slice in document order.
56
57use std::collections::{BTreeMap, BTreeSet};
58
59use schemars::JsonSchema;
60use serde::{Deserialize, Serialize};
61
62use crate::Objective;
63use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
64use crate::envelope::Campaign;
65use crate::ids::{AnchorId, EdgeId, FactId, FlagId, NodeId, ObjectiveId, QuestId};
66use crate::metrics::{MetricKind, Metrics, Reads};
67
68crate::dw_code! {
69    /// `DW0814`: the layout graph is not a graph — a duplicate id, an endpoint
70    /// naming no place, a self-loop, an `entry` that is not a node.
71    pub const DW_GRAPH_MALFORMED: DwCode = DwCode::new("DW0814", ExitTier::Build);
72}
73
74crate::dw_code! {
75    /// `DW0816`: a node the closure never reaches.
76    pub const DW_NODE_UNREACHED: DwCode = DwCode::new("DW0816", ExitTier::Build);
77}
78
79crate::dw_code! {
80    /// `DW0817`: the authored critical path does not hold.
81    pub const DW_CRITICAL_PATH: DwCode = DwCode::new("DW0817", ExitTier::Build);
82}
83
84crate::dw_code! {
85    /// `DW0818`: the graph names quest-side state that does not exist, or a
86    /// place-bound beat has no place.
87    pub const DW_GRAPH_MISSION: DwCode = DwCode::new("DW0818", ExitTier::Build);
88}
89
90crate::dw_code! {
91    /// `DW0819`: a one-way edge strands.
92    pub const DW_ONE_WAY_STRANDS: DwCode = DwCode::new("DW0819", ExitTier::Build);
93}
94
95crate::dw_code! {
96    /// `DW0820`: a shortcut closes no loop.
97    pub const DW_SHORTCUT_NO_LOOP: DwCode = DwCode::new("DW0820", ExitTier::Build);
98}
99
100crate::dw_code! {
101    /// `DW0822`: the pacing measurement — a projection, printed with no threshold.
102    pub const DW_PACING: DwCode = DwCode::new("DW0822", ExitTier::Build);
103}
104
105crate::dw_code! {
106    /// `DW0869`: a station takes a name in the engine's own namespace (spec-0052 §7.1).
107    pub const DW_STATION_RESERVED: DwCode = DwCode::new("DW0869", ExitTier::Build);
108}
109
110crate::dw_code! {
111    /// `DW0870`: two stations claim one name (spec-0052 §7.2).
112    pub const DW_STATION_DUPLICATE: DwCode = DwCode::new("DW0870", ExitTier::Build);
113}
114
115crate::dw_code! {
116    /// `DW0871`: a reference demands a shape the station is not (spec-0052 §7.3).
117    ///
118    /// Judged at the reference site from the DECLARATION, with zero pieces bound.
119    pub const DW_STATION_KIND: DwCode = DwCode::new("DW0871", ExitTier::Build);
120}
121
122// ---------------------------------------------------------------------------
123// Stage 2 — the geometry brief's machine-readable facts (spec-0049 §4.2)
124// ---------------------------------------------------------------------------
125
126/// The `geometry-brief` stage document's payload.
127///
128/// The brief's prose stays prose; only what is stated as a **fact** is
129/// checkable, and a site plan's `identities[]` bind to exactly these. The
130/// reference imagery keeps its standing — style authority, rank-only, never a
131/// gate (spec-0028) — so an identity binds to the written brief's numbers and
132/// never to a picture.
133#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
134#[serde(deny_unknown_fields)]
135pub struct GeometryBriefContent {
136    /// The brief's numbers, each with a name.
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub facts: Vec<BriefFact>,
139}
140
141/// One fact from the whole map's written brief: a number with a name.
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
143#[serde(deny_unknown_fields)]
144pub struct BriefFact {
145    /// Fact id (`fact/<kebab>`), unique within the brief.
146    pub id: FactId,
147    /// The number itself.
148    pub value: f64,
149    /// What the number counts (`blocks`, `storeys`, a ratio's `none`).
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub unit: Option<String>,
152    /// The sentence of the brief this number came from, so a reader of the plan
153    /// can see what the identity is holding the map to.
154    pub note: String,
155}
156
157// ---------------------------------------------------------------------------
158// Stage 3 — the layout graph (spec-0049 §3.1)
159// ---------------------------------------------------------------------------
160
161/// The `layout-graph` stage document's payload: the campaign's space as a graph,
162/// stated before any coordinate exists.
163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct LayoutGraphContent {
166    /// The places.
167    pub nodes: Vec<Node>,
168    /// The connections between them.
169    pub edges: Vec<Edge>,
170    /// Where a body starts.
171    pub entry: NodeId,
172    /// Where the campaign ends.
173    pub goal: NodeId,
174    /// The authored node sequence from `entry` to `goal`.
175    ///
176    /// **Authored rather than derived** so that it is a claim the machine
177    /// verifies (`DW0817`) and the walk sheet can print. A derived path would be
178    /// an answer with no author to disagree with.
179    pub critical_path: Vec<NodeId>,
180    /// Where each quest beat happens. Empty is legal and is the *graph before
181    /// mission* case — stated as a zero binding rather than passed over.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub beats: Vec<Beat>,
184}
185
186/// A **place**: a room, a courtyard, an arena, a stretch of shore, a cavern.
187#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
188#[serde(deny_unknown_fields)]
189pub struct Node {
190    /// Node id (`node/<kebab>`), unique within the graph.
191    pub id: NodeId,
192    /// What this place is for, in the author's own words.
193    ///
194    /// A free non-empty label — `arena`, `hub`, `vista`, `gate-house`,
195    /// `shortcut-landing` — that **no check keys on**. It is recorded judgement
196    /// for the reviewer and for the later per-place briefs, and it is kept
197    /// free-form deliberately: an enum of intents would be this month's genre
198    /// wearing a schema's clothes.
199    pub intent: String,
200    /// Anything the reviewer needs that `intent` does not carry.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub note: Option<String>,
203    /// **The named places inside this one** (spec-0052 §3).
204    ///
205    /// A site-plan campaign's anchor vocabulary is otherwise exactly the
206    /// synthesized set — `spawn`, one anchor per node, and the seam and unlock
207    /// anchors of barred edges — which is complete only for the authoring order
208    /// it was designed for: quests written against nodes before any piece
209    /// exists. The inverse order is real, and its quest layer names places
210    /// *inside* places: the fire pit in the camp, the aft deck of the galley.
211    /// Those names are the campaign's design, and flattening them onto box
212    /// centres un-writes its spatial script.
213    ///
214    /// A station is a **name and a shape, never a position** (§5): while the box
215    /// is massed the derivation realizes each station at a stand-in of its own,
216    /// and when a piece is bound the `detail-plan` `anchors` map re-binds it to
217    /// an anchor of that piece, which is where the name gets its real place.
218    /// There is no coordinate, offset or hint field here, and that absence is
219    /// the design rather than an omission.
220    ///
221    /// Declared names join the campaign vocabulary at the same authority as
222    /// every synthesized one — [`crate::siteplan::synthesized_anchors`] is still
223    /// the single place the question is answered, so a name that validates
224    /// cannot fail to exist in the built world.
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub stations: Vec<Station>,
227    /// **Whether a body reaches this place** (spec-0098 §14, a ruling).
228    /// `false` declares the place scenery — a box built to be seen and never
229    /// entered, a tree's crown over a treehouse — and the checks confirm that
230    /// intent both ways: the closure must not reach it (`DW0816`) and no body
231    /// may get into it in the built world (`DW0837`), exactly as a reached
232    /// place must be reached. Absent means reached.
233    #[serde(default = "reached_default", skip_serializing_if = "is_true")]
234    pub reached: bool,
235}
236
237fn reached_default() -> bool {
238    true
239}
240
241fn is_true(b: &bool) -> bool {
242    *b
243}
244
245/// **A named place inside a node** (spec-0052 §3).
246///
247/// Belongs to exactly one node. A connection *between* places is the edge's to
248/// declare, as ever — an interior gate station seals a volume inside its own
249/// place and can never gate node-to-node traversal, so topology stays the
250/// graph's structurally rather than by convention.
251#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
252#[serde(deny_unknown_fields)]
253pub struct Station {
254    /// The anchor id (`anchor/<kebab>`) quests reference.
255    ///
256    /// Unique across the whole graph, and refused in the engine's own namespace
257    /// — see [`DW_STATION_RESERVED`] and [`DW_STATION_DUPLICATE`].
258    pub anchor: AnchorId,
259    /// The station's **shape** — never its purpose.
260    pub kind: StationKind,
261    /// Recorded judgement for the reviewer and the later per-place detail
262    /// brief. **No check keys on it**, for the same reason none keys on
263    /// [`Node::intent`].
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub note: Option<String>,
266}
267
268/// **What shape a station is** (spec-0052 §3) — a cell to stand a body at, or a
269/// volume with a block that seals and clears.
270///
271/// The kind is the station's shape, never its purpose: there is no enum of
272/// bonfire / camera / shop, for the same reason [`Node::intent`] is free-form —
273/// a purpose vocabulary would be this month's genre wearing a schema's clothes.
274/// A bonfire, a camera subject and a shop counter are the same [`Self::Point`]
275/// to every check in this engine.
276///
277/// # Why two shapes and not three
278///
279/// spec-0052 §3 describes three, "mirroring the three shapes a piece anchor can
280/// take (a cell to stand a body at; a volume; a volume with a block that seals
281/// and clears)". Those are the three shapes a piece anchor may be **declared**
282/// in; they are not three shapes anything **consumes**. This engine resolves an
283/// anchor to `ResolvedAnchor::Point` or `ResolvedAnchor::Gate` and to nothing
284/// else, and a piece anchor declaring a bare `region` with no `block` is read as
285/// a gate whose fill block is `minecraft:air`.
286///
287/// Every volume-shaped consumer — a `lethal_volumes[]` region, `damage-players`'s
288/// `in`, a `volley` kill zone, `collapse`'s ceiling, `begin-stealth`,
289/// `fill-region`, `clear-region` — is a [`crate::StealthZone`], an
290/// **anchor-centred box** resolved from a *point* plus an extent. The engine
291/// states the reason in its own words at `Verb::FillRegion::region`: an
292/// anchor-centred box rather than a prefab `region` anchor, "because the
293/// assembled model deletes every gate-region anchor's cells, so a slab declared
294/// that way would already be gone".
295///
296/// So a third `region` variant would be a name a campaign could declare and bind
297/// and **no reference site could consume** — an inert surface whose stand-in
298/// would have to be a gate region the assembled world then deletes. It is left
299/// out deliberately, and spec-0052 §11's falsifier is what decides it: the first
300/// campaign brief that cannot state its place without one is the evidence, and
301/// the answer is a first-class surface with a consumer, or a refused feature.
302#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
303#[serde(rename_all = "kebab-case")]
304pub enum StationKind {
305    /// A cell to stand a body at — where a quest step, an NPC, a wave seat, a
306    /// cutscene subject, an affordance or a volume's centre lands.
307    Point,
308    /// A volume with a block that seals and clears — what `open-gate`,
309    /// `close-gate`, a `shortcut` and a `timed-gate` address.
310    Gate,
311}
312
313impl StationKind {
314    /// The word a diagnostic prints for this kind.
315    #[must_use]
316    pub fn word(self) -> &'static str {
317        match self {
318            Self::Point => "point",
319            Self::Gate => "gate",
320        }
321    }
322}
323
324/// Which way a one-way connection runs.
325#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
326#[serde(rename_all = "kebab-case")]
327pub enum Direction {
328    /// From the edge's `a` end to its `b` end.
329    AToB,
330    /// From the edge's `b` end to its `a` end.
331    BToA,
332}
333
334/// Which side of a barred connection can open it.
335#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
336#[serde(rename_all = "kebab-case")]
337pub enum OpensFrom {
338    /// Only from the `a` end.
339    A,
340    /// Only from the `b` end.
341    B,
342    /// From either end.
343    #[default]
344    Either,
345}
346
347/// What a body must already hold for a connection to be passable.
348///
349/// A **projection** of the campaign's runtime gating into topology, not the
350/// campaign's [`Gate`](crate::gate::Gate) — see the module docs for why the two
351/// are deliberately different objects, and for why this one carries no negative
352/// flag term and no numeric comparison.
353#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
354#[serde(deny_unknown_fields)]
355pub struct EdgeGating {
356    /// Flags a body must already have, each one some `set-flag` effect really
357    /// produces (`DW0818`).
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub flags: Vec<FlagId>,
360    /// A quest that must already be complete.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub quest: Option<QuestId>,
363}
364
365impl EdgeGating {
366    /// True if this gating demands nothing.
367    #[must_use]
368    pub fn is_empty(&self) -> bool {
369        self.flags.is_empty() && self.quest.is_none()
370    }
371
372    /// How many terms it has — the number a binding ledger reports.
373    #[must_use]
374    pub fn terms(&self) -> usize {
375        self.flags.len() + usize::from(self.quest.is_some())
376    }
377}
378
379/// A connection between two places.
380///
381/// Internally tagged on `class`, in the same shape every other tagged union in
382/// this DSL uses. The tagging is load-bearing rather than stylistic: `falls`
383/// belongs to a drop and `opens_from` belongs to a barred way, and writing
384/// either on a walk would be a declaration nothing reads. Under the tag, serde's
385/// `deny_unknown_fields` refuses it as an ordinary `DW0100` — so the illegal
386/// state is unrepresentable and no diagnostic has to police it.
387#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
388#[serde(tag = "class", rename_all = "kebab-case", deny_unknown_fields)]
389pub enum Edge {
390    /// A way a body walks, at grade.
391    Walk {
392        /// Edge id (`edge/<kebab>`), unique within the graph.
393        id: EdgeId,
394        /// One end.
395        a: NodeId,
396        /// The other end.
397        b: NodeId,
398        /// Declared directionality; absent means a body passes both ways.
399        #[serde(default, skip_serializing_if = "Option::is_none")]
400        one_way: Option<Direction>,
401        /// This connection exists to close a loop (`DW0820`).
402        #[serde(default, skip_serializing_if = "is_false")]
403        shortcut: bool,
404        /// What a body must hold to pass.
405        #[serde(default, skip_serializing_if = "Option::is_none")]
406        gating: Option<EdgeGating>,
407    },
408    /// A way a body climbs or descends on built treads.
409    Stair {
410        /// Edge id (`edge/<kebab>`), unique within the graph.
411        id: EdgeId,
412        /// One end.
413        a: NodeId,
414        /// The other end.
415        b: NodeId,
416        /// Declared directionality; absent means a body passes both ways.
417        #[serde(default, skip_serializing_if = "Option::is_none")]
418        one_way: Option<Direction>,
419        /// This connection exists to close a loop (`DW0820`).
420        #[serde(default, skip_serializing_if = "is_false")]
421        shortcut: bool,
422        /// What a body must hold to pass.
423        #[serde(default, skip_serializing_if = "Option::is_none")]
424        gating: Option<EdgeGating>,
425    },
426    /// A way a body climbs, on a ladder or a vine (spec-0099): a hole through
427    /// a floor, or a door high in a wall, reached by a climbable the lower
428    /// place hangs (spec-0098 §2c). No treads and no sill: the climb carries
429    /// the rise, and the proofs that move a body count it.
430    Climb {
431        /// Edge id (`edge/<kebab>`), unique within the graph.
432        id: EdgeId,
433        /// One end.
434        a: NodeId,
435        /// The other end.
436        b: NodeId,
437        /// Declared directionality; absent means a body passes both ways.
438        #[serde(default, skip_serializing_if = "Option::is_none")]
439        one_way: Option<Direction>,
440        /// This connection exists to close a loop (`DW0820`).
441        #[serde(default, skip_serializing_if = "is_false")]
442        shortcut: bool,
443        /// What a body must hold to pass.
444        #[serde(default, skip_serializing_if = "Option::is_none")]
445        gating: Option<EdgeGating>,
446    },
447    /// A fall. One-way **by construction**, which is why the direction is
448    /// required here and optional on its siblings: a body that has dropped
449    /// cannot climb back up the way it came.
450    Drop {
451        /// Edge id (`edge/<kebab>`), unique within the graph.
452        id: EdgeId,
453        /// One end.
454        a: NodeId,
455        /// The other end.
456        b: NodeId,
457        /// Which way it falls.
458        falls: Direction,
459        /// This connection exists to close a loop (`DW0820`).
460        #[serde(default, skip_serializing_if = "is_false")]
461        shortcut: bool,
462        /// What a body must hold to pass.
463        #[serde(default, skip_serializing_if = "Option::is_none")]
464        gating: Option<EdgeGating>,
465    },
466    /// A sealed connection some effect opens.
467    Barred {
468        /// Edge id (`edge/<kebab>`), unique within the graph.
469        id: EdgeId,
470        /// One end.
471        a: NodeId,
472        /// The other end.
473        b: NodeId,
474        /// Which side can open it. The one-side-openable door is spelled here,
475        /// and it is a property of the connection rather than of any campaign's
476        /// fiction.
477        #[serde(default)]
478        opens_from: OpensFrom,
479        /// Declared directionality once open; absent means both ways.
480        #[serde(default, skip_serializing_if = "Option::is_none")]
481        one_way: Option<Direction>,
482        /// This connection exists to close a loop (`DW0820`).
483        #[serde(default, skip_serializing_if = "is_false")]
484        shortcut: bool,
485        /// What opens it. **Required to say something** (`DW0818`): a barred way
486        /// nothing opens is a wall, and a barred way anything opens is not
487        /// barred.
488        gating: EdgeGating,
489    },
490    /// A connection a body is **carried** over (spec-0083 §7): no seam, no sill,
491    /// no door check and no body of its own. The derivation writes nothing for
492    /// it; at stage 5 every direction it allows is owed a link — a repeatable
493    /// trigger whose `teleport` leaves the `a` place's station for the `b`
494    /// place's mark — and every link is owed the edge (`DW0934`).
495    Carry {
496        /// Edge id (`edge/<kebab>`), unique within the graph.
497        id: EdgeId,
498        /// One end.
499        a: NodeId,
500        /// The other end.
501        b: NodeId,
502        /// Declared directionality; absent means a body is carried both ways,
503        /// and each direction is then owed a link.
504        #[serde(default, skip_serializing_if = "Option::is_none")]
505        one_way: Option<Direction>,
506        /// What carries it. **Required to say something** (`DW0818`), as a
507        /// barred way's is: a carry live from world load is a hole in the
508        /// graph's own claim.
509        gating: EdgeGating,
510    },
511    /// A line of sight between two places, in either direction. Carries no body,
512    /// so it is not a traversal edge and the reachability closure never walks
513    /// it; stage 4 gives it a sightline rather than a seam (spec-0049 §4.4).
514    Vision {
515        /// Edge id (`edge/<kebab>`), unique within the graph.
516        id: EdgeId,
517        /// One end.
518        a: NodeId,
519        /// The other end.
520        b: NodeId,
521    },
522}
523
524fn is_false(b: &bool) -> bool {
525    !*b
526}
527
528impl Edge {
529    /// The edge's id.
530    #[must_use]
531    pub fn id(&self) -> &EdgeId {
532        match self {
533            Edge::Walk { id, .. }
534            | Edge::Stair { id, .. }
535            | Edge::Climb { id, .. }
536            | Edge::Drop { id, .. }
537            | Edge::Barred { id, .. }
538            | Edge::Carry { id, .. }
539            | Edge::Vision { id, .. } => id,
540        }
541    }
542
543    /// The `a` end.
544    #[must_use]
545    pub fn a(&self) -> &NodeId {
546        match self {
547            Edge::Walk { a, .. }
548            | Edge::Stair { a, .. }
549            | Edge::Climb { a, .. }
550            | Edge::Drop { a, .. }
551            | Edge::Barred { a, .. }
552            | Edge::Carry { a, .. }
553            | Edge::Vision { a, .. } => a,
554        }
555    }
556
557    /// The `b` end.
558    #[must_use]
559    pub fn b(&self) -> &NodeId {
560        match self {
561            Edge::Walk { b, .. }
562            | Edge::Stair { b, .. }
563            | Edge::Climb { b, .. }
564            | Edge::Drop { b, .. }
565            | Edge::Barred { b, .. }
566            | Edge::Carry { b, .. }
567            | Edge::Vision { b, .. } => b,
568        }
569    }
570
571    /// The class name as the document spells it.
572    #[must_use]
573    pub fn class(&self) -> &'static str {
574        match self {
575            Edge::Walk { .. } => "walk",
576            Edge::Stair { .. } => "stair",
577            Edge::Climb { .. } => "climb",
578            Edge::Drop { .. } => "drop",
579            Edge::Barred { .. } => "barred",
580            Edge::Carry { .. } => "carry",
581            Edge::Vision { .. } => "vision",
582        }
583    }
584
585    /// True if a body passes along this edge. A `vision` edge does not.
586    #[must_use]
587    pub fn is_traversal(&self) -> bool {
588        !matches!(self, Edge::Vision { .. })
589    }
590
591    /// True if the geometry owes this edge a **seam** — an opening on a shared
592    /// face. A `vision` edge is owed a sightline instead, and a `carry` edge is
593    /// owed nothing: a body crosses it by being put down on the far side.
594    #[must_use]
595    pub fn has_seam(&self) -> bool {
596        !matches!(self, Edge::Vision { .. } | Edge::Carry { .. })
597    }
598
599    /// Which way a body may pass, or `None` for both ways (and for a `vision`
600    /// edge, which carries none).
601    #[must_use]
602    pub fn direction(&self) -> Option<Direction> {
603        match self {
604            Edge::Walk { one_way, .. }
605            | Edge::Stair { one_way, .. }
606            | Edge::Climb { one_way, .. }
607            | Edge::Barred { one_way, .. }
608            | Edge::Carry { one_way, .. } => *one_way,
609            Edge::Drop { falls, .. } => Some(*falls),
610            Edge::Vision { .. } => None,
611        }
612    }
613
614    /// True if this edge is marked as closing a loop.
615    #[must_use]
616    pub fn shortcut(&self) -> bool {
617        match self {
618            Edge::Walk { shortcut, .. }
619            | Edge::Stair { shortcut, .. }
620            | Edge::Climb { shortcut, .. }
621            | Edge::Drop { shortcut, .. }
622            | Edge::Barred { shortcut, .. } => *shortcut,
623            Edge::Carry { .. } | Edge::Vision { .. } => false,
624        }
625    }
626
627    /// What a body must hold to pass, or `None` where the edge demands nothing.
628    #[must_use]
629    pub fn gating(&self) -> Option<&EdgeGating> {
630        match self {
631            Edge::Walk { gating, .. }
632            | Edge::Stair { gating, .. }
633            | Edge::Climb { gating, .. }
634            | Edge::Drop { gating, .. } => gating.as_ref(),
635            Edge::Barred { gating, .. } | Edge::Carry { gating, .. } => Some(gating),
636            Edge::Vision { .. } => None,
637        }
638    }
639}
640
641/// Where one quest beat happens.
642#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
643#[serde(deny_unknown_fields)]
644pub struct Beat {
645    /// The quest the objective belongs to.
646    pub quest: QuestId,
647    /// The objective.
648    pub objective: ObjectiveId,
649    /// The place it happens in.
650    pub node: NodeId,
651}
652
653// ---------------------------------------------------------------------------
654// The closure (spec-0049 §3.2)
655// ---------------------------------------------------------------------------
656
657/// One thing a body can be holding: a produced flag, or a completed quest.
658#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
659pub enum Grant {
660    /// A flag some effect set.
661    Flag(String),
662    /// A quest that completed.
663    Quest(String),
664}
665
666/// The monotone reachability closure of a layout graph.
667///
668/// Exactly Dormans' loop, and exactly §3.2: beats grant, edges demand.
669/// Deterministic, linear in the number of edges times the number of rounds, and
670/// **optimistic in every direction it cannot decide** — which is one property,
671/// stated once, rather than a list of exceptions:
672///
673/// * it is **branch-blind**, so a campaign whose branch points set mutually
674///   exclusive flags can reach a node no single playthrough reaches;
675/// * a beat's grants include everything the campaign fires when that objective
676///   completes, including a `talk-to`'s whole dialogue tree, because talking is
677///   what a body does at the place the speaker stands.
678///
679/// The optimism can only under-report at graph time; the stage-5 battery is
680/// branch-aware over bytes and is what stops it shipping a broken world. If a
681/// campaign's graph-stage green turns into a repeated stage-5 red on
682/// branch-gated nodes, this closure gains branch awareness from
683/// `compiler::flow`'s existing branch enumeration — that is the trigger, and
684/// before it fires the simple closure is the cheaper instrument.
685#[derive(Debug, Clone, Default)]
686pub struct Closure {
687    /// Every node a body can be at.
688    pub reached: BTreeSet<String>,
689    /// Everything a body can be holding once the fixpoint settles.
690    pub obtained: BTreeSet<Grant>,
691    /// Per node, what was already obtained when that node was **first** reached.
692    /// The set `DW0819` judges a strand against.
693    pub obtained_when: BTreeMap<String, BTreeSet<Grant>>,
694}
695
696impl Closure {
697    /// True if `gating` is satisfied by `held`.
698    #[must_use]
699    pub fn satisfied(gating: Option<&EdgeGating>, held: &BTreeSet<Grant>) -> bool {
700        let Some(g) = gating else { return true };
701        g.flags
702            .iter()
703            .all(|f| held.contains(&Grant::Flag(f.0.clone())))
704            && g.quest
705                .as_ref()
706                .is_none_or(|q| held.contains(&Grant::Quest(q.0.clone())))
707    }
708
709    /// Run the closure from `entry`, with `grants` saying what a reached node
710    /// hands a body and `quest_grants` what completing a quest does.
711    #[must_use]
712    pub fn run(graph: &LayoutGraphContent, grants: &Grants) -> Closure {
713        let mut c = Closure::default();
714        c.reached.insert(graph.entry.0.clone());
715        c.obtained_when
716            .insert(graph.entry.0.clone(), BTreeSet::new());
717        loop {
718            let before = (c.reached.len(), c.obtained.len());
719            // (a) + (b): every edge whose demand is met carries a body, in the
720            // direction it allows.
721            for e in &graph.edges {
722                if !e.is_traversal() || !Closure::satisfied(e.gating(), &c.obtained) {
723                    continue;
724                }
725                let (a, b) = (e.a().0.as_str(), e.b().0.as_str());
726                let forward = e.direction() != Some(Direction::BToA);
727                let backward = e.direction() != Some(Direction::AToB);
728                if forward && c.reached.contains(a) && !c.reached.contains(b) {
729                    c.reached.insert(b.to_string());
730                    c.obtained_when.insert(b.to_string(), c.obtained.clone());
731                }
732                if backward && c.reached.contains(b) && !c.reached.contains(a) {
733                    c.reached.insert(a.to_string());
734                    c.obtained_when.insert(a.to_string(), c.obtained.clone());
735                }
736            }
737            // (c): every beat bound to a reached node hands over what it grants.
738            for (node, given) in &grants.by_node {
739                if c.reached.contains(node.as_str()) {
740                    c.obtained.extend(given.iter().cloned());
741                }
742            }
743            // A quest completes once every one of its beats is somewhere a body
744            // can stand.
745            for (quest, (nodes, given)) in &grants.by_quest {
746                if nodes.iter().all(|n| c.reached.contains(n.as_str())) {
747                    c.obtained.insert(Grant::Quest(quest.clone()));
748                    c.obtained.extend(given.iter().cloned());
749                }
750            }
751            if (c.reached.len(), c.obtained.len()) == before {
752                return c;
753            }
754        }
755    }
756}
757
758/// What each place hands a body, derived from the campaign's own quest
759/// documents — the *grant* half of §3.2's loop.
760#[derive(Debug, Clone, Default)]
761pub struct Grants {
762    /// node id -> what standing there eventually yields.
763    pub by_node: BTreeMap<String, BTreeSet<Grant>>,
764    /// quest id -> (the nodes its beats sit in, what completing it yields).
765    pub by_quest: BTreeMap<String, (BTreeSet<String>, BTreeSet<Grant>)>,
766}
767
768impl Grants {
769    /// Derive the grants a graph's `beats[]` imply, from the campaign's quests
770    /// and dialogue.
771    ///
772    /// A beat bound to an objective grants every flag the campaign sets when
773    /// that objective completes, plus — for a `talk-to` — every flag reachable
774    /// in the spoken-to NPC's dialogue tree, because the conversation happens
775    /// where the speaker stands. Both walks reach nested bundles, so a
776    /// `set-flag` inside a `sequence` step or an `on_respawn` hook counts
777    /// exactly as a top-level one does.
778    #[must_use]
779    pub fn of(c: &Campaign, graph: &LayoutGraphContent) -> Grants {
780        let mut g = Grants::default();
781        // objective id (scoped by quest) -> the flags its completion sets.
782        let mut on_objective: BTreeMap<(&str, &str), BTreeSet<Grant>> = BTreeMap::new();
783        let mut on_quest: BTreeMap<&str, BTreeSet<Grant>> = BTreeMap::new();
784        // NPC -> the flags anything in their dialogue tree can set.
785        let mut npc_flags: BTreeMap<&str, BTreeSet<Grant>> = BTreeMap::new();
786        for tree in &c.dialogue.content.dialogues {
787            let set = npc_flags.entry(tree.npc.0.as_str()).or_default();
788            for node in &tree.nodes {
789                for opt in &node.options {
790                    for eff in &opt.effects {
791                        if let Some(f) = eff.set_flag() {
792                            set.insert(Grant::Flag(f.0.clone()));
793                        }
794                    }
795                }
796            }
797        }
798        for q in &c.quests.content.quests {
799            let mut done: BTreeSet<Grant> = BTreeSet::new();
800            for eff in &q.on_complete {
801                eff.visit_deep(&mut |e| {
802                    if let Some(f) = e.set_flag() {
803                        done.insert(Grant::Flag(f.0.clone()));
804                    }
805                });
806            }
807            on_quest.insert(q.id.0.as_str(), done);
808            for (obj, effects) in &q.on_objective_complete {
809                let entry = on_objective
810                    .entry((q.id.0.as_str(), obj.0.as_str()))
811                    .or_default();
812                for eff in effects {
813                    eff.visit_deep(&mut |e| {
814                        if let Some(f) = e.set_flag() {
815                            entry.insert(Grant::Flag(f.0.clone()));
816                        }
817                    });
818                }
819            }
820            for obj in &q.objectives {
821                if let Objective::TalkTo { id, npc, .. } = obj
822                    && let Some(flags) = npc_flags.get(npc.0.as_str())
823                {
824                    on_objective
825                        .entry((q.id.0.as_str(), id.0.as_str()))
826                        .or_default()
827                        .extend(flags.iter().cloned());
828                }
829            }
830        }
831        for beat in &graph.beats {
832            let node = beat.node.0.clone();
833            let given = on_objective
834                .get(&(beat.quest.0.as_str(), beat.objective.0.as_str()))
835                .cloned()
836                .unwrap_or_default();
837            g.by_node.entry(node.clone()).or_default().extend(given);
838            let q = g
839                .by_quest
840                .entry(beat.quest.0.clone())
841                .or_insert_with(|| (BTreeSet::new(), BTreeSet::new()));
842            q.0.insert(node);
843            q.1 = on_quest
844                .get(beat.quest.0.as_str())
845                .cloned()
846                .unwrap_or_default();
847        }
848        g
849    }
850}
851
852// ---------------------------------------------------------------------------
853// The binding ledger
854// ---------------------------------------------------------------------------
855
856/// What a run's **map-pipeline** checks bound to — stages 2, 3 and 4.
857///
858/// Stated on every run whether or not anything was found, because a count only
859/// means something when the run that found nothing prints it too (CLAUDE.md). It
860/// is one struct with one constructor, so the number the CLI prints, the number
861/// the build ledger records and the number a diagnostic quotes cannot disagree.
862///
863/// One ledger for three documents rather than one per document, and that is the
864/// point: a site plan's boxes are counted beside the graph's places, so a run
865/// that embeds five of six places states both numbers on one line and the
866/// mismatch is visible without arithmetic.
867#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
868pub struct LayoutBinding {
869    /// Places declared — what `DW0816` examines.
870    pub nodes: usize,
871    /// Connections declared.
872    pub edges: usize,
873    /// Of those, connections a body passes along.
874    pub traversal_edges: usize,
875    /// Of those, connections marked as closing a loop — what `DW0820` examines.
876    pub shortcut_edges: usize,
877    /// Connections a body passes one way only — what `DW0819` examines.
878    pub one_way_edges: usize,
879    /// Connections that demand something before a body may pass.
880    pub gated_edges: usize,
881    /// Connections a body is carried over rather than walks (spec-0083 §7) —
882    /// what `DW0934` matches against the campaign's links.
883    pub carry_edges: usize,
884    /// **Named places inside places** — stations declared across the whole graph
885    /// (spec-0052 §4).
886    ///
887    /// A station no quest references is legal mid-authoring, so this is the
888    /// denominator rather than a count of what is used, and a **zero is stated**
889    /// like every other: a site-plan campaign with no stations names its places
890    /// at node granularity, which is a fact about that campaign and not a
891    /// silence.
892    pub stations: usize,
893    /// Of those, stations declared as a gate — the ones `open-gate`,
894    /// `close-gate`, a `shortcut` and a `timed-gate` may address. The remainder
895    /// are points, which is what every other consumer resolves.
896    pub gate_stations: usize,
897    /// Quest beats bound to a place.
898    pub beats: usize,
899    /// Of those, beats on the **mandatory quest spine** — the number `DW0817`'s
900    /// obligation to visit them actually quantifies over.
901    ///
902    /// It is carried separately because `beats` is not the binding: a graph can
903    /// declare a dozen beats and still ask `DW0817` to check nothing, if none of
904    /// their quests is one the finale depends on. A zero here is the *graph
905    /// before mission* case and is reported as a finding.
906    pub spine_beats: usize,
907    /// Steps of the authored critical path — what `DW0817` and `DW0822`
908    /// examine.
909    pub path_steps: usize,
910    /// Facts the geometry brief states — what a site plan's identities bind to.
911    pub brief_facts: usize,
912    /// What the site plan offers the stage-4 checks.
913    pub plan: crate::siteplan::PlanBinding,
914}
915
916impl LayoutBinding {
917    /// Count what a campaign's layout documents offer the checks.
918    #[must_use]
919    pub fn of(c: &Campaign) -> LayoutBinding {
920        let mut b = LayoutBinding {
921            brief_facts: c
922                .geometry_brief
923                .as_ref()
924                .map_or(0, |g| g.content.facts.len()),
925            ..LayoutBinding::default()
926        };
927        let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
928            b.plan = crate::siteplan::PlanBinding::of(c);
929            return b;
930        };
931        b.plan = crate::siteplan::PlanBinding::of(c);
932        b.nodes = graph.nodes.len();
933        b.edges = graph.edges.len();
934        b.beats = graph.beats.len();
935        let spine = c.quest_plan.content.spine();
936        b.spine_beats = graph
937            .beats
938            .iter()
939            .filter(|beat| spine.contains(beat.quest.0.as_str()))
940            .count();
941        b.path_steps = graph.critical_path.len().saturating_sub(1);
942        for n in &graph.nodes {
943            b.stations += n.stations.len();
944            b.gate_stations += n
945                .stations
946                .iter()
947                .filter(|s| s.kind == StationKind::Gate)
948                .count();
949        }
950        for e in &graph.edges {
951            if e.is_traversal() {
952                b.traversal_edges += 1;
953            }
954            if e.shortcut() {
955                b.shortcut_edges += 1;
956            }
957            if e.is_traversal() && e.direction().is_some() {
958                b.one_way_edges += 1;
959            }
960            if e.gating().is_some_and(|g| !g.is_empty()) {
961                b.gated_edges += 1;
962            }
963            if matches!(e, Edge::Carry { .. }) {
964                b.carry_edges += 1;
965            }
966        }
967        b
968    }
969
970    /// One line, for stderr and for the round summary.
971    #[must_use]
972    pub fn line(&self) -> String {
973        format!(
974            "layout-graph binding: {n} node(s), {e} edge(s) ({t} traversal, {ow} one-way, \
975             {s} shortcut, {g} gated, {c} carry), {st} station(s) of which {gs} gate(s), {b} beat(s) \
976             of which {sb} on the mandatory spine, {p} critical-path step(s); \
977             geometry-brief binding: {f} fact(s).",
978            n = self.nodes,
979            e = self.edges,
980            t = self.traversal_edges,
981            ow = self.one_way_edges,
982            s = self.shortcut_edges,
983            g = self.gated_edges,
984            c = self.carry_edges,
985            st = self.stations,
986            gs = self.gate_stations,
987            b = self.beats,
988            sb = self.spine_beats,
989            p = self.path_steps,
990            f = self.brief_facts,
991        )
992    }
993
994    /// The site-plan half, when the campaign carries one.
995    #[must_use]
996    pub fn plan_line(&self) -> String {
997        self.plan.line()
998    }
999}
1000
1001// ---------------------------------------------------------------------------
1002// Validation tier (spec-0049 §3.3): DW0814, DW0818, DW0820, DW0822, DW0812,
1003// and — since the reachability battery moved here — DW0816, DW0817, DW0819
1004// ---------------------------------------------------------------------------
1005
1006/// Every check the layout documents owe at **validation** tier.
1007///
1008/// Invoked from [`crate::validate::validate_campaign_with`] whenever the
1009/// campaign carries either document — the same event-bound shape stage 7's edit
1010/// script uses. There is no separate entry point to remember and no flag to
1011/// pass: a campaign directory holding a `layout-graph.json` cannot be validated
1012/// without running this.
1013pub fn check(c: &Campaign, reads: &mut Reads, d: &mut Vec<Diagnostic>) {
1014    let table = Metrics::table();
1015    if let Some(brief) = &c.geometry_brief {
1016        brief_checks(&brief.content, d);
1017    }
1018    let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
1019        return;
1020    };
1021    // Referential wellformedness first: every check below reads node ids, and a
1022    // dangling one would make each of them answer a question about a place that
1023    // is not there.
1024    let known: BTreeSet<&str> = graph.nodes.iter().map(|n| n.id.0.as_str()).collect();
1025    let malformed = wellformed(graph, &known, d);
1026    stations(graph, d);
1027    if malformed {
1028        return;
1029    }
1030    mission(c, graph, d);
1031    shortcut_loops(graph, d);
1032    pacing(c, graph, &table, reads, d);
1033    // The reachability proofs run HERE and nowhere else — see [`reachability`]
1034    // for why they are no longer raised from the analysis pass. The page tells
1035    // an author to loop `validate` at the graph step; this is what makes that
1036    // instruction true.
1037    d.extend(reachability(c));
1038}
1039
1040/// **The two refusals a declared station owes** (spec-0052 §7.1 and §7.2).
1041///
1042/// Runs before the malformed-graph return, because a station's name is judged
1043/// against the engine's namespace and against the other stations — neither of
1044/// which needs the node ids to resolve. A campaign with a dangling edge still
1045/// gets told its station name is taken.
1046fn stations(graph: &LayoutGraphContent, d: &mut Vec<Diagnostic>) {
1047    // ---- §7.1: a station in the engine's namespace.
1048    //
1049    // The PREFIX is the rule, not the collision: `anchor/seam-vestry-door` is
1050    // refused even where no such edge exists, so that adding the edge later
1051    // cannot turn a legal graph into two claims on one name.
1052    //
1053    // Reserving `spawn` by its exact name rather than by a prefix is not an
1054    // inconsistency — `spawn` is one name the derivation synthesizes, and it has
1055    // no family to reserve.
1056    let entry = crate::siteplan::ENTRY_ANCHOR;
1057    for (i, n) in graph.nodes.iter().enumerate() {
1058        for (j, s) in n.stations.iter().enumerate() {
1059            let name = s.anchor.as_str();
1060            let reserved = ["anchor/node-", "anchor/seam-", "anchor/unlock-"]
1061                .into_iter()
1062                .find(|p| name.starts_with(p));
1063            let why = if let Some(prefix) = reserved {
1064                format!("its name begins `{prefix}`")
1065            } else if name == entry {
1066                format!("`{entry}` is the name the entry place stands under")
1067            } else {
1068                continue;
1069            };
1070            d.push(Diagnostic::error(
1071                DW_STATION_RESERVED,
1072                "layout-graph",
1073                format!("/content/nodes/{i}/stations/{j}/anchor"),
1074                format!(
1075                    "station `{name}` takes a name the engine derives: {why}. The derivation \
1076                     synthesizes this campaign's whole spatial vocabulary — `{entry}`, an \
1077                     `anchor/node-…` for each place, and an `anchor/seam-…` plus an \
1078                     `anchor/unlock-…` for each barred connection — so those three prefixes and \
1079                     `{entry}` are reserved whether or not this graph happens to produce the \
1080                     name today. Name the station something of your own, and the quest layer \
1081                     references it exactly as it references a derived one."
1082                ),
1083            ));
1084        }
1085    }
1086
1087    // ---- §7.2: two claims on one name.
1088    //
1089    // **The scope of uniqueness is the area**, which is the scope every anchor
1090    // reference already resolves in — unchanged from the standing rule, and the
1091    // reason two pieces may both declare `anchor/door` and collide with nothing.
1092    // A site-plan campaign has one area, so this is the whole graph.
1093    let mut first: BTreeMap<&str, &NodeId> = BTreeMap::new();
1094    for (i, n) in graph.nodes.iter().enumerate() {
1095        for (j, s) in n.stations.iter().enumerate() {
1096            let name = s.anchor.as_str();
1097            if let Some(other) = first.get(name) {
1098                d.push(Diagnostic::error(
1099                    DW_STATION_DUPLICATE,
1100                    "layout-graph",
1101                    format!("/content/nodes/{i}/stations/{j}/anchor"),
1102                    format!(
1103                        "station `{name}` is declared by `{here}` and by `{other}`. A station \
1104                         name is unique within the AREA — the scope every anchor reference \
1105                         resolves in — and a site-plan campaign has exactly one, so the \
1106                         campaign's whole vocabulary shares it. Two places cannot both answer \
1107                         to one name, because a quest naming it would have two answers and \
1108                         nothing would say which. Rename one of them, or declare it on one \
1109                         place only: a quest in either place may name a station of the other.",
1110                        here = n.id,
1111                    ),
1112                ));
1113            } else {
1114                first.insert(name, &n.id);
1115            }
1116        }
1117    }
1118}
1119
1120/// `fact/<kebab>` ids, unique, and every fact carries the sentence it came from.
1121fn brief_checks(brief: &GeometryBriefContent, d: &mut Vec<Diagnostic>) {
1122    let mut seen: BTreeSet<&str> = BTreeSet::new();
1123    for (i, f) in brief.facts.iter().enumerate() {
1124        if !f.id.is_valid_syntax() {
1125            d.push(Diagnostic::error(
1126                crate::codes::ID_SYNTAX,
1127                "geometry-brief",
1128                format!("/content/facts/{i}/id"),
1129                format!(
1130                    "malformed fact id `{}` — a brief fact is named `fact/<kebab-case>`, so that \
1131                     a site plan's identity can bind to it by name.",
1132                    f.id
1133                ),
1134            ));
1135        }
1136        if !seen.insert(f.id.0.as_str()) {
1137            d.push(Diagnostic::error(
1138                crate::codes::ID_DUPLICATE,
1139                "geometry-brief",
1140                format!("/content/facts/{i}/id"),
1141                format!(
1142                    "duplicate fact id `{}` — rename one, because an identity binding to this \
1143                     name would otherwise hold the map to whichever number was written last.",
1144                    f.id
1145                ),
1146            ));
1147        }
1148    }
1149}
1150
1151/// `DW0814` and the id-syntax rules. Returns true if the graph is malformed
1152/// enough that the semantic checks below it would be answering about places that
1153/// are not there.
1154fn wellformed(graph: &LayoutGraphContent, known: &BTreeSet<&str>, d: &mut Vec<Diagnostic>) -> bool {
1155    let before = d.len();
1156    let mut seen_nodes: BTreeSet<&str> = BTreeSet::new();
1157    for (i, n) in graph.nodes.iter().enumerate() {
1158        if !n.id.is_valid_syntax() {
1159            d.push(Diagnostic::error(
1160                crate::codes::ID_SYNTAX,
1161                "layout-graph",
1162                format!("/content/nodes/{i}/id"),
1163                format!(
1164                    "malformed node id `{}` — a place is named `node/<kebab-case>`.",
1165                    n.id
1166                ),
1167            ));
1168        }
1169        if !seen_nodes.insert(n.id.0.as_str()) {
1170            d.push(Diagnostic::error(
1171                DW_GRAPH_MALFORMED,
1172                "layout-graph",
1173                format!("/content/nodes/{i}/id"),
1174                format!(
1175                    "duplicate node id `{}` — two places cannot share a name, because every \
1176                     edge, beat and critical-path step that names it would then name both.",
1177                    n.id
1178                ),
1179            ));
1180        }
1181        if n.intent.trim().is_empty() {
1182            d.push(Diagnostic::error(
1183                DW_GRAPH_MALFORMED,
1184                "layout-graph",
1185                format!("/content/nodes/{i}/intent"),
1186                format!(
1187                    "place `{}` declares an empty `intent` — no check keys on this label, which \
1188                     is exactly why it has to be written: it is the recorded judgement a \
1189                     reviewer and the later per-place brief read.",
1190                    n.id
1191                ),
1192            ));
1193        }
1194    }
1195    let mut seen_edges: BTreeSet<&str> = BTreeSet::new();
1196    for (i, e) in graph.edges.iter().enumerate() {
1197        if !e.id().is_valid_syntax() {
1198            d.push(Diagnostic::error(
1199                crate::codes::ID_SYNTAX,
1200                "layout-graph",
1201                format!("/content/edges/{i}/id"),
1202                format!(
1203                    "malformed edge id `{}` — a connection is named `edge/<kebab-case>`.",
1204                    e.id()
1205                ),
1206            ));
1207        }
1208        if !seen_edges.insert(e.id().0.as_str()) {
1209            d.push(Diagnostic::error(
1210                DW_GRAPH_MALFORMED,
1211                "layout-graph",
1212                format!("/content/edges/{i}/id"),
1213                format!(
1214                    "duplicate edge id `{}` — rename one, because a seam is allocated per edge \
1215                     and two edges of one name would allocate one seam between them.",
1216                    e.id()
1217                ),
1218            ));
1219        }
1220        for (end, node) in [("a", e.a()), ("b", e.b())] {
1221            if !known.contains(node.0.as_str()) {
1222                d.push(Diagnostic::error(
1223                    DW_GRAPH_MALFORMED,
1224                    "layout-graph",
1225                    format!("/content/edges/{i}/{end}"),
1226                    format!(
1227                        "connection `{}` ends at `{node}`, which is not a declared place — \
1228                         declare that node, or point the end at one of the {n} that exist.",
1229                        e.id(),
1230                        n = known.len(),
1231                    ),
1232                ));
1233            }
1234        }
1235        if e.a() == e.b() {
1236            d.push(Diagnostic::error(
1237                DW_GRAPH_MALFORMED,
1238                "layout-graph",
1239                format!("/content/edges/{i}"),
1240                format!(
1241                    "connection `{}` has both ends in `{}` — a self-loop states nothing a place \
1242                     does not already state, at every class, so it is refused rather than \
1243                     silently carried into a seam allocation with no face to sit on.",
1244                    e.id(),
1245                    e.a(),
1246                ),
1247            ));
1248        }
1249    }
1250    for (field, node) in [("entry", &graph.entry), ("goal", &graph.goal)] {
1251        if !known.contains(node.0.as_str()) {
1252            d.push(Diagnostic::error(
1253                DW_GRAPH_MALFORMED,
1254                "layout-graph",
1255                format!("/content/{field}"),
1256                format!(
1257                    "`{field}` names `{node}`, which is not a declared place — every proof over \
1258                     this graph starts or ends there, so it cannot be a name nothing defines."
1259                ),
1260            ));
1261        }
1262    }
1263    for (i, node) in graph.critical_path.iter().enumerate() {
1264        if !known.contains(node.0.as_str()) {
1265            d.push(Diagnostic::error(
1266                DW_GRAPH_MALFORMED,
1267                "layout-graph",
1268                format!("/content/critical_path/{i}"),
1269                format!("the critical path steps through `{node}`, which is not a declared place."),
1270            ));
1271        }
1272    }
1273    for (i, beat) in graph.beats.iter().enumerate() {
1274        if !known.contains(beat.node.0.as_str()) {
1275            d.push(Diagnostic::error(
1276                DW_GRAPH_MALFORMED,
1277                "layout-graph",
1278                format!("/content/beats/{i}/node"),
1279                format!(
1280                    "beat `{q}` / `{o}` happens in `{n}`, which is not a declared place.",
1281                    q = beat.quest,
1282                    o = beat.objective,
1283                    n = beat.node,
1284                ),
1285            ));
1286        }
1287    }
1288    d.len() != before
1289}
1290
1291/// `DW0818`: the graph and the mission agree, in both directions.
1292fn mission(c: &Campaign, graph: &LayoutGraphContent, d: &mut Vec<Diagnostic>) {
1293    let quests: BTreeMap<&str, BTreeSet<&str>> = c
1294        .quests
1295        .content
1296        .quests
1297        .iter()
1298        .map(|q| {
1299            (
1300                q.id.0.as_str(),
1301                q.objectives.iter().map(|o| o.id().0.as_str()).collect(),
1302            )
1303        })
1304        .collect();
1305    let produced = crate::validate::produced_flags(c);
1306
1307    // **Stage 5 being empty is not a graph mistake.** It is the ordinary state
1308    // of a campaign whose plan is written and whose quests are not — the state
1309    // `DW0150` names — and in it EVERY name the graph borrows from the mission
1310    // is absent, so every such refusal says the same thing and none of them is
1311    // the finding. Without this clause a finished layout graph reports a fault
1312    // per beat and per gated way, in a document the author completed two steps
1313    // ago, at exactly the moment they are being told to loop validation until
1314    // it is clean.
1315    //
1316    // It is attached to every refusal in this function that reads the stage-5
1317    // quest list, not to the one that was noticed: a beat and a gated
1318    // connection are the same borrowing, and a clause on one of them would be
1319    // the narrow binding this codebase keeps finding.
1320    let unwritten = if quests.is_empty() {
1321        " Stage 5 declares no quests at all here, so everything this graph borrows from the \
1322         mission is missing, every one of these lines says the same thing, and none of them is \
1323         the finding: see `DW0150`, which names that state. This clears when stage 5 is written."
1324    } else {
1325        ""
1326    };
1327
1328    // Direction one: nothing the graph names may be absent from the mission.
1329    let mut bound: BTreeMap<(&str, &str), usize> = BTreeMap::new();
1330    for (i, beat) in graph.beats.iter().enumerate() {
1331        match quests.get(beat.quest.0.as_str()) {
1332            None => d.push(Diagnostic::error(
1333                DW_GRAPH_MISSION,
1334                "layout-graph",
1335                format!("/content/beats/{i}/quest"),
1336                format!(
1337                    "beat names quest `{}`, which the quest documents do not declare — the graph \
1338                     says where the mission happens, so it can only name beats the mission \
1339                     has.{unwritten}",
1340                    beat.quest,
1341                ),
1342            )),
1343            Some(objectives) if !objectives.contains(beat.objective.0.as_str()) => {
1344                d.push(Diagnostic::error(
1345                    DW_GRAPH_MISSION,
1346                    "layout-graph",
1347                    format!("/content/beats/{i}/objective"),
1348                    format!(
1349                        "beat names objective `{o}`, which quest `{q}` does not declare.",
1350                        o = beat.objective,
1351                        q = beat.quest,
1352                    ),
1353                ));
1354            }
1355            Some(_) => {
1356                *bound
1357                    .entry((beat.quest.0.as_str(), beat.objective.0.as_str()))
1358                    .or_default() += 1;
1359            }
1360        }
1361    }
1362    for (i, e) in graph.edges.iter().enumerate() {
1363        let Some(g) = e.gating() else { continue };
1364        for (k, f) in g.flags.iter().enumerate() {
1365            if !produced.contains(f.0.as_str()) {
1366                d.push(Diagnostic::error(
1367                    DW_GRAPH_MISSION,
1368                    "layout-graph",
1369                    format!("/content/edges/{i}/gating/flags/{k}"),
1370                    format!(
1371                        "connection `{e_id}` waits on flag `{f}`, which no `set-flag` effect ever \
1372                         produces — a body could never hold it, so the connection is a wall \
1373                         wearing a gate's clothes. Produce the flag, or gate on one the campaign \
1374                         really sets.",
1375                        e_id = e.id(),
1376                    ),
1377                ));
1378            }
1379        }
1380        if let Some(q) = &g.quest
1381            && !quests.contains_key(q.0.as_str())
1382        {
1383            d.push(Diagnostic::error(
1384                DW_GRAPH_MISSION,
1385                "layout-graph",
1386                format!("/content/edges/{i}/gating/quest"),
1387                format!(
1388                    "connection `{e_id}` waits on quest `{q}`, which the quest documents do not \
1389                     declare.{unwritten}",
1390                    e_id = e.id(),
1391                ),
1392            ));
1393        }
1394        if matches!(e, Edge::Carry { .. }) && g.is_empty() {
1395            d.push(Diagnostic::error(
1396                DW_GRAPH_MISSION,
1397                "layout-graph",
1398                format!("/content/edges/{i}/gating"),
1399                format!(
1400                    "carry connection `{e_id}` says nothing about what makes it carry. A carry \
1401                     with an empty `gating` is live from world load, which leaves a hole in the \
1402                     graph's own claim; name the flag or the quest whose completion arms the \
1403                     link that realises it.",
1404                    e_id = e.id(),
1405                ),
1406            ));
1407        }
1408        if matches!(e, Edge::Barred { .. }) && g.is_empty() {
1409            d.push(Diagnostic::error(
1410                DW_GRAPH_MISSION,
1411                "layout-graph",
1412                format!("/content/edges/{i}/gating"),
1413                format!(
1414                    "barred connection `{e_id}` says nothing about what opens it. A barred way \
1415                     with an empty `gating` is passable from world load, which is not barred; \
1416                     name the flag or the quest whose completion opens it, and `DW0818` then \
1417                     holds that name to something the campaign really produces.",
1418                    e_id = e.id(),
1419                ),
1420            ));
1421        }
1422    }
1423
1424    // Direction two — the ordering tooth. Once a mission exists, every beat of
1425    // it has a place. This is what stops a graph arriving after the quests and
1426    // ignoring them, and it is why *graph before mission* is representable
1427    // without being silently green (spec-0049 §7).
1428    for q in &c.quests.content.quests {
1429        for (oi, obj) in q.objectives.iter().enumerate() {
1430            let n = bound
1431                .get(&(q.id.0.as_str(), obj.id().0.as_str()))
1432                .copied()
1433                .unwrap_or(0);
1434            if n == 0 {
1435                d.push(Diagnostic::error(
1436                    DW_GRAPH_MISSION,
1437                    "quests",
1438                    format!(
1439                        "/content/quests/{qi}/objectives/{oi}",
1440                        qi = quest_index(c, q)
1441                    ),
1442                    format!(
1443                        "objective `{o}` of quest `{q}` happens somewhere and the layout graph \
1444                         does not say where. Every objective is place-bound — a body has to be \
1445                         standing somewhere to talk, to reach, to fight or to take — so add a \
1446                         `beats[]` entry binding it to a node. This is the direction that keeps \
1447                         space and mission from silently disagreeing: a graph may be authored \
1448                         before the quests, never in ignorance of them.",
1449                        o = obj.id(),
1450                        q = q.id,
1451                    ),
1452                ));
1453            } else if n > 1 {
1454                d.push(Diagnostic::error(
1455                    DW_GRAPH_MISSION,
1456                    "layout-graph",
1457                    "/content/beats",
1458                    format!(
1459                        "objective `{o}` of quest `{q}` is bound to {n} places. A beat happens in \
1460                         exactly one place; two bindings make every proof over this graph pick \
1461                         one of them and no rule says which.",
1462                        o = obj.id(),
1463                        q = q.id,
1464                    ),
1465                ));
1466            }
1467        }
1468    }
1469}
1470
1471/// The index of a quest in the stage-5 document, for a diagnostic's path.
1472fn quest_index(c: &Campaign, q: &crate::Quest) -> usize {
1473    c.quests
1474        .content
1475        .quests
1476        .iter()
1477        .position(|x| std::ptr::eq(x, q))
1478        .unwrap_or(0)
1479}
1480
1481/// `DW0820`: a shortcut lies on a cycle.
1482fn shortcut_loops(graph: &LayoutGraphContent, d: &mut Vec<Diagnostic>) {
1483    for (i, e) in graph.edges.iter().enumerate() {
1484        if !e.shortcut() {
1485            continue;
1486        }
1487        if connected_without(graph, e.id(), e.a(), e.b()) {
1488            continue;
1489        }
1490        d.push(Diagnostic::error(
1491            DW_SHORTCUT_NO_LOOP,
1492            "layout-graph",
1493            format!("/content/edges/{i}/shortcut"),
1494            format!(
1495                "connection `{e_id}` is marked a shortcut and closes no loop: with it removed, \
1496                 `{a}` and `{b}` are no longer connected at all. A shortcut is the way back into \
1497                 ground a body has already crossed, so an edge that is the ONLY way between its \
1498                 two places is a corridor wearing a shortcut's name. Either drop the mark, or add \
1499                 the long way round it is meant to shorten.",
1500                e_id = e.id(),
1501                a = e.a(),
1502                b = e.b(),
1503            ),
1504        ));
1505    }
1506}
1507
1508/// Undirected connectivity between two places over every traversal edge but
1509/// `skip`. Direction-blind and gating-blind on purpose: the loop a shortcut
1510/// closes is **spatial**, and a long way round that is gated is still the long
1511/// way round.
1512fn connected_without(graph: &LayoutGraphContent, skip: &EdgeId, a: &NodeId, b: &NodeId) -> bool {
1513    let mut seen: BTreeSet<&str> = BTreeSet::new();
1514    let mut stack = vec![a.0.as_str()];
1515    seen.insert(a.0.as_str());
1516    while let Some(at) = stack.pop() {
1517        if at == b.0.as_str() {
1518            return true;
1519        }
1520        for e in &graph.edges {
1521            if !e.is_traversal() || e.id() == skip {
1522                continue;
1523            }
1524            let (x, y) = (e.a().0.as_str(), e.b().0.as_str());
1525            let other = if x == at {
1526                y
1527            } else if y == at {
1528                x
1529            } else {
1530                continue;
1531            };
1532            if seen.insert(other) {
1533                stack.push(other);
1534            }
1535        }
1536    }
1537    false
1538}
1539
1540/// `DW0822`: the pacing projection, printed with **no threshold**.
1541///
1542/// # A leg is measured, never looked up
1543///
1544/// A place's traverse is **its box's long horizontal extent** — a real number
1545/// the author declared by drawing the box — and nothing else is consulted.
1546///
1547/// Where the campaign carries **no site plan**, a place has no geometry yet and
1548/// the projection says so: the leg is counted as **unprojected** in the line's
1549/// own binding rather than being given an invented number. A campaign with a
1550/// graph and no plan is a real and intended state — the graph is authored
1551/// before the embedding — so this is the ordinary case and not a fault.
1552fn pacing(
1553    c: &Campaign,
1554    graph: &LayoutGraphContent,
1555    table: &Metrics,
1556    reads: &mut Reads,
1557    d: &mut Vec<Diagnostic>,
1558) {
1559    // The plan's boxes, when there is a plan. `DW0824` is what holds this to one
1560    // box per place; a lookup that finds nothing here is a campaign the graph
1561    // stage is looking at before the plan stage exists, which is legal.
1562    let runs: BTreeMap<&str, u64> = c
1563        .site_plan
1564        .as_ref()
1565        .map(|p| {
1566            p.content
1567                .boxes
1568                .iter()
1569                .map(|b| {
1570                    let (dx, dz) = (u64::from(b.extent[0].get()), u64::from(b.extent[1].get()));
1571                    (b.node.0.as_str(), dx.max(dz))
1572                })
1573                .collect()
1574        })
1575        .unwrap_or_default();
1576
1577    let mut blocks: u64 = 0;
1578    let mut legs = 0usize;
1579    let mut unprojected: Vec<String> = Vec::new();
1580    for node_id in &graph.critical_path {
1581        if !graph.nodes.iter().any(|n| &n.id == node_id) {
1582            continue;
1583        }
1584        match runs.get(node_id.0.as_str()) {
1585            Some(run) => {
1586                blocks += *run;
1587                legs += 1;
1588            }
1589            None => unprojected.push(format!("`{node_id}`")),
1590        }
1591    }
1592    let Ok(per_minute) = table.resolve(MetricKind::Pacing, "route-blocks-per-minute") else {
1593        return;
1594    };
1595    let crate::metrics::MetricValue::Count(rate) = per_minute.value(reads) else {
1596        return;
1597    };
1598    let rate = u64::from(*rate).max(1);
1599    d.push(Diagnostic::warning(
1600        DW_PACING,
1601        "layout-graph",
1602        "/content/critical_path",
1603        format!(
1604            "the critical path crosses {legs} place(s) over {steps} step(s), a measured \
1605             {blocks} blocks of route, which at {rate} blocks of route per minute of play \
1606             projects to about {minutes} minute(s) against this world's `target_minutes` of \
1607             {target}{un}. It carries no threshold and refuses nothing — see `DW0822` in \
1608             `docs/reference/dsl/layout.md` for what the number is worth.",
1609            steps = graph.critical_path.len().saturating_sub(1),
1610            minutes = blocks.div_ceil(rate),
1611            target = c.world.content.target_minutes,
1612            un = if unprojected.is_empty() {
1613                String::new()
1614            } else {
1615                format!(
1616                    ", with {n} leg(s) UNPROJECTED and not in that total ({names}) — what \
1617                     crossing a place costs is its box's long extent, and this campaign has no \
1618                     site plan to read it from yet. Embedding the graph is what projects them; \
1619                     nothing is wrong here",
1620                    n = unprojected.len(),
1621                    names = unprojected.join(", "),
1622                )
1623            },
1624        ),
1625    ));
1626}
1627
1628// ---------------------------------------------------------------------------
1629// Reachability (spec-0049 §3.3): DW0816, DW0817, DW0819
1630// ---------------------------------------------------------------------------
1631
1632/// The graph's reachability proofs — a place a body can never reach
1633/// (`DW0816`), an authored critical path that does not hold (`DW0817`), and a
1634/// one-way connection that strands (`DW0819`).
1635///
1636/// # Why this runs at VALIDATION tier
1637///
1638/// It used to be raised from `compiler::analyze::analyze_campaign`, on the
1639/// argument that a tier is decided by the pass that raises it and these are
1640/// reachability questions like `DW0202`–`DW0204`. The argument sorts the check
1641/// by its KIND; what decides a tier here is when the check can fire, and by
1642/// that measure it was unreachable at the step it exists for. `delvec analyze`
1643/// returns at the first validation-tier error, and a campaign at the graph step
1644/// carries `DW0150` by construction — the plan is written and stage 5 is not —
1645/// so **no verb raised these proofs until stage 5 was written**, which is after
1646/// the design gate and after the expensive authoring the gate exists to
1647/// protect. spec-0049 §3 is explicit that the graph is checked "cheaply, before
1648/// geometry exists to make it expensive"; a proof that first fires two steps
1649/// later is not that.
1650///
1651/// Nothing here reads geometry: the whole battery is a function of the campaign
1652/// documents, which is why it can move. Called from [`check`] and from nowhere
1653/// else, so there is one battery in one place and no second copy of any rule; a
1654/// campaign with no layout graph returns nothing and [`LayoutBinding`] states
1655/// that zero.
1656#[must_use]
1657pub fn reachability(c: &Campaign) -> Vec<Diagnostic> {
1658    let mut d = Vec::new();
1659    let Some(graph) = c.layout_graph.as_ref().map(|g| &g.content) else {
1660        return d;
1661    };
1662    let known: BTreeSet<&str> = graph.nodes.iter().map(|n| n.id.0.as_str()).collect();
1663    if !known.contains(graph.entry.0.as_str()) {
1664        return d; // `DW0814` refused it; a closure from nowhere says nothing.
1665    }
1666    let grants = Grants::of(c, graph);
1667    let closure = Closure::run(graph, &grants);
1668    let caveat = unwritten_mission_caveat(c, graph);
1669    unreached(graph, &closure, caveat, &mut d);
1670    critical_path(c, graph, &grants, caveat, &mut d);
1671    strands(graph, &closure, caveat, &mut d);
1672    d
1673}
1674
1675/// The caveat a reachability refusal owes while **stage 5 is unwritten**.
1676///
1677/// [`Closure`] reads the mission for its flag grants ([`Grants::of`]), so a
1678/// campaign between the plan and the quests has no `set-flag` anywhere and every
1679/// flag-gated way in its graph is shut to this proof. Now that the battery runs
1680/// at validation tier that state is the ordinary one at the graph step, and
1681/// without this the proof reports a fault in a document the author finished two
1682/// steps ago and cannot yet answer. `mission` says the same thing about
1683/// `DW0818` in its own words and for the same reason; the shared authority is
1684/// the PREDICATE below, not the wording, because the two consequences differ.
1685///
1686/// A quest-gated way is NOT affected: `Grants::of` credits a quest once every
1687/// one of its beats sits at a reached place, and beats are the graph's own
1688/// document. Only `gating.flags` needs an effect stage 5 has not written.
1689///
1690/// It is a CAVEAT and never a dismissal. A place can be unreached, a path can
1691/// fail to be a path and a drop can strand for reasons the mission has nothing
1692/// to do with, and every one of those is a finding now — so it is attached only
1693/// where the mission's absence could be the cause (a graph that gates on a flag
1694/// at all) and it says which half is which.
1695fn unwritten_mission_caveat(c: &Campaign, graph: &LayoutGraphContent) -> &'static str {
1696    let gates_on_a_flag = graph
1697        .edges
1698        .iter()
1699        .any(|e| e.gating().is_some_and(|g| !g.flags.is_empty()));
1700    if c.quests.content.quests.is_empty() && gates_on_a_flag {
1701        " Stage 5 declares no quests here, so no `set-flag` effect exists yet and every \
1702         flag-gated way in this graph is shut to this proof: a place behind one is closed off \
1703         for that reason alone and opens when stage 5 is written (see `DW0150`). Anything \
1704         unreached for any OTHER reason is a real finding now, and this line does not say which \
1705         of the two you are looking at — the graph does."
1706    } else {
1707        ""
1708    }
1709}
1710
1711/// `DW0816`: a node the closure never reaches.
1712fn unreached(graph: &LayoutGraphContent, closure: &Closure, caveat: &str, d: &mut Vec<Diagnostic>) {
1713    for (i, n) in graph.nodes.iter().enumerate() {
1714        let reached = closure.reached.contains(n.id.0.as_str());
1715        // Scenery (`reached: false`): the intent is confirmed the other way.
1716        if !n.reached {
1717            if reached {
1718                d.push(Diagnostic::error(
1719                    DW_NODE_UNREACHED,
1720                    "layout-graph",
1721                    format!("/content/nodes/{i}/reached"),
1722                    format!(
1723                        "place `{id}` is declared `reached: false` — scenery, built to be seen \
1724                         and never entered — and the closure reaches it from `{entry}`: a \
1725                         connection leads a body into it. Remove the connections that reach \
1726                         it (a vision edge is how a place is seen), or take `reached: false` \
1727                         off and make it a place a body visits.{caveat}",
1728                        id = n.id,
1729                        entry = graph.entry,
1730                    ),
1731                ));
1732            }
1733            continue;
1734        }
1735        if reached {
1736            continue;
1737        }
1738        let near = graph
1739            .edges
1740            .iter()
1741            .filter(|e| e.is_traversal())
1742            .find_map(|e| {
1743                let (a, b) = (e.a().0.as_str(), e.b().0.as_str());
1744                if a == n.id.0 && closure.reached.contains(b) {
1745                    Some(b)
1746                } else if b == n.id.0 && closure.reached.contains(a) {
1747                    Some(a)
1748                } else {
1749                    None
1750                }
1751            });
1752        let hint = match near {
1753            Some(other) => format!(
1754                "the nearest place a body can stand is `{other}`, so the missing link is between \
1755                 those two — either its gating demands something no reached beat grants, or it \
1756                 runs one way and the wrong way"
1757            ),
1758            None => "no connection reaches it from anywhere a body can stand at all".to_string(),
1759        };
1760        d.push(Diagnostic::error(
1761            DW_NODE_UNREACHED,
1762            "layout-graph",
1763            format!("/content/nodes/{i}"),
1764            format!(
1765                "place `{id}` is never reached: {hint}. Of the {total} place(s) this graph \
1766                 declares, {n} are reachable from `{entry}` under the campaign's own \
1767                 gating.{caveat}",
1768                id = n.id,
1769                total = graph.nodes.len(),
1770                n = closure.reached.len(),
1771                entry = graph.entry,
1772            ),
1773        ));
1774    }
1775}
1776
1777/// `DW0817`: the authored critical path holds.
1778fn critical_path(
1779    c: &Campaign,
1780    graph: &LayoutGraphContent,
1781    grants: &Grants,
1782    caveat: &str,
1783    d: &mut Vec<Diagnostic>,
1784) {
1785    let path = &graph.critical_path;
1786    let mut fault = |path_suffix: &str, msg: String| {
1787        d.push(Diagnostic::error(
1788            DW_CRITICAL_PATH,
1789            "layout-graph",
1790            format!("/content/critical_path{path_suffix}"),
1791            msg,
1792        ));
1793    };
1794    if path.first() != Some(&graph.entry) || path.last() != Some(&graph.goal) {
1795        fault(
1796            "",
1797            format!(
1798                "the critical path must run from `{entry}` to `{goal}`; it runs from {from} to \
1799                 {to}. It is authored rather than derived precisely so that it is a claim, and a \
1800                 claim that does not start where a body starts is not one.",
1801                entry = graph.entry,
1802                goal = graph.goal,
1803                from = path.first().map_or("nowhere".into(), |n| format!("`{n}`")),
1804                to = path.last().map_or("nowhere".into(), |n| format!("`{n}`")),
1805            ),
1806        );
1807    }
1808    // Stepwise: each step is a real connection, run the right way, and openable
1809    // with what the beats already visited have granted.
1810    let mut held: BTreeSet<Grant> = BTreeSet::new();
1811    let mut visited: BTreeSet<&str> = BTreeSet::new();
1812    let mut steps = 0usize;
1813    for (i, pair) in path.windows(2).enumerate() {
1814        let (from, to) = (&pair[0], &pair[1]);
1815        visited.insert(from.0.as_str());
1816        collect_grants(grants, &visited, &mut held);
1817        let edge = graph.edges.iter().find(|e| {
1818            e.is_traversal()
1819                && ((e.a() == from && e.b() == to && e.direction() != Some(Direction::BToA))
1820                    || (e.b() == from && e.a() == to && e.direction() != Some(Direction::AToB)))
1821        });
1822        match edge {
1823            None => fault(
1824                &format!("/{}", i + 1),
1825                format!(
1826                    "the critical path steps from `{from}` to `{to}` and no connection runs that \
1827                     way. Either the two places share no edge at all, or the one they share runs \
1828                     the other way."
1829                ),
1830            ),
1831            Some(e) if !Closure::satisfied(e.gating(), &held) => {
1832                // The caveat rides only the GATING fault. A step with no
1833                // connection at all, a path that does not run entry to goal and
1834                // a missed spine beat are judgements about the graph alone, and
1835                // an unwritten mission has nothing to say about any of them.
1836                let unwritten = if e.gating().is_some_and(|g| !g.flags.is_empty()) {
1837                    caveat
1838                } else {
1839                    ""
1840                };
1841                fault(
1842                    &format!("/{}", i + 1),
1843                    format!(
1844                        "the critical path steps from `{from}` to `{to}` over `{e_id}`, which is \
1845                         not open yet at that point in the walk: nothing bound to the {v} \
1846                         place(s) already visited grants what it waits on. Move the beat that \
1847                         opens it earlier on the path, or route the path through the place that \
1848                         grants it.{unwritten}",
1849                        e_id = e.id(),
1850                        v = visited.len(),
1851                    ),
1852                );
1853            }
1854            Some(_) => {}
1855        }
1856        steps += 1;
1857    }
1858    if let Some(last) = path.last() {
1859        visited.insert(last.0.as_str());
1860    }
1861    // The spine obligation, and the zero binding that goes with it.
1862    let spine = c.quest_plan.content.spine();
1863    let mut required = 0usize;
1864    for beat in &graph.beats {
1865        if !spine.contains(beat.quest.0.as_str()) {
1866            continue;
1867        }
1868        required += 1;
1869        if !visited.contains(beat.node.0.as_str()) {
1870            fault(
1871                "",
1872                format!(
1873                    "beat `{q}` / `{o}` happens in `{n}`, which the critical path never visits — \
1874                     and `{q}` is on the mandatory spine, so a body walking this path would reach \
1875                     the goal without doing it.",
1876                    q = beat.quest,
1877                    o = beat.objective,
1878                    n = beat.node,
1879                ),
1880            );
1881        }
1882    }
1883    // The binding is STATED, not raised. `delvec analyze` exits 2 on any reported
1884    // diagnostic, warning or error alike, so a "this bound to nothing" line here
1885    // would turn a green analyze red — a count is not a fault. It lives in
1886    // [`LayoutBinding`] instead, which every run prints and which flags the zero
1887    // as a finding, and `steps` and `required` are quoted in the faults above so
1888    // a red says what it examined.
1889    let _ = (steps, required);
1890}
1891
1892/// Everything the places visited so far have granted.
1893fn collect_grants(grants: &Grants, visited: &BTreeSet<&str>, held: &mut BTreeSet<Grant>) {
1894    for (node, given) in &grants.by_node {
1895        if visited.contains(node.as_str()) {
1896            held.extend(given.iter().cloned());
1897        }
1898    }
1899    for (quest, (nodes, given)) in &grants.by_quest {
1900        if nodes.iter().all(|n| visited.contains(n.as_str())) {
1901            held.insert(Grant::Quest(quest.clone()));
1902            held.extend(given.iter().cloned());
1903        }
1904    }
1905}
1906
1907/// `DW0819`: a one-way edge strands.
1908fn strands(graph: &LayoutGraphContent, closure: &Closure, caveat: &str, d: &mut Vec<Diagnostic>) {
1909    let spine: BTreeSet<&str> = graph.critical_path.iter().map(|n| n.0.as_str()).collect();
1910    for (i, e) in graph.edges.iter().enumerate() {
1911        if !e.is_traversal() {
1912            continue;
1913        }
1914        let Some(dir) = e.direction() else { continue };
1915        let (from, to) = match dir {
1916            Direction::AToB => (e.a(), e.b()),
1917            Direction::BToA => (e.b(), e.a()),
1918        };
1919        // A body can only be at `to` having been at `from`, holding at most what
1920        // it held on arriving at `from`.
1921        let Some(held) = closure.obtained_when.get(from.0.as_str()) else {
1922            continue; // `from` is unreachable; `DW0816` owns that.
1923        };
1924        if rejoins(graph, to, held, &spine) {
1925            continue;
1926        }
1927        d.push(Diagnostic::error(
1928            DW_ONE_WAY_STRANDS,
1929            "layout-graph",
1930            format!("/content/edges/{i}"),
1931            format!(
1932                "connection `{e_id}` runs one way from `{from}` into `{to}`, and from `{to}` \
1933                 there is no way back to the critical path. A body can only be in `{to}` having \
1934                 taken this connection, so a walk that takes it is a softlock. Add a way out of \
1935                 `{to}` — the shortcut back is the usual one — or make the connection \
1936                 two-way.{caveat}",
1937                e_id = e.id(),
1938            ),
1939        ));
1940    }
1941}
1942
1943/// Can a body standing at `at`, holding `held`, get back to the spine?
1944fn rejoins(
1945    graph: &LayoutGraphContent,
1946    at: &NodeId,
1947    held: &BTreeSet<Grant>,
1948    spine: &BTreeSet<&str>,
1949) -> bool {
1950    let mut seen: BTreeSet<&str> = BTreeSet::new();
1951    let mut stack = vec![at.0.as_str()];
1952    seen.insert(at.0.as_str());
1953    while let Some(here) = stack.pop() {
1954        if spine.contains(here) {
1955            return true;
1956        }
1957        for e in &graph.edges {
1958            if !e.is_traversal() || !Closure::satisfied(e.gating(), held) {
1959                continue;
1960            }
1961            let (a, b) = (e.a().0.as_str(), e.b().0.as_str());
1962            let next = if a == here && e.direction() != Some(Direction::BToA) {
1963                b
1964            } else if b == here && e.direction() != Some(Direction::AToB) {
1965                a
1966            } else {
1967                continue;
1968            };
1969            if seen.insert(next) {
1970                stack.push(next);
1971            }
1972        }
1973    }
1974    false
1975}