Skip to main content

steel_core/behavior/blocks/vegetation/
pointed_dripstone_block.rs

1use std::sync::Arc;
2
3use rand::RngExt;
4use steel_macros::block_behavior;
5use steel_registry::blocks::block_state_ext::BlockStateExt;
6use steel_registry::blocks::properties::{
7    BlockStateProperties, BoolProperty, EnumProperty, IntProperty, SpeleothemThickness,
8};
9use steel_registry::blocks::shapes::{BooleanOp, VoxelShape, join_is_not_empty};
10use steel_registry::{
11    fluid::FluidRef, level_events, vanilla_block_tags::BlockTag, vanilla_blocks,
12    vanilla_damage_types, vanilla_entities, vanilla_fluids, vanilla_game_events,
13};
14use steel_utils::{BlockLocalAabb, BlockPos, BlockStateId, Direction, types::UpdateFlags};
15
16use crate::behavior::BLOCK_BEHAVIORS;
17use crate::behavior::block::{
18    BlockBehavior, BlockCollisionContext, EntityFallDamage, EntityFallOnContext, push_entities_up,
19    schedule_water_tick_if_waterlogged,
20};
21use crate::behavior::context::BlockPlaceContext;
22use crate::entity::damage::DamageSource;
23use crate::entity::entities::FallingBlockEntity;
24use crate::entity::projectile::Projectile;
25use crate::fluid::FluidStateExt as _;
26use crate::world::game_event::GameEventContext;
27use crate::world::{ClipHitResult, World};
28use crate::world::{LevelReader, ScheduledTickAccess};
29
30use super::BlockRef;
31
32/// Vanilla `PointedDripstoneBlock` survival and thickness updates.
33///
34/// Survival mirrors vanilla's `isValidPointedDripstonePlacement`: the block
35/// opposite the tip direction must be face-sturdy on the face pointing toward
36/// us, or be another pointed dripstone with the same `vertical_direction`.
37#[block_behavior]
38pub struct PointedDripstoneBlock {
39    block: BlockRef,
40}
41
42const LEVEL_CAULDRON: &IntProperty = &BlockStateProperties::LEVEL_CAULDRON;
43const SPELEOTHEM_THICKNESS: &EnumProperty<SpeleothemThickness> =
44    &BlockStateProperties::SPELEOTHEM_THICKNESS;
45const VERTICAL_DIRECTION: &EnumProperty<Direction> = &BlockStateProperties::VERTICAL_DIRECTION;
46const WATERLOGGED: &BoolProperty = &BlockStateProperties::WATERLOGGED;
47
48impl PointedDripstoneBlock {
49    /// Creates a new pointed dripstone block behavior.
50    #[must_use]
51    pub const fn new(block: BlockRef) -> Self {
52        Self { block }
53    }
54
55    fn fall_damage_for_state(state: BlockStateId, fall_distance: f64) -> Option<EntityFallDamage> {
56        if state.get_value(VERTICAL_DIRECTION) != Direction::Up
57            || state.get_value(SPELEOTHEM_THICKNESS) != SpeleothemThickness::Tip
58        {
59            return None;
60        }
61
62        Some(EntityFallDamage::new(
63            fall_distance + 2.5,
64            2.0,
65            DamageSource::environment(&vanilla_damage_types::STALAGMITE),
66        ))
67    }
68
69    const fn speleothem(&self) -> SpeleothemBlockBehavior {
70        SpeleothemBlockBehavior {
71            block: self.block,
72            kind: SpeleothemKind::PointedDripstone,
73        }
74    }
75}
76
77impl BlockBehavior for PointedDripstoneBlock {
78    fn can_survive(&self, state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
79        self.speleothem().can_survive(state, world, pos)
80    }
81
82    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
83        self.speleothem().state_for_placement(context)
84    }
85
86    fn update_shape(
87        &self,
88        state: BlockStateId,
89        world: &dyn ScheduledTickAccess,
90        pos: BlockPos,
91        direction: Direction,
92        neighbor_pos: BlockPos,
93        neighbor_state: BlockStateId,
94    ) -> BlockStateId {
95        self.speleothem()
96            .update_shape(state, world, pos, direction, neighbor_pos, neighbor_state)
97    }
98
99    fn tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
100        self.speleothem().tick(state, world, pos);
101    }
102
103    fn random_tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
104        self.speleothem().random_tick(state, world, pos);
105    }
106
107    fn fall_on(
108        &self,
109        state: BlockStateId,
110        world: &Arc<World>,
111        pos: BlockPos,
112        context: EntityFallOnContext<'_>,
113    ) -> Option<EntityFallDamage> {
114        Self::fall_damage_for_state(state, context.fall_distance)
115            .or_else(|| self.default_fall_on(state, world, pos, context))
116    }
117
118    fn on_projectile_hit(
119        &self,
120        _state: BlockStateId,
121        world: &Arc<World>,
122        hit: &ClipHitResult,
123        projectile: &dyn Projectile,
124    ) {
125        SpeleothemBlockBehavior::on_projectile_hit(world, hit.block_pos, projectile);
126    }
127}
128
129/// Vanilla `SulfurSpikeBlock` behavior
130#[block_behavior]
131pub struct SulfurSpikeBlock {
132    block: BlockRef,
133}
134
135impl SulfurSpikeBlock {
136    /// Creates a new sulfur spike block
137    #[must_use]
138    pub const fn new(block: BlockRef) -> Self {
139        Self { block }
140    }
141
142    const fn speleothem(&self) -> SpeleothemBlockBehavior {
143        SpeleothemBlockBehavior {
144            block: self.block,
145            kind: SpeleothemKind::Sulfur,
146        }
147    }
148}
149
150impl BlockBehavior for SulfurSpikeBlock {
151    fn can_survive(&self, state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
152        self.speleothem().can_survive(state, world, pos)
153    }
154
155    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
156        self.speleothem().state_for_placement(context)
157    }
158
159    fn update_shape(
160        &self,
161        state: BlockStateId,
162        world: &dyn ScheduledTickAccess,
163        pos: BlockPos,
164        direction: Direction,
165        neighbor_pos: BlockPos,
166        neighbor_state: BlockStateId,
167    ) -> BlockStateId {
168        self.speleothem()
169            .update_shape(state, world, pos, direction, neighbor_pos, neighbor_state)
170    }
171
172    fn tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
173        self.speleothem().tick(state, world, pos);
174    }
175
176    fn random_tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
177        self.speleothem().random_tick(state, world, pos);
178    }
179
180    fn on_projectile_hit(
181        &self,
182        _state: BlockStateId,
183        world: &Arc<World>,
184        hit: &ClipHitResult,
185        projectile: &dyn Projectile,
186    ) {
187        SpeleothemBlockBehavior::on_projectile_hit(world, hit.block_pos, projectile);
188    }
189}
190
191struct SpeleothemBlockBehavior {
192    block: BlockRef,
193    kind: SpeleothemKind,
194}
195
196#[derive(Clone, Copy)]
197enum SpeleothemKind {
198    PointedDripstone,
199    Sulfur,
200}
201
202const GROWTH_PROBABILITY_PER_RANDOM_TICK: f32 = 0.011_377_778;
203const MAX_GROWTH_LENGTH: i32 = 7;
204const MAX_STALAGMITE_SEARCH_RANGE_WHEN_GROWING: i32 = 10;
205const WATER_TRANSFER_PROBABILITY_PER_RANDOM_TICK: f32 = 45.0 / 256.0;
206const LAVA_TRANSFER_PROBABILITY_PER_RANDOM_TICK: f32 = 15.0 / 256.0;
207const MAX_SEARCH_LENGTH_WHEN_CHECKING_DRIP_TYPE: i32 = 11;
208const MAX_SEARCH_LENGTH_BETWEEN_STALACTITE_TIP_AND_CAULDRON: i32 = 11;
209const DRIP_THROUGH_COLUMN_BOXES: &[BlockLocalAabb] =
210    &[BlockLocalAabb::new(0.375, 0.0, 0.375, 0.625, 1.0, 0.625)];
211const REQUIRED_SPACE_TO_DRIP_THROUGH_NON_SOLID_BLOCK: VoxelShape =
212    VoxelShape::from_boxes(DRIP_THROUGH_COLUMN_BOXES);
213
214struct FluidInfo {
215    pos: BlockPos,
216    fluid: FluidRef,
217    source_state: BlockStateId,
218}
219
220impl SpeleothemBlockBehavior {
221    fn projectile_can_break(projectile: &dyn Projectile, world: &World, pos: BlockPos) -> bool {
222        projectile.projectile_may_interact(world, pos)
223            && projectile.may_break(world)
224            && projectile.entity_type() == &vanilla_entities::TRIDENT
225            && projectile.velocity().length() > 0.6
226    }
227
228    fn on_projectile_hit(world: &Arc<World>, pos: BlockPos, projectile: &dyn Projectile) {
229        if Self::projectile_can_break(projectile, world, pos) {
230            world.destroy_block(pos, true);
231        }
232    }
233
234    fn state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
235        let default_tip_direction = context.get_nearest_looking_vertical_direction().opposite();
236        let tip_direction = self.calculate_tip_direction(
237            context.world.as_ref(),
238            context.place_pos(),
239            default_tip_direction,
240        )?;
241        let merge_opposing_tips = !context.is_secondary_use_active();
242        let thickness = self.calculate_thickness(
243            context.world.as_ref(),
244            context.place_pos(),
245            tip_direction,
246            merge_opposing_tips,
247        );
248        let state = self
249            .block
250            .default_state()
251            .set_value(VERTICAL_DIRECTION, tip_direction)
252            .set_value(WATERLOGGED, context.is_water_source());
253
254        Some(Self::with_thickness(state, thickness))
255    }
256
257    fn can_survive(&self, state: BlockStateId, world: &dyn LevelReader, pos: BlockPos) -> bool {
258        let tip_direction = state.get_value(VERTICAL_DIRECTION);
259        let behind_pos = pos.relative(tip_direction.opposite());
260        let behind_state = world.get_block_state(behind_pos);
261
262        world.is_face_sturdy(behind_state, behind_pos, tip_direction)
263            || (Self::is_speleothem_with_direction(behind_state, tip_direction)
264                && behind_state.get_block() == self.block)
265    }
266
267    fn update_shape(
268        &self,
269        state: BlockStateId,
270        world: &dyn ScheduledTickAccess,
271        pos: BlockPos,
272        direction: Direction,
273        _neighbor_pos: BlockPos,
274        _neighbor_state: BlockStateId,
275    ) -> BlockStateId {
276        schedule_water_tick_if_waterlogged(state, world, pos);
277
278        if direction != Direction::Up && direction != Direction::Down {
279            return state;
280        }
281
282        let tip_direction = state.get_value(VERTICAL_DIRECTION);
283        if tip_direction == Direction::Down && world.has_scheduled_block_tick(pos, self.block) {
284            return state;
285        }
286
287        if direction == tip_direction.opposite() && !self.can_survive(state, world, pos) {
288            let delay = if tip_direction == Direction::Down {
289                2
290            } else {
291                1
292            };
293            let _ = world.schedule_block_tick_default(pos, self.block, delay);
294            return state;
295        }
296
297        let merge_opposing_tips = Self::thickness(state) == SpeleothemThickness::TipMerge;
298        let thickness = self.calculate_thickness(world, pos, tip_direction, merge_opposing_tips);
299        Self::with_thickness(state, thickness)
300    }
301
302    fn tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
303        if Self::is_stalagmite(state) && !self.can_survive(state, world.as_ref(), pos) {
304            world.destroy_block(pos, true);
305        } else {
306            Self::spawn_falling_stalactite(state, world, pos);
307        }
308    }
309
310    /// Makes a speleothem block at `pos` (if any), and the other connected blocks directly below it, fall as a [`FallingBlockEntity`].
311    ///
312    /// These entities also deal damage. The further a connected block is below from the block at `pos`, the more damage its
313    /// falling block entity deals.
314    pub fn spawn_falling_stalactite(state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
315        let mut fall_pos = pos;
316        let mut fall_state = state;
317
318        while Self::is_stalactite(fall_state) {
319            let entity = FallingBlockEntity::fall(world, fall_pos, fall_state);
320            if Self::is_tip(fall_state, true) {
321                let size = (1 + pos.y() - fall_pos.y()).max(6);
322                let damage_per_fall_distance = size as f32;
323                entity.set_hurts_entities(damage_per_fall_distance, 40);
324                break;
325            }
326            fall_pos = fall_pos.relative(Direction::Down);
327            fall_state = world.get_block_state(fall_pos);
328        }
329    }
330
331    fn calculate_tip_direction(
332        &self,
333        world: &dyn LevelReader,
334        pos: BlockPos,
335        default_tip_direction: Direction,
336    ) -> Option<Direction> {
337        let default_state = self
338            .block
339            .default_state()
340            .set_value(VERTICAL_DIRECTION, default_tip_direction);
341        if self.can_survive(default_state, world, pos) {
342            return Some(default_tip_direction);
343        }
344
345        let opposite_tip_direction = default_tip_direction.opposite();
346        let opposite_state = self
347            .block
348            .default_state()
349            .set_value(VERTICAL_DIRECTION, opposite_tip_direction);
350        self.can_survive(opposite_state, world, pos)
351            .then_some(opposite_tip_direction)
352    }
353
354    fn calculate_thickness(
355        &self,
356        world: &dyn LevelReader,
357        pos: BlockPos,
358        tip_direction: Direction,
359        merge_opposing_tips: bool,
360    ) -> SpeleothemThickness {
361        let base_direction = tip_direction.opposite();
362        let in_front_state = world.get_block_state(pos.relative(tip_direction));
363        if Self::is_speleothem_with_direction(in_front_state, base_direction)
364            && in_front_state.get_block() == self.block
365        {
366            if merge_opposing_tips
367                || Self::thickness(in_front_state) == SpeleothemThickness::TipMerge
368            {
369                return SpeleothemThickness::TipMerge;
370            }
371            return SpeleothemThickness::Tip;
372        }
373
374        if !Self::is_speleothem_with_direction(in_front_state, tip_direction) {
375            return SpeleothemThickness::Tip;
376        }
377
378        let in_front_thickness = Self::thickness(in_front_state);
379        if matches!(
380            in_front_thickness,
381            SpeleothemThickness::Tip | SpeleothemThickness::TipMerge
382        ) {
383            return SpeleothemThickness::Frustum;
384        }
385
386        let behind_state = world.get_block_state(pos.relative(base_direction));
387        if !Self::is_speleothem_with_direction(behind_state, tip_direction) {
388            return SpeleothemThickness::Base;
389        }
390        SpeleothemThickness::Middle
391    }
392
393    fn is_speleothem_with_direction(state: BlockStateId, tip_direction: Direction) -> bool {
394        state.get_block().has_tag(&BlockTag::SPELEOTHEMS)
395            && state.get_value(VERTICAL_DIRECTION) == tip_direction
396    }
397
398    fn is_stalagmite(state: BlockStateId) -> bool {
399        Self::is_speleothem_with_direction(state, Direction::Up)
400    }
401
402    fn is_stalactite(state: BlockStateId) -> bool {
403        Self::is_speleothem_with_direction(state, Direction::Down)
404    }
405
406    fn is_stalactite_start_pos(
407        &self,
408        state: BlockStateId,
409        world: &dyn LevelReader,
410        pos: BlockPos,
411    ) -> bool {
412        Self::is_stalactite(state) && world.get_block_state(pos.above()).get_block() != self.block
413    }
414
415    fn random_tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
416        let mut rng = rand::rng();
417        if matches!(self.kind, SpeleothemKind::PointedDripstone) {
418            let random_value = rng.random::<f32>();
419            if (random_value <= WATER_TRANSFER_PROBABILITY_PER_RANDOM_TICK
420                || random_value <= LAVA_TRANSFER_PROBABILITY_PER_RANDOM_TICK)
421                && self.is_stalactite_start_pos(state, world.as_ref(), pos)
422            {
423                self.maybe_transfer_fluid(state, world, pos, random_value);
424            }
425        }
426
427        if rng.random::<f32>() < GROWTH_PROBABILITY_PER_RANDOM_TICK
428            && self.is_stalactite_start_pos(state, world.as_ref(), pos)
429        {
430            self.grow_stalactite_or_stalagmite_if_possible(state, world, pos, &mut rng);
431        }
432    }
433
434    fn grow_stalactite_or_stalagmite_if_possible<R: RngExt + ?Sized>(
435        &self,
436        stalactite_start_state: BlockStateId,
437        world: &Arc<World>,
438        stalactite_start_pos: BlockPos,
439        rng: &mut R,
440    ) {
441        if !self.can_grow(world.as_ref(), stalactite_start_pos) {
442            return;
443        }
444
445        let Some(stalactite_tip_pos) = self.find_tip(
446            stalactite_start_state,
447            world.as_ref(),
448            stalactite_start_pos,
449            MAX_GROWTH_LENGTH,
450            false,
451        ) else {
452            return;
453        };
454
455        let stalactite_tip_state = world.get_block_state(stalactite_tip_pos);
456        if !Self::is_free_hanging_stalactite(stalactite_tip_state)
457            || !self.can_tip_grow(stalactite_tip_state, world, stalactite_tip_pos)
458        {
459            return;
460        }
461
462        if rng.random::<bool>() {
463            self.grow(world, stalactite_tip_pos, Direction::Down);
464        } else {
465            self.grow_stalagmite_below(world, stalactite_tip_pos);
466        }
467    }
468
469    fn can_grow(&self, world: &dyn LevelReader, pos: BlockPos) -> bool {
470        if world.get_block_state(pos.above()).get_block() != self.block_to_grow_on() {
471            return false;
472        }
473
474        if !matches!(self.kind, SpeleothemKind::PointedDripstone) {
475            return true;
476        }
477
478        let fluid_state = world.get_block_state(pos.above_n(2)).get_fluid_state();
479        fluid_state.is_water() && fluid_state.is_source()
480    }
481
482    fn block_to_grow_on(&self) -> BlockRef {
483        match self.kind {
484            SpeleothemKind::PointedDripstone => &vanilla_blocks::DRIPSTONE_BLOCK,
485            SpeleothemKind::Sulfur => &vanilla_blocks::SULFUR,
486        }
487    }
488
489    fn find_tip(
490        &self,
491        speleothem_state: BlockStateId,
492        world: &dyn LevelReader,
493        speleothem_pos: BlockPos,
494        max_search_length: i32,
495        include_merged_tip: bool,
496    ) -> Option<BlockPos> {
497        if Self::is_tip(speleothem_state, include_merged_tip) {
498            return Some(speleothem_pos);
499        }
500
501        let search_direction = speleothem_state.get_value(VERTICAL_DIRECTION);
502        let mut current_pos = speleothem_pos;
503        for _ in 1..max_search_length {
504            current_pos = current_pos.relative(search_direction);
505            let state = world.get_block_state(current_pos);
506            if Self::is_tip(state, include_merged_tip) {
507                return Some(current_pos);
508            }
509
510            if world.is_outside_build_height(current_pos.y())
511                || state.get_block() != self.block
512                || state.get_value(VERTICAL_DIRECTION) != search_direction
513            {
514                return None;
515            }
516        }
517
518        None
519    }
520
521    fn is_tip(state: BlockStateId, include_merged_tip: bool) -> bool {
522        if !state.get_block().has_tag(&BlockTag::SPELEOTHEMS) {
523            return false;
524        }
525
526        let thickness = Self::thickness(state);
527        thickness == SpeleothemThickness::Tip
528            || (include_merged_tip && thickness == SpeleothemThickness::TipMerge)
529    }
530
531    fn is_free_hanging_stalactite(state: BlockStateId) -> bool {
532        Self::is_stalactite(state)
533            && Self::thickness(state) == SpeleothemThickness::Tip
534            && !state.get_value(WATERLOGGED)
535    }
536
537    fn can_tip_grow(&self, tip_state: BlockStateId, world: &Arc<World>, tip_pos: BlockPos) -> bool {
538        let grow_direction = tip_state.get_value(VERTICAL_DIRECTION);
539        let grow_pos = tip_pos.relative(grow_direction);
540        let state_at_grow_pos = world.get_block_state(grow_pos);
541        if state_at_grow_pos.has_fluid() {
542            return false;
543        }
544
545        state_at_grow_pos.is_air()
546            || self.is_unmerged_tip_with_direction(state_at_grow_pos, grow_direction.opposite())
547    }
548
549    fn is_unmerged_tip_with_direction(
550        &self,
551        state: BlockStateId,
552        tip_direction: Direction,
553    ) -> bool {
554        Self::is_tip(state, false)
555            && state.get_block() == self.block
556            && state.get_value(VERTICAL_DIRECTION) == tip_direction
557    }
558
559    fn grow(&self, world: &Arc<World>, grow_from_pos: BlockPos, grow_to_direction: Direction) {
560        let target_pos = grow_from_pos.relative(grow_to_direction);
561        let existing_state_at_target_pos = world.get_block_state(target_pos);
562        if self.is_unmerged_tip_with_direction(
563            existing_state_at_target_pos,
564            grow_to_direction.opposite(),
565        ) {
566            self.create_merged_tips(existing_state_at_target_pos, world, target_pos);
567            return;
568        }
569
570        if existing_state_at_target_pos.is_air()
571            || existing_state_at_target_pos.get_block() == &vanilla_blocks::WATER
572        {
573            self.create_speleothem(
574                world,
575                target_pos,
576                grow_to_direction,
577                SpeleothemThickness::Tip,
578            );
579        }
580    }
581
582    fn create_speleothem(
583        &self,
584        world: &Arc<World>,
585        pos: BlockPos,
586        direction: Direction,
587        thickness: SpeleothemThickness,
588    ) {
589        let waterlogged = world.get_block_state(pos).get_fluid_state().is_water();
590        let state = self
591            .block
592            .default_state()
593            .set_value(VERTICAL_DIRECTION, direction)
594            .set_value(SPELEOTHEM_THICKNESS, thickness)
595            .set_value(WATERLOGGED, waterlogged);
596        world.set_block(pos, state, UpdateFlags::UPDATE_ALL);
597    }
598
599    fn create_merged_tips(&self, tip_state: BlockStateId, world: &Arc<World>, tip_pos: BlockPos) {
600        let (stalactite_pos, stalagmite_pos) =
601            if tip_state.get_value(VERTICAL_DIRECTION) == Direction::Up {
602                (tip_pos.above(), tip_pos)
603            } else {
604                (tip_pos, tip_pos.below())
605            };
606
607        self.create_speleothem(
608            world,
609            stalactite_pos,
610            Direction::Down,
611            SpeleothemThickness::TipMerge,
612        );
613        self.create_speleothem(
614            world,
615            stalagmite_pos,
616            Direction::Up,
617            SpeleothemThickness::TipMerge,
618        );
619    }
620
621    fn grow_stalagmite_below(&self, world: &Arc<World>, pos_above_stalagmite: BlockPos) {
622        let mut pos = pos_above_stalagmite;
623        for _ in 0..MAX_STALAGMITE_SEARCH_RANGE_WHEN_GROWING {
624            pos = pos.below();
625            let state = world.get_block_state(pos);
626            if state.has_fluid() {
627                return;
628            }
629
630            if self.is_unmerged_tip_with_direction(state, Direction::Up)
631                && self.can_tip_grow(state, world, pos)
632            {
633                self.grow(world, pos, Direction::Up);
634                return;
635            }
636
637            let placement_state = self
638                .block
639                .default_state()
640                .set_value(VERTICAL_DIRECTION, Direction::Up);
641            if self.can_survive(placement_state, world.as_ref(), pos)
642                && !Self::is_water_at(world, pos.below())
643            {
644                self.grow(world, pos.below(), Direction::Up);
645                return;
646            }
647
648            if self.blocks_stalagmite_scan(world.as_ref(), pos, state) {
649                return;
650            }
651        }
652    }
653
654    fn is_water_at(world: &Arc<World>, pos: BlockPos) -> bool {
655        world.get_block_state(pos).get_fluid_state().is_water()
656    }
657
658    fn blocks_stalagmite_scan(
659        &self,
660        world: &dyn LevelReader,
661        pos: BlockPos,
662        state: BlockStateId,
663    ) -> bool {
664        match self.kind {
665            SpeleothemKind::PointedDripstone => !Self::can_drip_through(world, pos, state),
666            SpeleothemKind::Sulfur => false,
667        }
668    }
669
670    fn can_drip_through(world: &dyn LevelReader, pos: BlockPos, state: BlockStateId) -> bool {
671        if state.is_air() {
672            return true;
673        }
674
675        if state.is_solid_render() || state.has_fluid() {
676            return false;
677        }
678
679        let collision_shape = BLOCK_BEHAVIORS
680            .get_behavior(state.get_block())
681            .get_collision_shape(state, world, pos, BlockCollisionContext::empty());
682        !join_is_not_empty(
683            REQUIRED_SPACE_TO_DRIP_THROUGH_NON_SOLID_BLOCK,
684            collision_shape,
685            BooleanOp::And,
686        )
687    }
688
689    fn maybe_transfer_fluid(
690        &self,
691        state: BlockStateId,
692        world: &Arc<World>,
693        pos: BlockPos,
694        random_value: f32,
695    ) {
696        let Some(fluid_info) = self.get_fluid_above_stalactite(world, pos, state) else {
697            return;
698        };
699
700        let is_water = fluid_info.fluid == &vanilla_fluids::WATER;
701        let is_lava = fluid_info.fluid == &vanilla_fluids::LAVA;
702
703        let transfer_probability = if is_water {
704            WATER_TRANSFER_PROBABILITY_PER_RANDOM_TICK
705        } else if is_lava {
706            LAVA_TRANSFER_PROBABILITY_PER_RANDOM_TICK
707        } else {
708            return;
709        };
710
711        if random_value >= transfer_probability {
712            return;
713        }
714
715        let Some(tip_pos) = self.find_tip(
716            state,
717            world.as_ref(),
718            pos,
719            MAX_SEARCH_LENGTH_WHEN_CHECKING_DRIP_TYPE,
720            false,
721        ) else {
722            return;
723        };
724
725        if fluid_info.source_state.get_block() == &vanilla_blocks::MUD && is_water {
726            if !world.dimension_type.water_evaporates {
727                let clay_state = vanilla_blocks::CLAY.default_state();
728                let _ =
729                    push_entities_up(fluid_info.source_state, clay_state, world, fluid_info.pos);
730                world.set_block(fluid_info.pos, clay_state, UpdateFlags::UPDATE_ALL);
731                world.game_event(
732                    &vanilla_game_events::BLOCK_CHANGE,
733                    fluid_info.pos,
734                    &GameEventContext::new(None, Some(clay_state)),
735                );
736                world.level_event(level_events::DRIPSTONE_DRIP, tip_pos, 0, None);
737            }
738            return;
739        }
740
741        let Some(cauldron_pos) =
742            Self::find_fillable_cauldron_below_stalactite_tip(world, tip_pos, fluid_info.fluid)
743        else {
744            return;
745        };
746
747        world.level_event(level_events::DRIPSTONE_DRIP, tip_pos, 0, None);
748        let fall_distance = tip_pos.y() - cauldron_pos.y();
749        let delay = 50 + fall_distance;
750        let cauldron_state = world.get_block_state(cauldron_pos);
751        let _ = world.schedule_block_tick_default(cauldron_pos, cauldron_state.get_block(), delay);
752    }
753
754    fn get_fluid_above_stalactite(
755        &self,
756        world: &Arc<World>,
757        stalactite_pos: BlockPos,
758        stalactite_state: BlockStateId,
759    ) -> Option<FluidInfo> {
760        if !Self::is_stalactite(stalactite_state) {
761            return None;
762        }
763
764        let root_pos = self.find_root_block(
765            world,
766            stalactite_pos,
767            stalactite_state,
768            MAX_SEARCH_LENGTH_WHEN_CHECKING_DRIP_TYPE,
769        )?;
770        let above_pos = root_pos.above();
771        let above_state = world.get_block_state(above_pos);
772
773        let fluid = if above_state.get_block() == &vanilla_blocks::MUD
774            && !world.dimension_type.water_evaporates
775        {
776            &vanilla_fluids::WATER
777        } else {
778            above_state.get_fluid_state().fluid_id
779        };
780
781        Some(FluidInfo {
782            pos: above_pos,
783            fluid,
784            source_state: above_state,
785        })
786    }
787
788    fn find_root_block(
789        &self,
790        world: &Arc<World>,
791        pos: BlockPos,
792        dripstone_state: BlockStateId,
793        max_search_length: i32,
794    ) -> Option<BlockPos> {
795        let tip_direction = dripstone_state.get_value(VERTICAL_DIRECTION);
796        let search_direction = tip_direction.opposite();
797        let mut current_pos = pos;
798        for _ in 1..max_search_length {
799            current_pos = current_pos.relative(search_direction);
800            let state = world.get_block_state(current_pos);
801            if state.get_block() != self.block {
802                return Some(current_pos);
803            }
804            if state.get_value(VERTICAL_DIRECTION) != tip_direction {
805                return None;
806            }
807            if world.is_outside_build_height(current_pos.y()) {
808                return None;
809            }
810        }
811        None
812    }
813
814    fn find_fillable_cauldron_below_stalactite_tip(
815        world: &Arc<World>,
816        tip_pos: BlockPos,
817        fluid: FluidRef,
818    ) -> Option<BlockPos> {
819        let mut current_pos = tip_pos;
820        for _ in 1..MAX_SEARCH_LENGTH_BETWEEN_STALACTITE_TIP_AND_CAULDRON {
821            current_pos = current_pos.below();
822            let state = world.get_block_state(current_pos);
823
824            if Self::is_fillable_cauldron(state, fluid) {
825                return Some(current_pos);
826            }
827
828            if !Self::can_drip_through(world.as_ref(), current_pos, state) {
829                return None;
830            }
831
832            if world.is_outside_build_height(current_pos.y()) {
833                return None;
834            }
835        }
836        None
837    }
838
839    fn is_fillable_cauldron(state: BlockStateId, fluid: FluidRef) -> bool {
840        let block = state.get_block();
841        if fluid == &vanilla_fluids::WATER {
842            block == &vanilla_blocks::CAULDRON
843                || (block == &vanilla_blocks::WATER_CAULDRON
844                    && state.get_value(LEVEL_CAULDRON) < LEVEL_CAULDRON.max)
845        } else if fluid == &vanilla_fluids::LAVA {
846            block == &vanilla_blocks::CAULDRON
847        } else {
848            false
849        }
850    }
851
852    fn thickness(state: BlockStateId) -> SpeleothemThickness {
853        state.get_value(SPELEOTHEM_THICKNESS)
854    }
855
856    fn with_thickness(state: BlockStateId, thickness: SpeleothemThickness) -> BlockStateId {
857        state.set_value(SPELEOTHEM_THICKNESS, thickness)
858    }
859}
860
861/// Searches upward from cauldron position to find an overhanging stalactite tip.
862#[must_use]
863pub fn find_stalactite_tip_above_cauldron(
864    world: &dyn LevelReader,
865    cauldron_pos: BlockPos,
866) -> Option<BlockPos> {
867    let mut current_pos = cauldron_pos;
868    for _ in 1..MAX_SEARCH_LENGTH_BETWEEN_STALACTITE_TIP_AND_CAULDRON {
869        current_pos = current_pos.above();
870        let state = world.get_block_state(current_pos);
871
872        if SpeleothemBlockBehavior::is_free_hanging_stalactite(state) {
873            return Some(current_pos);
874        }
875
876        if !SpeleothemBlockBehavior::can_drip_through(world, current_pos, state) {
877            return None;
878        }
879
880        if world.is_outside_build_height(current_pos.y()) {
881            return None;
882        }
883    }
884    None
885}
886
887/// Determines which fluid (water or lava) a stalactite would fill a cauldron with.
888#[must_use]
889pub fn get_cauldron_fill_fluid_type(
890    world: &Arc<World>,
891    stalactite_pos: BlockPos,
892) -> Option<FluidRef> {
893    let state = world.get_block_state(stalactite_pos);
894    let dripstone = SpeleothemBlockBehavior {
895        block: &vanilla_blocks::POINTED_DRIPSTONE,
896        kind: SpeleothemKind::PointedDripstone,
897    };
898    let fluid = dripstone
899        .get_fluid_above_stalactite(world, stalactite_pos, state)?
900        .fluid;
901
902    if fluid == &vanilla_fluids::WATER || fluid == &vanilla_fluids::LAVA {
903        Some(fluid)
904    } else {
905        None
906    }
907}
908
909#[cfg(test)]
910mod tests {
911    use crate::test_support::advance_test_game_time_to;
912    use std::sync::Weak;
913
914    use super::*;
915    use glam::DVec3;
916    use steel_utils::ChunkPos;
917
918    use steel_registry::{
919        entity_type::EntityTypeRef, init_vanilla_registry, vanilla_blocks, vanilla_damage_types,
920    };
921
922    use crate::{
923        behavior::init_behaviors,
924        entity::{Entity, EntityBase, projectile::ProjectileBase},
925        test_support::{fresh_test_world, insert_ready_full_chunk, test_world},
926    };
927
928    struct TestProjectile {
929        base: EntityBase,
930        projectile_base: ProjectileBase,
931        entity_type: EntityTypeRef,
932    }
933
934    impl TestProjectile {
935        fn new(entity_type: EntityTypeRef, velocity: DVec3) -> Self {
936            let projectile = Self {
937                base: EntityBase::new(1, DVec3::ZERO, entity_type.dimensions, Weak::new()),
938                projectile_base: ProjectileBase::new(),
939                entity_type,
940            };
941            projectile.set_velocity(velocity);
942            projectile
943        }
944    }
945
946    crate::entity::impl_test_downcast_type!(TestProjectile);
947
948    impl Entity for TestProjectile {
949        fn base(&self) -> &EntityBase {
950            &self.base
951        }
952
953        fn entity_type(&self) -> EntityTypeRef {
954            self.entity_type
955        }
956    }
957
958    impl Projectile for TestProjectile {
959        fn projectile_base(&self) -> &ProjectileBase {
960            &self.projectile_base
961        }
962    }
963
964    fn pointed_dripstone_state(
965        direction: Direction,
966        thickness: SpeleothemThickness,
967    ) -> BlockStateId {
968        init_vanilla_registry();
969        vanilla_blocks::POINTED_DRIPSTONE
970            .default_state()
971            .set_value(VERTICAL_DIRECTION, direction)
972            .set_value(SPELEOTHEM_THICKNESS, thickness)
973    }
974
975    #[test]
976    fn upward_tip_uses_stalagmite_fall_damage() {
977        let state = pointed_dripstone_state(Direction::Up, SpeleothemThickness::Tip);
978        let fall_damage = PointedDripstoneBlock::fall_damage_for_state(state, 4.0)
979            .expect("upward tip should request stalagmite damage");
980
981        assert!((fall_damage.fall_distance - 6.5).abs() < f64::EPSILON);
982        assert!((fall_damage.damage_modifier - 2.0).abs() < f32::EPSILON);
983        assert_eq!(
984            &fall_damage.source.damage_type.key,
985            &vanilla_damage_types::STALAGMITE.key,
986        );
987    }
988
989    #[test]
990    fn non_tip_uses_default_fall_damage() {
991        let state = pointed_dripstone_state(Direction::Up, SpeleothemThickness::Frustum);
992
993        assert!(PointedDripstoneBlock::fall_damage_for_state(state, 4.0).is_none());
994    }
995
996    #[test]
997    fn downward_tip_uses_default_fall_damage() {
998        let state = pointed_dripstone_state(Direction::Down, SpeleothemThickness::Tip);
999
1000        assert!(PointedDripstoneBlock::fall_damage_for_state(state, 4.0).is_none());
1001    }
1002
1003    #[test]
1004    fn only_fast_tridents_break_speleothems() {
1005        init_vanilla_registry();
1006        let pos = BlockPos::ZERO;
1007        let fast_trident = TestProjectile::new(&vanilla_entities::TRIDENT, DVec3::X * 0.61);
1008        let threshold_trident = TestProjectile::new(&vanilla_entities::TRIDENT, DVec3::X * 0.6);
1009        let firework = TestProjectile::new(&vanilla_entities::FIREWORK_ROCKET, DVec3::X);
1010
1011        assert!(SpeleothemBlockBehavior::projectile_can_break(
1012            &fast_trident,
1013            test_world(),
1014            pos,
1015        ));
1016        assert!(!SpeleothemBlockBehavior::projectile_can_break(
1017            &threshold_trident,
1018            test_world(),
1019            pos,
1020        ));
1021        assert!(!SpeleothemBlockBehavior::projectile_can_break(
1022            &firework,
1023            test_world(),
1024            pos,
1025        ));
1026    }
1027
1028    fn stalactite_setup(
1029        world: &Arc<World>,
1030        pos: BlockPos,
1031    ) -> (SpeleothemBlockBehavior, BlockStateId) {
1032        let dripstone = vanilla_blocks::POINTED_DRIPSTONE
1033            .default_state()
1034            .set_value(VERTICAL_DIRECTION, Direction::Down)
1035            .set_value(SPELEOTHEM_THICKNESS, SpeleothemThickness::Tip);
1036        let root = vanilla_blocks::DRIPSTONE_BLOCK.default_state();
1037        assert!(world.set_block(pos, dripstone, UpdateFlags::UPDATE_NONE));
1038        assert!(world.set_block(pos.above(), root, UpdateFlags::UPDATE_NONE));
1039        let behavior = SpeleothemBlockBehavior {
1040            block: &vanilla_blocks::POINTED_DRIPSTONE,
1041            kind: SpeleothemKind::PointedDripstone,
1042        };
1043        (behavior, dripstone)
1044    }
1045
1046    #[test]
1047    fn stalactite_drip_converts_mud_to_clay() {
1048        init_vanilla_registry();
1049        init_behaviors();
1050        let world = fresh_test_world("mud_to_clay");
1051        let pos = BlockPos::new(8, 64, 8);
1052        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
1053
1054        let mud = vanilla_blocks::MUD.default_state();
1055        assert!(world.set_block(pos.above_n(2), mud, UpdateFlags::UPDATE_NONE));
1056
1057        let (behavior, dripstone) = stalactite_setup(&world, pos);
1058        behavior.maybe_transfer_fluid(dripstone, &world, pos, 0.0);
1059
1060        assert_eq!(
1061            world.get_block_state(pos.above_n(2)).get_block(),
1062            &vanilla_blocks::CLAY,
1063        );
1064    }
1065
1066    #[test]
1067    fn stalactite_drip_fills_empty_cauldron_with_water() {
1068        init_vanilla_registry();
1069        init_behaviors();
1070        let world = fresh_test_world("water_cauldron_fill");
1071        let pos = BlockPos::new(8, 64, 8);
1072        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
1073
1074        let source_water = vanilla_blocks::WATER.default_state();
1075        assert!(world.set_block(pos.above_n(2), source_water, UpdateFlags::UPDATE_NONE));
1076        assert!(world.set_block(
1077            pos.below(),
1078            vanilla_blocks::CAULDRON.default_state(),
1079            UpdateFlags::UPDATE_NONE,
1080        ));
1081
1082        let (behavior, dripstone) = stalactite_setup(&world, pos);
1083        behavior.maybe_transfer_fluid(dripstone, &world, pos, 0.0);
1084
1085        advance_test_game_time_to(&world, 51);
1086        world.chunk_map.tick_game(&world, 51, 0, true);
1087
1088        let cauldron_state = world.get_block_state(pos.below());
1089        assert_eq!(cauldron_state.get_block(), &vanilla_blocks::WATER_CAULDRON);
1090        assert_eq!(cauldron_state.get_value(LEVEL_CAULDRON), 1,);
1091    }
1092
1093    #[test]
1094    fn stalactite_drip_increments_layered_water_cauldron() {
1095        init_vanilla_registry();
1096        init_behaviors();
1097        let world = fresh_test_world("water_cauldron_inc");
1098        let pos = BlockPos::new(8, 64, 8);
1099        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
1100
1101        let source_water = vanilla_blocks::WATER.default_state();
1102        assert!(world.set_block(pos.above_n(2), source_water, UpdateFlags::UPDATE_NONE));
1103        let water_cauldron = vanilla_blocks::WATER_CAULDRON
1104            .default_state()
1105            .set_value(LEVEL_CAULDRON, 1);
1106        assert!(world.set_block(pos.below(), water_cauldron, UpdateFlags::UPDATE_NONE));
1107
1108        let (behavior, dripstone) = stalactite_setup(&world, pos);
1109        behavior.maybe_transfer_fluid(dripstone, &world, pos, 0.0);
1110
1111        advance_test_game_time_to(&world, 51);
1112        world.chunk_map.tick_game(&world, 51, 0, true);
1113
1114        let cauldron_state = world.get_block_state(pos.below());
1115        assert_eq!(cauldron_state.get_block(), &vanilla_blocks::WATER_CAULDRON);
1116        assert_eq!(cauldron_state.get_value(LEVEL_CAULDRON), 2,);
1117    }
1118
1119    #[test]
1120    fn stalactite_drip_does_not_overflow_full_water_cauldron() {
1121        init_vanilla_registry();
1122        init_behaviors();
1123        let world = fresh_test_world("water_cauldron_full");
1124        let pos = BlockPos::new(8, 64, 8);
1125        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
1126
1127        let source_water = vanilla_blocks::WATER.default_state();
1128        assert!(world.set_block(pos.above_n(2), source_water, UpdateFlags::UPDATE_NONE));
1129        let full_cauldron = vanilla_blocks::WATER_CAULDRON
1130            .default_state()
1131            .set_value(LEVEL_CAULDRON, 3);
1132        assert!(world.set_block(pos.below(), full_cauldron, UpdateFlags::UPDATE_NONE));
1133
1134        let (behavior, dripstone) = stalactite_setup(&world, pos);
1135        behavior.maybe_transfer_fluid(dripstone, &world, pos, 0.0);
1136
1137        let cauldron_state = world.get_block_state(pos.below());
1138        assert_eq!(cauldron_state.get_block(), &vanilla_blocks::WATER_CAULDRON);
1139        assert_eq!(cauldron_state.get_value(LEVEL_CAULDRON), 3,);
1140    }
1141
1142    #[test]
1143    fn stalactite_drip_fills_empty_cauldron_with_lava() {
1144        init_vanilla_registry();
1145        init_behaviors();
1146        let world = fresh_test_world("lava_cauldron_fill");
1147        let pos = BlockPos::new(8, 64, 8);
1148        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
1149
1150        let source_lava = vanilla_blocks::LAVA.default_state();
1151        assert!(world.set_block(pos.above_n(2), source_lava, UpdateFlags::UPDATE_NONE));
1152        assert!(world.set_block(
1153            pos.below(),
1154            vanilla_blocks::CAULDRON.default_state(),
1155            UpdateFlags::UPDATE_NONE,
1156        ));
1157
1158        let (behavior, dripstone) = stalactite_setup(&world, pos);
1159        behavior.maybe_transfer_fluid(dripstone, &world, pos, 0.0);
1160
1161        advance_test_game_time_to(&world, 51);
1162        world.chunk_map.tick_game(&world, 51, 0, true);
1163
1164        assert_eq!(
1165            world.get_block_state(pos.below()).get_block(),
1166            &vanilla_blocks::LAVA_CAULDRON,
1167        );
1168    }
1169
1170    #[test]
1171    fn stalactite_drip_does_not_fill_lava_cauldron() {
1172        init_vanilla_registry();
1173        init_behaviors();
1174        let world = fresh_test_world("lava_cauldron_skip");
1175        let pos = BlockPos::new(8, 64, 8);
1176        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
1177
1178        let source_lava = vanilla_blocks::LAVA.default_state();
1179        assert!(world.set_block(pos.above_n(2), source_lava, UpdateFlags::UPDATE_NONE));
1180        assert!(world.set_block(
1181            pos.below(),
1182            vanilla_blocks::LAVA_CAULDRON.default_state(),
1183            UpdateFlags::UPDATE_NONE,
1184        ));
1185
1186        let (behavior, dripstone) = stalactite_setup(&world, pos);
1187        behavior.maybe_transfer_fluid(dripstone, &world, pos, 0.0);
1188
1189        assert_eq!(
1190            world.get_block_state(pos.below()).get_block(),
1191            &vanilla_blocks::LAVA_CAULDRON,
1192        );
1193    }
1194
1195    #[test]
1196    fn stalactite_drip_skips_drip_when_random_value_exceeds_water_probability() {
1197        init_vanilla_registry();
1198        init_behaviors();
1199        let world = fresh_test_world("high_random_water");
1200        let pos = BlockPos::new(8, 64, 8);
1201        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
1202
1203        let source_water = vanilla_blocks::WATER.default_state();
1204        assert!(world.set_block(pos.above_n(2), source_water, UpdateFlags::UPDATE_NONE));
1205        assert!(world.set_block(
1206            pos.below(),
1207            vanilla_blocks::CAULDRON.default_state(),
1208            UpdateFlags::UPDATE_NONE,
1209        ));
1210
1211        let (behavior, dripstone) = stalactite_setup(&world, pos);
1212        behavior.maybe_transfer_fluid(
1213            dripstone,
1214            &world,
1215            pos,
1216            WATER_TRANSFER_PROBABILITY_PER_RANDOM_TICK + 0.01,
1217        );
1218
1219        assert_eq!(
1220            world.get_block_state(pos.below()).get_block(),
1221            &vanilla_blocks::CAULDRON,
1222        );
1223    }
1224
1225    #[test]
1226    fn stalactite_drip_skips_drip_when_random_value_exceeds_lava_probability() {
1227        init_vanilla_registry();
1228        init_behaviors();
1229        let world = fresh_test_world("high_random_lava");
1230        let pos = BlockPos::new(8, 64, 8);
1231        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
1232
1233        let source_lava = vanilla_blocks::LAVA.default_state();
1234        assert!(world.set_block(pos.above_n(2), source_lava, UpdateFlags::UPDATE_NONE));
1235        assert!(world.set_block(
1236            pos.below(),
1237            vanilla_blocks::CAULDRON.default_state(),
1238            UpdateFlags::UPDATE_NONE,
1239        ));
1240
1241        let (behavior, dripstone) = stalactite_setup(&world, pos);
1242        behavior.maybe_transfer_fluid(
1243            dripstone,
1244            &world,
1245            pos,
1246            LAVA_TRANSFER_PROBABILITY_PER_RANDOM_TICK + 0.01,
1247        );
1248
1249        assert_eq!(
1250            world.get_block_state(pos.below()).get_block(),
1251            &vanilla_blocks::CAULDRON,
1252        );
1253    }
1254}