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
//! spec-0051 — optional is a claim about the proof.
//!
//! The fence in both directions, the three partition refusals with a green half
//! each, and the non-vacuity perturbations that show each gate goes RED when the
//! safety is removed rather than green.
//!
//! Every fixture here is built by **structurally patching** the hello-world
//! campaign through [`common::patch_doc`], which panics when a patch matches
//! nothing — a textual splice that silently no-ops would leave the assertion
//! running against an unpatched document and passing for the wrong reason.

mod common;

use std::collections::BTreeSet;

use delvewright_dsl::{DSL_VERSION, RawCampaign, check_campaign};
use serde_json::{Value, json};

const OLD: &str = DSL_VERSION;
const NEW: &str = DSL_VERSION;

// ---------------------------------------------------------------------------
// Fixture construction
// ---------------------------------------------------------------------------

/// The shape every fixture below is a variation of: a two-quest campaign whose
/// finale is the hello-world quest and whose second quest is a side strand.
///
/// `plan_version` is the stage-4 `dsl_version` — the thing the fence reads.
/// `optional` decides the strand's `mandatory`. `attach` decides whether the
/// strand hangs off the spine by a `depends_on` edge.
struct Fixture {
    plan_version: &'static str,
    strand_optional: bool,
    /// `depends_on` for the strand quest.
    strand_deps: Vec<&'static str>,
    /// When set, the FINALE is given this `depends_on` list instead of `[]`.
    finale_deps: Vec<&'static str>,
    /// When true the finale itself is declared optional.
    finale_optional: bool,
    /// When set, the finale's first objective requires this flag.
    finale_requires: Option<&'static str>,
    /// When set, the strand's `on_complete` sets this flag.
    strand_sets: Option<&'static str>,
    /// When true the strand's stage-5 trigger is `quest-complete` of the finale;
    /// when false it is `campaign-start`.
    strand_after_finale: bool,
    /// When set, the FINALE's stage-5 trigger is `quest-complete` of this quest.
    finale_triggered_by: Option<&'static str>,
}

impl Default for Fixture {
    fn default() -> Self {
        Fixture {
            plan_version: NEW,
            strand_optional: true,
            strand_deps: vec![],
            finale_deps: vec![],
            finale_optional: false,
            finale_requires: None,
            strand_sets: None,
            strand_after_finale: false,
            finale_triggered_by: None,
        }
    }
}

const FINALE: &str = "quest/open-the-door";
const STRAND: &str = "quest/side-strand";

impl Fixture {
    fn raw(&self) -> RawCampaign {
        let mut c = common::valid_raw();

        c.quest_plan = common::patch_doc(&c.quest_plan, |v| {
            v["dsl_version"] = json!(self.plan_version);
            let quests = v["content"]["quests"]
                .as_array_mut()
                .expect("stage-4 quests is an array");
            assert_eq!(quests.len(), 1, "hello-world has exactly one planned quest");
            quests[0]["mandatory"] = json!(!self.finale_optional);
            quests[0]["depends_on"] = json!(self.finale_deps);
            quests.push(json!({
                "act": 1,
                "area": "area/keep",
                "depends_on": self.strand_deps,
                "goal": "A side strand the mainline neither needs nor fears.",
                "id": STRAND,
                "mandatory": !self.strand_optional,
                "npcs": [],
            }));
        });

        c.quests = common::patch_doc(&c.quests, |v| {
            // The flag surfaces this fixture uses (`requires_flags`, `set-flag`)
            // are v0.6 QUESTS-stage surfaces. Pinning this stage at the newest
            // version keeps every assertion below about spec-0051's fence and
            // not about that one — and makes the stage-4 fence test stronger,
            // since it must still refuse with a neighbour at the top version.
            v["dsl_version"] = json!(OLD);
            let quests = v["content"]["quests"]
                .as_array_mut()
                .expect("stage-5 quests is an array");
            assert_eq!(
                quests.len(),
                1,
                "hello-world has exactly one expanded quest"
            );

            if let Some(flag) = self.finale_requires {
                let objs = quests[0]["objectives"]
                    .as_array_mut()
                    .expect("objectives is an array");
                objs[0]["requires_flags"] = json!([flag]);
            }
            if let Some(src) = self.finale_triggered_by {
                quests[0]["trigger"] = json!({ "type": "quest-complete", "quest": src });
            }

            let trigger = if self.strand_after_finale {
                json!({ "type": "quest-complete", "quest": FINALE })
            } else {
                json!({ "type": "campaign-start" })
            };
            let on_complete: Vec<Value> = match self.strand_sets {
                Some(flag) => vec![json!({ "type": "set-flag", "flag": flag })],
                None => vec![],
            };
            quests.push(json!({
                "id": STRAND,
                "objectives": [{
                    "anchor": "anchor/exit",
                    "id": "obj/strand-look",
                    "radius": 2,
                    "type": "reach-anchor",
                }],
                "on_complete": on_complete,
                "trigger": trigger,
            }));
        });

        c
    }

