use std::collections::BTreeMap;
use std::sync::OnceLock;
pub const WIKI_PAGES: [&str; 6] = [
"Blindness",
"Darkness",
"Nausea",
"Night Vision",
"Commands/particle",
"Sprinting",
];
const PARTICLES_JSON: &str = include_str!("../data/particles-1.21.11.json");
pub fn particle_registry() -> &'static BTreeMap<String, bool> {
static REG: OnceLock<BTreeMap<String, bool>> = OnceLock::new();
REG.get_or_init(|| {
let raw: BTreeMap<String, serde_json::Value> =
serde_json::from_str(PARTICLES_JSON).expect("vendored particle registry is valid JSON");
raw.into_iter()
.map(|(id, v)| {
let takes = v
.get("options")
.and_then(serde_json::Value::as_bool)
.expect("every particle registry entry states `options`");
(id, takes)
})
.collect()
})
}
pub fn namespaced(id: &str) -> String {
if id.contains(':') {
id.to_string()
} else {
format!("minecraft:{id}")
}
}
pub fn particle_takes_options(id: &str) -> Option<bool> {
particle_registry().get(&namespaced(id)).copied()
}
pub const BLINDING: [(&str, bool, &str); 2] = [
("minecraft:blindness", true, "Blindness"),
("minecraft:darkness", false, "Darkness"),
];
pub fn blinding(effect: &str) -> Option<bool> {
let id = namespaced(effect);
BLINDING
.iter()
.find(|(e, _, _)| *e == id)
.map(|(_, no_sprint, _)| *no_sprint)
}
pub const SIGHT: [(&str, u32, &str); 3] = [
("minecraft:night_vision", 200, "Night Vision"),
("minecraft:blindness", 20, "Blindness"),
("minecraft:darkness", 20, "Darkness"),
];
pub fn sight_wind_down_ticks(effect: &str) -> Option<u32> {
let id = namespaced(effect);
SIGHT
.iter()
.find(|(e, _, _)| *e == id)
.map(|(_, ticks, _)| *ticks)
}
pub fn reach_moves(seconds: u32, forbids_sprint: bool) -> usize {
let speed = if forbids_sprint {
crate::metrics::WALK_SPEED_BLOCKS_PER_SECOND
} else {
crate::metrics::SPRINT_SPEED_BLOCKS_PER_SECOND
};
(f64::from(seconds) * speed).ceil() as usize
}
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::{QuestEffect, Verb};
crate::dw_code! {
pub const PERCEPTION_UNKNOWN_PARTICLE: DwCode = DwCode::new("DW0941", ExitTier::Build);
}
crate::dw_code! {
pub const PERCEPTION_AUDIENCE_ON_A_PARTY_FACT: DwCode = DwCode::new("DW0942", ExitTier::Build);
}
crate::dw_code! {
pub const PERCEPTION_SIGHT_UNDER_A_CAMERA: DwCode = DwCode::new("DW0944", ExitTier::Build);
}
pub(crate) fn perception_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
fn descend(stage: &'static str, path: String, eff: &QuestEffect, d: &mut Vec<Diagnostic>) {
perception_one(stage, &path, eff, d);
for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
if !matches!(eff.verb, Verb::Sequence { .. }) {
sight_under_camera(stage, &[(0, format!("{path}/{pseg}"), list)], d);
}
for (j, inner) in list.iter().enumerate() {
descend(stage, format!("{path}/{pseg}/{j}"), inner, d);
}
}
}
crate::effects::for_each_effect_root(c, &mut |site, effs| {
sight_under_camera(site.stage, &[(0, site.path.clone(), effs)], d);
for (i, eff) in effs.iter().enumerate() {
descend(site.stage, format!("{}/{i}", site.path), eff, d);
}
});
}
fn perception_one(stage: &'static str, path: &str, eff: &QuestEffect, d: &mut Vec<Diagnostic>) {
use crate::perception;
if let Verb::Particle {
particle, count, ..
} = &eff.verb
{
match perception::particle_takes_options(particle) {
None => d.push(Diagnostic::error(
PERCEPTION_UNKNOWN_PARTICLE,
stage,
format!("{path}/particle"),
format!(
"`particle` `{particle}` is not a particle type the pinned 1.21.11 game \
registers ({} types, `crates/dsl/data/particles-1.21.11.json`). Use a \
registered id — the full-screen face is `minecraft:elder_guardian`",
perception::particle_registry().len()
),
)),
Some(true) => d.push(Diagnostic::error(
PERCEPTION_UNKNOWN_PARTICLE,
stage,
format!("{path}/particle"),
format!(
"`particle` `{particle}` is a type that takes options (a colour, a block, an \
item, a destination), and the `particle` verb carries none, so the game would \
refuse the command. A particle with options is excluded until the engine \
states what each one takes; choose a type a bare id spawns"
),
)),
Some(false) => {}
}
if *count == Some(0) {
d.push(Diagnostic::error(
codes::SCHEMA,
stage,
format!("{path}/count"),
"`particle` `count` is 0. Zero is vanilla's spelling of a different thing — one \
particle given a velocity — and the schema's minimum is 1. Write the number of \
particles, at least 1"
.to_string(),
));
}
}
if !eff.addresses_players() {
for (field, present) in [
("audience", eff.audience.is_some()),
("in", eff.within.is_some()),
] {
if present {
d.push(Diagnostic::error(
PERCEPTION_AUDIENCE_ON_A_PARTY_FACT,
stage,
format!("{path}/{field}"),
format!(
"`{}` declares `{field}`, and a `{}` fires once for the world — it \
addresses no player, so there is no audience to narrow. `audience` and \
`in` belong on the effects a player sees, hears or receives (`narrate`, \
`play-sound`, `particle`, `give-effect`, `clear-effect`, \
`damage-players`, `give-item`); a `sequence`'s steps each state their \
own. Remove `{field}` here, or move it onto those effects",
eff.verb.tag(),
eff.verb.tag()
),
));
}
}
}
if let Verb::Sequence { steps } = &eff.verb {
let groups: Vec<(u32, String, &[QuestEffect])> = steps
.iter()
.enumerate()
.map(|(si, st)| {
(
st.at_ticks,
format!("{path}/steps/{si}/effects"),
st.effects.as_slice(),
)
})
.collect();
sight_under_camera(stage, &groups, d);
}
}
fn sight_under_camera(
stage: &'static str,
groups: &[(u32, String, &[QuestEffect])],
d: &mut Vec<Diagnostic>,
) {
let mut shots: Vec<(String, u32, u32)> = Vec::new();
for (at, list_path, effects) in groups {
for (ei, e) in effects.iter().enumerate() {
if let Some(list) = e.cutscene_shots().filter(|l| !l.is_empty()) {
let len: u32 = list.iter().map(|s| s.resolved_seconds() * 20).sum();
shots.push((format!("{list_path}/{ei}"), *at, at + len));
}
}
}
if shots.is_empty() {
return;
}
for (at, list_path, effects) in groups {
for (ei, e) in effects.iter().enumerate() {
let Some((effect, seconds, _, _, _)) = e.give_effect() else {
continue;
};
let Some(wind) = crate::perception::sight_wind_down_ticks(effect) else {
continue;
};
let begin = *at;
let end = begin + seconds * 20;
for (shot, c0, c1) in &shots {
let (c0, c1) = (*c0, *c1);
let overlaps = begin < c1 && end > c0;
if overlaps && end < c1 + wind {
d.push(Diagnostic::error(
PERCEPTION_SIGHT_UNDER_A_CAMERA,
stage,
format!("{list_path}/{ei}"),
format!(
"`give-effect` `{effect}` runs from tick {begin} to tick {end} of \
this timeline, and the cutscene at `{shot}` holds the camera from \
tick {c0} to tick {c1}. The grant ends at tick {end}, inside the \
shot or within {wind} tick(s) of its end — the effect's wind-down \
— so it starts ramping down on screen. A granted sight effect \
outlasts any authored camera it overlaps, plus its wind-down: write \
a `seconds` of at least {need}, or start it after the shot (a later \
`at_ticks`)",
need = (c1 + wind - begin).div_ceil(20),
),
));
}
}
}
}
}