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#[block_behavior]
35pub struct PointedDripstoneBlock {
36 block: BlockRef,
37}
38
39impl PointedDripstoneBlock {
40 #[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#[block_behavior]
123pub struct SulfurSpikeBlock {
124 block: BlockRef,
125}
126
127impl SulfurSpikeBlock {
128 #[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#[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#[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}