    fn codes(&self) -> BTreeSet<String> {
        check_campaign(&self.raw())
            .iter()
            .map(|d| d.code.clone())
            .collect()
    }
}

fn set(codes: &[&str]) -> BTreeSet<String> {
    codes.iter().map(|s| (*s).to_string()).collect()
}

// ---------------------------------------------------------------------------
// Criterion 1 — the fence pair, proved in BOTH directions
// ---------------------------------------------------------------------------

/// The same document at the adopting version compiles green — no diagnostics at
/// all, not merely a different set.
#[test]
fn at_the_fence_optional_is_accepted() {
    let f = Fixture::default();
    assert_eq!(
        f.codes(),
        BTreeSet::new(),
        "the same document at {NEW} must be green"
    );
}

// ---------------------------------------------------------------------------
// Criterion 2 — the partition is legal and the mainline is unchanged
// ---------------------------------------------------------------------------

/// A campaign with one mandatory spine and one optional quest is green, and the
/// optional quest is genuinely OFF the finale's closure — which is the fact the
/// whole proof rests on. Asserted against the ONE authority rather than assumed.
#[test]
fn the_optional_quest_is_off_the_spine() {
    let raw = Fixture::default().raw();
    let c = delvewright_dsl::parse_campaign(&raw).expect("fixture parses");
    let spine = c.quest_plan.content.spine();
    let optional = c.quest_plan.content.optional();

    assert!(spine.contains(FINALE), "the finale is always on the spine");
    assert!(
        !spine.contains(STRAND),
        "the strand must be off the closure"
    );
    assert_eq!(optional, [STRAND].into_iter().collect::<BTreeSet<_>>());
    // The partition is a partition: nothing is both.
    assert!(spine.intersection(&optional).next().is_none());
}

/// **Skipped is not absent.** An optional quest triggered by a mandatory
/// completion still activates for every party — so it is legal for its trigger
/// to chain off the spine, and that is not a `DW0867`.
#[test]
fn an_optional_quest_may_be_triggered_by_the_mainline() {
    let f = Fixture {
        strand_after_finale: true,
        ..Fixture::default()
    };
    assert_eq!(f.codes(), BTreeSet::new());
}

/// An optional quest may depend on a mandatory one — that is a strand's
/// attachment to the spine (§4), and it must not be read as the mainline
/// hanging off elective content.
#[test]
fn an_optional_quest_may_depend_on_a_mandatory_one() {
    let f = Fixture {
        strand_deps: vec![FINALE],
        ..Fixture::default()
    };
    assert_eq!(f.codes(), BTreeSet::new());
}

// ---------------------------------------------------------------------------
// Criterion 5 — the mismatch pair, both directions
// ---------------------------------------------------------------------------

/// §8.1 — an optional quest the finale cannot fire without. Here the finale
/// declares ITSELF optional, which is the one shape that reaches this rule
/// without also tripping the edge rule below.
#[test]
fn an_optional_quest_in_the_finale_closure_is_refused() {
    let f = Fixture {
        finale_optional: true,
        ..Fixture::default()
    };
    assert_eq!(f.codes(), set(&["DW0866"]));
}

