delvewright_dsl/perception.rs
1//! A perception beat's surface, and **every game fact it is built on, in one
2//! file** (spec-0085).
3//!
4//! A perception beat is no verb of its own: it is a `sequence` of `give-effect`,
5//! `particle` and `play-sound` effects addressed by the envelope's `audience`
6//! and `in`. What this module holds is the game facts those pieces consume —
7//! which particle types exist and which take options, which status effects hide
8//! the floor, which effects change what the player sees and how long each takes
9//! to wind down — each with the wiki page or the measurement it came from, so a
10//! re-pin is one diff here and the validator, the blind-reach proof and the
11//! skill page all read the same table.
12//!
13//! The rules built on the facts (`DW0941`, `DW0943`, `DW0944`) are **authored**
14//! and live with the checks that state them.
15
16use std::collections::BTreeMap;
17use std::sync::OnceLock;
18
19/// The wiki pages the facts below were read from (spec-0085 *Research*). Named so
20/// a re-pin knows what to re-read.
21pub const WIKI_PAGES: [&str; 6] = [
22 "Blindness",
23 "Darkness",
24 "Nausea",
25 "Night Vision",
26 "Commands/particle",
27 "Sprinting",
28];
29
30/// The pinned particle-type registry, derived from the server jar by
31/// `tools/maintenance/extract-particle-registry.py` (provenance in
32/// `crates/delvec/data/PROVENANCE.md`).
33const PARTICLES_JSON: &str = include_str!("../data/particles-1.21.11.json");
34
35/// `id → takes options`, over every particle type the pinned game registers.
36pub fn particle_registry() -> &'static BTreeMap<String, bool> {
37 static REG: OnceLock<BTreeMap<String, bool>> = OnceLock::new();
38 REG.get_or_init(|| {
39 let raw: BTreeMap<String, serde_json::Value> =
40 serde_json::from_str(PARTICLES_JSON).expect("vendored particle registry is valid JSON");
41 raw.into_iter()
42 .map(|(id, v)| {
43 let takes = v
44 .get("options")
45 .and_then(serde_json::Value::as_bool)
46 .expect("every particle registry entry states `options`");
47 (id, takes)
48 })
49 .collect()
50 })
51}
52
53/// The namespaced spelling of a particle id (`minecraft:` when none is written).
54pub fn namespaced(id: &str) -> String {
55 if id.contains(':') {
56 id.to_string()
57 } else {
58 format!("minecraft:{id}")
59 }
60}
61
62/// What the pinned registry says about a particle id: `None` for an id the game
63/// does not register, `Some(true)` for a type that takes options, `Some(false)`
64/// for one a bare id spawns.
65pub fn particle_takes_options(id: &str) -> Option<bool> {
66 particle_registry().get(&namespaced(id)).copied()
67}
68
69/// **The status effects whose screen hides the floor** (spec-0085 §4.1, §6.2) —
70/// the effects a grant of which owes the blind-reach proof (`DW0943`), each with
71/// the wiki page that says what it does to the screen and whether it forbids the
72/// sprint.
73///
74/// `blindness`: "a thick black fog" leaving only the immediate area, and "the
75/// effect also prevents the player from sprinting" [cited — *Blindness*].
76/// `darkness`: the brightness cycles "between complete darkness, and relative
77/// visibility … roughly 14 blocks" [cited — *Darkness*]; the sprint is not
78/// forbidden. `nausea` warps the view and hides nothing, so it is not here.
79pub const BLINDING: [(&str, bool, &str); 2] = [
80 ("minecraft:blindness", true, "Blindness"),
81 ("minecraft:darkness", false, "Darkness"),
82];
83
84/// Whether a status effect id hides the floor, and if so whether it forbids the
85/// sprint: `None` for an effect that is not blinding.
86pub fn blinding(effect: &str) -> Option<bool> {
87 let id = namespaced(effect);
88 BLINDING
89 .iter()
90 .find(|(e, _, _)| *e == id)
91 .map(|(_, no_sprint, _)| *no_sprint)
92}
93
94/// **The sight effects** (spec-0085 §5.3) — the status effects that change what
95/// the player sees, so that one ending under an authored camera begins ramping
96/// down on screen — each with its **wind-down** in ticks: how long before its
97/// end the screen starts to change back.
98///
99/// The set is the findings-ledger row's. The pages read for this spec
100/// (*Night Vision*, *Blindness*, *Darkness*) state no wind-down, so:
101///
102/// * **night vision, 200 ticks** — the number this engine already stood on for
103/// the area mitigation's lease: the client's `GameRenderer` ramps the
104/// night-vision brightness down (the flicker) once the remaining duration
105/// drops to 200 ticks. The mitigation lease reads it from here, so the two
106/// uses are one number;
107/// * **blindness and darkness, 20 ticks** — **authored from memory** (the fog
108/// fades over about a second), named as such, and looked at on the demo
109/// level. A longer wind-down refuses more grants, never fewer.
110pub const SIGHT: [(&str, u32, &str); 3] = [
111 ("minecraft:night_vision", 200, "Night Vision"),
112 ("minecraft:blindness", 20, "Blindness"),
113 ("minecraft:darkness", 20, "Darkness"),
114];
115
116/// The wind-down in ticks of a sight effect, or `None` for an effect that is
117/// not one.
118pub fn sight_wind_down_ticks(effect: &str) -> Option<u32> {
119 let id = namespaced(effect);
120 SIGHT
121 .iter()
122 .find(|(e, _, _)| *e == id)
123 .map(|(_, ticks, _)| *ticks)
124}
125
126/// The **body moves** a blinding grant of `seconds` reaches (spec-0085 §6.2):
127/// `ceil(seconds × speed)`, at walking speed for an effect that forbids the
128/// sprint and at sprinting speed for one that does not.
129pub fn reach_moves(seconds: u32, forbids_sprint: bool) -> usize {
130 let speed = if forbids_sprint {
131 crate::metrics::WALK_SPEED_BLOCKS_PER_SECOND
132 } else {
133 crate::metrics::SPRINT_SPEED_BLOCKS_PER_SECOND
134 };
135 (f64::from(seconds) * speed).ceil() as usize
136}
137
138// ---------------------------------------------------------------------------
139// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
140// ---------------------------------------------------------------------------
141
142use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
143use crate::envelope::Campaign;
144use crate::{QuestEffect, Verb};
145
146crate::dw_code! {
147 /// (spec-0085 §4.3) **A particle the game does not draw from a bare id.** A
148 /// `particle` effect names an id the pinned registry
149 /// (`crates/dsl/data/particles-1.21.11.json`) does not hold, or one whose
150 /// type takes options (`dust`, `block`, `item`, …) — which the verb cannot
151 /// carry, so the emitted command would be refused by the game.
152 ///
153 /// Error tier, validation (exit 1).
154 pub const PERCEPTION_UNKNOWN_PARTICLE: DwCode = DwCode::new("DW0941", ExitTier::Build);
155}
156
157crate::dw_code! {
158 /// (spec-0085 §3.3) **An audience on a party fact.** An effect states the
159 /// envelope's `audience` or `in` on a verb the emitter fires once for the
160 /// world ([`crate::Verb::addresses_players`] answers `false`) — a
161 /// flag, a gate, a block, a region, a timeline, a rocket. A box has no party
162 /// and a world fact has no audience; a `sequence`'s steps each state their
163 /// own.
164 ///
165 /// Error tier, validation (exit 1).
166 pub const PERCEPTION_AUDIENCE_ON_A_PARTY_FACT: DwCode = DwCode::new("DW0942", ExitTier::Build);
167}
168
169crate::dw_code! {
170 /// (spec-0085 §5.3) **A sight effect that ends under a camera.** In one
171 /// timeline, a `give-effect` of a sight effect
172 /// ([`crate::perception::SIGHT`]) overlaps a `cutscene` step and ends inside
173 /// it, or within the effect's wind-down after it, so the effect starts
174 /// ramping down on screen. The `give-effect` half of the findings-ledger
175 /// row whose general form is that a granted sight effect outlasts any
176 /// authored camera it can overlap.
177 ///
178 /// Error tier, validation (exit 1).
179 pub const PERCEPTION_SIGHT_UNDER_A_CAMERA: DwCode = DwCode::new("DW0944", ExitTier::Build);
180}
181
182/// **The perception surface, at every effect root** (spec-0085).
183///
184/// Four rules over one descent (the single root enumeration and the single
185/// nesting authority, so a beat inside a `sequence` step of a dialogue
186/// `on_respawn` is asked exactly what a top-level one is):
187///
188/// * `DW0941` — a `particle` id the pinned registry does not hold, or one whose
189/// type takes options;
190/// * `DW0100` — a `particle` `count` of zero, which the exported schema refuses
191/// (`minimum: 1`) and serde does not;
192/// * `DW0942` — the envelope's `audience` or `in` on a verb the emitter fires
193/// once for the world ([`Verb::addresses_players`]);
194/// * `DW0944` — in one timeline, a sight grant whose window overlaps a cutscene
195/// step's and ends inside it or within its own wind-down after it.
196pub(crate) fn perception_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
197 fn descend(stage: &'static str, path: String, eff: &QuestEffect, d: &mut Vec<Diagnostic>) {
198 perception_one(stage, &path, eff, d);
199 for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
200 if !matches!(eff.verb, Verb::Sequence { .. }) {
201 // A nested bundle is a timeline of one step, at tick 0.
202 sight_under_camera(stage, &[(0, format!("{path}/{pseg}"), list)], d);
203 }
204 for (j, inner) in list.iter().enumerate() {
205 descend(stage, format!("{path}/{pseg}/{j}"), inner, d);
206 }
207 }
208 }
209 crate::effects::for_each_effect_root(c, &mut |site, effs| {
210 // A root's bundle fires all at once: a timeline of one step, at tick 0.
211 sight_under_camera(site.stage, &[(0, site.path.clone(), effs)], d);
212 for (i, eff) in effs.iter().enumerate() {
213 descend(site.stage, format!("{}/{i}", site.path), eff, d);
214 }
215 });
216}
217
218/// One effect's perception rules, at the pointer it was found at.
219fn perception_one(stage: &'static str, path: &str, eff: &QuestEffect, d: &mut Vec<Diagnostic>) {
220 use crate::perception;
221 if let Verb::Particle {
222 particle, count, ..
223 } = &eff.verb
224 {
225 match perception::particle_takes_options(particle) {
226 None => d.push(Diagnostic::error(
227 PERCEPTION_UNKNOWN_PARTICLE,
228 stage,
229 format!("{path}/particle"),
230 format!(
231 "`particle` `{particle}` is not a particle type the pinned 1.21.11 game \
232 registers ({} types, `crates/dsl/data/particles-1.21.11.json`). Use a \
233 registered id — the full-screen face is `minecraft:elder_guardian`",
234 perception::particle_registry().len()
235 ),
236 )),
237 Some(true) => d.push(Diagnostic::error(
238 PERCEPTION_UNKNOWN_PARTICLE,
239 stage,
240 format!("{path}/particle"),
241 format!(
242 "`particle` `{particle}` is a type that takes options (a colour, a block, an \
243 item, a destination), and the `particle` verb carries none, so the game would \
244 refuse the command. A particle with options is excluded until the engine \
245 states what each one takes; choose a type a bare id spawns"
246 ),
247 )),
248 Some(false) => {}
249 }
250 if *count == Some(0) {
251 d.push(Diagnostic::error(
252 codes::SCHEMA,
253 stage,
254 format!("{path}/count"),
255 "`particle` `count` is 0. Zero is vanilla's spelling of a different thing — one \
256 particle given a velocity — and the schema's minimum is 1. Write the number of \
257 particles, at least 1"
258 .to_string(),
259 ));
260 }
261 }
262 if !eff.addresses_players() {
263 for (field, present) in [
264 ("audience", eff.audience.is_some()),
265 ("in", eff.within.is_some()),
266 ] {
267 if present {
268 d.push(Diagnostic::error(
269 PERCEPTION_AUDIENCE_ON_A_PARTY_FACT,
270 stage,
271 format!("{path}/{field}"),
272 format!(
273 "`{}` declares `{field}`, and a `{}` fires once for the world — it \
274 addresses no player, so there is no audience to narrow. `audience` and \
275 `in` belong on the effects a player sees, hears or receives (`narrate`, \
276 `play-sound`, `particle`, `give-effect`, `clear-effect`, \
277 `damage-players`, `give-item`); a `sequence`'s steps each state their \
278 own. Remove `{field}` here, or move it onto those effects",
279 eff.verb.tag(),
280 eff.verb.tag()
281 ),
282 ));
283 }
284 }
285 }
286 if let Verb::Sequence { steps } = &eff.verb {
287 let groups: Vec<(u32, String, &[QuestEffect])> = steps
288 .iter()
289 .enumerate()
290 .map(|(si, st)| {
291 (
292 st.at_ticks,
293 format!("{path}/steps/{si}/effects"),
294 st.effects.as_slice(),
295 )
296 })
297 .collect();
298 sight_under_camera(stage, &groups, d);
299 }
300}
301
302/// `DW0944`: in one timeline, a sight grant that ends under a camera
303/// (spec-0085 §5.3). A bundle — a root's list or a nested one — fires all at
304/// once, so it is judged as a timeline of one step at tick 0; a `sequence` is
305/// judged step by step.
306///
307/// A grant's window is `[at_ticks, at_ticks + 20 × seconds)`; a cutscene step's
308/// is `[at_ticks, at_ticks + 20 × Σ shot seconds)`. A grant that overlaps a
309/// shot's window and ends at or after its start and before its end plus the
310/// effect's wind-down ([`crate::perception::sight_wind_down_ticks`]) starts
311/// ramping down on screen. Nothing outside one timeline is examined: a grant
312/// with no cutscene in its timeline is not this rule's business.
313fn sight_under_camera(
314 stage: &'static str,
315 groups: &[(u32, String, &[QuestEffect])],
316 d: &mut Vec<Diagnostic>,
317) {
318 let mut shots: Vec<(String, u32, u32)> = Vec::new();
319 for (at, list_path, effects) in groups {
320 for (ei, e) in effects.iter().enumerate() {
321 if let Some(list) = e.cutscene_shots().filter(|l| !l.is_empty()) {
322 let len: u32 = list.iter().map(|s| s.resolved_seconds() * 20).sum();
323 shots.push((format!("{list_path}/{ei}"), *at, at + len));
324 }
325 }
326 }
327 if shots.is_empty() {
328 return;
329 }
330 for (at, list_path, effects) in groups {
331 for (ei, e) in effects.iter().enumerate() {
332 let Some((effect, seconds, _, _, _)) = e.give_effect() else {
333 continue;
334 };
335 let Some(wind) = crate::perception::sight_wind_down_ticks(effect) else {
336 continue;
337 };
338 let begin = *at;
339 let end = begin + seconds * 20;
340 for (shot, c0, c1) in &shots {
341 let (c0, c1) = (*c0, *c1);
342 let overlaps = begin < c1 && end > c0;
343 if overlaps && end < c1 + wind {
344 d.push(Diagnostic::error(
345 PERCEPTION_SIGHT_UNDER_A_CAMERA,
346 stage,
347 format!("{list_path}/{ei}"),
348 format!(
349 "`give-effect` `{effect}` runs from tick {begin} to tick {end} of \
350 this timeline, and the cutscene at `{shot}` holds the camera from \
351 tick {c0} to tick {c1}. The grant ends at tick {end}, inside the \
352 shot or within {wind} tick(s) of its end — the effect's wind-down \
353 — so it starts ramping down on screen. A granted sight effect \
354 outlasts any authored camera it overlaps, plus its wind-down: write \
355 a `seconds` of at least {need}, or start it after the shot (a later \
356 `at_ticks`)",
357 need = (c1 + wind - begin).div_ceil(20),
358 ),
359 ));
360 }
361 }
362 }
363 }
364}