Skip to main content

steel_core/world/explosion/
mod.rs

1//! Server-side explosion calculation and application.
2
3use std::sync::Arc;
4
5use glam::DVec3;
6use steel_protocol::packet_traits::EncodedPacket;
7use steel_protocol::packets::game::{CExplode, ExplosionParticleInfo, ExplosionParticlePalette};
8use steel_protocol::utils::ConnectionProtocol;
9use steel_registry::blocks::block_state_ext::BlockStateExt as _;
10use steel_registry::fluid::FluidState;
11use steel_registry::game_rules::GameRule;
12use steel_registry::particle_type::ParticleData;
13use steel_registry::sound_event::SoundEventHolder;
14use steel_registry::vanilla_game_rules::{
15    BLOCK_EXPLOSION_DROP_DECAY, MOB_EXPLOSION_DROP_DECAY, MOB_GRIEFING, TNT_EXPLOSION_DROP_DECAY,
16};
17use steel_registry::{sound_events, vanilla_particle_types};
18use steel_utils::{BlockPos, BlockStateId};
19
20use crate::behavior::FLUID_BEHAVIORS;
21use crate::entity::damage::DamageSource;
22use crate::entity::{Entity, SharedEntity};
23use crate::player::connection::NetworkConnection as _;
24use crate::world::World;
25
26use server::ServerExplosion;
27
28mod server;
29
30const MAX_PACKET_DISTANCE_SQUARED: f64 = 64.0 * 64.0;
31
32/// High-level source category used to resolve Vanilla explosion gamerules.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum ExplosionInteraction {
35    /// Damage entities without changing blocks.
36    None,
37    /// A block-created explosion governed by `block_explosion_drop_decay`.
38    Block,
39    /// A mob-created explosion governed by mob griefing and mob drop decay.
40    Mob,
41    /// A TNT explosion governed by `tnt_explosion_drop_decay`.
42    Tnt,
43}
44
45/// How an explosion interacts with blocks after its affected positions are calculated.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum BlockInteraction {
48    /// Preserve blocks.
49    Keep,
50    /// Destroy affected blocks without explosion decay.
51    Destroy,
52    /// Destroy affected blocks with the explosion radius in their loot context.
53    DestroyWithDecay,
54}
55
56impl BlockInteraction {
57    /// Returns Vanilla's default block-like-entity interaction flag.
58    #[must_use]
59    pub const fn should_affect_blocklike_entities(self) -> bool {
60        matches!(self, Self::Destroy | Self::DestroyWithDecay)
61    }
62}
63
64/// Read-only explosion context exposed to blocks, entities, and calculators.
65pub trait Explosion {
66    /// Returns the world executing this explosion.
67    fn world(&self) -> &Arc<World>;
68    /// Returns the damage source carried by this explosion.
69    fn damage_source(&self) -> &DamageSource;
70    /// Returns the resolved block interaction policy.
71    fn block_interaction(&self) -> BlockInteraction;
72    /// Returns the living entity credited for this explosion, if any.
73    fn indirect_source_entity(&self) -> Option<&dyn Entity>;
74    /// Returns the entity directly creating this explosion, if any.
75    fn direct_source_entity(&self) -> Option<&dyn Entity>;
76    /// Returns the explosion radius.
77    fn radius(&self) -> f32;
78    /// Returns the exact explosion center.
79    fn center(&self) -> DVec3;
80    /// Returns whether block-like entities may be affected.
81    fn should_affect_blocklike_entities(&self) -> bool;
82}
83
84/// Customizable Vanilla explosion resistance, damage, and knockback policy.
85pub trait ExplosionDamageCalculator: Send + Sync {
86    /// Returns the effective resistance at an affected block position.
87    fn block_explosion_resistance(
88        &self,
89        _explosion: &dyn Explosion,
90        _world: &World,
91        _pos: BlockPos,
92        state: BlockStateId,
93        fluid: FluidState,
94    ) -> Option<f32> {
95        default_block_explosion_resistance(state, fluid)
96    }
97
98    /// Returns whether the current ray may affect this block position.
99    fn should_block_explode(
100        &self,
101        _explosion: &dyn Explosion,
102        _world: &World,
103        _pos: BlockPos,
104        _state: BlockStateId,
105        _power: f32,
106    ) -> bool {
107        true
108    }
109
110    /// Returns whether the explosion damages this entity.
111    fn should_damage_entity(&self, _explosion: &dyn Explosion, _entity: &dyn Entity) -> bool {
112        true
113    }
114
115    /// Returns the multiplier applied to this entity's explosion knockback.
116    fn knockback_multiplier(&self, _entity: &dyn Entity) -> f32 {
117        1.0
118    }
119
120    /// Calculates Vanilla explosion damage for one entity and exposure value.
121    #[expect(
122        clippy::manual_midpoint,
123        reason = "preserve Vanilla's exact floating-point expression"
124    )]
125    fn entity_damage_amount(
126        &self,
127        explosion: &dyn Explosion,
128        entity: &dyn Entity,
129        exposure: f32,
130    ) -> f32 {
131        let double_radius = explosion.radius() * 2.0;
132        let distance = entity.position().distance(explosion.center()) / f64::from(double_radius);
133        let impact = (1.0 - distance) * f64::from(exposure);
134        (((impact * impact + impact) / 2.0) * 7.0 * f64::from(double_radius) + 1.0) as f32
135    }
136}
137
138/// State-only view available to explicitly immutable explosion block calculators.
139///
140/// Keeping this surface narrow prevents worker tasks from reaching mutable world,
141/// entity, block-entity, or general [`crate::world::LevelReader`] behavior.
142pub(crate) trait ExplosionBlockReader: Sync {
143    /// Returns `None` when `pos` is outside a bounded reader's stable view.
144    fn block_state(&self, pos: BlockPos) -> Option<BlockStateId>;
145}
146
147impl ExplosionBlockReader for World {
148    #[inline]
149    fn block_state(&self, pos: BlockPos) -> Option<BlockStateId> {
150        Some(self.get_block_state(pos))
151    }
152}
153
154/// Opt-in capability for calculators whose block-ray decisions are pure.
155///
156/// This trait is crate-private so public custom calculators continue through the
157/// sequential compatibility lane unless Steel can prove their implementation is
158/// immutable. Implementations may only inspect block states through
159/// [`ExplosionBlockReader`].
160pub(crate) trait ImmutableExplosionBlockCalculator: Send + Sync {
161    /// Returns the greatest per-axis block offset (Chebyshev radius) this calculator reads from
162    /// each ray position. Implementations opting in must never increase ray power through a
163    /// returned resistance.
164    ///
165    /// Opting in permits one bounded, stable world view for the ray phase. Every reader access
166    /// made by [`Self::explosion_resistance`] and [`Self::should_explode`] must remain within this
167    /// distance. Returning `None` retains the live-world compatibility path.
168    fn bounded_block_read_radius(&self) -> Option<u32> {
169        None
170    }
171
172    /// Whether repeated resistance queries for an unchanged position may reuse one result.
173    ///
174    /// A bounded reader guarantees stable block states during ray traversal. An implementation
175    /// must still opt in because the compatibility contract permits calculators to observe how
176    /// often this method runs. Live-world fallback traversal does not cache reads or resistance.
177    fn can_cache_explosion_resistance(&self) -> bool {
178        false
179    }
180
181    /// Whether [`Self::should_explode`] unconditionally returns `true` without side effects.
182    ///
183    /// This permits the immutable ray lane to omit the virtual callback and repeated affected-set
184    /// probes for positions that have already been accepted. Calculators must retain the default
185    /// unless every state and remaining-power value is allowed.
186    fn always_allows_block_explosion(&self) -> bool {
187        false
188    }
189
190    /// Returns the effective resistance at a ray position.
191    fn explosion_resistance(
192        &self,
193        _reader: &dyn ExplosionBlockReader,
194        _pos: BlockPos,
195        state: BlockStateId,
196        fluid: FluidState,
197    ) -> Option<f32> {
198        default_block_explosion_resistance(state, fluid)
199    }
200
201    fn should_explode(
202        &self,
203        _reader: &dyn ExplosionBlockReader,
204        _pos: BlockPos,
205        _state: BlockStateId,
206        _power: f32,
207    ) -> bool {
208        true
209    }
210}
211
212impl ImmutableExplosionBlockCalculator for DefaultExplosionDamageCalculator {
213    fn bounded_block_read_radius(&self) -> Option<u32> {
214        Some(0)
215    }
216
217    fn can_cache_explosion_resistance(&self) -> bool {
218        true
219    }
220
221    fn always_allows_block_explosion(&self) -> bool {
222        true
223    }
224}
225
226fn default_block_explosion_resistance(state: BlockStateId, fluid: FluidState) -> Option<f32> {
227    if state.is_air() && fluid.is_empty() {
228        return None;
229    }
230
231    let block_resistance = state.get_block().config.explosion_resistance;
232    let fluid_resistance = FLUID_BEHAVIORS
233        .get_behavior(fluid.fluid_id)
234        .explosion_resistance();
235    Some(block_resistance.max(fluid_resistance))
236}
237
238/// Vanilla's source-less default explosion calculator.
239#[derive(Debug, Default)]
240pub struct DefaultExplosionDamageCalculator;
241
242impl ExplosionDamageCalculator for DefaultExplosionDamageCalculator {}
243
244/// Vanilla's calculator that delegates resistance and block decisions to the source entity.
245pub struct EntityBasedExplosionDamageCalculator<'a> {
246    source: &'a dyn Entity,
247}
248
249impl<'a> EntityBasedExplosionDamageCalculator<'a> {
250    /// Creates a calculator that delegates block decisions to `source`.
251    #[must_use]
252    pub const fn new(source: &'a dyn Entity) -> Self {
253        Self { source }
254    }
255}
256
257impl ExplosionDamageCalculator for EntityBasedExplosionDamageCalculator<'_> {
258    fn block_explosion_resistance(
259        &self,
260        explosion: &dyn Explosion,
261        world: &World,
262        pos: BlockPos,
263        state: BlockStateId,
264        fluid: FluidState,
265    ) -> Option<f32> {
266        DefaultExplosionDamageCalculator
267            .block_explosion_resistance(explosion, world, pos, state, fluid)
268            .map(|resistance| {
269                self.source
270                    .block_explosion_resistance(explosion, world, pos, state, fluid, resistance)
271            })
272    }
273
274    fn should_block_explode(
275        &self,
276        explosion: &dyn Explosion,
277        world: &World,
278        pos: BlockPos,
279        state: BlockStateId,
280        power: f32,
281    ) -> bool {
282        self.source
283            .should_block_explode(explosion, world, pos, state, power)
284    }
285}
286
287enum SelectedDamageCalculator<'a> {
288    Default,
289    Entity(SharedEntity),
290    Custom(&'a dyn ExplosionDamageCalculator),
291}
292
293impl SelectedDamageCalculator<'_> {
294    /// Returns whether entity-effect hooks use Steel's side-effect-free Vanilla defaults.
295    const fn has_builtin_entity_effects(&self) -> bool {
296        matches!(self, Self::Default | Self::Entity(_))
297    }
298}
299
300impl ExplosionDamageCalculator for SelectedDamageCalculator<'_> {
301    fn block_explosion_resistance(
302        &self,
303        explosion: &dyn Explosion,
304        world: &World,
305        pos: BlockPos,
306        state: BlockStateId,
307        fluid: FluidState,
308    ) -> Option<f32> {
309        match self {
310            Self::Default => DefaultExplosionDamageCalculator
311                .block_explosion_resistance(explosion, world, pos, state, fluid),
312            Self::Entity(source) => EntityBasedExplosionDamageCalculator::new(source.as_ref())
313                .block_explosion_resistance(explosion, world, pos, state, fluid),
314            Self::Custom(calculator) => {
315                calculator.block_explosion_resistance(explosion, world, pos, state, fluid)
316            }
317        }
318    }
319
320    fn should_block_explode(
321        &self,
322        explosion: &dyn Explosion,
323        world: &World,
324        pos: BlockPos,
325        state: BlockStateId,
326        power: f32,
327    ) -> bool {
328        match self {
329            Self::Default => DefaultExplosionDamageCalculator
330                .should_block_explode(explosion, world, pos, state, power),
331            Self::Entity(source) => EntityBasedExplosionDamageCalculator::new(source.as_ref())
332                .should_block_explode(explosion, world, pos, state, power),
333            Self::Custom(calculator) => {
334                calculator.should_block_explode(explosion, world, pos, state, power)
335            }
336        }
337    }
338
339    fn should_damage_entity(&self, explosion: &dyn Explosion, entity: &dyn Entity) -> bool {
340        match self {
341            Self::Custom(calculator) => calculator.should_damage_entity(explosion, entity),
342            Self::Default | Self::Entity(_) => {
343                DefaultExplosionDamageCalculator.should_damage_entity(explosion, entity)
344            }
345        }
346    }
347
348    fn knockback_multiplier(&self, entity: &dyn Entity) -> f32 {
349        match self {
350            Self::Custom(calculator) => calculator.knockback_multiplier(entity),
351            Self::Default | Self::Entity(_) => {
352                DefaultExplosionDamageCalculator.knockback_multiplier(entity)
353            }
354        }
355    }
356
357    fn entity_damage_amount(
358        &self,
359        explosion: &dyn Explosion,
360        entity: &dyn Entity,
361        exposure: f32,
362    ) -> f32 {
363        match self {
364            Self::Custom(calculator) => {
365                calculator.entity_damage_amount(explosion, entity, exposure)
366            }
367            Self::Default | Self::Entity(_) => {
368                DefaultExplosionDamageCalculator.entity_damage_amount(explosion, entity, exposure)
369            }
370        }
371    }
372}
373
374/// Full visual and gameplay parameters for one server explosion.
375pub struct ExplosionOptions<'a> {
376    /// Entity directly creating the explosion.
377    pub source: Option<SharedEntity>,
378    /// Optional non-default damage source.
379    pub damage_source: Option<DamageSource>,
380    /// Optional non-default damage calculator.
381    pub damage_calculator: Option<&'a dyn ExplosionDamageCalculator>,
382    /// Proven-immutable block-ray calculator for Steel-owned explosion sources.
383    pub(crate) immutable_block_calculator: Option<&'a dyn ImmutableExplosionBlockCalculator>,
384    /// Exact explosion center.
385    pub center: DVec3,
386    /// Explosion radius.
387    pub radius: f32,
388    /// Whether the explosion can create fire.
389    pub fire: bool,
390    /// High-level block interaction category.
391    pub interaction: ExplosionInteraction,
392    /// Particle used for small or non-block-interacting explosions.
393    pub small_explosion_particle: ParticleData,
394    /// Particle used for large block-interacting explosions.
395    pub large_explosion_particle: ParticleData,
396    /// Weighted client-side block debris particle palette.
397    pub block_particles: ExplosionParticlePalette,
398    /// Client-side explosion sound.
399    pub explosion_sound: SoundEventHolder,
400}
401
402impl ExplosionOptions<'_> {
403    /// Creates an explosion with Vanilla's standard visual effects.
404    #[must_use]
405    pub fn new(center: DVec3, radius: f32, interaction: ExplosionInteraction) -> Self {
406        Self {
407            source: None,
408            damage_source: None,
409            damage_calculator: None,
410            immutable_block_calculator: None,
411            center,
412            radius,
413            fire: false,
414            interaction,
415            small_explosion_particle: ParticleData::simple(&vanilla_particle_types::EXPLOSION),
416            large_explosion_particle: ParticleData::simple(
417                &vanilla_particle_types::EXPLOSION_EMITTER,
418            ),
419            block_particles: default_block_particles(),
420            explosion_sound: SoundEventHolder::registry(&sound_events::ENTITY_GENERIC_EXPLODE),
421        }
422    }
423}
424
425/// Observable server result of running one explosion.
426#[derive(Clone, Copy, Debug, Eq, PartialEq)]
427pub struct ExplosionOutcome {
428    /// Number of unique positions reached by the explosion rays.
429    pub affected_block_count: usize,
430}
431
432impl World {
433    /// Runs one explosion atomically on the game tick and sends its client effects.
434    pub fn explode(self: &Arc<Self>, options: ExplosionOptions<'_>) -> ExplosionOutcome {
435        let block_interaction = self.resolve_block_interaction(options.interaction);
436        let mut explosion = ServerExplosion::new(
437            self,
438            options.source,
439            options.damage_source,
440            options.damage_calculator,
441            options.immutable_block_calculator,
442            options.center,
443            options.radius,
444            options.fire,
445            block_interaction,
446        );
447        let affected_block_count = explosion.explode();
448        let explosion_particle = if explosion.is_small() {
449            options.small_explosion_particle
450        } else {
451            options.large_explosion_particle
452        };
453
454        let play_sound = explosion
455            .direct_source_entity()
456            .is_none_or(|source| !source.is_silent());
457        let packet_block_count = i32::try_from(affected_block_count).unwrap_or(i32::MAX);
458
459        let packet = |player_knockback| CExplode {
460            center: options.center,
461            radius: options.radius,
462            block_count: packet_block_count,
463            player_knockback,
464            explosion_particle: explosion_particle.clone(),
465            explosion_sound: options.explosion_sound.clone(),
466            block_particles: options.block_particles.clone(),
467            play_sound,
468        };
469        // Players without knockback receive identical bytes, so that packet is encoded once.
470        let mut shared = None;
471        self.players.iter_players(|_, player| {
472            if player.position().distance_squared(options.center) >= MAX_PACKET_DISTANCE_SQUARED {
473                return true;
474            }
475            if let Some(&knockback) = explosion.hit_players.get(&player.id()) {
476                player.send_packet(packet(Some(knockback)));
477                return true;
478            }
479            let encoded = shared.get_or_insert_with(|| {
480                EncodedPacket::from_bare(packet(None), self.compression, ConnectionProtocol::Play)
481                    .inspect_err(|error| {
482                        tracing::warn!(?error, "Failed to encode explosion packet");
483                    })
484                    .ok()
485            });
486            if let Some(encoded) = encoded {
487                player.connection.send_encoded(encoded.clone());
488            }
489            true
490        });
491
492        ExplosionOutcome {
493            affected_block_count,
494        }
495    }
496
497    // TODO: Add trigger branch with windcharges
498    fn resolve_block_interaction(&self, interaction: ExplosionInteraction) -> BlockInteraction {
499        match interaction {
500            ExplosionInteraction::None => BlockInteraction::Keep,
501            ExplosionInteraction::Block => self.destroy_interaction(&BLOCK_EXPLOSION_DROP_DECAY),
502            ExplosionInteraction::Mob => {
503                if self.get_game_rule(&MOB_GRIEFING) {
504                    self.destroy_interaction(&MOB_EXPLOSION_DROP_DECAY)
505                } else {
506                    BlockInteraction::Keep
507                }
508            }
509            ExplosionInteraction::Tnt => self.destroy_interaction(&TNT_EXPLOSION_DROP_DECAY),
510        }
511    }
512
513    fn destroy_interaction(&self, decay_rule: &GameRule<bool>) -> BlockInteraction {
514        if self.get_game_rule(decay_rule) {
515            BlockInteraction::DestroyWithDecay
516        } else {
517            BlockInteraction::Destroy
518        }
519    }
520}
521
522fn default_block_particles() -> ExplosionParticlePalette {
523    let entries = [
524        ExplosionParticleInfo {
525            particle: ParticleData::simple(&vanilla_particle_types::POOF),
526            scaling: 0.5,
527            speed: 1.0,
528        },
529        ExplosionParticleInfo {
530            particle: ParticleData::simple(&vanilla_particle_types::SMOKE),
531            scaling: 1.0,
532            speed: 1.0,
533        },
534    ]
535    .into_iter()
536    .map(|particle| (particle, 1))
537    .collect();
538    let Ok(palette) = ExplosionParticlePalette::try_new(entries) else {
539        panic!("Vanilla explosion particle palette must be valid");
540    };
541    palette
542}