mod common;
use delvewright_dsl::{RawCampaign, check_campaign, l10n, localize, parse_campaign};
use std::sync::LazyLock;
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" }
]
}
}"#,
)
});
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" }
]
}
}"#,
)
});
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,
}
}
#[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:#?}"
);
}
#[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:#?}"
);
}
#[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:#?}"
);
}
#[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:#?}"
);
}
#[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:#?}"
);
}
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,
}
}
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" } ]
}
]
}
}"#,
)
});
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" } ]
}
]
}
}"#,
)
});
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" } ]
}
]
}
}"#,
)
});
#[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:#?}"
);
}
#[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:#?}"
);
}
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" } ]
}
]
}
}"#,
)
});
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" } ]
}
]
}
}"#,
)
});
const NESTED_NARRATE_KEY: &str = "fx.open-the-door.oc.talk.0.seq.1.0.narrate";
#[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");
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:#?}"
);
assert_eq!(
inv,
l10n::inventory(&campaign),
"inventory is deterministic"
);
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:#?}"
);
}
#[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:#?}"
);
}
#[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:#?}"
);
}
#[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:#?}"
);
}
#[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:#?}"
);
}
#[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:#?}"
);
}