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