Skip to main content

steel_core/behavior/blocks/fluid/
liquid_block.rs

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