Skip to main content

steel_core/behavior/items/
bucket.rs

1//! Bucket item behavior implementation.
2//!
3//! Handles water buckets, lava buckets, and empty buckets.
4//!
5//! Mirrors vanilla's `BucketItem(Fluid fluid)`: `fluid_block = None` = empty bucket,
6//! `Some(block)` = filled bucket. Logic is dispatched in `use_item`.
7//!
8use crate::behavior::context::InteractionResult;
9use crate::behavior::item_utils::create_filled_result;
10use crate::behavior::{
11    BLOCK_BEHAVIORS, BlockStateBehaviorExt, FLUID_BEHAVIORS, ItemBehavior, UseItemContext,
12    pickup_waterlogged_block,
13};
14use crate::fluid::FluidStateExt;
15use crate::world::RaytraceAction;
16use steel_macros::item_behavior;
17use steel_registry::blocks::BlockRef;
18use steel_registry::blocks::block_state_ext::BlockStateExt;
19use steel_registry::blocks::properties::Direction;
20use steel_registry::fluid::FluidState;
21use steel_registry::item_stack::ItemStack;
22use steel_registry::level_events;
23use steel_registry::sound_events;
24use steel_registry::vanilla_blocks;
25use steel_registry::vanilla_fluids;
26use steel_registry::vanilla_game_events;
27use steel_registry::vanilla_items;
28use steel_utils::types::UpdateFlags;
29use steel_utils::{BlockPos, BlockStateId};
30
31use crate::world::game_event::GameEventContext;
32
33/// Handles all bucket variants (empty, water, lava).
34#[item_behavior]
35pub struct BucketItem {
36    #[json_arg(vanilla_blocks, json = "content", optional = "empty")]
37    fluid_block: Option<BlockRef>,
38}
39
40impl BucketItem {
41    /// Creates a new bucket behavior. `None` = empty bucket, `Some(block)` = filled.
42    #[must_use]
43    pub const fn new(fluid_block: Option<BlockRef>) -> Self {
44        Self { fluid_block }
45    }
46}
47
48impl ItemBehavior for BucketItem {
49    fn use_item(&self, context: &mut UseItemContext) -> InteractionResult {
50        match self.fluid_block {
51            None => use_empty_bucket(context),
52            Some(fluid_block) => use_filled_bucket(fluid_block, context),
53        }
54    }
55}
56
57fn filled_bucket_success_stack(context: &UseItemContext) -> ItemStack {
58    if context.player.has_infinite_materials() {
59        context
60            .inv
61            .with_item(|item| item.copy_with_count(item.count()))
62    } else {
63        ItemStack::new(&vanilla_items::BUCKET)
64    }
65}
66
67fn use_empty_bucket(context: &mut UseItemContext) -> InteractionResult {
68    let (start, end) = context.player.get_ray_endpoints();
69
70    // Raytrace: stop on source fluids
71    let (hit_block, _) = context.world.raytrace(start, end, |pos, world| {
72        let state = world.get_block_state(pos);
73        let block = state.get_block();
74
75        if block == &vanilla_blocks::AIR {
76            return RaytraceAction::Pass;
77        }
78
79        let fluid_state = state.get_fluid_state();
80        if fluid_state.is_source() {
81            return RaytraceAction::ImmediateHit;
82        }
83        // Vanilla parity: ClipContext.Fluid.SOURCE_ONLY — flowing fluid is transparent.
84        if !fluid_state.is_empty() {
85            return RaytraceAction::Pass;
86        }
87
88        RaytraceAction::CheckShape
89    });
90
91    // Vanilla returns PASS when raytrace misses (allows other handlers to try)
92    let Some(hit_pos) = hit_block else {
93        return InteractionResult::Pass;
94    };
95
96    let hit_state = context.world.get_block_state(hit_pos);
97    let block_behavior = BLOCK_BEHAVIORS.get_behavior(hit_state.get_block());
98
99    if let Some(result) =
100        block_behavior.pickup_block(context.world, hit_pos, hit_state, Some(context.player))
101    {
102        // Apply sound
103        if let Some(sound) = result.sound {
104            context
105                .world
106                .play_block_sound(sound, hit_pos, 1.0, 1.0, None);
107        }
108
109        // Give filled bucket
110        create_filled_result(context, result.filled_bucket, true);
111        context.world.game_event(
112            &vanilla_game_events::FLUID_PICKUP,
113            hit_pos,
114            &GameEventContext::new(Some(context.player), None),
115        );
116
117        return InteractionResult::Success;
118    }
119
120    // TODO: Remove fallback once all waterloggable blocks implement pickup_block.
121    if let Some(result) = pickup_waterlogged_block(
122        block_behavior,
123        context.world,
124        hit_pos,
125        hit_state,
126        Some(context.player),
127    ) {
128        if let Some(sound) = result.sound {
129            context
130                .world
131                .play_block_sound(sound, hit_pos, 1.0, 1.0, None);
132        }
133
134        create_filled_result(context, result.filled_bucket, true);
135        context.world.game_event(
136            &vanilla_game_events::FLUID_PICKUP,
137            hit_pos,
138            &GameEventContext::new(Some(context.player), None),
139        );
140
141        return InteractionResult::Success;
142    }
143
144    // Nothing was picked up — no fluid source block and no waterlogged block found.
145    // Vanilla returns FAIL here so the client knows no item change occurred.
146    InteractionResult::Fail
147}
148
149// TODO: Refactor into smaller helpers once all bucket types are implemented
150#[expect(
151    clippy::too_many_lines,
152    reason = "mirrors vanilla's emptyContents flow; splitting would obscure the sequential placement logic"
153)]
154fn use_filled_bucket(fluid_block: BlockRef, context: &mut UseItemContext) -> InteractionResult {
155    // Raytrace to find target block
156    let (start, end) = context.player.get_ray_endpoints();
157    let (ray_block, ray_dir) = context.world.raytrace(start, end, |pos, world| {
158        let state = world.get_block_state(pos);
159        let block = state.get_block();
160        // Filled buckets use ClipContext.Fluid.NONE: ignore fluid shapes, but
161        // still test the block shape of waterlogged/container blocks.
162        if block == &vanilla_blocks::AIR {
163            return RaytraceAction::Pass;
164        }
165        RaytraceAction::CheckShape
166    });
167
168    // Vanilla returns PASS when raytrace misses (allows other handlers to try)
169    let (Some(clicked_pos), Some(direction)) = (ray_block, ray_dir) else {
170        return InteractionResult::Pass;
171    };
172
173    // If the block is out of bounds, return fail
174    if !context.world.is_in_valid_bounds(clicked_pos) {
175        return InteractionResult::Fail;
176    }
177
178    let clicked_state = context.world.get_block_state(clicked_pos);
179    let is_sneaking = context.player.is_crouching();
180
181    // Define fluid placement logic as a closure to reuse for primary/secondary targets.
182    // `check_sneak`: true for primary attempt, false for secondary (vanilla parity:
183    // recursive emptyContents passes hitResult=null for fallback, bypassing sneak check).
184    let try_place_fluid = |pos: BlockPos, check_sneak: bool| -> bool {
185        if !context.world.is_in_valid_bounds(pos) {
186            return false;
187        }
188
189        let state = context.world.get_block_state(pos);
190        let fluid_state = state.get_fluid_state();
191
192        // Vanilla parity (bl4): when sneaking, only air allows placement at this position.
193        // Non-air blocks redirect to the neighbor — handled by the secondary call.
194        // The secondary call bypasses this check (hitResult == null in vanilla).
195        if check_sneak && is_sneaking && !state.get_block().config.is_air {
196            return false;
197        }
198
199        let is_water_bucket = fluid_block == &vanilla_blocks::WATER;
200        let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
201        let is_liquid_container = state.is_liquid_container();
202        let can_place_liquid = is_water_bucket
203            && is_liquid_container
204            && behavior.can_place_liquid_with_player(
205                state,
206                FluidState::source(&vanilla_fluids::WATER).fluid_id,
207                Some(context.player),
208            );
209        let can_replace = state.can_be_replaced_by_fluid(fluid_block);
210
211        // Vanilla parity: block must be replaceable or liquid-container-admissible for placement.
212        if !can_replace && !can_place_liquid {
213            return false;
214        }
215
216        // Vanilla parity: in worlds where water evaporates (e.g. the Nether),
217        // water buckets fizz out without placing any fluid.
218        // TODO: Per-position environment attributes (vanilla uses EnvironmentAttributes.WATER_EVAPORATES per-pos)
219        if is_water_bucket && context.world.dimension_type.water_evaporates {
220            context
221                .world
222                .level_event(level_events::PARTICLES_WATER_EVAPORATING, pos, 0, None);
223            return true;
224        }
225
226        // 1. Try LiquidBlockContainer handling (only if Water bucket).
227        if is_water_bucket && is_liquid_container {
228            let source_water = FluidState::source(&vanilla_fluids::WATER);
229            behavior.place_liquid(context.world, pos, state, source_water);
230            play_empty_sound_and_event(context, pos, true);
231            return true;
232        }
233
234        // 2. Try Standard Placement (Replaceable block)
235        if can_replace {
236            // If same fluid already exists and is source, just consume bucket (parity)
237            let is_same_fluid = if is_water_bucket {
238                fluid_state.is_water()
239            } else {
240                fluid_state.is_lava()
241            };
242
243            if is_same_fluid && fluid_state.is_source() {
244                play_empty_sound_and_event(context, pos, is_water_bucket);
245                return true;
246            }
247
248            // Vanilla parity: destroy non-liquid replaceable blocks first so they
249            // drop their items (e.g. tall grass, flowers, snow layers).
250            if !state.get_block().config.liquid && !state.get_block().config.is_air {
251                context.player.get_world().destroy_block(pos, true);
252            }
253
254            // Place fluid block
255            let fluid_state_to_place = fluid_block.default_state();
256            if context
257                .world
258                .set_block(pos, fluid_state_to_place, UpdateFlags::UPDATE_ALL_IMMEDIATE)
259            {
260                let fluid_ref = if is_water_bucket {
261                    &vanilla_fluids::WATER
262                } else {
263                    &vanilla_fluids::LAVA
264                };
265                let tick_delay = FLUID_BEHAVIORS
266                    .get_behavior(fluid_ref)
267                    .tick_delay(context.world);
268                context
269                    .world
270                    .schedule_fluid_tick_default(pos, fluid_ref, tick_delay);
271
272                play_empty_sound_and_event(context, pos, is_water_bucket);
273
274                return true;
275            }
276        }
277        false
278    };
279
280    // Vanilla parity (BucketItem.java): position selection mirrors
281    // `instanceof LiquidBlockContainer && content == Fluids.WATER ? pos : directionOffsetPos`.
282    // If primary fails, secondary retries at the offset pos without sneak check,
283    // matching vanilla's recursive `emptyContents(hitResult=null)` fallback.
284    let is_water_bucket = fluid_block == &vanilla_blocks::WATER;
285    let primary_pos =
286        filled_bucket_primary_pos(clicked_state, clicked_pos, direction, is_water_bucket);
287
288    // Attempt Primary (with sneak check)
289    if try_place_fluid(primary_pos, true) {
290        let result_stack = filled_bucket_success_stack(context);
291        create_filled_result(context, result_stack, true);
292        return InteractionResult::Success;
293    }
294
295    // Attempt Secondary (Fallback — no sneak check, matching vanilla hitResult=null).
296    // Vanilla's emptyContents always recurses with hitResult=null at the offset position
297    // when the primary attempt fails, regardless of bucket type.
298    let secondary_pos = direction.relative(clicked_pos);
299    if try_place_fluid(secondary_pos, false) {
300        let result_stack = filled_bucket_success_stack(context);
301        create_filled_result(context, result_stack, true);
302        return InteractionResult::Success;
303    }
304
305    InteractionResult::Fail
306}
307
308fn play_empty_sound_and_event(context: &UseItemContext, pos: BlockPos, is_water_bucket: bool) {
309    let sound_event = if is_water_bucket {
310        &sound_events::ITEM_BUCKET_EMPTY
311    } else {
312        &sound_events::ITEM_BUCKET_EMPTY_LAVA
313    };
314    context
315        .world
316        .play_block_sound(sound_event, pos, 1.0, 1.0, None);
317    context.world.game_event(
318        &vanilla_game_events::FLUID_PLACE,
319        pos,
320        &GameEventContext::new(Some(context.player), None),
321    );
322}
323
324fn filled_bucket_primary_pos(
325    clicked_state: BlockStateId,
326    clicked_pos: BlockPos,
327    direction: Direction,
328    is_water_bucket: bool,
329) -> BlockPos {
330    if is_water_bucket && clicked_state.is_liquid_container() {
331        clicked_pos
332    } else {
333        direction.relative(clicked_pos)
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use crate::behavior::init_behaviors;
340    use steel_registry::{init_vanilla_registry, vanilla_blocks};
341
342    use super::*;
343
344    #[test]
345    fn filled_water_bucket_targets_non_waterlogged_liquid_container_in_place() {
346        init_vanilla_registry();
347        init_behaviors();
348
349        let kelp = vanilla_blocks::KELP.default_state();
350
351        assert_eq!(
352            filled_bucket_primary_pos(kelp, BlockPos::ZERO, Direction::North, true),
353            BlockPos::ZERO
354        );
355        assert_eq!(
356            filled_bucket_primary_pos(kelp, BlockPos::ZERO, Direction::North, false),
357            Direction::North.relative(BlockPos::ZERO)
358        );
359    }
360}