Skip to main content

steel_core/fluid/
conversion.rs

1//! Fluid state computation and source conversion logic.
2//!
3//! Equivalent to FlowingFluid#getNewLiquid and related helpers.
4
5use std::sync::Arc;
6
7use crate::behavior::FLUID_BEHAVIORS;
8use crate::fluid::can_pass_through_wall;
9use crate::fluid::collision::{
10    can_hold_fluid, can_hold_specific_fluid, can_pass_horizontally_internal,
11};
12use crate::fluid::spread_context::SpreadContext;
13use crate::world::World;
14use steel_registry::blocks::block_state_ext::BlockStateExt;
15use steel_registry::blocks::properties::{BlockStateProperties, Direction};
16use steel_registry::fluid::{FluidRef, FluidState};
17use steel_utils::{BlockPos, BlockStateId};
18
19/// Calculates the new fluid state at a position based on neighbors.
20#[must_use]
21pub fn get_new_liquid(
22    world: &Arc<World>,
23    pos: BlockPos,
24    state: BlockStateId,
25    fluid_id: FluidRef,
26    drop_off: u8,
27) -> FluidState {
28    let behavior = FLUID_BEHAVIORS.get_behavior(fluid_id);
29    let mut max_incoming_amount = 0u8;
30    let mut source_count = 0u8;
31
32    for direction in [
33        Direction::North,
34        Direction::South,
35        Direction::East,
36        Direction::West,
37    ] {
38        let neighbor_pos = direction.relative(pos);
39        let neighbor_state = world.get_block_state(neighbor_pos);
40        let neighbor_fluid = neighbor_state.get_fluid_state();
41
42        if !behavior.is_same(neighbor_fluid.fluid_id) {
43            continue;
44        }
45
46        if !can_pass_through_wall(world, pos, state, neighbor_pos, neighbor_state, direction) {
47            continue;
48        }
49
50        if neighbor_fluid.is_source() {
51            source_count += 1;
52            max_incoming_amount = max_incoming_amount.max(8u8.saturating_sub(drop_off));
53        } else {
54            max_incoming_amount =
55                max_incoming_amount.max(neighbor_fluid.amount.saturating_sub(drop_off));
56        }
57    }
58
59    // Source conversion — delegate to the fluid's own canConvertToSource, which
60    // encapsulates the game rule check (WATER/LAVA_SOURCE_CONVERSION).
61    if source_count >= 2 && behavior.can_convert_to_source(world) {
62        let below_pos = pos.below();
63        let below_state = world.get_block_state(below_pos);
64        let below_fluid = below_state.get_fluid_state();
65        if below_state.is_solid()
66            || (behavior.is_same(below_fluid.fluid_id) && below_fluid.is_source())
67        {
68            return FluidState::source(fluid_id.source_variant());
69        }
70    }
71
72    // Check above for falling fluid
73    let above_pos = pos.above();
74    let above_state = world.get_block_state(above_pos);
75    let above_fluid = above_state.get_fluid_state();
76    if behavior.is_same(above_fluid.fluid_id)
77        && can_pass_through_wall(world, pos, state, above_pos, above_state, Direction::Up)
78    {
79        return FluidState::flowing(fluid_id.flowing_variant(), 8, true);
80    }
81
82    if max_incoming_amount > 0 {
83        FluidState::flowing(fluid_id.flowing_variant(), max_incoming_amount, false)
84    } else {
85        FluidState::EMPTY
86    }
87}
88
89/// Returns true if the position below is a hole (fluid can flow downward).
90///
91/// Vanilla equivalent: `FlowingFluid.isWaterHole()`.
92/// Checks wall passability, then either same-fluid presence or `canHoldFluid`.
93#[must_use]
94pub fn is_hole(
95    world: &Arc<World>,
96    top_pos: BlockPos,
97    top_state: BlockStateId,
98    bottom_pos: BlockPos,
99    bottom_state: BlockStateId,
100    fluid_id: FluidRef,
101) -> bool {
102    if !world.is_in_valid_bounds(bottom_pos) {
103        return false;
104    }
105
106    if !can_pass_through_wall(
107        world,
108        top_pos,
109        top_state,
110        bottom_pos,
111        bottom_state,
112        Direction::Down,
113    ) {
114        return false;
115    }
116
117    can_flow_down_into(bottom_state, bottom_state.get_fluid_state(), fluid_id)
118}
119
120fn can_flow_down_into(
121    below_state: steel_utils::BlockStateId,
122    below_fluid: FluidState,
123    fluid_id: FluidRef,
124) -> bool {
125    // Vanilla: bottomState.getFluidState().getType().isSame(this)
126    //     ? true
127    //     : canHoldFluid(..., this.getFlowing())
128    if FLUID_BEHAVIORS
129        .get_behavior(fluid_id)
130        .is_same(below_fluid.fluid_id)
131    {
132        return true;
133    }
134
135    can_hold_fluid(below_state, fluid_id.flowing_variant())
136}
137
138/// Computes slope distance using DFS search.
139///
140/// Vanilla equivalent: `FlowingFluid.getSlopeDistance()`.
141/// Uses `canPassThrough` = `canMaybePassThrough` + `canHoldSpecificFluid`.
142#[must_use]
143fn get_slope_distance(
144    ctx: &mut SpreadContext,
145    pos: BlockPos,
146    state: BlockStateId,
147    depth: u8,
148    from_direction: Option<Direction>,
149    fluid_id: FluidRef,
150    max_depth: u8,
151) -> u16 {
152    let mut min_distance: u16 = 1000;
153
154    // Check all horizontal directions except the one we came from
155    for direction in [
156        Direction::North,
157        Direction::South,
158        Direction::East,
159        Direction::West,
160    ] {
161        // Skip the direction we came from
162        if let Some(from) = from_direction
163            && direction == from.opposite()
164        {
165            continue;
166        }
167
168        let neighbor = direction.relative(pos);
169        let neighbor_state = ctx.get_block_state(neighbor);
170
171        // Vanilla: canPassThrough = canMaybePassThrough + canHoldSpecificFluid
172        if !can_pass_horizontally_internal(neighbor_state, fluid_id) {
173            continue;
174        }
175
176        if !can_pass_through_wall(ctx.world(), pos, state, neighbor, neighbor_state, direction) {
177            continue;
178        }
179
180        // canHoldSpecificFluid check (part of vanilla's canPassThrough).
181        // getSlopeDistance passes getFlowing() to canPassThrough.
182        if !can_hold_specific_fluid(neighbor_state, fluid_id.flowing_variant()) {
183            continue;
184        }
185
186        if ctx.is_hole(neighbor, fluid_id) {
187            return u16::from(depth); // Found a hole at this depth
188        }
189
190        // If we haven't reached max depth, continue searching
191        if depth < max_depth {
192            let distance = get_slope_distance(
193                ctx,
194                neighbor,
195                neighbor_state,
196                depth + 1,
197                Some(direction),
198                fluid_id,
199                max_depth,
200            );
201            if distance < min_distance {
202                min_distance = distance;
203            }
204        }
205    }
206
207    min_distance
208}
209
210/// Gets the spread map for a fluid.
211///
212/// Returns a list of `(Direction, FluidState)` pairs to spread to, filtered to
213/// the directions with the shortest slope distance. For each candidate direction,
214/// the target's existing `FluidState.canBeReplacedWith()` is checked before
215/// adding it to the result.
216///
217/// Vanilla equivalent: `FlowingFluid.getSpread()`.
218#[must_use]
219pub fn get_spread(
220    world: &Arc<World>,
221    pos: BlockPos,
222    state: BlockStateId,
223    fluid_id: FluidRef,
224    drop_off: u8,
225    slope_find_distance: u8,
226) -> Vec<(Direction, FluidState)> {
227    let mut candidates: Vec<(Direction, FluidState, FluidState, u16)> = Vec::new();
228    // Lazily initialized on first use, matching vanilla's SpreadContext init.
229    // Shared across all directions so cached block states and hole checks are
230    // reused, matching vanilla's single-context-per-getSpread() behavior.
231    let mut ctx: Option<SpreadContext<'_>> = None;
232
233    for direction in [
234        Direction::North,
235        Direction::South,
236        Direction::East,
237        Direction::West,
238    ] {
239        let neighbor = direction.relative(pos);
240        let neighbor_state = match &mut ctx {
241            Some(ctx) => ctx.get_block_state(neighbor),
242            None => world.get_block_state(neighbor),
243        };
244        let neighbor_fluid = neighbor_state.get_fluid_state();
245
246        // Vanilla: canMaybePassThrough (source check + canHoldAnyFluid + wall check)
247        if !can_pass_horizontally_internal(neighbor_state, fluid_id) {
248            continue;
249        }
250        if !can_pass_through_wall(world, pos, state, neighbor, neighbor_state, direction) {
251            continue;
252        }
253
254        // Calculate what fluid should exist at the neighbor position.
255        let new_fluid = get_new_liquid(world, neighbor, neighbor_state, fluid_id, drop_off);
256
257        // Vanilla: canHoldSpecificFluid check after getNewLiquid.
258        if !can_hold_specific_fluid(neighbor_state, new_fluid.fluid_id) {
259            continue;
260        }
261
262        // Vanilla parity: canHoldSpecificFluid passes newFluid.getType() to canPlaceLiquid.
263        // Waterloggable blocks only accept source water (fluid == Fluids.WATER), so flowing
264        // water is rejected. Only allow waterloggable targets when the computed fluid is source.
265        if neighbor_state
266            .try_get_value(&BlockStateProperties::WATERLOGGED)
267            .is_some()
268            && !new_fluid.is_source()
269        {
270            continue;
271        }
272
273        // Skip if no valid fluid would be placed.
274        if new_fluid.is_empty() {
275            continue;
276        }
277
278        // Calculate slope distance.
279        let ctx = ctx.get_or_insert_with(|| SpreadContext::new(world, pos));
280        ctx.cache_block_state(neighbor, neighbor_state);
281        let distance = if ctx.is_hole(neighbor, fluid_id) {
282            0
283        } else if slope_find_distance > 0 {
284            get_slope_distance(
285                ctx,
286                neighbor,
287                neighbor_state,
288                1,
289                Some(direction),
290                fluid_id,
291                slope_find_distance,
292            )
293        } else {
294            1000
295        };
296
297        // Vanilla inline: if (distance < lowest) result.clear(); if (distance <= lowest) ...
298        candidates.push((direction, new_fluid, neighbor_fluid, distance));
299    }
300
301    if candidates.is_empty() {
302        return Vec::new();
303    }
304
305    // Find the minimum slope distance
306    let min_distance = candidates
307        .iter()
308        .map(|(_, _, _, distance)| *distance)
309        .min()
310        .unwrap_or(1000);
311
312    // Return only directions with the minimum distance AND where the existing
313    // fluid at the target allows replacement.
314    candidates
315        .into_iter()
316        .filter(|(direction, new_fluid, existing_fluid, distance)| {
317            if *distance != min_distance {
318                return false;
319            }
320            let neighbor = direction.relative(pos);
321
322            let existing_behavior = FLUID_BEHAVIORS.get_behavior(existing_fluid.fluid_id);
323            existing_behavior.can_be_replaced_with(
324                *existing_fluid,
325                world,
326                neighbor,
327                new_fluid.fluid_id,
328                *direction,
329            )
330        })
331        .map(|(direction, fluid, _, _)| (direction, fluid))
332        .collect()
333}
334
335#[cfg(test)]
336mod tests {
337    use crate::behavior::init_behaviors;
338    use crate::test_support::{fresh_test_world, insert_ready_full_chunk};
339    use steel_registry::blocks::properties::BlockStateProperties;
340    use steel_registry::{init_vanilla_registry, vanilla_blocks, vanilla_fluids};
341    use steel_utils::ChunkPos;
342    use steel_utils::types::UpdateFlags;
343
344    use super::*;
345
346    #[test]
347    fn hole_check_rejects_dry_waterloggable_for_flowing_water_fallback() {
348        init_vanilla_registry();
349        init_behaviors();
350
351        let dry_waterloggable = vanilla_blocks::OAK_LEAVES
352            .default_state()
353            .set_value(&BlockStateProperties::WATERLOGGED, false);
354
355        assert!(!can_flow_down_into(
356            dry_waterloggable,
357            FluidState::EMPTY,
358            &vanilla_fluids::WATER
359        ));
360    }
361
362    #[test]
363    fn hole_check_treats_source_and_flowing_variants_as_same_fluid_below() {
364        init_vanilla_registry();
365        init_behaviors();
366
367        let flowing_water = vanilla_blocks::WATER
368            .default_state()
369            .set_value(&BlockStateProperties::LEVEL, 1);
370
371        assert!(can_flow_down_into(
372            flowing_water,
373            flowing_water.get_fluid_state(),
374            &vanilla_fluids::WATER
375        ));
376    }
377
378    #[test]
379    fn spread_keeps_the_closest_slope_even_when_its_target_rejects_replacement() {
380        init_vanilla_registry();
381        init_behaviors();
382
383        let world = fresh_test_world("fluid_spread_closest_slope");
384        insert_ready_full_chunk(&world, ChunkPos::new(0, 0));
385        let origin = BlockPos::new(8, 64, 8);
386        let source_water = vanilla_blocks::WATER.default_state();
387        let thin_lava = vanilla_blocks::LAVA
388            .default_state()
389            .set_value(&BlockStateProperties::LEVEL, 7);
390        let stone = vanilla_blocks::STONE.default_state();
391        let flags = UpdateFlags::UPDATE_NONE | UpdateFlags::UPDATE_SKIP_ON_PLACE;
392
393        for (pos, state) in [
394            (origin, source_water),
395            (origin.north(), thin_lava),
396            (origin.south().below(), stone),
397            (origin.east(), stone),
398            (origin.west(), stone),
399        ] {
400            assert!(world.set_block_with_limit(pos, state, flags, 0));
401        }
402
403        let blocked = get_spread(&world, origin, source_water, &vanilla_fluids::WATER, 1, 4);
404        assert!(blocked.is_empty());
405
406        assert!(world.set_block_with_limit(
407            origin.north(),
408            vanilla_blocks::AIR.default_state(),
409            flags,
410            0,
411        ));
412        let open = get_spread(&world, origin, source_water, &vanilla_fluids::WATER, 1, 4);
413        assert_eq!(
414            open,
415            vec![(
416                Direction::North,
417                FluidState::flowing(&vanilla_fluids::FLOWING_WATER, 7, false),
418            )]
419        );
420    }
421}