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