delvewright-dsl 0.1.0

Staged JSON DSL types and schemas for Delvewright adventure-map campaigns — the format the delvec compiler reads.
Documentation

delvewright-dsl

serde types, validation, canonical serialization and JSON Schema export for the staged campaign DSL (spec-0001). This crate is the source of truth for the DSL: JSON Schemas are exported from the Rust types, never hand-maintained.

What it provides

  • Types (stages, envelope, ids): serde types for the envelope { dsl_version, campaign_id, stage, content } and the six stage payloads (v0.2: stage 2 is a structured persona, stage 6 is dialogue), every struct #[serde(deny_unknown_fields)]. IDs are type-prefixed kebab-case newtypes (area/…, npc/…, class/…, quest/…, dlg/…, obj/…, anchor/…, prefab/…, pool/…) that parse permissively and expose is_valid_syntax().
  • Validation (validate::validate_campaign): all spec-0001 v0.2 rule groups plus the v0.3 verb/wave/flag group (DW0170–DW0173, gated on dsl_version), returning Diagnostic { code, severity, stage, path, message } in the spec-0002 --json shape.
  • Canonical serialization (canonical::to_canonical_string): the single canonical writer — 2-space pretty, struct-declaration field order, sorted map keys (BTreeMap), trailing newline. Round-tripping any valid fixture is byte-identical (enforced by tests/roundtrip.rs).
  • JSON Schema export (schema::stage_schema): one full-envelope JSON Schema (draft 2020-12) per stage, via schemars.
  • Registries (registry): ItemRegistry, EntityRegistry (v0.3 wave mobs) and AnchorRegistry traits, with small vendored v0 implementations (see Registries).

Determinism (ADR-0006): all iteration is over BTreeMap/BTreeSet or slices; nothing depends on hash order, wall-clock, or absolute paths.

Entry points

use delvewright_dsl::{RawCampaign, check_campaign, parse_campaign, validate_campaign};

// From six raw JSON strings (compiler input):
let diags = check_campaign(&raw); // parse (DW0100 on failure) then validate

// Or in two steps:
let campaign = parse_campaign(&raw)?;          // Result<Campaign, Vec<Diagnostic>>
let diags = validate_campaign(&campaign);      // Vec<Diagnostic>

validate_campaign uses the vendored v0 registries. The compiler injects full registries via validate_campaign_with(&campaign, &items, &anchors).

Diagnostic codes (DW01xx)

Codes are a stable API; the CI fixture matrix asserts exact codes. Each code has ≥1 invalid fixture under fixtures/invalid/ that violates only that rule.

Code Rule group Meaning
DW0100 1 Envelope Document does not conform to its stage schema (unknown field / wrong type / malformed value, incl. a missing required persona field). Reported at parse time.
DW0101 1 Envelope stage field does not match the document's slot (e.g. world.json says stage: "npcs").
DW0102 1 Envelope Unsupported dsl_version (only 0.2.0 in v0.2).
DW0103 1 Envelope campaign_id differs across stages.
DW0110 2 IDs Malformed id syntax (not kebab-case, or wrong/missing type prefix).
DW0111 2 IDs Duplicate id within its namespace (incl. two dialogue trees for one NPC).
DW0112 2 IDs Dangling reference (incl. a persona relationship to an unknown NPC, or any forward/undeclared reference — references must be strictly backward).
DW0120 3 Dialogue (stage 6) Dialogue node unreachable from root.
DW0121 3 Dialogue (stage 6) Dialogue root/option next references an unknown node.
DW0122 3 Dialogue (stage 6) Dialogue effect targets an objective that is unknown, not a talk-to, or a talk-to on a different NPC (foreign effect).
DW0123 3 Dialogue (stage 6) A stage-5 talk-to objective has no reachable completing option in its NPC's tree (static half of the compiler's DW0203).
DW0130 4 Quest plan Quest depends_on graph contains a cycle.
DW0131 4 Quest plan finale is not a declared quest.
DW0132 4 Quest plan finale is not the convergent sink of the plan (see note).
DW0133 4 Quest plan Non-mandatory quest (mandatory: false), reserved until M3.
DW0140 5 Quests Objective after ordering contains a cycle.
DW0141 2–5 Reserved Reserved enum value used (see Reserved).
DW0142 5 Quests Anchor not provided by the area's bound prefab.
DW0143 5 Quests Item id not in the pinned 1.21.11 registry.
DW0150 6 Cross-stage Planned quest (stage 4) has no expansion in stage 5.
DW0151 6 Cross-stage Stage-5 quest is not planned in stage 4.
DW0152 6 Cross-stage Stage-2 NPC has no stage-6 dialogue tree.
DW0153 6 Cross-stage Stage-6 dialogue tree references an NPC not declared in stage 2.
DW0160 6 Prefab binding Area binds neither or both of prefab / prefab_pool (exactly one required).
DW0161 6 Prefab binding Area prefab_pool references a pool absent from prefabs/ metadata.
DW0170 5 Waves (v0.3) A kill objective or spawn-wave effect references a wave/<id> not declared in the stage-5 waves section (dangling wave ref).
DW0171 5 Waves (v0.3) A declared wave is referenced by a kill objective but is never spawned by any spawn-wave effect (a wave must be spawned before its kill objective is reachable).
DW0172 5 Flags (v0.3) A requires_flags entry references a flag/<id> that no set-flag effect ever produces (dangling flag ref).
DW0173 5 Waves (v0.3) A wave mob entity is not a known vanilla entity id. Item-id checks for collect.item, interact.requires_item and give-item.item reuse DW0143; their anchors reuse DW0142.
DW0180 i18n An l10n sidecar does not correctly cover a declared language: the l10n/<code>.json file is absent, its envelope (campaign_id/lang/dsl_version) is inconsistent, or it is missing an inventory key (under-coverage). Also fired if en is declared (it is implicit and never listed).
DW0181 i18n An l10n sidecar carries an orphan key not in the string inventory derived from the stage docs (over-coverage).
DW0190 6 v0.4 skins A mannequin NPC skin.texture_id is malformed (not a bare kebab token) or duplicated across NPCs. A missing model is a schema error (DW0100); a missing PNG is a build error (DW0309).
DW0191 6 v0.4 dialogue A talk-to objective has no ungated completing dialogue option — every completing option is requires_flags-gated, so it can deadlock the moment it activates. Keep one ungated completing path.
DW0192 6 v0.4 waves A wave-mob effects[].effect is not a known 1.21.11 status-effect id.
DW0193 6 v0.4 props A set-block / interact.prop block id is not a known 1.21.11 block id (validated against the item registry + technical/fluid allowlist).
DW0194 6 v0.4 triggers An environment-trigger id is malformed or duplicated, or an approach trigger has range 0.
DW0195 6 v0.4 lifecycle A talk-to targets an NPC despawned by a prerequisite quest (gone before the objective activates).

