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}