mod common;
use delvewright_dsl::{DSL_VERSION, RawCampaign, check_campaign};
use std::sync::LazyLock;
static TWO_CLASSES_ONE_CARRIER: LazyLock<String> = LazyLock::new(|| {
common::at_dsl_version(
r#"{
"dsl_version": "%dsl_version%",
"campaign_id": "hello-world",
"stage": "classes",
"content": {
"classes": [
{
"id": "class/wanderer",
"name": "Wanderer",
"blurb": "Sturdy boots, no questions.",
"kit": [
{ "item": "minecraft:iron_sword", "count": 1 },
{ "item": "minecraft:stripped_oak_log", "count": 1 }
]
},
{
"id": "class/warder",
"name": "Warder",
"blurb": "A shield and a long memory.",
"kit": [ { "item": "minecraft:shield", "count": 1 } ]
}
]
}
}"#,
)
});
static TWO_CLASSES_BOTH_CARRY: LazyLock<String> = LazyLock::new(|| {
common::at_dsl_version(
r#"{
"dsl_version": "%dsl_version%",
"campaign_id": "hello-world",
"stage": "classes",
"content": {
"classes": [
{
"id": "class/wanderer",
"name": "Wanderer",
"blurb": "Sturdy boots, no questions.",
"kit": [
{ "item": "minecraft:iron_sword", "count": 1 },
{ "item": "minecraft:stripped_oak_log", "count": 1 }
]
},
{
"id": "class/warder",
"name": "Warder",
"blurb": "A shield and a long memory.",
"kit": [
{ "item": "minecraft:shield", "count": 1 },
{ "item": "minecraft:stripped_oak_log", "count": 1 }
]
}
]
}
}"#,
)
});
fn quests(extra_objective: &str, extra_effect: &str, extra_section: &str) -> String {
format!(
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" }}{extra_objective},
{{ "type": "interact", "id": "obj/pry", "anchor": "anchor/exit",
"after": ["obj/talk"], "requires_item": "minecraft:stripped_oak_log" }}
],
"on_complete": [ {{ "type": "campaign-complete" }}{extra_effect} ]
}}
]{extra_section}
}}
}}"#
)
}
fn campaign(classes: &str, quests: String) -> RawCampaign {
RawCampaign {
world: common::read_valid("world.json"),
npcs: common::read_valid("npcs.json"),
classes: classes.to_string(),
quest_plan: common::read_valid("quest-plan.json"),
quests,
dialogue: common::read_valid("dialogue.json"),
world_edits: None,
geometry_brief: None,
layout_graph: None,
site_plan: None,
detail_plan: None,
design: None,
}
}
fn dw0849(diags: &[delvewright_dsl::Diagnostic]) -> Vec<&delvewright_dsl::Diagnostic> {
diags.iter().filter(|d| d.code == "DW0849").collect()
}
#[test]
fn an_item_only_one_class_carries_is_refused() {
let diags = check_campaign(&campaign(
TWO_CLASSES_ONE_CARRIER.as_str(),
quests("", "", ""),
));
let hits = dw0849(&diags);
assert_eq!(
hits.len(),
1,
"expected exactly one DW0849 for an item only one class carries, got: {diags:#?}"
);
let m = &hits[0].message;
assert!(
m.contains("class/warder"),
"DW0849 names the class that cannot bring it: {m}"
);
assert!(
!m.contains("class/wanderer"),
"DW0849 must NOT name the class that CAN bring it: {m}"
);
assert!(
m.contains("minecraft:stripped_oak_log"),
"DW0849 names the item: {m}"
);
assert!(m.contains("obj/pry"), "DW0849 names the objective: {m}");
assert_eq!(
hits[0].path, "/content/quests/0/objectives/1/requires_item",
"DW0849 points at the gate, not at the class list"
);
}
#[test]
fn an_item_nothing_supplies_at_all_is_refused_and_says_so() {
let no_carrier =
TWO_CLASSES_ONE_CARRIER.replace("minecraft:stripped_oak_log", "minecraft:stick");
let diags = check_campaign(&campaign(&no_carrier, quests("", "", "")));
let hits = dw0849(&diags);
assert_eq!(
hits.len(),
1,
"an item with no source anywhere is one DW0849, got: {diags:#?}"
);
assert!(
hits[0]
.message
.contains("nothing in this campaign supplies it at all"),
"the two shapes read differently — no supply is not 'another class's kit': {}",
hits[0].message
);
}
#[test]
fn an_item_every_class_carries_is_clean() {
let diags = check_campaign(&campaign(
TWO_CLASSES_BOTH_CARRY.as_str(),
quests("", "", ""),
));
assert!(
dw0849(&diags).is_empty(),
"an item in every kit binds no class: {diags:#?}"
);
}
#[test]
fn a_collect_objective_discharges_the_gate() {
let collect = r#",
{ "type": "collect", "id": "obj/gather", "item": "minecraft:stripped_oak_log",
"count": 1, "anchor": "anchor/exit", "after": ["obj/talk"] }"#;
let diags = check_campaign(&campaign(
TWO_CLASSES_ONE_CARRIER.as_str(),
quests(collect, "", ""),
));
assert!(
dw0849(&diags).is_empty(),
"a `collect` supplies the item to whoever opens the container: {diags:#?}"
);
}
#[test]
fn a_give_item_effect_discharges_the_gate() {
let give = r#",
{ "type": "give-item", "item": "minecraft:stripped_oak_log", "count": 1 }"#;
let diags = check_campaign(&campaign(
TWO_CLASSES_ONE_CARRIER.as_str(),
quests("", give, ""),
));
assert!(
dw0849(&diags).is_empty(),
"a `give-item` supplies the item class-blind: {diags:#?}"
);
}
#[test]
fn a_loot_container_discharges_the_gate() {
let loot = r#",
"loot": [
{ "id": "loot/woodpile", "anchor": "anchor/exit",
"items": [ { "item": "minecraft:stripped_oak_log", "count": 1 } ] }
]"#;
let diags = check_campaign(&campaign(
TWO_CLASSES_ONE_CARRIER.as_str(),
quests("", "", loot),
));
assert!(
dw0849(&diags).is_empty(),
"a `loot` container supplies the item class-blind: {diags:#?}"
);
}
#[test]
fn a_campaign_with_no_item_gate_binds_zero_and_says_nothing() {
let ungated =
quests("", "", "").replace(r#", "requires_item": "minecraft:stripped_oak_log""#, "");
assert!(
!ungated.contains("requires_item"),
"the perturbation must actually remove the gate — a replacement that matched \
nothing is a silent no-op"
);
let diags = check_campaign(&campaign(TWO_CLASSES_ONE_CARRIER.as_str(), ungated));
assert!(
dw0849(&diags).is_empty(),
"no item gate, nothing to say: {diags:#?}"
);
}