Coverage codes (DW0180/DW0181) are compiler-level (they need the campaign dir's l10n/ sidecars, so they run in delvec validate/analyze/build, not the single-document DSL fixture matrix). They are exercised by crates/compiler/tests/cli.rs (mutating keep-trial's real sidecar).

severity is error for every v0 code; warning exists in the shape for future advisory rules. path is a JSON-pointer-ish location within the stage document (map-key segments are not ~1-escaped — it is a locator, not a strict pointer). DW0100's path is the document root, since serde parse errors are not path-addressable.

DSL versions (0.2.0 and 0.3.0)

v0.3 is an additive superset of v0.2 (SUPPORTED_DSL_VERSIONS): a v0.2 campaign remains valid and compiles byte-identically. The new stage-5 verbs (kill/collect/interact), effects (give-item/set-flag/spawn-wave), the waves section and requires_flags are gated on dsl_version 0.3.0 — the gate is the quests-stage version (all the v0.3 surface lives in stage 5). The v03_checks group (DW0170–DW0173, plus reuse of DW0142/DW0143) runs only under 0.3.0; under 0.2.0 those verbs/effects are still rejected as reserved (DW0141). A campaign is expected to use a uniform version across its six documents; the mixed-version invalid fixtures are a testing convenience.

Reserved values

DW0141 covers enum values that are not yet implemented for the campaign's dsl_version:

  • npcs: role: vendor / role: boss (reserved in both 0.2.0 and 0.3.0).
  • quests under 0.2.0 only: objective type: kill | collect | interact; effect type: give-item | set-flag | spawn-wave. Under 0.3.0 these are implemented (see DSL versions).

(prefab_pool is no longer reserved in v0.2 — it is a real stage-1 binding, validated by DW0160/DW0161.)

These parse (so authors get a clean diagnostic instead of an opaque error) and are rejected by validation. The reserved kit-item fields (lore, enchantments, attributes) are the exception: they are intentionally not defined as fields, so a document using them is rejected as an unknown field (DW0100).

Fixtures

Valid — fixtures/valid/hello-world/

The complete canonical hello-world campaign, six documents (world, npcs, classes, quest-plan, quests, dialogue) — v0.2: npcs carries the keeper's structured persona, dialogue carries his tree. It validates with zero diagnostics and is byte-identical under the canonical writer.

Invalid — fixtures/invalid/

Self-describing patch files named <code>-<slug>.json:

{
  "description": "npc references an unknown area",
  "expect": "DW0112",
  "documents": { "npcs": { "...": "full replacement envelope for this stage" } }
}
  • expect: the single diagnostic code the fixture must produce.
  • documents: a map of stage name → the full stage envelope that replaces the valid one. Most fixtures replace exactly one stage; a few (e.g. DW0132) must replace two to violate their rule in isolation without tripping the cross-stage 1:1 rule.
  • schema_reject (optional, default false): the overridden document must also be rejected by the exported JSON Schema (schema-level violations only, e.g. DW0100).

