delvewright-dsl 0.39.0

Staged JSON DSL types and schemas for Delvewright adventure-map campaigns — the format the delvec compiler reads.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
//! DSL v0.6 (spec-0013): the stage-1 `horizon` and `boundary` world fields
//! validate under `0.6.0`. Under 0.6.0,
//! `horizon: "ocean"` without a `boundary` is `DW0320` and a `boundary.margin`
//! outside `0..=64` is `DW0321`.
//!
//! Built on the hello-world casting/quests/dialogue (unchanged, 0.2.0) with a
//! v0.6 stage-1 `world` document — additive fields, so the rest is untouched.

mod common;

use delvewright_dsl::{RawCampaign, check_campaign, l10n, localize, parse_campaign};
use std::sync::LazyLock;

/// A v0.6 stage-1 world document: ocean horizon + a boundary (the happy path).
static WORLD_V06: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "dsl_version": "%dsl_version%",
  "campaign_id": "hello-world",
  "stage": "world",
  "content": {
    "title": "The Keeper's Door",
    "theme": "A lonely keep at the edge of the moor.",
    "premise": "One locked door stands between you and the road home.",
    "seed": 20260729,
    "target_minutes": 5,
    "time": "noon",
    "weather": "clear",
    "horizon": "ocean",
    "boundary": { "margin": 24, "message": "The tide turns you back." },
    "areas": [
      { "id": "area/keep", "name": "The Keep", "prefab": "prefab/hello-room" }
    ]
  }
}"#,
    )
});

/// Ocean horizon with NO boundary — the `DW0320` authoring error.
static WORLD_V06_OCEAN_NO_BOUNDARY: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "dsl_version": "%dsl_version%",
  "campaign_id": "hello-world",
  "stage": "world",
  "content": {
    "title": "The Keeper's Door",
    "theme": "A lonely keep at the edge of the moor.",
    "premise": "One locked door stands between you and the road home.",
    "seed": 20260729,
    "target_minutes": 5,
    "time": "noon",
    "weather": "clear",
    "horizon": "ocean",
    "areas": [
      { "id": "area/keep", "name": "The Keep", "prefab": "prefab/hello-room" }
    ]
  }
}"#,
    )
});

/// Explicit void horizon, no boundary — valid (void needs no return rule).
static WORLD_V06_VOID: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "dsl_version": "%dsl_version%",
  "campaign_id": "hello-world",
  "stage": "world",
  "content": {
    "title": "The Keeper's Door",
    "theme": "A lonely keep at the edge of the moor.",
    "premise": "One locked door stands between you and the road home.",
    "seed": 20260729,
    "target_minutes": 5,
    "time": "noon",
    "weather": "clear",
    "horizon": "void",
    "areas": [
      { "id": "area/keep", "name": "The Keep", "prefab": "prefab/hello-room" }
    ]
  }
}"#,
    )
});

fn campaign_with_world(world: &str) -> RawCampaign {
    RawCampaign {
        world: world.to_string(),
        npcs: common::read_valid("npcs.json"),
        classes: common::read_valid("classes.json"),
        quest_plan: common::read_valid("quest-plan.json"),
        quests: common::read_valid("quests.json"),
        dialogue: common::read_valid("dialogue.json"),
        world_edits: None,
        geometry_brief: None,
        layout_graph: None,
        site_plan: None,
        detail_plan: None,
        design: None,
    }
}

/// v0.6 horizon + boundary validate clean under `dsl_version 0.6.0`.
#[test]
fn v06_world_surface_validates_clean() {
    let diags = check_campaign(&campaign_with_world(WORLD_V06.as_str()));
    assert!(
        diags.is_empty(),
        "expected zero diagnostics for the v0.6 world surface, got: {diags:#?}"
    );
}

/// `horizon: "ocean"` without a `boundary` is `DW0320`.
#[test]
fn v06_ocean_without_boundary_is_dw0320() {
    let diags = check_campaign(&campaign_with_world(WORLD_V06_OCEAN_NO_BOUNDARY.as_str()));
    assert!(
        diags.iter().any(|d| d.code == "DW0320"),
        "ocean without boundary must be DW0320: {diags:#?}"
    );
}

/// `boundary.margin` above 64 is `DW0321`.
#[test]
fn v06_margin_out_of_range_is_dw0321() {
    let bad = WORLD_V06.replacen("\"margin\": 24", "\"margin\": 65", 1);
    let diags = check_campaign(&campaign_with_world(&bad));
    assert!(
        diags.iter().any(|d| d.code == "DW0321"),
        "margin 65 must be DW0321 (range 0..=64): {diags:#?}"
    );
}

