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