tests/matrix.rs walks the matrix: every invalid fixture yields exactly its expect code, and the valid campaign yields zero. tests/schema.rs validates every valid fixture against its exported schema and checks that every schema_reject fixture is rejected.

i18n (native localization)

Owner-approved 2026-07-31 (spec-0001 i18n addendum). English is canonical; stage docs stay pure English. A campaign opts into translations by listing BCP-47-style codes in world.content.languages (e.g. ["zh-cn"]); en is implicit and never listed. Each declared language ships one l10n sidecar under the campaign dir at l10n/<code>.json, an envelope in the stage-doc style plus a flat content map of stable key → translated string:

{ "dsl_version": "0.3.0", "campaign_id": "keep-trial", "kind": "l10n",
  "lang": "zh-cn", "content": { "world.title": "…", "npc.keeper.name": "…", … } }

The compiler derives the authoritative key inventory from the stage docs (l10n::inventory) and the build-time swap (l10n::localize, driven by delvec build --lang <code>) walk the same traversal, so a key is never checked without being applied. Player-visible strings only; the key scheme:

Key Source
world.title stage-1 title
area.<area>.name area name
class.<class>.name / .blurb / .kit.<i>.name class name/blurb + kit item display name (if set)
npc.<npc>.name NPC name
quest.<quest>.goal stage-4 quest goal
obj.<quest>.<obj>.title / .hint objective title/hint (if set)
obj.<quest>.<obj>.missing_item_hint interact empty-hand line (v0.7, if set)
dlg.<npc>.<node>.text / .opt.<i>.label dialogue node text + option labels
wave.<wave>.mob.<i>.name wave mob custom name (if set)

<…> is the id's local part (after <prefix>/, kebab preserved); ids are unique within their namespace, so keys never collide. Excluded (authoring context the player never sees): world theme/premise, NPC persona fields and relationships. Coverage is exact — a declared language's sidecar must cover the inventory with no missing (DW0180) and no orphan (DW0181) keys; this runs on every validate/analyze/build, independent of --lang. A --lang <code> build differs from en only in string content (critical-path.json and the world layout are byte-identical); an en build is byte-identical to a pre-i18n one.

Registries

Item-id and anchor checks go through the ItemRegistry / AnchorRegistry traits. This crate ships small vendored v0 implementations covering only what the M1 fixtures use:

  • VendoredItemRegistry::v1_21_11() — the item ids in data/items-1.21.11.json. The full 1.21.11 item registry is vendored in the compiler task (spec-0002); the compiler injects it via validate_campaign_with.
  • VendoredEntityRegistry::v1_21_11() — the hostile-mob ids in data/entities-1.21.11.json, used to validate v0.3 wave mobs (DW0173). A full 1.21.11 entity registry is injected by the compiler (currently the same vendored subset, pending the wave-emission task).
  • VendoredAnchorRegistry::hello_world() — the anchors the hello-world prefab declares (data/anchors.json) plus a fixture pool (data/pools.json) so prefab-pool existence checks are non-vacuous. Real prefab anchor metadata lives in prefabs/ (ADR-0004) and is resolved by the compiler; the trait lets the compiler inject it.

AnchorRegistry is the prefab-metadata surface DSL validation resolves refs against. Beyond anchors_for, it exposes has_pool (prefab-pool existence, DW0161) and lighting_for (the typed Lighting / LightingProfile block, consumed by the compiler's dark-mitigation analysis, DW0210). The compiler's PrefabRegistry implements all three from prefabs/ metadata.

Spec notes / resolved ambiguities

  • DW0132 (finale reachability). In a valid DAG with all references present, a finale is always reachable by dependency traversal, so the rule cannot be violated in isolation under a literal reading. v0 implements the concrete, independently-testable reading: every planned quest must be a transitive dependency of finale — the plan converges on the finale. This is a structural stage-4 check and is distinct from the compiler's deeper semantic reachability (analyze, exit 2, DW0201 in spec-0002); the compiler may run both.
  • Objective/dialogue id uniqueness. spec-0001 calls dialogue/objective ids "stage-local". v0 enforces: dialogue node ids unique within each NPC's graph; objective ids unique across all of stage 5 (so cross-stage complete-objective refs resolve unambiguously).
  • quest-complete trigger. The reference field is named quest ({ "type": "quest-complete", "quest": "quest/…" }).
  • Anchor resolution is performed here via AnchorRegistry, but prefab metadata itself (including the spawn requirement) is owned by the compiler and prefabs/; the vendored registry only knows the hello-world prefab.

Dependencies

serde, serde_json, schemars, thiserror (all MIT/Apache-2.0). Dev-only: jsonschema (MIT) with default features disabled (its HTTP/TLS resolver tree is not needed for offline validation and is excluded).