Skip to main content

steel_core/behavior/blocks/fluid/
liquid_block.rs

1//! Liquid block behavior (water, lava).
2//!
3//! Based on vanilla's LiquidBlock.java.
4use std::sync::Arc;
5
6use steel_macros::block_behavior;
7use steel_registry::REGISTRY;
8use steel_registry::blocks::BlockRef;
9use steel_registry::blocks::block_state_ext::BlockStateExt;
10use steel_registry::blocks::properties::{BlockStateProperties, Direction, IntProperty};
11use steel_registry::fluid::FluidRef;
12use steel_registry::item_stack::ItemStack;
13use steel_registry::vanilla_block_tags::BlockTag;
14use steel_registry::vanilla_blocks;
15use steel_registry::vanilla_fluid_tags::FluidTag;
16use steel_utils::BlockPos;
17use steel_utils::BlockStateId;
18use steel_utils::types::UpdateFlags;
19
20use steel_registry::level_events;
21use steel_registry::sound_events;
22use steel_registry::vanilla_items;
23
24use crate::behavior::FLUID_BEHAVIORS;
25use crate::behavior::block::{BlockBehavior, PickupResult};
26use crate::behavior::context::BlockPlaceContext;
27use crate::entity::ai::path::PathComputationType;
28use crate::fluid::{FluidStateExt, is_lava_fluid, is_water_fluid};
29use crate::player::Player;
30use crate::world::{ConditionalBlockSetResult, ScheduledTickAccess, World};
31
32use super::BubbleColumnBlock;
33
34/// Behavior for liquid blocks (water and lava).
35///
36/// Liquid blocks have a LEVEL property (0-15) that determines the fluid state:
37/// - LEVEL 0 = source block (full fluid)
38/// - LEVEL 1-7 = flowing fluid with decreasing height
39/// - LEVEL 8-15 = falling fluid
40#[block_behavior]
41pub struct LiquidBlock {
42    block: BlockRef,
43    #[json_arg(vanilla_fluids, ref)]
44    fluid: FluidRef,
45}
46
47const LEVEL: &IntProperty = &BlockStateProperties::LEVEL;
48
49impl LiquidBlock {
50    /// Creates a new liquid block behavior.
51    #[must_use]
52    pub const fn new(block: BlockRef, fluid: FluidRef) -> Self {
53        Self { block, fluid }
54    }
55
56    /// Checks if this liquid should spread and handles lava-water interactions.
57    /// Based on vanilla's `LiquidBlock.shouldSpreadLiquid()`.
58    ///
59    /// Returns `true` if the liquid should spread (schedule tick),
60    /// Returns `false` if the liquid was converted to a block (obsidian/cobblestone/basalt).
61    fn should_spread_liquid(&self, world: &Arc<World>, pos: BlockPos) -> bool {
62        // Only lava has special interactions with water and blue ice
63        if !is_lava_fluid(self.fluid) {
64            return true;
65        }
66        // Check if there's soul soil below (for basalt generation)
67        let below_pos = pos.offset(0, -1, 0);
68        let below_state = world.get_block_state(below_pos);
69        let has_soul_soil_below = below_state.get_block() == &vanilla_blocks::SOUL_SOIL;
70
71        // Get fluid state to check if this is a source
72        let fluid_state = world.get_block_state(pos).get_fluid_state();
73
74        for direction in Direction::FLOW_NEIGHBOR_CHECK {
75            let neighbor_pos = direction.relative(pos);
76            let neighbor_fluid = world.get_block_state(neighbor_pos).get_fluid_state();
77
78            // Check for water (including flowing_water and waterlogged blocks)
79            // Using fluid tag check to support modded fluids registered in the water tag
80            if neighbor_fluid.is_water() {
81                // Lava + Water = Obsidian (if source) or Cobblestone (if flowing)
82                let new_block = if fluid_state.is_source() {
83                    &vanilla_blocks::OBSIDIAN
84                } else {
85                    &vanilla_blocks::COBBLESTONE
86                };
87
88                let new_state = REGISTRY.blocks.get_default_state_id(new_block);
89                world.set_block(pos, new_state, UpdateFlags::UPDATE_ALL);
90                world.level_event(level_events::LAVA_FIZZ, pos, 0, None);
91                return false; // Don't schedule fluid tick - block was converted
92            }
93
94            // Check for basalt generation: soul soil below + blue ice adjacent
95            if has_soul_soil_below {
96                let neighbor_state = world.get_block_state(neighbor_pos);
97                if neighbor_state.get_block() == &vanilla_blocks::BLUE_ICE {
98                    let new_state = REGISTRY
99                        .blocks
100                        .get_default_state_id(&vanilla_blocks::BASALT);
101                    world.set_block(pos, new_state, UpdateFlags::UPDATE_ALL);
102                    world.level_event(level_events::LAVA_FIZZ, pos, 0, None);
103                    return false; // Don't schedule fluid tick - block was converted
104                }
105            }
106        }
107
108        true // No interaction occurred, proceed with normal fluid tick
109    }
110
111    fn should_bubble_column_occupy(state: BlockStateId) -> bool {
112        let fluid_state = state.get_fluid_state();
113        fluid_state
114            .fluid_id
115            .has_tag(&FluidTag::BUBBLE_COLUMN_CAN_OCCUPY)
116            && fluid_state.is_source()
117            && fluid_state.is_full()
118    }
119
120    fn try_schedule_bubble_block_column(
121        &self,
122        ticks: &dyn ScheduledTickAccess,
123        pos: BlockPos,
124        state_below: BlockStateId,
125    ) {
126        let block_below = state_below.get_block();
127        if block_below.has_tag(&BlockTag::ENABLES_BUBBLE_COLUMN_DRAG_DOWN)
128            || block_below.has_tag(&BlockTag::ENABLES_BUBBLE_COLUMN_PUSH_UP)
129        {
130            let _ = ticks.schedule_block_tick_default(pos, self.block, 20);
131        }
132    }
133}
134
135impl BlockBehavior for LiquidBlock {
136    fn is_liquid_block(&self) -> bool {
137        true
138    }
139
140    fn get_state_for_placement(&self, _context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
141        Some(self.block.default_state())
142    }
143
144    fn is_pathfindable(
145        &self,
146        _state: BlockStateId,
147        _computation_type: PathComputationType,
148    ) -> bool {
149        !is_lava_fluid(self.fluid)
150    }
151
152    /// Called when the block is placed.
153    fn on_place(
154        &self,
155        state: BlockStateId,
156        world: &Arc<World>,
157        pos: BlockPos,
158        _old_state: BlockStateId,
159        _moved_by_piston: bool,
160    ) {
161        if self.should_spread_liquid(world, pos) {
162            let fluid = state.get_fluid_state().fluid_id;
163            let delay = FLUID_BEHAVIORS.get_behavior(fluid).tick_delay(world);
164            world.schedule_fluid_tick_default(pos, fluid, delay);
165        }
166
167        if Self::should_bubble_column_occupy(state) {
168            self.try_schedule_bubble_block_column(world, pos, world.get_block_state(pos.below()));
169        }
170    }
171
172    fn tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
173        if Self::should_bubble_column_occupy(state) {
174            BubbleColumnBlock::update_column(
175                &vanilla_blocks::BUBBLE_COLUMN,
176                world,
177                pos,
178                world.get_block_state(pos.below()),
179            );
180        }
181    }
182
183    /// Called when a neighboring block changes.
184    fn handle_neighbor_changed(
185        &self,
186        state: BlockStateId,
187        world: &Arc<World>,
188        pos: BlockPos,
189        _source_block: BlockRef,
190        _moved_by_piston: bool,
191    ) {
192        if self.should_spread_liquid(world, pos) {
193            let fluid = world.get_block_state(pos).get_fluid_state().fluid_id;
194            let delay = FLUID_BEHAVIORS.get_behavior(fluid).tick_delay(world);
195            world.schedule_fluid_tick_default(pos, fluid, delay);
196        }
197
198        if Self::should_bubble_column_occupy(state) {
199            self.try_schedule_bubble_block_column(world, pos, world.get_block_state(pos.below()));
200        }
201    }
202
203    /// Called when a neighbor's shape changes.
204    ///
205    /// Vanilla parity: `LiquidBlock.updateShape` schedules a tick only when
206    /// either the current block or the neighbor contains a source fluid.
207    fn update_shape(
208        &self,
209        state: BlockStateId,
210        world: &dyn ScheduledTickAccess,
211        pos: BlockPos,
212        direction: Direction,
213        _neighbor_pos: BlockPos,
214        neighbor_state: BlockStateId,
215    ) -> BlockStateId {
216        let fluid_state = state.get_fluid_state();
217        let neighbor_fluid = neighbor_state.get_fluid_state();
218
219        if fluid_state.is_source() || neighbor_fluid.is_source() {
220            let delay = world.fluid_tick_delay(fluid_state.fluid_id);
221            world.schedule_fluid_tick_default(pos, fluid_state.fluid_id, delay);
222        }
223
224        if direction == Direction::Down && Self::should_bubble_column_occupy(state) {
225            self.try_schedule_bubble_block_column(world, pos, neighbor_state);
226        }
227
228        state
229    }
230
231    /// Vanilla parity: `LiquidBlock.randomTick` delegates to the fluid.
232    fn random_tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
233        FLUID_BEHAVIORS
234            .get_behavior(state.get_fluid_state().fluid_id)
235            .random_tick(world, pos);
236    }
237
238    fn pickup_block(
239        &self,
240        world: &Arc<World>,
241        pos: BlockPos,
242        state: BlockStateId,
243        _player: Option<&Player>,
244    ) -> Option<PickupResult> {
245        if state.try_get_value(LEVEL) != Some(0) {
246            return None;
247        }
248
249        let air = REGISTRY.blocks.get_default_state_id(&vanilla_blocks::AIR);
250        if world.set_block_if_unchanged(pos, state, air, UpdateFlags::UPDATE_ALL_IMMEDIATE)
251            != ConditionalBlockSetResult::Changed
252        {
253            return None;
254        }
255
256        let bucket = if is_water_fluid(self.fluid) {
257            &vanilla_items::WATER_BUCKET
258        } else {
259            &vanilla_items::LAVA_BUCKET
260        };
261
262        let sound = if is_water_fluid(self.fluid) {
263            &sound_events::ITEM_BUCKET_FILL
264        } else {
265            &sound_events::ITEM_BUCKET_FILL_LAVA
266        };
267
268        Some(PickupResult {
269            filled_bucket: ItemStack::new(bucket),
270            sound: Some(sound),
271        })
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use crate::behavior::init_behaviors;
278    use steel_registry::{init_vanilla_registry, vanilla_fluids};
279
280    use crate::test_support::TestLevel;
281
282    use super::*;
283
284    #[test]
285    fn update_shape_schedules_actual_flowing_fluid_variant() {
286        init_vanilla_registry();
287        init_behaviors();
288
289        let block = LiquidBlock::new(&vanilla_blocks::WATER, &vanilla_fluids::WATER);
290        let state = vanilla_blocks::WATER.default_state().set_value(LEVEL, 1);
291        let neighbor_state = vanilla_blocks::WATER.default_state();
292        let level = TestLevel::default();
293
294        let updated = block.update_shape(
295            state,
296            &level,
297            BlockPos::ZERO,
298            Direction::North,
299            Direction::North.relative(BlockPos::ZERO),
300            neighbor_state,
301        );
302
303        assert_eq!(updated, state);
304        assert_eq!(
305            level
306                .scheduled_fluid_ticks
307                .borrow()
308                .iter()
309                .map(|tick| (tick.fluid, tick.delay))
310                .collect::<Vec<_>>(),
311            vec![(&vanilla_fluids::FLOWING_WATER, 5)]
312        );
313    }
314
315    #[test]
316    fn source_water_above_soul_sand_schedules_bubble_column_tick() {
317        init_vanilla_registry();
318        init_behaviors();
319
320        let block = LiquidBlock::new(&vanilla_blocks::WATER, &vanilla_fluids::WATER);
321        let state = vanilla_blocks::WATER.default_state();
322        let level = TestLevel::default();
323
324        let updated = block.update_shape(
325            state,
326            &level,
327            BlockPos::ZERO,
328            Direction::Down,
329            BlockPos::ZERO.below(),
330            vanilla_blocks::SOUL_SAND.default_state(),
331        );
332
333        assert_eq!(updated, state);
334        assert!(
335            level
336                .scheduled_block_ticks
337                .borrow()
338                .iter()
339                .any(|tick| tick.block == &vanilla_blocks::WATER && tick.delay == 20)
340        );
341    }
342
343    #[test]
344    fn flowing_water_does_not_schedule_bubble_column_tick() {
345        init_vanilla_registry();
346        init_behaviors();
347
348        let block = LiquidBlock::new(&vanilla_blocks::WATER, &vanilla_fluids::WATER);
349        let state = vanilla_blocks::WATER.default_state().set_value(LEVEL, 1);
350        let level = TestLevel::default();
351
352        let updated = block.update_shape(
353            state,
354            &level,
355            BlockPos::ZERO,
356            Direction::Down,
357            BlockPos::ZERO.below(),
358            vanilla_blocks::SOUL_SAND.default_state(),
359        );
360
361        assert_eq!(updated, state);
362        assert!(level.scheduled_block_ticks.borrow().is_empty());
363    }
364
365    #[test]
366    fn liquid_block_behavior_and_ext_identify_liquid_blocks() {
367        use crate::behavior::BlockStateBehaviorExt as _;
368
369        init_vanilla_registry();
370        init_behaviors();
371
372        let liquid = LiquidBlock::new(&vanilla_blocks::WATER, &vanilla_fluids::WATER);
373        assert!(liquid.is_liquid_block());
374
375        assert!(vanilla_blocks::WATER.default_state().is_liquid_block());
376        assert!(vanilla_blocks::LAVA.default_state().is_liquid_block());
377        assert!(!vanilla_blocks::STONE.default_state().is_liquid_block());
378        assert!(!vanilla_blocks::AIR.default_state().is_liquid_block());
379    }
380}