/// The other half of the pair: a MANDATORY quest the closure does not reach
/// keeps today's convergence refusal, asserted by code. The partition does not
/// let a wiring mistake quietly become optional content.
#[test]
fn a_mandatory_quest_off_the_closure_keeps_dw0132() {
    let f = Fixture {
        strand_optional: false,
        ..Fixture::default()
    };
    assert_eq!(f.codes(), set(&["DW0132"]));
}

// ---------------------------------------------------------------------------
// Criterion 4 — direction of dependency
// ---------------------------------------------------------------------------

/// §8.2 — a mandatory quest whose stage-5 `quest-complete` trigger names an
/// optional quest. The party may never complete the strand, so the mainline
/// would stop there.
///
/// The trigger edge is the one that reaches this rule alone: a `depends_on`
/// edge from a mandatory quest necessarily also puts the strand in the finale's
/// closure, so it co-fires `DW0866` (asserted separately below).
#[test]
fn a_mandatory_quest_triggered_by_an_optional_one_is_refused() {
    let f = Fixture {
        finale_triggered_by: Some(STRAND),
        ..Fixture::default()
    };
    assert_eq!(f.codes(), set(&["DW0867"]));
}

/// The reversed direction greens: the optional quest triggered by the mandatory
/// one is the ordinary attachment.
#[test]
fn the_reversed_trigger_direction_is_green() {
    let f = Fixture {
        strand_after_finale: true,
        ..Fixture::default()
    };
    assert_eq!(f.codes(), BTreeSet::new());
}

/// The `depends_on` arm of §8.2, and the co-firing stated rather than left to be
/// discovered. Both diagnostics are true and they prescribe different repairs:
/// `DW0867` names the edge to cut, `DW0866` names the claim to withdraw.
#[test]
fn a_mandatory_depends_on_edge_reports_both_the_edge_and_the_closure() {
    let f = Fixture {
        finale_deps: vec![STRAND],
        ..Fixture::default()
    };
    assert_eq!(f.codes(), set(&["DW0866", "DW0867"]));
}

// ---------------------------------------------------------------------------
// Criterion 3 — a key behind participation, refused at the edge
// ---------------------------------------------------------------------------

/// §8.3 — a mandatory objective gated on a flag only an optional quest sets.
#[test]
fn a_mainline_key_behind_participation_is_refused() {
    let f = Fixture {
        finale_requires: Some("flag/strand-token"),
        strand_sets: Some("flag/strand-token"),
        ..Fixture::default()
    };
    assert_eq!(f.codes(), set(&["DW0868"]));
}

/// Moving the producer onto a mandatory quest greens the same document — the
/// green half criterion 3 demands, and the one that shows the rule is about
/// WHERE the producer sits rather than about the gate existing.
#[test]
fn the_same_gate_greens_when_the_producer_is_mandatory() {
    let f = Fixture {
        finale_requires: Some("flag/strand-token"),
        strand_sets: Some("flag/strand-token"),
        strand_optional: false,
        strand_deps: vec![],
        finale_deps: vec![STRAND],
        ..Fixture::default()
    };
    assert_eq!(f.codes(), BTreeSet::new());
}

// ---------------------------------------------------------------------------
// Non-vacuity: perturb TOWARD the vacuous shape and check the gate goes RED
// ---------------------------------------------------------------------------

