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::diagnostic::{Diagnostic, codes};
7use crate::ids::CampaignId;
8use crate::stages::{
9    ClassesContent, DialogueContent, NpcsContent, QuestPlanContent, QuestsContent, WorldContent,
10    WorldEditsContent,
11};
12
13/// The latest `dsl_version` this crate implements (identity / tooling default).
14pub const SUPPORTED_DSL_VERSION: &str = "0.9.0";
15
16/// Every `dsl_version` this crate accepts. Each version is an **additive
17/// superset** of the previous: v0.3 added the stage-5 verbs/waves/flags; v0.4
18/// (spec-0008) adds dialogue state, props, narration, live-threat tuning, NPC
19/// lifecycle + skins, environment triggers, cutscenes and named given items;
20/// v0.5 (spec-0010) adds declared world `time`/`weather`, per-area `lighting`
21/// (deterministic relight), and the `set-time`/`set-weather` effect verbs; v0.6
22/// (spec-0012/spec-0013/spec-0014) adds checkpoints (`set-checkpoint` +
23/// `on_respawn`), the stealth-zone verbs (`begin-stealth`/`end-stealth`), the
24/// stage-1 `horizon` (`ocean` backdrop) + `boundary` (playable region +
25/// return-to-checkpoint enforcement), and the staging surface — the `play-sound`
26/// effect and the `narrate` `art` style — alongside the actors/sequence surface
27/// from sibling PRs; v0.7 (spec-0020) adds the per-quest `cast` ledger; v0.8
28/// (spec-0025) adds declared stage-4 `branch_points`, the per-node `happening`
29/// declaration and the named `campaign-complete` `ending`, and (spec-0016 §1
30/// owner rulings) the bonfire rest interaction — the `bonfire` effect's
31/// authorable option strings and the class-kit `flask`.
32/// Older campaigns remain valid and compile byte-identically. A construct
33/// introduced in a later version is rejected with `DW0141` in an earlier one.
34pub const SUPPORTED_DSL_VERSIONS: &[&str] = &[
35    "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0", "0.8.0", "0.9.0",
36];
37
38/// True if `version` is a `dsl_version` this crate accepts.
39pub fn is_supported_version(version: &str) -> bool {
40    SUPPORTED_DSL_VERSIONS.contains(&version)
41}
42
43/// The minor-version ordinal of a supported `dsl_version` (`0.4.0` → 4); `0` for
44/// anything this crate does not accept.
45///
46/// Every version predicate below is `ordinal(version) >= n`, so adding a version
47/// to [`SUPPORTED_DSL_VERSIONS`] is the *only* edit a version bump needs. The
48/// hand-written `version == "0.4.0" || version == "0.5.0" || …` chains this
49/// replaced had to be extended in lockstep in five places; forgetting one made
50/// the newest campaigns silently lose an older version's surface.
51fn ordinal(version: &str) -> u32 {
52    match version {
53        "0.2.0" => 2,
54        "0.3.0" => 3,
55        "0.4.0" => 4,
56        "0.5.0" => 5,
57        "0.6.0" => 6,
58        "0.7.0" => 7,
59        "0.8.0" => 8,
60        "0.9.0" => 9,
61        _ => 0,
62    }
63}
64
65/// True if `version` enables the DSL v0.3 verbs (`kill`/`collect`/`interact`,
66/// `give-item`/`set-flag`/`spawn-wave`, waves, flags). v0.4 is an additive
67/// superset, so it enables the whole v0.3 surface too.
68pub fn is_v03(version: &str) -> bool {
69    ordinal(version) >= 3
70}
71
72/// True if `version` enables the DSL v0.4 surface (spec-0008): dialogue
73/// `set-flag` + `requires_flags`, props (`interact.prop`, `set-block`),
74/// `narrate`, wave `attributes`/`effects`, `despawn-npc`/`move-npc`, `cutscene`,
75/// NPC `skin`, stage-5 `triggers`, named `give-item`, objective `stealth`.
76pub fn is_v04(version: &str) -> bool {
77    ordinal(version) >= 4
78}
79
80/// True if `version` enables the DSL v0.5 surface (spec-0010): declared world
81/// `time`/`weather`, per-area `lighting` (deterministic relight fixtures), and
82/// the `set-time`/`set-weather` effect verbs. v0.6 is an additive superset.
83pub fn is_v05(version: &str) -> bool {
84    ordinal(version) >= 5
85}
86
87/// True if `version` enables the DSL v0.6 surface: the `set-checkpoint` effect
88/// (with its optional `on_respawn` hook) and the `begin-stealth`/`end-stealth`
89/// verbs (spec-0012 checkpoints + spec-0014 stealth zones), the stage-1 `horizon`
90/// (`ocean`) and `boundary` (playable region) world fields (spec-0013), the
91/// `play-sound` effect + `narrate` `art` style (spec-0014), and the stage-5
92/// scripted `actors` + staging effects (`spawn`/`despawn`/`move`/`unleash-actor`,
93/// `sequence`, spec-0014). Additive over v0.5; a campaign that uses none is
94/// byte-identical, and a use of the v0.6 surface in an earlier campaign is
95/// rejected with `DW0141`.
96pub fn is_v06(version: &str) -> bool {
97    ordinal(version) >= 6
98}
99
100/// True if `version` enables the DSL v0.7 surface (spec-0020): the per-quest
101/// `cast` ledger — for every live stage-2 NPC, where it stands, what it is doing,
102/// and what its right-click offers during that quest. Additive over v0.6: a
103/// campaign that declares no `cast` compiles byte-identically, and a pre-0.7
104/// campaign that declares none keeps building with the `DW0465` deprecation
105/// warning for one version window.
106pub fn is_v07(version: &str) -> bool {
107    ordinal(version) >= 7
108}
109
110/// True if `version` enables the DSL v0.8 surface. Two specs land in it:
111/// spec-0025's stage-4 `branch_points` declaration, per-node `happening` and
112/// named `ending` on `campaign-complete`; and spec-0016 §1's bonfire **rest
113/// interaction** (owner rulings 2026-08-03) — the `bonfire` effect's authorable
114/// `prompt` / `rest_label` / `save_label` strings and the stage-3 kit item
115/// `flask` marker a rest replenishes. Additive over v0.7 — a campaign that
116/// declares none of it compiles byte-identically, and any use of the surface in
117/// an earlier campaign is rejected with `DW0141`. The **requirement** side (a
118/// story node without a `happening` is `DW0481`; an undeclared fork is `DW0480`)
119/// fires only at 0.8.0 and above, which is what keeps every 0.6/0.7 campaign's
120/// datapack byte-for-byte unchanged.
121pub fn is_v08(version: &str) -> bool {
122    ordinal(version) >= 8
123}
124
125/// True if `version` enables the DSL v0.9 surface (task #179, owner ruling
126/// 2026-08-04): declared **drops** on an elite/boss — the `drops[]` list on a
127/// wave mob and on an actor, and the `collect` `dropped_by` that turns a boss's
128/// quest token into a proved link in the quest graph. Additive over v0.8: a
129/// campaign that declares none of it compiles byte-identically (every
130/// undeclared slot keeps drop chance `0.0`, which is exactly what pre-0.9
131/// emission wrote), and any use of the surface in an earlier campaign is
132/// rejected with `DW0141`.
133pub fn is_v09(version: &str) -> bool {
134    ordinal(version) >= 9
135}
136
137/// Which stage a document belongs to.
138#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(rename_all = "kebab-case")]
140pub enum Stage {
141    /// Stage 1.
142    World,
143    /// Stage 2.
144    Npcs,
145    /// Stage 3.
146    Classes,
147    /// Stage 4.
148    QuestPlan,
149    /// Stage 5.
150    Quests,
151    /// Stage 6.
152    Dialogue,
153    /// Stage 7 (optional; DSL v0.6, spec-0017): the map-editor edit script.
154    WorldEdits,
155}
156
157impl Stage {
158    /// The wire/filename name (`world`, `npcs`, `classes`, `quest-plan`,
159    /// `quests`, `dialogue`).
160    pub fn name(self) -> &'static str {
161        match self {
162            Stage::World => "world",
163            Stage::Npcs => "npcs",
164            Stage::Classes => "classes",
165            Stage::QuestPlan => "quest-plan",
166            Stage::Quests => "quests",
167            Stage::Dialogue => "dialogue",
168            Stage::WorldEdits => "world-edits",
169        }
170    }
171}
172
173/// A stage document: `{ dsl_version, campaign_id, stage, content }`.
174#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
175#[serde(deny_unknown_fields)]
176pub struct Envelope<T> {
177    /// DSL version string.
178    pub dsl_version: String,
179    /// Owning campaign id.
180    pub campaign_id: CampaignId,
181    /// The stage this document is for.
182    pub stage: Stage,
183    /// The stage payload.
184    pub content: T,
185}
186
187/// The parsed stage documents that make up one campaign: the six required
188/// stages plus the optional stage-7 edit script (spec-0017).
189#[derive(Clone, Debug, PartialEq)]
190pub struct Campaign {
191    /// Stage 1.
192    pub world: Envelope<WorldContent>,
193    /// Stage 2.
194    pub npcs: Envelope<NpcsContent>,
195    /// Stage 3.
196    pub classes: Envelope<ClassesContent>,
197    /// Stage 4.
198    pub quest_plan: Envelope<QuestPlanContent>,
199    /// Stage 5.
200    pub quests: Envelope<QuestsContent>,
201    /// Stage 6.
202    pub dialogue: Envelope<DialogueContent>,
203    /// Stage 7 (optional; DSL v0.6, spec-0017): the map-editor edit script.
204    /// `None` = no `world-edits.json` in the campaign directory — byte-identical
205    /// to a campaign from before the stage existed.
206    pub world_edits: Option<Envelope<WorldEditsContent>>,
207}
208
209/// The stage documents as raw JSON strings (compiler input): six required, the
210/// stage-7 edit script optional.
211#[derive(Clone, Debug, PartialEq)]
212pub struct RawCampaign {
213    /// `world.json`.
214    pub world: String,
215    /// `npcs.json`.
216    pub npcs: String,
217    /// `classes.json`.
218    pub classes: String,
219    /// `quest-plan.json`.
220    pub quest_plan: String,
221    /// `quests.json`.
222    pub quests: String,
223    /// `dialogue.json`.
224    pub dialogue: String,
225    /// `world-edits.json` (optional stage 7, spec-0017); `None` when the
226    /// campaign directory ships none.
227    pub world_edits: Option<String>,
228}
229
230fn parse_stage<T: for<'de> Deserialize<'de>>(
231    src: &str,
232    stage: Stage,
233    out: &mut Result<Envelope<T>, ()>,
234    diags: &mut Vec<Diagnostic>,
235) {
236    match serde_json::from_str::<Envelope<T>>(src) {
237        Ok(env) => *out = Ok(env),
238        Err(e) => {
239            *out = Err(());
240            diags.push(Diagnostic::error(
241                codes::SCHEMA,
242                stage.name(),
243                "",
244                format!(
245                    "`{}` stage document does not conform to its schema: {e}. Fix the offending \
246                     field (unknown field, wrong type, or missing required one) in the campaign \
247                     JSON to match the schema — run `delvec schema --stage <1..7>` to see the \
248                     exact shape.",
249                    stage.name()
250                ),
251            ));
252        }
253    }
254}
255
256/// Parse all six stage documents.
257///
258/// On any schema/parse failure returns every `DW0100` diagnostic collected
259/// (validation cannot run on unparseable input).
260pub fn parse_campaign(raw: &RawCampaign) -> Result<Campaign, Vec<Diagnostic>> {
261    let mut diags = Vec::new();
262    let mut world = Err(());
263    let mut npcs = Err(());
264    let mut classes = Err(());
265    let mut quest_plan = Err(());
266    let mut quests = Err(());
267    let mut dialogue = Err(());
268
269    parse_stage(&raw.world, Stage::World, &mut world, &mut diags);
270    parse_stage(&raw.npcs, Stage::Npcs, &mut npcs, &mut diags);
271    parse_stage(&raw.classes, Stage::Classes, &mut classes, &mut diags);
272    parse_stage(
273        &raw.quest_plan,
274        Stage::QuestPlan,
275        &mut quest_plan,
276        &mut diags,
277    );
278    parse_stage(&raw.quests, Stage::Quests, &mut quests, &mut diags);
279    parse_stage(&raw.dialogue, Stage::Dialogue, &mut dialogue, &mut diags);
280    // The optional stage-7 edit script (spec-0017): absent = `None`; present but
281    // unparseable = a `DW0100` like any other stage.
282    let mut world_edits: Result<Option<Envelope<WorldEditsContent>>, ()> = Ok(None);
283    if let Some(src) = &raw.world_edits {
284        let mut parsed = Err(());
285        parse_stage(src, Stage::WorldEdits, &mut parsed, &mut diags);
286        world_edits = parsed.map(Some);
287    }
288
289    match (
290        world,
291        npcs,
292        classes,
293        quest_plan,
294        quests,
295        dialogue,
296        world_edits,
297    ) {
298        (
299            Ok(world),
300            Ok(npcs),
301            Ok(classes),
302            Ok(quest_plan),
303            Ok(quests),
304            Ok(dialogue),
305            Ok(world_edits),
306        ) => {
307            let mut campaign = Campaign {
308                world,
309                npcs,
310                classes,
311                quest_plan,
312                quests,
313                dialogue,
314                world_edits,
315            };
316            // spec-0016 §3: expand the `ambush` sugar into real environment
317            // triggers, ONCE, at the DSL boundary. Every downstream consumer —
318            // validation, l10n, the flow producer scans, nav, emission — then
319            // sees the same `triggers` list it always has, so the sugar has no
320            // second code path to drift down and an ambush is exactly as
321            // debuggable as the trigger an author would otherwise hand-write.
322            campaign.quests.content.expand_ambushes();
323            Ok(campaign)
324        }
325        _ => Err(diags),
326    }
327}
328
329/// Parse then validate. Convenience over [`parse_campaign`] +
330/// [`crate::validate::validate_campaign`].
331pub fn check_campaign(raw: &RawCampaign) -> Vec<Diagnostic> {
332    match parse_campaign(raw) {
333        Ok(campaign) => crate::validate::validate_campaign(&campaign),
334        Err(diags) => diags,
335    }
336}