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, DwCode, ExitTier, codes};
9use crate::ids::CampaignId;
10use crate::layout::{GeometryBriefContent, LayoutGraphContent};
11use crate::siteplan::SitePlanContent;
12use crate::{
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    /// The site plan's heightmap as the loader read it (spec-0098 §2c), or
164    /// `None` when nothing has read one — every campaign whose terrain is not a
165    /// heightmap, and a campaign parsed from documents alone. A plan whose
166    /// terrain IS a heightmap and whose image was never read is refused at
167    /// validation (`DW0826`) rather than built over a guessed ground.
168    pub heightmap: Option<crate::siteplan::HeightmapRead>,
169}
170
171/// The stage documents as raw JSON strings (compiler input): six required, the
172/// stage-7 edit script optional.
173#[derive(Clone, Debug, PartialEq)]
174pub struct RawCampaign {
175    /// `world.json`.
176    pub world: String,
177    /// `npcs.json`.
178    pub npcs: String,
179    /// `classes.json`.
180    pub classes: String,
181    /// `quest-plan.json`.
182    pub quest_plan: String,
183    /// `quests.json`.
184    pub quests: String,
185    /// `dialogue.json`.
186    pub dialogue: String,
187    /// `world-edits.json` (optional stage 7, spec-0017); `None` when the
188    /// campaign directory ships none.
189    pub world_edits: Option<String>,
190    /// `geometry-brief.json` (optional; spec-0049 §4.2).
191    pub geometry_brief: Option<String>,
192    /// `layout-graph.json` (optional; spec-0049 §3).
193    pub layout_graph: Option<String>,
194    /// `site-plan.json` (optional; spec-0049 §4).
195    pub site_plan: Option<String>,
196    /// `detail-plan.json` (optional; spec-0050 §1).
197    pub detail_plan: Option<String>,
198    /// `design.json` (optional; spec-0061 §2).
199    pub design: Option<String>,
200}
201
202fn parse_stage<T: for<'de> Deserialize<'de>>(
203    src: &str,
204    stage: Stage,
205    out: &mut Result<Envelope<T>, ()>,
206    diags: &mut Vec<Diagnostic>,
207) {
208    match serde_json::from_str::<Envelope<T>>(src) {
209        Ok(env) => *out = Ok(env),
210        Err(e) => {
211            *out = Err(());
212            diags.push(Diagnostic::error(
213                codes::SCHEMA,
214                stage.name(),
215                "",
216                format!(
217                    "`{name}` stage document does not conform to its schema: {e}. Fix the \
218                     offending field (unknown field, wrong type, or missing required one) in the \
219                     campaign JSON to match the schema — run `delvec schema --stage {name}` to \
220                     see the exact shape of THIS document. The schema is the authority on the \
221                     form; a spec that disagrees with it is the stale one.",
222                    name = stage.name()
223                ),
224            ));
225        }
226    }
227}
228
229/// Parse all six stage documents.
230///
231/// On any schema/parse failure returns every `DW0100` diagnostic collected
232/// (validation cannot run on unparseable input).
233pub fn parse_campaign(raw: &RawCampaign) -> Result<Campaign, Vec<Diagnostic>> {
234    let mut diags = Vec::new();
235    let mut world = Err(());
236    let mut npcs = Err(());
237    let mut classes = Err(());
238    let mut quest_plan = Err(());
239    let mut quests = Err(());
240    let mut dialogue = Err(());
241
242    parse_stage(&raw.world, Stage::World, &mut world, &mut diags);
243    parse_stage(&raw.npcs, Stage::Npcs, &mut npcs, &mut diags);
244    parse_stage(&raw.classes, Stage::Classes, &mut classes, &mut diags);
245    parse_stage(
246        &raw.quest_plan,
247        Stage::QuestPlan,
248        &mut quest_plan,
249        &mut diags,
250    );
251    parse_stage(&raw.quests, Stage::Quests, &mut quests, &mut diags);
252    parse_stage(&raw.dialogue, Stage::Dialogue, &mut dialogue, &mut diags);
253    // The optional stage-7 edit script (spec-0017): absent = `None`; present but
254    // unparseable = a `DW0100` like any other stage.
255    let mut world_edits: Result<Option<Envelope<WorldEditsContent>>, ()> = Ok(None);
256    if let Some(src) = &raw.world_edits {
257        let mut parsed = Err(());
258        parse_stage(src, Stage::WorldEdits, &mut parsed, &mut diags);
259        world_edits = parsed.map(Some);
260    }
261    // The spec-0049 map-pipeline documents, on the same terms: absent = `None`
262    // and a campaign that ships neither is byte-identical to one from before
263    // they existed; present = parsed, validated and hashed like any other stage.
264    let mut geometry_brief: Result<Option<Envelope<GeometryBriefContent>>, ()> = Ok(None);
265    if let Some(src) = &raw.geometry_brief {
266        let mut parsed = Err(());
267        parse_stage(src, Stage::GeometryBrief, &mut parsed, &mut diags);
268        geometry_brief = parsed.map(Some);
269    }
270    let mut layout_graph: Result<Option<Envelope<LayoutGraphContent>>, ()> = Ok(None);
271    if let Some(src) = &raw.layout_graph {
272        let mut parsed = Err(());
273        parse_stage(src, Stage::LayoutGraph, &mut parsed, &mut diags);
274        layout_graph = parsed.map(Some);
275    }
276    let mut site_plan: Result<Option<Envelope<SitePlanContent>>, ()> = Ok(None);
277    if let Some(src) = &raw.site_plan {
278        let mut parsed = Err(());
279        parse_stage(src, Stage::SitePlan, &mut parsed, &mut diags);
280        site_plan = parsed.map(Some);
281    }
282    let mut detail_plan: Result<Option<Envelope<DetailPlanContent>>, ()> = Ok(None);
283    if let Some(src) = &raw.detail_plan {
284        let mut parsed = Err(());
285        parse_stage(src, Stage::DetailPlan, &mut parsed, &mut diags);
286        detail_plan = parsed.map(Some);
287    }
288    // The design record (spec-0061 §2), on the same terms: absent = a campaign
289    // that has approved no design yet, which validation measures and staging
290    // refuses; present = parsed, validated and hashed like any other stage.
291    let mut design: Result<Option<Envelope<DesignContent>>, ()> = Ok(None);
292    if let Some(src) = &raw.design {
293        let mut parsed = Err(());
294        parse_stage(src, Stage::Design, &mut parsed, &mut diags);
295        design = parsed.map(Some);
296    }
297
298    match (
299        world,
300        npcs,
301        classes,
302        quest_plan,
303        quests,
304        dialogue,
305        world_edits,
306        geometry_brief,
307        layout_graph,
308        site_plan,
309        detail_plan,
310        design,
311    ) {
312        (
313            Ok(world),
314            Ok(npcs),
315            Ok(classes),
316            Ok(quest_plan),
317            Ok(quests),
318            Ok(dialogue),
319            Ok(world_edits),
320            Ok(geometry_brief),
321            Ok(layout_graph),
322            Ok(site_plan),
323            Ok(detail_plan),
324            Ok(design),
325        ) => {
326            let mut campaign = Campaign {
327                world,
328                npcs,
329                classes,
330                quest_plan,
331                quests,
332                dialogue,
333                world_edits,
334                geometry_brief,
335                layout_graph,
336                site_plan,
337                detail_plan,
338                design,
339                heightmap: None,
340            };
341            // spec-0016 §3: expand the `ambush` sugar into real environment
342            // triggers, ONCE, at the DSL boundary. Every downstream consumer —
343            // validation, l10n, the flow producer scans, nav, emission — then
344            // sees the same `triggers` list it always has, so the sugar has no
345            // second code path to drift down and an ambush is exactly as
346            // debuggable as the trigger an author would otherwise hand-write.
347            campaign.quests.content.expand_ambushes();
348            Ok(campaign)
349        }
350        _ => Err(diags),
351    }
352}
353
354/// Parse then validate.
355///
356/// Convenience over [`parse_campaign`] and [`crate::validate::validate_campaign`]:
357/// a document that does not parse yields its `DW0100` list; one that parses
358/// yields every finding validation raises against it.
359pub fn check_campaign(raw: &RawCampaign) -> Vec<Diagnostic> {
360    match parse_campaign(raw) {
361        Ok(campaign) => crate::validate::validate_campaign(&campaign),
362        Err(diags) => diags,
363    }
364}
365
366// ---------------------------------------------------------------------------
367// Validation
368// ---------------------------------------------------------------------------
369
370crate::dw_code! {
371    /// Envelope `stage` does not match the document's slot.
372    pub const STAGE_MISMATCH: DwCode = DwCode::new("DW0101", ExitTier::Build);
373}
374
375crate::dw_code! {
376    /// Inconsistent `campaign_id` across stages.
377    pub const CAMPAIGN_ID_MISMATCH: DwCode = DwCode::new("DW0103", ExitTier::Build);
378}
379
380/// The envelope of every stage document: its `stage` (`DW0101`), its
381/// `dsl_version` (`DW0102`), and its `campaign_id`'s syntax (`DW0110`) and
382/// agreement with the world stage's (`DW0103`).
383pub(crate) fn envelope_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
384    let stages = [
385        (Stage::World, c.world.stage, c.world.dsl_version.as_str()),
386        (Stage::Npcs, c.npcs.stage, c.npcs.dsl_version.as_str()),
387        (
388            Stage::Classes,
389            c.classes.stage,
390            c.classes.dsl_version.as_str(),
391        ),
392        (
393            Stage::QuestPlan,
394            c.quest_plan.stage,
395            c.quest_plan.dsl_version.as_str(),
396        ),
397        (Stage::Quests, c.quests.stage, c.quests.dsl_version.as_str()),
398        (
399            Stage::Dialogue,
400            c.dialogue.stage,
401            c.dialogue.dsl_version.as_str(),
402        ),
403    ];
404    let stages: Vec<(Stage, Stage, &str)> = stages
405        .into_iter()
406        .chain(
407            c.world_edits
408                .iter()
409                .map(|e| (Stage::WorldEdits, e.stage, e.dsl_version.as_str())),
410        )
411        .chain(
412            c.geometry_brief
413                .iter()
414                .map(|e| (Stage::GeometryBrief, e.stage, e.dsl_version.as_str())),
415        )
416        .chain(
417            c.layout_graph
418                .iter()
419                .map(|e| (Stage::LayoutGraph, e.stage, e.dsl_version.as_str())),
420        )
421        .chain(
422            c.site_plan
423                .iter()
424                .map(|e| (Stage::SitePlan, e.stage, e.dsl_version.as_str())),
425        )
426        .chain(
427            c.detail_plan
428                .iter()
429                .map(|e| (Stage::DetailPlan, e.stage, e.dsl_version.as_str())),
430        )
431        .chain(
432            c.design
433                .iter()
434                .map(|e| (Stage::Design, e.stage, e.dsl_version.as_str())),
435        )
436        .collect();
437    for (expected, actual, version) in stages {
438        if actual != expected {
439            d.push(Diagnostic::error(
440                STAGE_MISMATCH,
441                expected.name(),
442                "/stage",
443                format!(
444                    "`stage` is `{}` but this is the `{}` stage document — set `stage` to `{}` (or \
445                     move this content into the `{}` document it belongs to)",
446                    actual.name(),
447                    expected.name(),
448                    expected.name(),
449                    actual.name(),
450                ),
451            ));
452        }
453        if version != DSL_VERSION {
454            d.push(Diagnostic::error(
455                codes::DSL_VERSION,
456                expected.name(),
457                "/dsl_version",
458                format!(
459                    "dsl_version `{version}` is not the one this engine accepts — set it to \
460                     `{DSL_VERSION}` and revise the document against that surface. An engine \
461                     accepts exactly the number it implements (ADR-0024); a document written \
462                     for another number is built by the engine that implements that number."
463                ),
464            ));
465        }
466    }
467
468    let ids: Vec<(Stage, &crate::ids::CampaignId)> = [
469        (Stage::World, &c.world.campaign_id),
470        (Stage::Npcs, &c.npcs.campaign_id),
471        (Stage::Classes, &c.classes.campaign_id),
472        (Stage::QuestPlan, &c.quest_plan.campaign_id),
473        (Stage::Quests, &c.quests.campaign_id),
474        (Stage::Dialogue, &c.dialogue.campaign_id),
475    ]
476    .into_iter()
477    .chain(
478        c.world_edits
479            .iter()
480            .map(|e| (Stage::WorldEdits, &e.campaign_id)),
481    )
482    .chain(
483        c.geometry_brief
484            .iter()
485            .map(|e| (Stage::GeometryBrief, &e.campaign_id)),
486    )
487    .chain(
488        c.layout_graph
489            .iter()
490            .map(|e| (Stage::LayoutGraph, &e.campaign_id)),
491    )
492    .chain(
493        c.site_plan
494            .iter()
495            .map(|e| (Stage::SitePlan, &e.campaign_id)),
496    )
497    .chain(
498        c.detail_plan
499            .iter()
500            .map(|e| (Stage::DetailPlan, &e.campaign_id)),
501    )
502    .chain(c.design.iter().map(|e| (Stage::Design, &e.campaign_id)))
503    .collect();
504    let canonical = c.world.campaign_id.as_str();
505    for (stage, id) in ids {
506        if !id.is_valid_syntax() {
507            d.push(Diagnostic::error(
508                codes::ID_SYNTAX,
509                stage.name(),
510                "/campaign_id",
511                format!("malformed campaign_id `{id}` (expected kebab-case)"),
512            ));
513        }
514        if id.as_str() != canonical {
515            d.push(Diagnostic::error(
516                CAMPAIGN_ID_MISMATCH,
517                stage.name(),
518                "/campaign_id",
519                format!(
520                    "campaign_id `{id}` differs from `{canonical}` (the world stage's id) — set \
521                     every stage's `campaign_id` to `{canonical}` so all six documents name one \
522                     campaign"
523                ),
524            ));
525        }
526    }
527}
528
529#[cfg(test)]
530mod version_tests {
531    use super::*;
532
533    /// The number is the crate's own (ADR-0024) by construction, so nothing here
534    /// can hold the two apart. What is still worth asserting is its SHAPE: every
535    /// gate that reads it — `delvec fmt`, `DW0102`, the release plumbing — treats
536    /// it as an exact `major.minor.patch`, and a manifest version carrying a
537    /// pre-release or build suffix would reach them as one.
538    #[test]
539    fn the_accepted_dsl_version_is_an_exact_three_part_number() {
540        let parts: Vec<&str> = DSL_VERSION.split('.').collect();
541        assert_eq!(parts.len(), 3, "DSL_VERSION is `{DSL_VERSION}`");
542        assert!(
543            parts
544                .iter()
545                .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())),
546            "DSL_VERSION is `{DSL_VERSION}`"
547        );
548    }
549}