delvewright_dsl/envelope.rs
1//! The stage envelope, the assembled [`Campaign`], and parsing from raw JSON.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::design::DesignContent;
7use crate::detailplan::DetailPlanContent;
8use crate::diagnostic::{Diagnostic, codes};
9use crate::ids::CampaignId;
10use crate::layout::{GeometryBriefContent, LayoutGraphContent};
11use crate::siteplan::SitePlanContent;
12use crate::stages::{
13 ClassesContent, DialogueContent, NpcsContent, QuestPlanContent, QuestsContent, WorldContent,
14 WorldEditsContent,
15};
16
17/// **The one `dsl_version` this engine accepts** (ADR-0024).
18///
19/// Every envelope this engine reads — the stage documents, the map-pipeline
20/// documents, an l10n sidecar — declares exactly this number; any other is
21/// refused at the envelope with `DW0102`, which names this constant. The number
22/// says which surface a document was written against, so a refusal can say
23/// why, and it promises nothing about any other engine: a released campaign is
24/// built by the engine it pins (`versions.toml`), and a surface change bumps
25/// this number and moves every document in this repository with it.
26///
27/// **This crate's package version IS the format's number** (ADR-0024), so the
28/// number is read from the manifest rather than restated here: there is one
29/// place to move it, and a literal in this file could not disagree with
30/// `Cargo.toml` even in principle.
31pub const DSL_VERSION: &str = env!("CARGO_PKG_VERSION");
32
33/// Which stage a document belongs to.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "kebab-case")]
36pub enum Stage {
37 /// Stage 1.
38 World,
39 /// Stage 2.
40 Npcs,
41 /// Stage 3.
42 Classes,
43 /// Stage 4.
44 QuestPlan,
45 /// Stage 5.
46 Quests,
47 /// Stage 6.
48 Dialogue,
49 /// Stage 7 (optional; DSL v0.6, spec-0017): the map-editor edit script.
50 WorldEdits,
51 /// The whole map's written brief, reduced to numbers (optional; DSL v0.13,
52 /// spec-0049 §4.2). Named, never renumbered into the 1..7 sequence: it is a
53 /// different pipeline's document and the two orderings are unrelated.
54 GeometryBrief,
55 /// The campaign's space as a graph, before any coordinate exists (optional;
56 /// DSL v0.13, spec-0049 §3).
57 LayoutGraph,
58 /// The geometric embedding of that graph — the whole map's design of record
59 /// (optional; DSL v0.14, spec-0049 §4).
60 SitePlan,
61 /// Which piece stands in which of the plan's places (optional; DSL v0.15,
62 /// spec-0050). Named, never renumbered, for the reason `GeometryBrief`
63 /// gives.
64 DetailPlan,
65 /// **The approved design's record** (optional; DSL v0.22, spec-0061): one
66 /// row per approved reference image, each naming the sky the picture was
67 /// drawn under. Named, never renumbered — it is the design step's document,
68 /// and the design step is not a position in the 1..7 sequence.
69 Design,
70}
71
72impl Stage {
73 /// The wire/filename name (`world`, `npcs`, `classes`, `quest-plan`,
74 /// `quests`, `dialogue`).
75 pub fn name(self) -> &'static str {
76 match self {
77 Stage::World => "world",
78 Stage::Npcs => "npcs",
79 Stage::Classes => "classes",
80 Stage::QuestPlan => "quest-plan",
81 Stage::Quests => "quests",
82 Stage::Dialogue => "dialogue",
83 Stage::WorldEdits => "world-edits",
84 Stage::GeometryBrief => "geometry-brief",
85 Stage::LayoutGraph => "layout-graph",
86 Stage::SitePlan => "site-plan",
87 Stage::DetailPlan => "detail-plan",
88 Stage::Design => "design",
89 }
90 }
91
92 /// **Every stage, in document order.** The one enumeration.
93 ///
94 /// Hand-written stage lists are how a new document escapes a gate that was
95 /// written before it existed: `crates/dsl/tests/gate_consumers.rs` walked
96 /// seven stages by name, so a schema object declaring part of the gate in an
97 /// eighth would have been invisible to the check whose whole subject is that
98 /// no such object exists. Anything that means "over the stages" reads this.
99 pub const ALL: [Stage; 12] = [
100 Stage::World,
101 Stage::Npcs,
102 Stage::Classes,
103 Stage::QuestPlan,
104 Stage::Quests,
105 Stage::Dialogue,
106 Stage::WorldEdits,
107 Stage::GeometryBrief,
108 Stage::LayoutGraph,
109 Stage::SitePlan,
110 Stage::DetailPlan,
111 Stage::Design,
112 ];
113}
114
115/// A stage document: `{ dsl_version, campaign_id, stage, content }`.
116#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
117#[serde(deny_unknown_fields)]
118pub struct Envelope<T> {
119 /// DSL version string.
120 pub dsl_version: String,
121 /// Owning campaign id.
122 pub campaign_id: CampaignId,
123 /// The stage this document is for.
124 pub stage: Stage,
125 /// The stage payload.
126 pub content: T,
127}
128
129/// The parsed stage documents that make up one campaign: the six required
130/// stages plus the optional stage-7 edit script (spec-0017).
131#[derive(Clone, Debug, PartialEq)]
132pub struct Campaign {
133 /// Stage 1.
134 pub world: Envelope<WorldContent>,
135 /// Stage 2.
136 pub npcs: Envelope<NpcsContent>,
137 /// Stage 3.
138 pub classes: Envelope<ClassesContent>,
139 /// Stage 4.
140 pub quest_plan: Envelope<QuestPlanContent>,
141 /// Stage 5.
142 pub quests: Envelope<QuestsContent>,
143 /// Stage 6.
144 pub dialogue: Envelope<DialogueContent>,
145 /// Stage 7 (optional; DSL v0.6, spec-0017): the map-editor edit script.
146 /// `None` = no `world-edits.json` in the campaign directory — byte-identical
147 /// to a campaign from before the stage existed.
148 pub world_edits: Option<Envelope<WorldEditsContent>>,
149 /// The whole map's brief as numbers (optional; DSL v0.13, spec-0049 §4.2).
150 pub geometry_brief: Option<Envelope<GeometryBriefContent>>,
151 /// The campaign's space as a graph (optional; DSL v0.13, spec-0049 §3).
152 pub layout_graph: Option<Envelope<LayoutGraphContent>>,
153 /// The geometric embedding of that graph (optional; DSL v0.14, spec-0049 §4).
154 pub site_plan: Option<Envelope<SitePlanContent>>,
155 /// Which piece fills which of the plan's places (optional; DSL v0.15,
156 /// spec-0050 §1).
157 pub detail_plan: Option<Envelope<DetailPlanContent>>,
158 /// The approved design's record (optional; DSL v0.22, spec-0061): the rows
159 /// `DW0890` holds the world's reachable skies to. `None` = the campaign
160 /// ships no `design.json`, which is a measured zero of an optional surface
161 /// at validation and a refusal at staging.
162 pub design: Option<Envelope<DesignContent>>,
163}
164
165/// The stage documents as raw JSON strings (compiler input): six required, the
166/// stage-7 edit script optional.
167#[derive(Clone, Debug, PartialEq)]
168pub struct RawCampaign {
169 /// `world.json`.
170 pub world: String,
171 /// `npcs.json`.
172 pub npcs: String,
173 /// `classes.json`.
174 pub classes: String,
175 /// `quest-plan.json`.
176 pub quest_plan: String,
177 /// `quests.json`.
178 pub quests: String,
179 /// `dialogue.json`.
180 pub dialogue: String,
181 /// `world-edits.json` (optional stage 7, spec-0017); `None` when the
182 /// campaign directory ships none.
183 pub world_edits: Option<String>,
184 /// `geometry-brief.json` (optional; spec-0049 §4.2).
185 pub geometry_brief: Option<String>,
186 /// `layout-graph.json` (optional; spec-0049 §3).
187 pub layout_graph: Option<String>,
188 /// `site-plan.json` (optional; spec-0049 §4).
189 pub site_plan: Option<String>,
190 /// `detail-plan.json` (optional; spec-0050 §1).
191 pub detail_plan: Option<String>,
192 /// `design.json` (optional; spec-0061 §2).
193 pub design: Option<String>,
194}
195
196fn parse_stage<T: for<'de> Deserialize<'de>>(
197 src: &str,
198 stage: Stage,
199 out: &mut Result<Envelope<T>, ()>,
200 diags: &mut Vec<Diagnostic>,
201) {
202 match serde_json::from_str::<Envelope<T>>(src) {
203 Ok(env) => *out = Ok(env),
204 Err(e) => {
205 *out = Err(());
206 diags.push(Diagnostic::error(
207 codes::SCHEMA,
208 stage.name(),
209 "",
210 format!(
211 "`{name}` stage document does not conform to its schema: {e}. Fix the \
212 offending field (unknown field, wrong type, or missing required one) in the \
213 campaign JSON to match the schema — run `delvec schema --stage {name}` to \
214 see the exact shape of THIS document. The schema is the authority on the \
215 form; a spec that disagrees with it is the stale one.",
216 name = stage.name()
217 ),
218 ));
219 }
220 }
221}
222
223/// Parse all six stage documents.
224///
225/// On any schema/parse failure returns every `DW0100` diagnostic collected
226/// (validation cannot run on unparseable input).
227pub fn parse_campaign(raw: &RawCampaign) -> Result<Campaign, Vec<Diagnostic>> {
228 let mut diags = Vec::new();
229 let mut world = Err(());
230 let mut npcs = Err(());
231 let mut classes = Err(());
232 let mut quest_plan = Err(());
233 let mut quests = Err(());
234 let mut dialogue = Err(());
235
236 parse_stage(&raw.world, Stage::World, &mut world, &mut diags);
237 parse_stage(&raw.npcs, Stage::Npcs, &mut npcs, &mut diags);
238 parse_stage(&raw.classes, Stage::Classes, &mut classes, &mut diags);
239 parse_stage(
240 &raw.quest_plan,
241 Stage::QuestPlan,
242 &mut quest_plan,
243 &mut diags,
244 );
245 parse_stage(&raw.quests, Stage::Quests, &mut quests, &mut diags);
246 parse_stage(&raw.dialogue, Stage::Dialogue, &mut dialogue, &mut diags);
247 // The optional stage-7 edit script (spec-0017): absent = `None`; present but
248 // unparseable = a `DW0100` like any other stage.
249 let mut world_edits: Result<Option<Envelope<WorldEditsContent>>, ()> = Ok(None);
250 if let Some(src) = &raw.world_edits {
251 let mut parsed = Err(());
252 parse_stage(src, Stage::WorldEdits, &mut parsed, &mut diags);
253 world_edits = parsed.map(Some);
254 }
255 // The spec-0049 map-pipeline documents, on the same terms: absent = `None`
256 // and a campaign that ships neither is byte-identical to one from before
257 // they existed; present = parsed, validated and hashed like any other stage.
258 let mut geometry_brief: Result<Option<Envelope<GeometryBriefContent>>, ()> = Ok(None);
259 if let Some(src) = &raw.geometry_brief {
260 let mut parsed = Err(());
261 parse_stage(src, Stage::GeometryBrief, &mut parsed, &mut diags);
262 geometry_brief = parsed.map(Some);
263 }
264 let mut layout_graph: Result<Option<Envelope<LayoutGraphContent>>, ()> = Ok(None);
265 if let Some(src) = &raw.layout_graph {
266 let mut parsed = Err(());
267 parse_stage(src, Stage::LayoutGraph, &mut parsed, &mut diags);
268 layout_graph = parsed.map(Some);
269 }
270 let mut site_plan: Result<Option<Envelope<SitePlanContent>>, ()> = Ok(None);
271 if let Some(src) = &raw.site_plan {
272 let mut parsed = Err(());
273 parse_stage(src, Stage::SitePlan, &mut parsed, &mut diags);
274 site_plan = parsed.map(Some);
275 }
276 let mut detail_plan: Result<Option<Envelope<DetailPlanContent>>, ()> = Ok(None);
277 if let Some(src) = &raw.detail_plan {
278 let mut parsed = Err(());
279 parse_stage(src, Stage::DetailPlan, &mut parsed, &mut diags);
280 detail_plan = parsed.map(Some);
281 }
282 // The design record (spec-0061 §2), on the same terms: absent = a campaign
283 // that has approved no design yet, which validation measures and staging
284 // refuses; present = parsed, validated and hashed like any other stage.
285 let mut design: Result<Option<Envelope<DesignContent>>, ()> = Ok(None);
286 if let Some(src) = &raw.design {
287 let mut parsed = Err(());
288 parse_stage(src, Stage::Design, &mut parsed, &mut diags);
289 design = parsed.map(Some);
290 }
291
292 match (
293 world,
294 npcs,
295 classes,
296 quest_plan,
297 quests,
298 dialogue,
299 world_edits,
300 geometry_brief,
301 layout_graph,
302 site_plan,
303 detail_plan,
304 design,
305 ) {
306 (
307 Ok(world),
308 Ok(npcs),
309 Ok(classes),
310 Ok(quest_plan),
311 Ok(quests),
312 Ok(dialogue),
313 Ok(world_edits),
314 Ok(geometry_brief),
315 Ok(layout_graph),
316 Ok(site_plan),
317 Ok(detail_plan),
318 Ok(design),
319 ) => {
320 let mut campaign = Campaign {
321 world,
322 npcs,
323 classes,
324 quest_plan,
325 quests,
326 dialogue,
327 world_edits,
328 geometry_brief,
329 layout_graph,
330 site_plan,
331 detail_plan,
332 design,
333 };
334 // spec-0016 §3: expand the `ambush` sugar into real environment
335 // triggers, ONCE, at the DSL boundary. Every downstream consumer —
336 // validation, l10n, the flow producer scans, nav, emission — then
337 // sees the same `triggers` list it always has, so the sugar has no
338 // second code path to drift down and an ambush is exactly as
339 // debuggable as the trigger an author would otherwise hand-write.
340 campaign.quests.content.expand_ambushes();
341 Ok(campaign)
342 }
343 _ => Err(diags),
344 }
345}
346
347/// Parse then validate.
348///
349/// Convenience over [`parse_campaign`] and [`crate::validate::validate_campaign`]:
350/// a document that does not parse yields its `DW0100` list; one that parses
351/// yields every finding validation raises against it.
352pub fn check_campaign(raw: &RawCampaign) -> Vec<Diagnostic> {
353 match parse_campaign(raw) {
354 Ok(campaign) => crate::validate::validate_campaign(&campaign),
355 Err(diags) => diags,
356 }
357}
358
359#[cfg(test)]
360mod version_tests {
361 use super::*;
362
363 /// The number is the crate's own (ADR-0024) by construction, so nothing here
364 /// can hold the two apart. What is still worth asserting is its SHAPE: every
365 /// gate that reads it — `delvec fmt`, `DW0102`, the release plumbing — treats
366 /// it as an exact `major.minor.patch`, and a manifest version carrying a
367 /// pre-release or build suffix would reach them as one.
368 #[test]
369 fn the_accepted_dsl_version_is_an_exact_three_part_number() {
370 let parts: Vec<&str> = DSL_VERSION.split('.').collect();
371 assert_eq!(parts.len(), 3, "DSL_VERSION is `{DSL_VERSION}`");
372 assert!(
373 parts
374 .iter()
375 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())),
376 "DSL_VERSION is `{DSL_VERSION}`"
377 );
378 }
379}