/// `margin: 0` is in range (0..=64 inclusive) — no `DW0321`.
#[test]
fn v06_margin_zero_is_in_range() {
    let ok = WORLD_V06.replacen("\"margin\": 24", "\"margin\": 0", 1);
    let diags = check_campaign(&campaign_with_world(&ok));
    assert!(
        !diags.iter().any(|d| d.code == "DW0321"),
        "margin 0 is in range and must not be DW0321: {diags:#?}"
    );
}

/// An explicit `void` horizon with no boundary validates clean.
#[test]
fn v06_void_horizon_needs_no_boundary() {
    let diags = check_campaign(&campaign_with_world(WORLD_V06_VOID.as_str()));
    assert!(
        diags.is_empty(),
        "explicit void horizon needs no boundary: {diags:#?}"
    );
}

// ---------------------------------------------------------------------------
// v0.6 per-effect `requires_flags` gate + blockstate suffix
// ---------------------------------------------------------------------------

/// Build a full campaign with a custom stage-5 `quests` document (0.6.0), reusing
/// the valid hello-world documents for every other stage.
fn campaign_with_quests(quests: &str) -> RawCampaign {
    RawCampaign {
        world: common::read_valid("world.json"),
        npcs: common::read_valid("npcs.json"),
        classes: common::read_valid("classes.json"),
        quest_plan: common::read_valid("quest-plan.json"),
        quests: quests.to_string(),
        dialogue: common::read_valid("dialogue.json"),
        world_edits: None,
        geometry_brief: None,
        layout_graph: None,
        site_plan: None,
        detail_plan: None,
        design: None,
    }
}

/// A 0.6.0 quests document: an `open-gate` effect gated on a flag the same
/// objective sets first (the happy path for per-effect `requires_flags`).
static QUESTS_V06_GATED_EFFECT: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "dsl_version": "%dsl_version%",
  "campaign_id": "hello-world",
  "stage": "quests",
  "content": {
    "quests": [
      {
        "id": "quest/open-the-door",
        "trigger": { "type": "campaign-start" },
        "objectives": [
          { "type": "talk-to", "id": "obj/talk", "npc": "npc/keeper" },
          { "type": "reach-anchor", "id": "obj/exit", "anchor": "anchor/exit", "radius": 2, "after": ["obj/talk"] }
        ],
        "on_objective_complete": {
          "obj/talk": [
            { "type": "set-flag", "flag": "flag/opened" },
            { "type": "open-gate", "when": { "requires_flags": ["flag/opened"] }, "anchor": "anchor/door" }
          ]
        },
        "on_complete": [ { "type": "campaign-complete" } ]
      }
    ]
  }
}"#,
    )
});

/// A per-effect `requires_flags` that references a flag no `set-flag` produces.
static QUESTS_V06_GATED_EFFECT_UNKNOWN_FLAG: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "dsl_version": "%dsl_version%",
  "campaign_id": "hello-world",
  "stage": "quests",
  "content": {
    "quests": [
      {
        "id": "quest/open-the-door",
        "trigger": { "type": "campaign-start" },
        "objectives": [
          { "type": "talk-to", "id": "obj/talk", "npc": "npc/keeper" },
          { "type": "reach-anchor", "id": "obj/exit", "anchor": "anchor/exit", "radius": 2, "after": ["obj/talk"] }
        ],
        "on_objective_complete": {
          "obj/talk": [
            { "type": "open-gate", "when": { "requires_flags": ["flag/never-set"] }, "anchor": "anchor/door" }
          ]
        },
        "on_complete": [ { "type": "campaign-complete" } ]
      }
    ]
  }
}"#,
    )
});

/// A 0.6.0 quests document placing a block that carries a vanilla blockstate.
static QUESTS_V06_BLOCKSTATE: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "dsl_version": "%dsl_version%",
  "campaign_id": "hello-world",
  "stage": "quests",
  "content": {
    "quests": [
      {
        "id": "quest/open-the-door",
        "trigger": { "type": "campaign-start" },
        "objectives": [
          { "type": "talk-to", "id": "obj/talk", "npc": "npc/keeper" },
          { "type": "reach-anchor", "id": "obj/exit", "anchor": "anchor/exit", "radius": 2, "after": ["obj/talk"] }
        ],
        "on_objective_complete": {
          "obj/talk": [
            { "type": "set-block", "anchor": "anchor/door", "block": "minecraft:water[level=0]" }
          ]
        },
        "on_complete": [ { "type": "campaign-complete" } ]
      }
    ]
  }
}"#,
    )
});

