delvewright_dsl/placement.rs
1//! **Where a campaign's places and anchor names come from — asked once.**
2//!
3//! A campaign has exactly one placement authority. The usual one is stage-1
4//! `areas[]`, which seats prefab pieces on the compiler's fixed stride; a
5//! campaign planned as a whole map hands the space to its `site-plan.json`
6//! instead and the blockout is derived. `DW0839` refuses a campaign holding
7//! both.
8//!
9//! Every rule that resolves an area id or an anchor name already asks which of
10//! the two it is — [`crate::validate::AnchorProviders`] does it to build the
11//! resolvable set, and three area sets in `crate::validate` do it to admit
12//! [`crate::siteplan::SITE_AREA`]. What none of them asked is the second half of
13//! the same question: **when the reference does not resolve, what is this author
14//! allowed to write instead?**
15//!
16//! That half was hand-written beside each refusal at twenty sites, in the
17//! vocabulary of the only kind of campaign that existed when the rules were
18//! written. So a site-plan campaign was refused by `DW0112` with *"declare it in
19//! stage-1 `world.areas`"* — which is precisely what `DW0839` refuses in a
20//! campaign carrying a site plan, and which `DW0160` refuses again for having no
21//! prefab to bind — and by `DW0142` with *"anchor names come from prefab
22//! metadata; do NOT invent one"* against names that are synthesized by design and
23//! have no metadata anywhere.
24//!
25//! CLAUDE.md names the shape: **when one gate's prescription is another gate's
26//! refusal, the defect belongs to the PAIR**, and a gate that names a remedy owes
27//! a check that the remedy is reachable. The repair is the constitution's own —
28//! a capability belongs to the object class it acts on, so *"what may this author
29//! write"* is a property of the campaign's placement authority and is answered in
30//! one place, not re-decided at each verb.
31//!
32//! Nothing here decides whether a reference is wrong. Every predicate is exactly
33//! what it was; only the sentence the author reads afterwards is chosen by the
34//! campaign rather than by the site.
35
36use crate::envelope::Campaign;
37
38/// The one thing a campaign hands its space to.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Placement {
41 /// Stage-1 `areas[]` seats prefab pieces. Anchor names come from prefab
42 /// metadata; area ids are the ones the world document declares.
43 Prefabs,
44 /// A `site-plan.json` owns the space. There is exactly one area
45 /// ([`crate::siteplan::SITE_AREA`]) and the anchor names are the ones the
46 /// derivation places ([`crate::siteplan::synthesized_anchors`]) — the
47 /// `spawn`/`node-`/`seam-`/`unlock-` set the graph's own shape dictates,
48 /// plus every `stations[]` name its nodes declare (spec-0052). The station
49 /// half is why the remedy tells the author they may *declare* the name as
50 /// well as correct it: it is the only anchor name in this engine an author
51 /// writes by hand, so an unresolved one is as likely a missing declaration
52 /// as a typo.
53 SitePlan,
54 /// **Neither.** `areas[]` is empty and there is no site plan, so the campaign
55 /// declares no place and nothing places an anchor: every area reference and
56 /// every anchor reference in it is unresolvable, whatever it says.
57 ///
58 /// This is a real authoring state and it is the one the old messages served
59 /// worst — a story layer written before its map. Refusing it is correct; the
60 /// only question is what the author is told to do about it, and the answer
61 /// is not "declare it in `world.areas`", because that names one of the two
62 /// halves of a choice the author has not made yet and hides the other.
63 NoMap,
64}
65
66impl Placement {
67 /// Ask the campaign, once.
68 ///
69 /// A site plan wins when both are present, because that is what the
70 /// resolution does: [`crate::validate::AnchorProviders`] and all three area
71 /// sets admit the derived vocabulary whenever a plan is on disk, whatever
72 /// `areas[]` says. Such a campaign is refused by `DW0839`, whose message is
73 /// the one that matters there — this one is describing the set the resolver
74 /// actually used.
75 #[must_use]
76 pub fn of(c: &Campaign) -> Self {
77 if c.site_plan.is_some() {
78 Self::SitePlan
79 } else if c.world.content.areas.is_empty() {
80 Self::NoMap
81 } else {
82 Self::Prefabs
83 }
84 }
85
86 /// **What to write instead of an area id that does not resolve.**
87 ///
88 /// The `Prefabs` arm is the sentence every area refusal has always carried,
89 /// unchanged, so a prefab campaign reads exactly what it read before.
90 #[must_use]
91 pub fn area_remedy(self) -> &'static str {
92 match self {
93 Self::Prefabs => "declare it in stage-1 `world.areas` or correct the reference",
94 Self::SitePlan => {
95 "this campaign's map is its site plan, so it has exactly one area, \
96 `area/site`. Point the reference at that, and do NOT declare the id in \
97 `world.areas`: a campaign carrying a site plan declares an empty `areas` list \
98 (`DW0839`), and an entry with no prefab bound to it is refused again \
99 (`DW0160`)"
100 }
101 Self::NoMap => {
102 "this campaign declares no area at all: `world.areas` is empty and there is \
103 no `site-plan.json`, so it has no map for a reference to land in. Give it one \
104 placement authority, and only one (`DW0839`): either declare the area in \
105 stage-1 `world.areas` with a `prefab` or `prefab_pool` bound to it, or write \
106 the map pipeline (`geometry-brief.json`, then `layout-graph.json`, then \
107 `site-plan.json`) and name the campaign's one area `area/site`"
108 }
109 }
110 }
111
112 /// **What to write instead of an anchor name that does not resolve.**
113 ///
114 /// `prefab` is the sentence this call site has always printed for a prefab
115 /// campaign, passed in rather than centralised because it is genuinely
116 /// per-verb — a trap is told about `anchor/trap` markers, a trigger about
117 /// its `at`. It is returned verbatim, so a prefab campaign's refusal is
118 /// byte-identical to the one it printed before this module existed.
119 ///
120 /// It is *dropped* on the other two arms rather than appended, and that is
121 /// the whole point: every one of those sentences prescribes a prefab
122 /// operation, and a derived map has no prefab for the author to reach.
123 #[must_use]
124 pub fn anchor_remedy(self, prefab: &str) -> &str {
125 match self {
126 Self::Prefabs => prefab,
127 Self::SitePlan => {
128 "this campaign's map is its site plan, so there is no prefab metadata to \
129 read. Its anchor names are the ones the derivation places: `anchor/node-<place>` \
130 for each place the layout graph declares, `anchor/seam-<edge>` over each barred \
131 connection, `anchor/unlock-<edge>` on the far side of a one-sided one, and \
132 `spawn` for the entry — plus every `stations[]` name its nodes declare. Write \
133 one of those, or declare this name as a station on the node it belongs to, and \
134 do NOT add an `areas[]` entry to get a prefab: a campaign carrying a site plan \
135 declares an empty `areas` list (`DW0839`)"
136 }
137 Self::NoMap => {
138 "this campaign has no map yet: `world.areas` is empty and there is no \
139 `site-plan.json`, so nothing places an anchor and no name can resolve. Give it \
140 one placement authority, and only one (`DW0839`): either declare an area in \
141 stage-1 `world.areas` with a `prefab` bound to it and write an anchor that \
142 prefab's metadata exposes, or write the map pipeline (`geometry-brief.json`, \
143 then `layout-graph.json`, then `site-plan.json`) and write one of the names its \
144 derivation places (`anchor/node-<place>`, `anchor/seam-<edge>`, \
145 `anchor/unlock-<edge>`, `spawn`, or a `stations[]` name a node declares)"
146 }
147 }
148 }
149
150 /// **Where this campaign writes a lighting declaration.**
151 ///
152 /// Not a sentence but a field name, because that is the whole of what moves
153 /// between the two kinds: a site plan carries ONE `lighting`, applied to
154 /// every enclosed box, and a prefab campaign carries one per `areas[]`
155 /// entry. The prose around it — which fixture, what `min_light` is for, what
156 /// not to do about it — is the same either way and stays where it is
157 /// written, which is also where `tools/ci/check-diagnostic-messages.py` reads
158 /// it.
159 ///
160 /// `NoMap` answers with the `areas[]` field: such a campaign has no area for
161 /// the light pass to walk, so this arm is reached only by a caller asking in
162 /// the abstract, and `areas[]` is the surface it would be writing.
163 ///
164 /// Returned BARE, with no stage qualifier, because two callers need two
165 /// different qualifiers of one name — `DW0210` says "declare …" and `DW0211`
166 /// says "Fix in …" — and a second accessor for the second phrasing would be
167 /// the duplication this module exists to remove.
168 #[must_use]
169 pub fn lighting_field(self) -> &'static str {
170 match self {
171 Self::Prefabs | Self::NoMap => "`world.areas[].lighting`",
172 Self::SitePlan => "the site plan's `lighting`",
173 }
174 }
175
176 /// Whether this campaign has the per-area **`mitigation`** surface at all.
177 ///
178 /// It lives on an `areas[]` entry, and a site-plan campaign is required to
179 /// declare an empty `areas` list (`DW0839`), so it has none. That is a
180 /// capability a derived map does not have rather than a wording question: a
181 /// darkness refusal offers the option only where it exists, because offering
182 /// it anywhere else is a prescription the campaign is refused for carrying
183 /// out.
184 #[must_use]
185 pub fn has_area_mitigation(self) -> bool {
186 !matches!(self, Self::SitePlan)
187 }
188}
189
190/// **What declares how big this campaign's map is** — asked once, by both tiers
191/// that need the answer.
192///
193/// A horizon that BUILDS terrain has to ring a stated extent, and whether one is
194/// stated is a fact about the documents: validation refuses `DW0855` on it
195/// before a block is placed, and the compiler derives the rectangle from the
196/// same answer. Two implementations of one predicate is two verdicts waiting to
197/// disagree — the tiers already disagreed once about which arm of `DW0855`'s own
198/// message applied — so the predicate lives here and the rectangle is derived
199/// from the authority this names.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum Extent {
202 /// **The site plan's `region`**: the whole map's design of record, the
203 /// brief's number flowing down. A box outside it is `DW0826`, so nothing a
204 /// part does can grow it.
205 Region,
206 /// **The one piece's own declared region**: the campaign is one area bound
207 /// to one `prefab`, so its whole map is that piece and the piece's declared
208 /// structure size is the map's extent. This is what a *site* is — a building
209 /// with its island, its moat and its banks inside one box — and the
210 /// declaration is the prefab document's, held to the `.nbt` by `DW0888`.
211 OnePiece,
212 /// **Nothing states one.** Two or more areas, or one area drawing from a
213 /// pool, or no area at all: the only candidate left is the union of what
214 /// happens to get placed, which `DW0855` refuses because areas sit on the
215 /// compiler's fixed stride and a pool's footprint is the solver's answer.
216 Unstated,
217}
218
219impl Extent {
220 /// Ask the campaign, once.
221 ///
222 /// The site plan wins where both could apply, for [`Placement::of`]'s
223 /// reason: a campaign carrying both is `DW0839`, and this describes the
224 /// authority the rest of the compiler actually uses.
225 #[must_use]
226 pub fn of(c: &Campaign) -> Self {
227 if c.site_plan.is_some() {
228 Self::Region
229 } else if c.world.content.areas.len() == 1 && c.world.content.areas[0].prefab.is_some() {
230 Self::OnePiece
231 } else {
232 Self::Unstated
233 }
234 }
235
236 /// Whether the campaign states an extent at all — the predicate `DW0855`
237 /// refuses on.
238 #[must_use]
239 pub fn is_stated(self) -> bool {
240 !matches!(self, Self::Unstated)
241 }
242}
243
244/// **Whether this campaign's anchor vocabulary can be known at all** — asked
245/// before any rule refuses a name for not being in it.
246///
247/// A [`Placement::SitePlan`] campaign's anchor names are DERIVED: a `node-` per
248/// place, a `seam-` per barred way, an `unlock-` on the openable side of a
249/// one-sided one, `spawn` for the entry, and every `stations[]` name its nodes
250/// declare — all of them read off `layout-graph.json`. With that document absent
251/// the derived set is not EMPTY, it is **unknown**, and `DW0824` is the finding:
252/// the plan embeds a graph, and there is no graph to embed.
253///
254/// Refusing an anchor reference in that state is this module's own defect,
255/// committed against itself. [`Placement::anchor_remedy`]'s `SitePlan` sentence
256/// tells the author to write one of the derived names or to declare a station on
257/// the node it belongs to; with no graph there are no places, no ways and no
258/// nodes, so **neither half of the remedy can be taken** — the pair rule this
259/// module opens with, applied to the prescription it hands out. Measured on the
260/// gallery's site-plan point with `layout-graph.json` removed: `DW0824` (the
261/// finding, one line) followed by thirteen refusals of names that are all
262/// correct — ten `DW0142`, two `DW0371`, one `DW0343` — each printing that
263/// unreachable sentence, ahead of the one line the author was there to act on.
264///
265/// So every anchor rule asks here first and stays silent, exactly as it stays
266/// silent for a `prefab_pool` whose draw the compiler has not made yet: the
267/// answer is not known at this tier. Nothing is lost by the silence — the run
268/// stops at `DW0824` either way, and every one of those names is re-judged, with
269/// the same rules, the moment the graph exists.
270///
271/// False for a prefab campaign at every state of its documents: its vocabulary
272/// is prefab metadata, which does not come from the map pipeline.
273#[must_use]
274pub fn anchor_vocabulary_unknowable(c: &Campaign) -> bool {
275 matches!(Placement::of(c), Placement::SitePlan) && c.layout_graph.is_none()
276}