Skip to main content

steel_core/behavior/items/
bucket.rs

1//! Bucket item behavior implementation.
2use crate::behavior::context::InteractionResult;
3use crate::behavior::item_utils::create_filled_result;
4use crate::behavior::{
5    BLOCK_BEHAVIORS, BlockStateBehaviorExt, FLUID_BEHAVIORS, ItemBehavior, UseItemContext,
6    pickup_waterlogged_block,
7};
8use crate::entity::Entity;
9use crate::fluid::{fluid_state_to_block, is_lava_fluid, is_water_fluid};
10use crate::world::RaytraceAction;
11use steel_macros::item_behavior;
12use steel_registry::blocks::block_state_ext::BlockStateExt;
13use steel_registry::blocks::properties::Direction;
14use steel_registry::fluid::{FluidRef, FluidState};
15use steel_registry::item_stack::ItemStack;
16use steel_registry::level_events;
17use steel_registry::sound_events;
18use steel_registry::stat::vanilla_stat_types;
19use steel_registry::vanilla_blocks;
20use steel_registry::vanilla_fluids;
21use steel_registry::vanilla_game_events;
22use steel_registry::vanilla_items;
23use steel_utils::types::UpdateFlags;
24use steel_utils::{BlockPos, BlockStateId};
25
26use crate::world::game_event::GameEventContext;
27
28/// A bucket holding `content`, or the empty bucket when `content` is the empty fluid.
29#[item_behavior]
30pub struct BucketItem {
31    #[json_arg(vanilla_fluids, ref, json = "content")]
32    content: FluidRef,
33}
34
35impl BucketItem {
36    /// Creates a bucket behavior for `content`.
37    #[must_use]
38    pub const fn new(content: FluidRef) -> Self {
39        Self { content }
40    }
41}
42
43impl ItemBehavior for BucketItem {
44    fn use_item(&self, context: &mut UseItemContext) -> InteractionResult {
45        if self.content.is_empty {
46            return use_empty_bucket(context);
47        }
48        use_filled_bucket(self.content, context)
49    }
50}
51
52fn filled_bucket_success_stack(context: &UseItemContext) -> ItemStack {
53    if context.player.has_infinite_materials() {
54        context
55            .inv
56            .with_item(|item| item.copy_with_count(item.count()))
57    } else {
58        ItemStack::new(&vanilla_items::BUCKET)
59    }
60}
61
62fn use_empty_bucket(context: &mut UseItemContext) -> InteractionResult {
63    let (start, end) = context.player.get_ray_endpoints();
64
65    // Raytrace: stop on source fluids
66    let (hit_block, hit_direction) = context.world.raytrace(start, end, |pos, world| {
67        let state = world.get_block_state(pos);
68        let block = state.get_block();
69
70        if block == &vanilla_blocks::AIR {
71            return RaytraceAction::Pass;
72        }
73
74        let fluid_state = state.get_fluid_state();
75        if fluid_state.is_source() {
76            return RaytraceAction::ImmediateHit;
77        }
78        // Flowing fluid is transparent to empty buckets.
79        if !fluid_state.is_empty() {
80            return RaytraceAction::Pass;
81        }
82
83        RaytraceAction::CheckShape
84    });
85
86    // A miss passes, so other handlers can try.
87    let (Some(hit_pos), Some(direction)) = (hit_block, hit_direction) else {
88        return InteractionResult::Pass;
89    };
90
91    if !may_use_bucket_at(context, hit_pos, direction) {
92        return InteractionResult::Fail;
93    }
94
95    let hit_state = context.world.get_block_state(hit_pos);
96    let block_behavior = BLOCK_BEHAVIORS.get_behavior(hit_state.get_block());
97
98    // TODO: Remove fallback once all waterloggable blocks implement pickup_block.
99    let picked = block_behavior
100        .pickup_block(context.world, hit_pos, hit_state, Some(context.player))
101        .or_else(|| {
102            pickup_waterlogged_block(
103                block_behavior,
104                context.world,
105                hit_pos,
106                hit_state,
107                Some(context.player),
108            )
109        });
110    // Fail, not pass, so the client knows no item changed.
111    let Some(result) = picked else {
112        return InteractionResult::Fail;
113    };
114
115    context.inv.with_item(|item| {
116        context
117            .player
118            .award_stat(&vanilla_stat_types::ITEM_USED, item.item());
119    });
120    if let Some(sound) = result.sound {
121        context.player.play_sound(sound, 1.0, 1.0);
122    }
123
124    create_filled_result(context, result.filled_bucket, true);
125    context.world.game_event(
126        &vanilla_game_events::FLUID_PICKUP,
127        hit_pos,
128        &GameEventContext::new(Some(context.player.as_ref()), None),
129    );
130
131    InteractionResult::Success
132}
133
134fn use_filled_bucket(fluid: FluidRef, context: &mut UseItemContext) -> InteractionResult {
135    let (start, end) = context.player.get_ray_endpoints();
136    let (ray_block, ray_dir) = context.world.raytrace(start, end, |pos, world| {
137        let state = world.get_block_state(pos);
138        let block = state.get_block();
139        // Filled buckets use ClipContext.Fluid.NONE: ignore fluid shapes, but
140        // still test the block shape of waterlogged/container blocks.
141        if block == &vanilla_blocks::AIR {
142            return RaytraceAction::Pass;
143        }
144        RaytraceAction::CheckShape
145    });
146
147    let (Some(clicked_pos), Some(direction)) = (ray_block, ray_dir) else {
148        return InteractionResult::Pass;
149    };
150
151    if !context.world.is_in_valid_bounds(clicked_pos) {
152        return InteractionResult::Fail;
153    }
154
155    if !may_use_bucket_at(context, clicked_pos, direction) {
156        return InteractionResult::Fail;
157    }
158
159    let clicked_state = context.world.get_block_state(clicked_pos);
160    let is_sneaking = context.player.is_crouching();
161    // Only water itself enters liquid containers, not water-tagged fluids.
162    let is_water_bucket = fluid == &vanilla_fluids::WATER;
163
164    // `check_sneak` is false for the secondary attempt, which ignores sneaking.
165    let try_place_fluid = |pos: BlockPos, check_sneak: bool| -> bool {
166        if !context.world.is_in_valid_bounds(pos) {
167            return false;
168        }
169
170        let state = context.world.get_block_state(pos);
171        let fluid_state = state.get_fluid_state();
172
173        // When sneaking, only air accepts the fluid here; other blocks redirect to the
174        // neighbor through the secondary attempt.
175        if check_sneak && is_sneaking && !state.get_block().config.is_air {
176            return false;
177        }
178
179        let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
180        let is_liquid_container = state.is_liquid_container();
181        let can_place_liquid = is_liquid_container
182            && behavior.can_place_liquid_with_player(state, fluid, Some(context.player));
183        let can_replace = state.can_be_replaced_by_fluid(fluid);
184
185        if !can_replace && !can_place_liquid {
186            return false;
187        }
188
189        // Water-tagged fluids fizz out where water evaporates (e.g. the Nether).
190        // TODO: Read the per-position WATER_EVAPORATES attribute once Steel has environment attributes.
191        if is_water_fluid(fluid) && context.world.dimension_type.water_evaporates {
192            context
193                .world
194                .level_event(level_events::PARTICLES_WATER_EVAPORATING, pos, 0, None);
195            return true;
196        }
197
198        if is_water_bucket && is_liquid_container {
199            let source_water = FluidState::source(&vanilla_fluids::WATER);
200            behavior.place_liquid(context.world, pos, state, source_water);
201            play_empty_sound_and_event(context, pos, fluid);
202            return true;
203        }
204
205        // Containers that accept a fluid other than water get overwritten here too.
206        // An existing source of the same fluid just consumes the bucket.
207        let is_same_fluid = FLUID_BEHAVIORS
208            .get_behavior(fluid)
209            .is_same(fluid_state.fluid_id);
210
211        if is_same_fluid && fluid_state.is_source() {
212            play_empty_sound_and_event(context, pos, fluid);
213            return true;
214        }
215
216        // Destroy non-liquid replaceable blocks first so they drop their items.
217        if can_replace && !state.get_block().config.liquid && !state.get_block().config.is_air {
218            context.player.get_world().destroy_block(pos, true);
219        }
220
221        let fluid_state_to_place = fluid_state_to_block(FluidState::source(fluid));
222        if context
223            .world
224            .set_block(pos, fluid_state_to_place, UpdateFlags::UPDATE_ALL_IMMEDIATE)
225        {
226            let tick_delay = FLUID_BEHAVIORS
227                .get_behavior(fluid)
228                .tick_delay(context.world);
229            context
230                .world
231                .schedule_fluid_tick_default(pos, fluid, tick_delay);
232
233            play_empty_sound_and_event(context, pos, fluid);
234
235            return true;
236        }
237        false
238    };
239
240    // If the primary attempt fails, the secondary one retries at the offset position,
241    // whatever the bucket.
242    let primary_pos =
243        filled_bucket_primary_pos(clicked_state, clicked_pos, direction, is_water_bucket);
244
245    if !try_place_fluid(primary_pos, true)
246        && !try_place_fluid(direction.relative(clicked_pos), false)
247    {
248        return InteractionResult::Fail;
249    }
250
251    let result_stack = filled_bucket_success_stack(context);
252    create_filled_result(context, result_stack, true);
253    InteractionResult::Success
254}
255
256/// Whether the player may use the held bucket against the `direction` face of `pos`.
257fn may_use_bucket_at(context: &UseItemContext, pos: BlockPos, direction: Direction) -> bool {
258    let item = context.inv.with_item(|item| item.clone());
259    context
260        .player
261        .may_use_item_at(direction.relative(pos), direction, &item)
262}
263
264fn play_empty_sound_and_event(context: &UseItemContext, pos: BlockPos, fluid: FluidRef) {
265    let sound_event = if is_lava_fluid(fluid) {
266        &sound_events::ITEM_BUCKET_EMPTY_LAVA
267    } else {
268        &sound_events::ITEM_BUCKET_EMPTY
269    };
270    context.world.play_block_sound(
271        sound_event,
272        pos,
273        1.0,
274        1.0,
275        // Excludes the placing player.
276        Some(context.player.id()),
277    );
278    context.world.game_event(
279        &vanilla_game_events::FLUID_PLACE,
280        pos,
281        &GameEventContext::new(Some(context.player.as_ref()), None),
282    );
283}
284
285fn filled_bucket_primary_pos(
286    clicked_state: BlockStateId,
287    clicked_pos: BlockPos,
288    direction: Direction,
289    is_water_bucket: bool,
290) -> BlockPos {
291    if is_water_bucket && clicked_state.is_liquid_container() {
292        clicked_pos
293    } else {
294        direction.relative(clicked_pos)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use crate::{
301        behavior::init_behaviors,
302        player::game_mode::use_item,
303        test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk},
304    };
305    use glam::DVec3;
306    use std::sync::Arc;
307    use steel_registry::{
308        RegistryHolderSet,
309        data_component_predicate::DataComponentMatchers,
310        data_components::{
311            AdventureModePredicate, BlockPredicate, vanilla_components::CAN_PLACE_ON,
312        },
313        init_vanilla_registry,
314        item_stack::ItemStack,
315        vanilla_blocks, vanilla_items,
316    };
317    use steel_utils::{
318        BlockPos, ChunkPos,
319        types::{GameType, InteractionHand, UpdateFlags},
320    };
321
322    use super::*;
323
324    #[test]
325    fn filled_water_bucket_targets_non_waterlogged_liquid_container_in_place() {
326        init_vanilla_registry();
327        init_behaviors();
328
329        let kelp = vanilla_blocks::KELP.default_state();
330
331        assert_eq!(
332            filled_bucket_primary_pos(kelp, BlockPos::ZERO, Direction::North, true),
333            BlockPos::ZERO
334        );
335        assert_eq!(
336            filled_bucket_primary_pos(kelp, BlockPos::ZERO, Direction::North, false),
337            Direction::North.relative(BlockPos::ZERO)
338        );
339    }
340
341    #[test]
342    fn adventure_buckets_require_can_place_on_and_fail_when_denied() {
343        init_vanilla_registry();
344        init_behaviors();
345        let world_fixture = fresh_test_world("adventure_bucket_restriction");
346        let world = &world_fixture.world;
347        let pos = BlockPos::new(0, 65, 2);
348        insert_ready_full_chunk(world, ChunkPos::from_block_pos(pos));
349        assert!(world.set_block(
350            pos,
351            vanilla_blocks::WATER.default_state(),
352            UpdateFlags::UPDATE_ALL,
353        ));
354
355        let player = TestPlayerBuilder::new(Arc::clone(world), "TestPlayer", 1).build();
356        player.base().set_position_local(DVec3::new(0.5, 64.0, 0.5));
357        player.set_rotation((0.0, 0.0));
358        player.restore_game_modes(GameType::Adventure, None);
359        player
360            .abilities
361            .lock()
362            .update_for_game_mode(GameType::Adventure);
363
364        player
365            .inventory
366            .lock()
367            .set_selected_item(ItemStack::new(&vanilla_items::BUCKET));
368        assert_eq!(
369            use_item(&player, world, InteractionHand::MainHand),
370            InteractionResult::Fail
371        );
372        assert_eq!(
373            world.get_block_state(pos),
374            vanilla_blocks::WATER.default_state()
375        );
376
377        let predicate = BlockPredicate::new(
378            Some(RegistryHolderSet::Direct(vec![&vanilla_blocks::WATER])),
379            None,
380            None,
381            DataComponentMatchers::ANY,
382        );
383        let mut allowed_bucket = ItemStack::new(&vanilla_items::BUCKET);
384        allowed_bucket.set(
385            CAN_PLACE_ON,
386            AdventureModePredicate::new(vec![predicate]).expect("one block predicate is valid"),
387        );
388        player.inventory.lock().set_selected_item(allowed_bucket);
389        assert!(use_item(&player, world, InteractionHand::MainHand).consumes_action());
390        assert_eq!(
391            world.get_block_state(pos),
392            vanilla_blocks::AIR.default_state()
393        );
394
395        assert!(world.set_block(
396            pos,
397            vanilla_blocks::STONE.default_state(),
398            UpdateFlags::UPDATE_ALL,
399        ));
400        player
401            .inventory
402            .lock()
403            .set_selected_item(ItemStack::new(&vanilla_items::WATER_BUCKET));
404        assert_eq!(
405            use_item(&player, world, InteractionHand::MainHand),
406            InteractionResult::Fail
407        );
408        assert_eq!(
409            world.get_block_state(pos),
410            vanilla_blocks::STONE.default_state()
411        );
412    }
413}