/// A per-effect flag gate that resolves validates clean under 0.6.0.
#[test]
fn v06_effect_requires_flags_validates_clean() {
    let diags = check_campaign(&campaign_with_quests(QUESTS_V06_GATED_EFFECT.as_str()));
    assert!(
        diags.is_empty(),
        "a resolved per-effect requires_flags must validate clean at 0.6.0: {diags:#?}"
    );
}

/// A per-effect `requires_flags` referencing an unproduced flag is `DW0172`.
#[test]
fn v06_effect_requires_flags_unknown_is_dw0172() {
    let diags = check_campaign(&campaign_with_quests(
        QUESTS_V06_GATED_EFFECT_UNKNOWN_FLAG.as_str(),
    ));
    assert!(
        diags.iter().any(|d| d.code == "DW0172"),
        "an unproduced effect requires_flags must be DW0172: {diags:#?}"
    );
}

/// A flag produced **only** by a `set-flag` nested inside a `sequence` step is a
/// real producer: a later `requires_flags` referencing it must resolve, not
/// spuriously trip `DW0172`. Regression for the shallow producer scan that skipped
/// nested `set-flag`s.
static QUESTS_V06_SEQUENCE_SETS_FLAG: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "dsl_version": "%dsl_version%",
  "campaign_id": "hello-world",
  "stage": "quests",
  "content": {
    "quests": [
      {
        "id": "quest/open-the-door",
        "trigger": { "type": "campaign-start" },
        "objectives": [
          { "type": "talk-to", "id": "obj/talk", "npc": "npc/keeper" },
          { "type": "reach-anchor", "id": "obj/exit", "anchor": "anchor/exit", "radius": 2, "after": ["obj/talk"] }
        ],
        "on_objective_complete": {
          "obj/talk": [
            { "type": "sequence", "steps": [
              { "at_ticks": 0, "effects": [ { "type": "set-flag", "flag": "flag/opened" } ] }
            ] },
            { "type": "open-gate", "when": { "requires_flags": ["flag/opened"] }, "anchor": "anchor/door" }
          ]
        },
        "on_complete": [ { "type": "campaign-complete" } ]
      }
    ]
  }
}"#,
    )
});

/// A quests doc with a `narrate` nested inside a `sequence` step (the Q4/Q7
/// cinematic shape): its player-visible text must be inventoried under a stable,
/// position-derived nested key so a translated build ships it localized instead of
/// English-only.
static QUESTS_V06_SEQUENCE_NARRATE: LazyLock<String> = LazyLock::new(|| {
    common::at_dsl_version(
        r#"{
  "dsl_version": "%dsl_version%",
  "campaign_id": "hello-world",
  "stage": "quests",
  "content": {
    "quests": [
      {
        "id": "quest/open-the-door",
        "trigger": { "type": "campaign-start" },
        "objectives": [
          { "type": "talk-to", "id": "obj/talk", "npc": "npc/keeper" },
          { "type": "reach-anchor", "id": "obj/exit", "anchor": "anchor/exit", "radius": 2, "after": ["obj/talk"] }
        ],
        "on_objective_complete": {
          "obj/talk": [
            { "type": "sequence", "steps": [
              { "at_ticks": 0, "effects": [ { "type": "narrate", "text": "Top-of-step line." } ] },
              { "at_ticks": 40, "effects": [ { "type": "narrate", "text": "The seal cracks open." } ] }
            ] }
          ]
        },
        "on_complete": [ { "type": "campaign-complete" } ]
      }
    ]
  }
}"#,
    )
});

/// The nested-narrate key for a `sequence` narrate in quest `open-the-door`,
/// objective `talk`, top-level effect 0, step 1, nested effect 0.
const NESTED_NARRATE_KEY: &str = "fx.open-the-door.oc.talk.0.seq.1.0.narrate";

