Skip to main content

delvewright_dsl/
firework.rs

1//! A firework's surface, and **every game fact it is built on, in one file**
2//! (spec-0068).
3//!
4//! # Why the constants are here and not spread through the emitter
5//!
6//! Each number below is read off a page of the Minecraft Wiki rather than
7//! measured on a server, and a cited number that is copied to its three call
8//! sites is three numbers a re-pin has to find. So the pages are named
9//! ([`WIKI_PAGES`]), each constant says which one it came from, and the
10//! emitter, the refusal and the skill page all read them from here: re-pinning
11//! the firework against a later game is one diff in this file.
12//!
13//! The rules built on the facts — the fixed `LifeTime`, the roof column, the
14//! five-block reach — are **authored**, and they live with the check that
15//! states them.
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20/// The wiki pages every constant in this module was read from (spec-0068
21/// *Ground*). Named so a re-pin knows what to re-read, and asserted by
22/// `crates/dsl/tests/v29_firework.rs` so the list cannot quietly shrink.
23pub const WIKI_PAGES: [&str; 3] = [
24    "Firework Rocket",
25    "Data component format/fireworks",
26    "Data component format/equippable",
27];
28
29/// The vanilla entity a firework effect summons.
30pub const ROCKET_ENTITY: &str = "minecraft:firework_rocket";
31
32/// The item id the summoned rocket's own item stack carries.
33pub const ROCKET_ITEM: &str = "minecraft:firework_rocket";
34
35/// The item component that holds the bursts [cited — *Data component
36/// format/fireworks*].
37pub const FIREWORKS_COMPONENT: &str = "minecraft:fireworks";
38
39/// **The entity's item field** [cited — *Firework Rocket*, entity data]. The
40/// summoned rocket reads its bursts from the stack under this key; a renamed
41/// key would ship a rocket that flies and shows nothing, so the emitted spelling
42/// is asserted against this constant by the emission test.
43pub const ITEM_FIELD: &str = "FireworksItem";
44
45/// The shortest flight the game crafts, and this verb's default.
46pub const MIN_FLIGHT: u8 = 1;
47
48/// The longest flight the game crafts [cited — *Firework Rocket*]. Durations
49/// beyond it are a non-goal: the wiki states a burst height for one, two and
50/// three, and for nothing else.
51pub const MAX_FLIGHT: u8 = 3;
52
53/// At least one burst: a rocket with none is a flare that glides along whatever
54/// it meets and shows nothing [cited — *Firework Rocket*].
55pub const MIN_EXPLOSIONS: usize = 1;
56
57/// At most seven bursts — **the game's own crafting cap**, and the largest count
58/// the page states a damage for [cited — *Firework Rocket*]. The component would
59/// take 256; a display of more is a `sequence` of rockets, not one rocket that
60/// could kill an unhurt player by itself.
61pub const MAX_EXPLOSIONS: usize = 7;
62
63/// Damage a one-star burst deals, in HP, to a body within [`BLAST_RADIUS`]
64/// blocks and not behind a solid block [cited — *Firework Rocket*].
65pub const DAMAGE_ONE_STAR_HP: u32 = 7;
66
67/// Additional HP per star beyond the first [cited — *Firework Rocket*].
68pub const DAMAGE_PER_EXTRA_STAR_HP: u32 = 2;
69
70/// How far a burst reaches, in blocks [cited — *Firework Rocket*].
71pub const BLAST_RADIUS: i32 = 5;
72
73/// The worst burst this verb can write, in HP: [`MAX_EXPLOSIONS`] stars.
74///
75/// Under a full body's twenty, which is the whole reason [`MAX_EXPLOSIONS`] is
76/// seven — no rocket this verb writes can kill an unhurt player by itself.
77#[must_use]
78pub fn worst_damage_hp() -> u32 {
79    DAMAGE_ONE_STAR_HP + DAMAGE_PER_EXTRA_STAR_HP * (MAX_EXPLOSIONS as u32 - 1)
80}
81
82/// The fixed term of the game's randomised `LifeTime`
83/// (`(flight + 1) × LIFETIME_STEP_TICKS + random(0..5) + random(0..6)` ticks)
84/// [cited — *Firework Rocket*, entity data].
85pub const LIFETIME_STEP_TICKS: u32 = 10;
86
87/// **The `LifeTime` the emitter writes**: the floor of the game's randomised
88/// range, `(flight + 1) × 10` ticks.
89///
90/// Authored, on the cited formula. Left unset the game rolls the two random
91/// terms at launch, so one datapack would burst at two heights and the reach
92/// proof would be about a number nobody chose. The floor is the conservative
93/// side of that proof: the burst is the lowest the game would ever put it.
94#[must_use]
95pub fn lifetime_ticks(flight: u8) -> u32 {
96    (u32::from(flight) + 1) * LIFETIME_STEP_TICKS
97}
98
99/// The burst height in blocks above the launch cell, per flight duration
100/// 1, 2, 3 — the **floor** of each range the wiki states (8–20, 18–34, 32–52),
101/// because [`lifetime_ticks`] fixes `LifeTime` at the range's floor [cited —
102/// *Firework Rocket*].
103pub const BURST_HEIGHTS: [i32; 3] = [8, 18, 32];
104
105/// The burst height for a flight duration, clamped to the crafted range.
106#[must_use]
107pub fn burst_height(flight: u8) -> i32 {
108    let i = flight.clamp(MIN_FLIGHT, MAX_FLIGHT) as usize - 1;
109    BURST_HEIGHTS[i]
110}
111
112/// One of the game's five explosion shapes [cited — *Data component
113/// format/fireworks*], spelled as the component spells it.
114#[derive(
115    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
116)]
117#[serde(rename_all = "snake_case")]
118pub enum FireworkShape {
119    /// A small ball — the plain firework star.
120    SmallBall,
121    /// A large ball.
122    LargeBall,
123    /// A star-shaped burst (a gold-nugget star).
124    Star,
125    /// A creeper-face burst (a creeper-head star).
126    Creeper,
127    /// A burst (a feather star).
128    Burst,
129}
130
131impl FireworkShape {
132    /// Every shape the game has, in the component's own order.
133    pub const ALL: [FireworkShape; 5] = [
134        FireworkShape::SmallBall,
135        FireworkShape::LargeBall,
136        FireworkShape::Star,
137        FireworkShape::Creeper,
138        FireworkShape::Burst,
139    ];
140
141    /// The token the `minecraft:fireworks` component's `shape` field carries —
142    /// the same string the DSL writes.
143    #[must_use]
144    pub fn token(self) -> &'static str {
145        match self {
146            FireworkShape::SmallBall => "small_ball",
147            FireworkShape::LargeBall => "large_ball",
148            FireworkShape::Star => "star",
149            FireworkShape::Creeper => "creeper",
150            FireworkShape::Burst => "burst",
151        }
152    }
153}
154
155/// One burst of a [`crate::Verb::Firework`] — one firework star.
156#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
157#[serde(deny_unknown_fields)]
158pub struct FireworkExplosion {
159    /// Which of the game's five shapes this burst takes.
160    pub shape: FireworkShape,
161    /// The burst's colours, one or more `#rrggbb` literals — the spelling
162    /// [`crate::PotionContents::color`] uses, emitted as the packed integers the
163    /// component reads. At least one: a star with no colour is not a star.
164    #[schemars(length(min = 1), inner(pattern(r"^#[0-9a-fA-F]{6}$")))]
165    pub colors: Vec<String>,
166    /// Colours the burst fades to, same spelling. Empty = no fade.
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    #[schemars(inner(pattern(r"^#[0-9a-fA-F]{6}$")))]
169    pub fade_colors: Vec<String>,
170    /// Whether the burst trails (a diamond star). Default false.
171    #[serde(default, skip_serializing_if = "is_false")]
172    pub trail: bool,
173    /// Whether the burst twinkles/crackles (a glowstone-dust star). Default
174    /// false.
175    #[serde(default, skip_serializing_if = "is_false")]
176    pub twinkle: bool,
177}
178
179/// serde `skip_serializing_if` helper: skip a `false` flag.
180fn is_false(b: &bool) -> bool {
181    !*b
182}
183
184// ---------------------------------------------------------------------------
185// Validation — the checks `dsl::validate` runs over this object (ADR-0031)
186// ---------------------------------------------------------------------------
187
188use crate::diagnostic::{Diagnostic, codes};
189use crate::envelope::Campaign;
190use crate::{QuestEffect, Verb};
191
192/// **A firework's shape, at every effect root** (spec-0068 §3.1).
193///
194/// Three bounds the exported schema states and serde does not enforce — the
195/// flight's `1..=3`, the explosion list's `1..=7`, and every colour's
196/// `#rrggbb` pattern — so each is restated here, at the schema tier, because
197/// that is what each of them is: a document that does not conform to its own
198/// schema. The verb's anchor is not this function's business; `DW0142` and
199/// `DW0360` own a mark whose anchor is nothing, as they do for every
200/// anchor-bearing effect.
201pub(crate) fn firework_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
202    let mut found: Vec<(String, &'static str, String)> = Vec::new();
203    // The roots come from the single enumeration and the nesting from the single
204    // descent authority, so a `firework` inside a `sequence` step of a dialogue
205    // option's `on_respawn` bundle is asked exactly what a top-level one is —
206    // and the finding is reported against the stage document it really lives in.
207    fn descend(
208        stage: &'static str,
209        path: String,
210        eff: &QuestEffect,
211        found: &mut Vec<(String, &'static str, String)>,
212    ) {
213        firework_shape(stage, &path, eff, found);
214        for (pseg, _kseg, list) in eff.nested_effect_lists_labeled() {
215            for (j, inner) in list.iter().enumerate() {
216                descend(stage, format!("{path}/{pseg}/{j}"), inner, found);
217            }
218        }
219    }
220    crate::effects::for_each_effect_root(c, &mut |site, effs| {
221        for (i, eff) in effs.iter().enumerate() {
222            descend(site.stage, format!("{}/{i}", site.path), eff, &mut found);
223        }
224    });
225    for (path, stage, message) in found {
226        d.push(Diagnostic::error(codes::SCHEMA, stage, path, message));
227    }
228}
229
230/// One firework effect's shape, at the pointer it was found at.
231fn firework_shape(
232    stage: &'static str,
233    path: &str,
234    eff: &QuestEffect,
235    found: &mut Vec<(String, &'static str, String)>,
236) {
237    use crate::firework;
238    let Verb::Firework {
239        flight, explosions, ..
240    } = &eff.verb
241    else {
242        return;
243    };
244    if let Some(f) = flight
245        && !(firework::MIN_FLIGHT..=firework::MAX_FLIGHT).contains(f)
246    {
247        found.push((
248            format!("{path}/flight"),
249            stage,
250            format!(
251                "`firework` `flight` is {f}. A flight duration is one of the three the game \
252                 crafts — {min}, {min2} or {max} — and the wiki states a burst height for \
253                 those and for nothing else, so a fourth would put the burst at a height this \
254                 engine cannot state. Write {min}, {min2} or {max}.",
255                min = firework::MIN_FLIGHT,
256                min2 = firework::MIN_FLIGHT + 1,
257                max = firework::MAX_FLIGHT,
258            ),
259        ));
260    }
261    if explosions.len() < firework::MIN_EXPLOSIONS {
262        found.push((
263            format!("{path}/explosions"),
264            stage,
265            format!(
266                "`firework` declares no explosion. A rocket with none is a flare: it glides \
267                 along whatever it meets and shows nothing. Declare between {} and {} \
268                 burst(s).",
269                firework::MIN_EXPLOSIONS,
270                firework::MAX_EXPLOSIONS,
271            ),
272        ));
273    }
274    if explosions.len() > firework::MAX_EXPLOSIONS {
275        found.push((
276            format!("{path}/explosions"),
277            stage,
278            format!(
279                "`firework` declares {n} explosions, and a rocket carries at most {max} — the \
280                 game's own crafting cap, and the largest count the page states a damage for \
281                 ({worst} HP, under a full body's twenty). A display of more rockets is a \
282                 `sequence` of `firework` effects, not one rocket that could kill an unhurt \
283                 player by itself.",
284                n = explosions.len(),
285                max = firework::MAX_EXPLOSIONS,
286                worst = firework::worst_damage_hp(),
287            ),
288        ));
289    }
290    for (i, ex) in explosions.iter().enumerate() {
291        if ex.colors.is_empty() {
292            found.push((
293                format!("{path}/explosions/{i}/colors"),
294                stage,
295                "`firework` explosion declares no `colors`. A star with no colour is not a \
296                 star — write at least one `#rrggbb` literal (e.g. `#ffd700`)."
297                    .to_string(),
298            ));
299        }
300        for (field, list) in [("colors", &ex.colors), ("fade_colors", &ex.fade_colors)] {
301            for (j, col) in list.iter().enumerate() {
302                if !crate::color::is_hex(col) {
303                    found.push((
304                        format!("{path}/explosions/{i}/{field}/{j}"),
305                        stage,
306                        format!(
307                            "`firework` colour `{col}` is malformed — write a burst colour as \
308                             `#rrggbb` (e.g. `#ffd700`), the spelling a potion's `color` \
309                             uses. The schema's own pattern is `{pat}`.",
310                            pat = crate::color::HEX_PATTERN,
311                        ),
312                    ));
313                }
314            }
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn the_worst_burst_cannot_kill_an_unhurt_player() {
325        assert_eq!(worst_damage_hp(), 19);
326        assert!(worst_damage_hp() < 20);
327    }
328
329    #[test]
330    fn lifetime_is_the_floor_of_the_games_range() {
331        assert_eq!(lifetime_ticks(1), 20);
332        assert_eq!(lifetime_ticks(2), 30);
333        assert_eq!(lifetime_ticks(3), 40);
334    }
335
336    #[test]
337    fn a_height_per_crafted_flight() {
338        assert_eq!(burst_height(1), 8);
339        assert_eq!(burst_height(2), 18);
340        assert_eq!(burst_height(3), 32);
341    }
342}