1use 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
17pub const DSL_VERSION: &str = env!("CARGO_PKG_VERSION");
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "kebab-case")]
36pub enum Stage {
37 World,
39 Npcs,
41 Classes,
43 QuestPlan,
45 Quests,
47 Dialogue,
49 WorldEdits,
51 GeometryBrief,
55 LayoutGraph,
58 SitePlan,
61 DetailPlan,
65 Design,
70}
71
72impl Stage {
73 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
117#[serde(deny_unknown_fields)]
118pub struct Envelope<T> {
119 pub dsl_version: String,
121 pub campaign_id: CampaignId,
123 pub stage: Stage,
125 pub content: T,
127}
128
129#[derive(Clone, Debug, PartialEq)]
132pub struct Campaign {
133 pub world: Envelope<WorldContent>,
135 pub npcs: Envelope<NpcsContent>,
137 pub classes: Envelope<ClassesContent>,
139 pub quest_plan: Envelope<QuestPlanContent>,
141 pub quests: Envelope<QuestsContent>,
143 pub dialogue: Envelope<DialogueContent>,
145 pub world_edits: Option<Envelope<WorldEditsContent>>,
149 pub geometry_brief: Option<Envelope<GeometryBriefContent>>,
151 pub layout_graph: Option<Envelope<LayoutGraphContent>>,
153 pub site_plan: Option<Envelope<SitePlanContent>>,
155 pub detail_plan: Option<Envelope<DetailPlanContent>>,
158 pub design: Option<Envelope<DesignContent>>,
163 pub heightmap: Option<crate::siteplan::HeightmapRead>,
169}
170
171#[derive(Clone, Debug, PartialEq)]
174pub struct RawCampaign {
175 pub world: String,
177 pub npcs: String,
179 pub classes: String,
181 pub quest_plan: String,
183 pub quests: String,
185 pub dialogue: String,
187 pub world_edits: Option<String>,
190 pub geometry_brief: Option<String>,
192 pub layout_graph: Option<String>,
194 pub site_plan: Option<String>,
196 pub detail_plan: Option<String>,
198 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
229pub 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 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 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 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 campaign.quests.content.expand_ambushes();
348 Ok(campaign)
349 }
350 _ => Err(diags),
351 }
352}
353
354pub 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
366crate::dw_code! {
371 pub const STAGE_MISMATCH: DwCode = DwCode::new("DW0101", ExitTier::Build);
373}
374
375crate::dw_code! {
376 pub const CAMPAIGN_ID_MISMATCH: DwCode = DwCode::new("DW0103", ExitTier::Build);
378}
379
380pub(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 #[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}