/// A `narrate` nested inside a `sequence` step enters the l10n inventory under a
/// stable, position-derived key, and `localize` swaps it — so a translated build
/// no longer ships nested cinematic narration English-only. Regression for the
/// shallow inventory that only walked top-level effects.
#[test]
fn v06_sequence_narrate_is_inventoried_and_localized() {
    let raw = campaign_with_quests(QUESTS_V06_SEQUENCE_NARRATE.as_str());
    assert!(
        check_campaign(&raw).is_empty(),
        "the sequence-narrate campaign validates clean: {:#?}",
        check_campaign(&raw)
    );
    let mut campaign = parse_campaign(&raw).expect("parses");

    // The nested narrate is inventoried with its canonical English text.
    let inv = l10n::inventory(&campaign);
    assert_eq!(
        inv.get(NESTED_NARRATE_KEY).map(String::as_str),
        Some("The seal cracks open."),
        "sequence narrate must be inventoried under a stable nested key; inventory: {inv:#?}"
    );
    // Determinism: the key derivation is stable across builds (byte-identity gate).
    assert_eq!(
        inv,
        l10n::inventory(&campaign),
        "inventory is deterministic"
    );

    // Localize swaps the nested narrate in place (the emission path reads this).
    let mut tr = std::collections::BTreeMap::new();
    tr.insert(NESTED_NARRATE_KEY.to_string(), "封印裂开了。".to_string());
    localize(&mut campaign, &tr);
    let oid = delvewright_dsl::ObjectiveId("obj/talk".to_string());
    let narrate_texts: Vec<&str> = campaign.quests.content.quests[0].on_objective_complete[&oid]
        .iter()
        .flat_map(|e| e.nested_effect_lists())
        .flatten()
        .filter_map(|e| match &e.verb {
            delvewright_dsl::Verb::Narrate { text, .. } => Some(text.as_str()),
            _ => None,
        })
        .collect();
    assert!(
        narrate_texts.contains(&"封印裂开了。"),
        "localize must swap the nested sequence narrate, got {narrate_texts:?}"
    );
}

#[test]
fn v06_set_flag_nested_in_sequence_is_a_producer_no_dw0172() {
    let diags = check_campaign(&campaign_with_quests(
        QUESTS_V06_SEQUENCE_SETS_FLAG.as_str(),
    ));
    assert!(
        !diags.iter().any(|d| d.code == "DW0172"),
        "a set-flag nested in a sequence produces its flag — requires_flags must \
         resolve, no spurious DW0172: {diags:#?}"
    );
    assert!(
        diags.is_empty(),
        "the nested-set-flag campaign must validate clean: {diags:#?}"
    );
}

/// A block field carrying a well-formed vanilla blockstate validates clean.
#[test]
fn v06_blockstate_suffix_validates_clean() {
    let diags = check_campaign(&campaign_with_quests(QUESTS_V06_BLOCKSTATE.as_str()));
    assert!(
        diags.is_empty(),
        "a well-formed blockstate suffix must validate clean: {diags:#?}"
    );
}

/// A malformed blockstate suffix reuses the invalid-block diagnostic `DW0193`.
#[test]
fn v06_malformed_blockstate_is_dw0193() {
    let bad =
        QUESTS_V06_BLOCKSTATE.replacen("minecraft:water[level=0]", "minecraft:water[level=0", 1);
    let diags = check_campaign(&campaign_with_quests(&bad));
    assert!(
        diags.iter().any(|d| d.code == "DW0193"),
        "an unbalanced blockstate suffix must be DW0193: {diags:#?}"
    );
}

/// An unknown base id with a well-formed state suffix is still `DW0193`.
#[test]
fn v06_blockstate_base_id_still_registry_checked() {
    let bad = QUESTS_V06_BLOCKSTATE.replacen(
        "minecraft:water[level=0]",
        "minecraft:not_a_block[level=0]",
        1,
    );
    let diags = check_campaign(&campaign_with_quests(&bad));
    assert!(
        diags.iter().any(|d| d.code == "DW0193"),
        "an unknown base id must still be DW0193 even with a valid state: {diags:#?}"
    );
}

/// Blocks and items are different registries: a block with no item form is a
/// valid `set-block` target (`DW0193` asks the block registry).
#[test]
fn v06_block_without_item_form_validates_clean() {
    let ok = QUESTS_V06_BLOCKSTATE.replacen(
        "minecraft:water[level=0]",
        "minecraft:wall_torch[facing=north]",
        1,
    );
    let diags = check_campaign(&campaign_with_quests(&ok));
    assert!(
        diags.is_empty(),
        "`minecraft:wall_torch` is a block with no item form and must validate: {diags:#?}"
    );
}

/// An item that is not a block is not a `set-block` target.
#[test]
fn v06_item_that_is_not_a_block_is_dw0193() {
    let bad = QUESTS_V06_BLOCKSTATE.replacen("minecraft:water[level=0]", "minecraft:iron_sword", 1);
    let diags = check_campaign(&campaign_with_quests(&bad));
    assert!(
        diags.iter().any(|d| d.code == "DW0193"),
        "`minecraft:iron_sword` is in the item registry and is not a block: {diags:#?}"
    );
}