/// The binding counts, **computed from the objects rather than written beside
/// them**. A constant in this position is the vacuity the count exists to
/// expose, and `clippy::assertions_on_constants` is right to object to it.
///
/// Each row perturbs one thing and states which refusal it is the only possible
/// cause of.
#[test]
fn every_refusal_binds_and_is_the_only_thing_that_could_have_caught_it() {
    // (label, fixture, the code it must raise)
    let cases: Vec<(&str, Fixture, &str)> = vec![
        (
            "finale declares itself optional",
            Fixture {
                finale_optional: true,
                ..Fixture::default()
            },
            "DW0866",
        ),
        (
            "mandatory finale triggered by an optional quest",
            Fixture {
                finale_triggered_by: Some(STRAND),
                ..Fixture::default()
            },
            "DW0867",
        ),
        (
            "mandatory objective keyed on an optional-only flag",
            Fixture {
                finale_requires: Some("flag/strand-token"),
                strand_sets: Some("flag/strand-token"),
                ..Fixture::default()
            },
            "DW0868",
        ),
    ];

    let mut bound = 0usize;
    for (label, f, code) in &cases {
        let got = f.codes();
        assert!(got.contains(*code), "{label}: expected {code}, got {got:?}");

        // The perturbation is the ONLY thing that could have caught it: with the
        // partition withdrawn — the strand marked mandatory, or the finale's
        // claim withdrawn — the SAME document must stop raising this code. If it
        // still raised it, some unrelated mechanism was doing the catching and
        // the demonstration would prove nothing about this rule.
        let withdrawn = Fixture {
            strand_optional: false,
            finale_optional: false,
            ..Fixture {
                plan_version: f.plan_version,
                strand_deps: f.strand_deps.clone(),
                finale_deps: f.finale_deps.clone(),
                finale_requires: f.finale_requires,
                strand_sets: f.strand_sets,
                strand_after_finale: f.strand_after_finale,
                finale_triggered_by: f.finale_triggered_by,
                ..Fixture::default()
            }
        };
        let after = withdrawn.codes();
        assert!(
            !after.contains(*code),
            "{label}: {code} still fires with the partition withdrawn — something \
             other than the optional declaration is catching this, so the \
             demonstration says nothing about {code}. Got {after:?}"
        );
        bound += 1;
    }

    assert_eq!(bound, cases.len(), "every case must have been exercised");
    assert!(bound >= 3, "binding count: {bound} refusals demonstrated");
}

/// The complement, and it is the direction that matters most: with the surface
/// unused — every quest mandatory — **not one** of the new refusals can fire, at
/// any version. This is what makes spec-0051 byte-identical on every committed
/// campaign, measured rather than promised.
#[test]
fn the_new_refusals_are_inert_when_nothing_is_optional() {
    let new_codes = ["DW0866", "DW0867", "DW0868"];
    let mut checked = 0usize;
    for version in [DSL_VERSION, DSL_VERSION, OLD, NEW] {
        for finale_deps in [vec![], vec![STRAND]] {
            for strand_after_finale in [false, true] {
                let f = Fixture {
                    plan_version: version,
                    strand_optional: false,
                    strand_deps: if finale_deps.is_empty() {
                        vec![FINALE]
                    } else {
                        vec![]
                    },
                    finale_deps: finale_deps.clone(),
                    strand_after_finale,
                    ..Fixture::default()
                };
                let got = f.codes();
                for code in new_codes {
                    assert!(
                        !got.contains(code),
                        "{code} fired at {version} with nothing optional: {got:?}"
                    );
                }
                checked += 1;
            }
        }
    }
    assert_eq!(checked, 16, "population actually swept");
}

// ---------------------------------------------------------------------------
// Criterion 9 — dead elective content stays dead
// ---------------------------------------------------------------------------

/// Marking a quest optional laundering nothing: an optional quest gated on a
/// flag NOTHING produces is still refused by the existing reachability family.
/// The opt-out is secured by a property the defect cannot supply.
#[test]
fn optional_does_not_launder_a_broken_strand() {
    let mut c = Fixture::default().raw();
    c.quests = common::patch_doc(&c.quests, |v| {
        let quests = v["content"]["quests"].as_array_mut().unwrap();
        let strand = quests
            .iter_mut()
            .find(|q| q["id"] == STRAND)
            .expect("the strand is in the fixture");
        strand["objectives"][0]["requires_flags"] = json!(["flag/nobody-sets-this"]);
    });
    let codes: BTreeSet<String> = check_campaign(&c).iter().map(|d| d.code.clone()).collect();
    assert!(
        codes.contains("DW0172"),
        "an optional quest gated on an unproduced flag must still be refused, got {codes:?}"
    );
}