Skip to main content

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