Skip to main content

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}