use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::ClassId;
use crate::serde_fields::is_false;
#[cfg(doc)]
use crate::Verb;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Carrier {
#[default]
All,
One,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ClassesContent {
pub classes: Vec<Class>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Class {
pub id: ClassId,
pub name: String,
pub blurb: String,
pub kit: Vec<KitItem>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct KitItem {
pub item: String,
pub count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carrier: Option<Carrier>,
#[serde(default, skip_serializing_if = "is_false")]
pub flask: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contents: Option<PotionContents>,
}
pub const POTION_BEARING_ITEMS: &[&str] = &[
"minecraft:lingering_potion",
"minecraft:potion",
"minecraft:splash_potion",
"minecraft:tipped_arrow",
];
pub fn is_potion_bearing_item(item_id: &str) -> bool {
let norm = if item_id.contains(':') {
item_id.to_string()
} else {
format!("minecraft:{item_id}")
};
POTION_BEARING_ITEMS.contains(&norm.as_str())
}
pub const INSTANT_EFFECTS: &[&str] = &["minecraft:instant_health", "minecraft:instant_damage"];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PotionContents {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub potion: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<PotionEffect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PotionEffect {
pub effect: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amplifier: Option<u32>,
}
impl PotionEffect {
pub fn is_instant(&self) -> bool {
let norm = crate::registry::namespaced_effect_id(&self.effect);
INSTANT_EFFECTS.contains(&norm.as_str())
}
}
pub const MAX_POTION_DURATION_TICKS: u32 = 1_000_000;
pub const MAX_POTION_AMPLIFIER: u32 = 255;
pub const MAX_EFFECT_SECONDS: u32 = MAX_POTION_DURATION_TICKS / 20;
use std::collections::BTreeSet;
use crate::Objective;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::registry::EffectRegistry;
crate::dw_code! {
pub const BONFIRE_NO_FLASK: DwCode = DwCode::new("DW0476", ExitTier::Build);
}
crate::dw_code! {
pub const ITEM_GATE_UNBRINGABLE: DwCode = DwCode::new("DW0849", ExitTier::Build);
}
crate::dw_code! {
pub const KIT_POTION_INVALID: DwCode = DwCode::new("DW0486", ExitTier::Build);
}
crate::dw_code! {
pub const KIT_POTION_MISSING: DwCode = DwCode::new("DW0487", ExitTier::Build);
}
fn ns_item(id: &str) -> String {
if id.contains(':') {
id.to_string()
} else {
format!("minecraft:{id}")
}
}
fn class_blind_item_sources(c: &Campaign) -> BTreeSet<String> {
let mut src: BTreeSet<String> = BTreeSet::new();
let quests = &c.quests.content;
crate::for_each_campaign_effect(c, &mut |_path, _site, eff| {
if let Some(item) = eff.give_item() {
src.insert(ns_item(item));
}
});
for q in &quests.quests {
for o in &q.objectives {
if let Objective::Collect { item, .. } = o {
src.insert(ns_item(item));
}
}
}
for l in &quests.loot {
for it in &l.items {
src.insert(ns_item(&it.item));
}
}
for w in &quests.waves {
for m in &w.mobs {
for drop in &m.drops {
match (drop.item(), drop.slot()) {
(Some(item), _) => {
src.insert(ns_item(item));
}
(None, Some(slot)) => {
if let Some(eq) = m.equipment.as_ref().and_then(|e| e.filled(slot)) {
src.insert(ns_item(eq.item()));
}
}
(None, None) => {}
}
}
}
}
src
}
pub(crate) fn item_gate_class_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let classes = &c.classes.content.classes;
if classes.is_empty() {
return;
}
let blind = class_blind_item_sources(c);
for (i, q) in c.quests.content.quests.iter().enumerate() {
for (j, o) in q.objectives.iter().enumerate() {
let Objective::Interact {
id, requires_item, ..
} = o
else {
continue;
};
let Some(raw) = requires_item.as_deref() else {
continue;
};
let item = ns_item(raw);
if blind.contains(&item) {
continue;
}
let cannot: Vec<&str> = classes
.iter()
.filter(|cl| !cl.kit.iter().any(|k| ns_item(&k.item) == item))
.map(|cl| cl.id.as_str())
.collect();
if cannot.is_empty() {
continue;
}
let supply = if cannot.len() == classes.len() {
"nothing in this campaign supplies it at all".to_string()
} else {
format!(
"its only supply is another class's kit, so {} cannot bring it: {}",
if cannot.len() == 1 {
"one class"
} else {
"those classes"
},
cannot.join(", ")
)
};
d.push(Diagnostic::error(
ITEM_GATE_UNBRINGABLE,
"quests",
format!("/content/quests/{i}/objectives/{j}/requires_item"),
format!(
"objective `{}` completes only for a player HOLDING `{raw}`, and {supply}. A \
delve is played by one to four players who each pick one class, so a solo \
player of any class is a party this campaign must be finishable by — and \
this one is assembled unable to finish, which it learns standing at the \
thing it cannot press. Three ways to supply it, and any one is enough: put \
the item in a `collect` objective or a `loot` container on the way to this \
gate; hand it out with a `give-item` effect (its default `carrier` is `all` \
— every party member); or add it to EVERY class kit rather than one. Do not \
drop `requires_item` to silence this — presenting the item is the beat.",
id.as_str()
),
));
}
}
}
pub(crate) fn kit_potion_checks(
c: &Campaign,
effects: &dyn EffectRegistry,
d: &mut Vec<Diagnostic>,
) {
for (i, cl) in c.classes.content.classes.iter().enumerate() {
for (k, item) in cl.kit.iter().enumerate() {
let bearing = crate::is_potion_bearing_item(&item.item);
let path = format!("/content/classes/{i}/kit/{k}");
let Some(contents) = &item.contents else {
if bearing {
d.push(Diagnostic::error(
KIT_POTION_MISSING,
"classes",
format!("{path}/contents"),
format!(
"kit item `{}` declares no `contents`, so it compiles to the \
*Uncraftable Potion* — a bottle with no `minecraft:potion_contents` \
component, which grants nothing when drunk however it is named. \
Declare what is in it: `\"contents\": {{\"potion\": \
\"minecraft:strong_healing\"}}`, or an `\"effects\"` list of \
`{{\"effect\", \"duration\", \"amplifier\"}}`. Do NOT rename the \
bottle instead — semantics never key on player-facing text \
(spec-0016 §1).",
item.item
),
));
}
continue;
};
if !bearing {
d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{path}/contents"),
format!(
"kit item `{}` cannot carry potion `contents` — in 1.21.11 only \
`minecraft:potion`, `minecraft:splash_potion`, \
`minecraft:lingering_potion` and `minecraft:tipped_arrow` carry a \
`minecraft:potion_contents` component, and on anything else the game \
discards it. Put the contents on a potion item, or drop the field.",
item.item
),
));
continue;
}
if contents.potion.is_none() && contents.effects.is_empty() {
d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{path}/contents"),
"empty potion `contents` — it names no `potion` and lists no `effects`, so \
the bottle still pours nothing. Name a vanilla potion (e.g. \
`\"potion\": \"minecraft:strong_healing\"`) or list at least one effect."
.to_string(),
));
}
if let Some(p) = &contents.potion
&& !crate::registry::is_potion_id(p)
{
d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{path}/contents/potion"),
format!(
"`{p}` is not in the pinned 1.21.11 `potion` registry — use a real potion \
id (`minecraft:healing`, `minecraft:strong_healing`, \
`minecraft:long_night_vision`, …). Note the 1.20.5+ spelling: strength \
and duration are part of the id (`strong_`/`long_` prefixes), not \
separate fields."
),
));
}
if let Some(col) = &contents.color
&& !is_hex_color(col)
{
d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{path}/contents/color"),
format!(
"potion `color` `{col}` is malformed — write the bottle colour as \
`#rrggbb` (e.g. `#ff9c30`), or omit it and take the colour vanilla \
derives from the effects."
),
));
}
for (e, eff) in contents.effects.iter().enumerate() {
let epath = format!("{path}/contents/effects/{e}");
if !effects.contains(&eff.effect) {
d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{epath}/effect"),
format!(
"potion effect `{}` is not a known 1.21.11 status-effect id — use a \
valid namespaced effect id (e.g. `minecraft:instant_health`).",
eff.effect
),
));
}
if let Some(amp) = eff.amplifier
&& amp > crate::MAX_POTION_AMPLIFIER
{
d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{epath}/amplifier"),
format!(
"potion effect `amplifier` {amp} is out of range — vanilla stores it \
in an unsigned byte, so it must be 0–{max} (0 = level I).",
max = crate::MAX_POTION_AMPLIFIER
),
));
}
match (eff.is_instant(), eff.duration) {
(true, Some(dur)) => d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{epath}/duration"),
format!(
"`{}` is instantaneous — it lands once, on the tick the potion is \
drunk, so the `duration` of {dur} tick(s) here is never read. Drop \
the field; for healing that ticks over time use \
`minecraft:regeneration`, which does take a duration.",
eff.effect
),
)),
(false, None) => d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{epath}/duration"),
format!(
"potion effect `{}` lasts over time and declares no `duration` — \
vanilla would default it to zero ticks, i.e. nothing. Declare the \
duration in ticks (20 = one second).",
eff.effect
),
)),
(false, Some(dur)) if dur == 0 || dur > crate::MAX_POTION_DURATION_TICKS => {
d.push(Diagnostic::error(
KIT_POTION_INVALID,
"classes",
format!("{epath}/duration"),
format!(
"potion effect `duration` {dur} is out of range — it is in \
**ticks** (20 = one second) and must be 1–{max} \
(≈13.9 hours, past the delve ceiling).",
max = crate::MAX_POTION_DURATION_TICKS
),
));
}
_ => {}
}
}
}
}
}
fn is_hex_color(s: &str) -> bool {
crate::color::is_hex(s)
}
pub(crate) fn kit_item_checks(
c: &Campaign,
items: &dyn crate::registry::ItemRegistry,
d: &mut Vec<Diagnostic>,
) {
for (i, cl) in c.classes.content.classes.iter().enumerate() {
for (j, it) in cl.kit.iter().enumerate() {
if !items.contains(&it.item) {
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"classes",
format!("/content/classes/{i}/kit/{j}/item"),
format!(
"kit item `{}` is not in the pinned 1.21.11 item registry — use a valid \
namespaced item id (e.g. `minecraft:iron_sword`)",
it.item
),
));
}
}
}
}
pub(crate) fn bonfire_flask_checks(c: &Campaign, has_bonfire: bool, d: &mut Vec<Diagnostic>) {
if has_bonfire {
let flaskless: Vec<&str> = c
.classes
.content
.classes
.iter()
.filter(|cl| !cl.kit.iter().any(|k| k.flask))
.map(|cl| cl.id.as_str())
.collect();
if !flaskless.is_empty() {
d.push(Diagnostic::error(
BONFIRE_NO_FLASK,
"classes",
"/content/classes".to_string(),
format!(
"this campaign places a `bonfire` but {} no `flask` kit item: {}. \
Resting at a bonfire replenishes every kit entry marked `\"flask\": true` to \
its declared `count` — with none, the rest option recovers nothing and the \
souls loop has no consumable to spend (spec-0016 §1). \
Add a recovery item to each class kit and mark it \
`\"flask\": true` (this needs `dsl_version` 0.8.0 on the classes stage). Do \
NOT drop the bonfire to silence this — the rest point is the design.",
if flaskless.len() == 1 {
"one class declares".to_string()
} else {
format!("{} classes declare", flaskless.len())
},
flaskless.join(", ")
),
));
}
}
}
pub(crate) fn class_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, cl) in c.classes.content.classes.iter().enumerate() {
crate::ids::id_syntax!(d, cl.id, "classes", format!("/content/classes/{i}/id"));
}
}
pub(crate) fn class_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::ids::dup_check(
c.classes
.content
.classes
.iter()
.enumerate()
.map(|(i, cl)| (cl.id.as_str(), format!("/content/classes/{i}/id"))),
"classes",
"class",
d,
);
}