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