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