Pour a refused water bucket beside the clicked block

BucketItem.emptyContents retries at the block beside the clicked face
when the clicked block refuses the fluid. Without it, a water bucket on
a double slab did nothing, and a sneak-click on a waterloggable block
failed instead of pouring next to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
some-du6eandClaude Opus 5.5 committed 2026-11-01 20:30:53 -06:00
1 parent 10d2551439
commit f37728687d
4 files changed
+103 -23

No files matched your search

+11 -4
View File
@@ -13048,18 +13048,25 @@ impl Game<'_> {
return Ok(UseResult::Fail);
}
let pos = BlockPos::new(target[0], target[1], target[2]);
let beside = BlockPos::new(
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
);
// `Player.isShiftKeyDown`: only air takes a fluid under Shift.
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let creative = self.creative;
let emptied = self.with_level(|b, level, ticks| {
b.bucket_empty(level, ticks, pos, content, sound, sneaking, creative)
b.bucket_empty_beside(
level, ticks, pos, beside, content, sound, sneaking, creative,
)
});
self.process_level_events()?;
if !emptied {
let Some(emptied) = emptied else {
return Ok(UseResult::Fail);
}
};
if self.dim == Dim::Nether && content.is_water() {
self.evaporation_smoke(target);
self.evaporation_smoke([emptied.x, emptied.y, emptied.z]);
}
// `BucketItem.emptyContents` uses `playSound(player, …)`: the
// actor's client already played the empty / evaporate sound from
+14 -6
View File
@@ -7652,18 +7652,26 @@ impl Game<'_> {
};
let target = self.bucket_target(content, &hit);
let pos = horizon_core::position::BlockPos::new(target[0], target[1], target[2]);
let beside = horizon_core::position::BlockPos::new(
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
);
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let creative = self.creative;
// A water bucket on a liquid container fills it server-side only.
// A water bucket on a liquid container fills it server-side only;
// a container that refuses passes it to the block beside, which
// this client changes itself.
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let client_change =
!(content.is_water() && self.res.behaviours().is_liquid_container(clicked));
let container = content.is_water() && self.res.behaviours().is_liquid_container(clicked);
self.with_level(|b, level, ticks| {
let mut level = DryLevel::new(level);
let emptied =
b.bucket_empty(&mut level, ticks, pos, content, sound, sneaking, creative);
let emptied = b.bucket_empty_beside(
&mut level, ticks, pos, beside, content, sound, sneaking, creative,
);
let client_change = !(container && emptied == Some(pos));
(
emptied,
emptied.is_some(),
client_change.then(|| level.changed.into_iter().collect()),
std::mem::take(&mut level.sounds),
)
+40 -13
View File
@@ -60,18 +60,22 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
};
self.pickup_block(pos, state, creative).then_some(item)
}
/// `BucketItem.emptyContents(user, level, pos, hitResult)`. `sound` is the
/// bucket's empty sound (mob buckets have their own); `sneaking` is
/// `BucketItem.emptyContents(user, level, pos, hitResult)`: where the
/// contents went, None when nothing was emptied. `beside` is
/// `hitResult.getBlockPos().relative(hitResult.getDirection())` (None for
/// a dispenser, which has no hit result). `sound` is the bucket's empty
/// sound (mob buckets have their own); `sneaking` is
/// `user.isShiftKeyDown()` (false for a dispenser, whose user is null);
/// `creative` is whether that user is a creative player.
pub(crate) fn bucket_empty(
&mut self,
pos: BlockPos,
beside: Option<BlockPos>,
content: BucketContent,
sound: &str,
sneaking: bool,
creative: bool,
) -> bool {
) -> Option<BlockPos> {
let b = self.b;
let fluid = match content {
BucketContent::Powder => {
@@ -82,14 +86,14 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
self.game_event(pos, "minecraft:fluid_place");
let sound: Arc<str> = sound.into();
self.sound(pos, &sound, 1.0, 1.0);
return true;
return Some(pos);
}
return false;
return None;
}
BucketContent::Nothing => {
let sound: Arc<str> = sound.into();
self.sound(pos, &sound, 1.0, 1.0);
return true;
return Some(pos);
}
BucketContent::MobWater => FluidKind::Water,
BucketContent::Fluid(fluid) => fluid,
@@ -108,7 +112,12 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
// when the player is not sneaking (a dispenser has no user and so
// no hit result, which always allows it).
if !(b.is_air(state) || place_liquid && !sneaking) {
return false;
// A refused click (a double slab, or Shift on a waterloggable
// block) pours into the block beside the clicked face instead,
// without a hit result, so Shift no longer matters there.
return beside.and_then(|beside| {
self.bucket_empty(beside, None, content, sound, false, creative)
});
}
let water = matches!(fluid, FluidKind::Water | FluidKind::FlowingWater);
if water && self.level.dimension() == Dimension::Nether {
@@ -120,7 +129,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
volume: 0.5,
pitch,
});
return true;
return Some(pos);
}
if liquid_container && water {
self.place_liquid(
@@ -130,7 +139,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
creative,
);
self.empty_sound(pos, sound, mob);
return true;
return Some(pos);
}
if may_replace && !b.instance_of(state, Class::Liquid) {
self.destroy_block(pos, true, super::flags::UPDATE_LIMIT);
@@ -148,10 +157,10 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
if !self.set_block(pos, legacy, super::flags::UPDATE_ALL_IMMEDIATE)
&& !self.fluid_at(pos).is_source()
{
return false;
return None;
}
self.empty_sound(pos, sound, mob);
true
Some(pos)
}
/// `playEmptySound`: `MobBucketItem` overrides it without the game event.
fn empty_sound(&mut self, pos: BlockPos, sound: &str, mob: bool) {
@@ -175,7 +184,7 @@ impl Behaviours {
) -> Option<&'static str> {
self.sim(level, ticks).bucket_pickup(pos, creative)
}
/// `DispensibleContainerItem.emptyContents(user, level, pos, hitResult)`.
/// `DispensibleContainerItem.emptyContents(user, level, pos, null)`.
pub fn bucket_empty<L: LevelAccess + ?Sized>(
&self,
level: &mut L,
@@ -187,6 +196,24 @@ impl Behaviours {
creative: bool,
) -> bool {
self.sim(level, ticks)
.bucket_empty(pos, content, sound, sneaking, creative)
.bucket_empty(pos, None, content, sound, sneaking, creative)
.is_some()
}
/// `BucketItem.emptyContents(user, level, pos, hitResult)` for a click:
/// `beside` is the block beside the clicked face, tried when `pos`
/// refuses. Returns where the contents went.
pub fn bucket_empty_beside<L: LevelAccess + ?Sized>(
&self,
level: &mut L,
ticks: &mut Ticks,
pos: BlockPos,
beside: BlockPos,
content: BucketContent,
sound: &str,
sneaking: bool,
creative: bool,
) -> Option<BlockPos> {
self.sim(level, ticks)
.bucket_empty(pos, Some(beside), content, sound, sneaking, creative)
}
}
+38
View File
@@ -2295,6 +2295,44 @@ fn dispenser_buckets_pick_up_and_empty() {
.bucket_empty(&mut w.level, &mut w.ticks, at, content, sound, true, false)
);
assert_eq!(w.prop(at, "waterlogged"), "false");
// A click on the slab pours beside it, where Shift no longer matters.
let above = BlockPos::new(at.x, at.y + 1, at.z);
assert_eq!(
w.b().bucket_empty_beside(
&mut w.level,
&mut w.ticks,
at,
above,
content,
sound,
true,
false
),
Some(above)
);
assert_eq!(w.prop(at, "waterlogged"), "false");
assert_eq!(w.name(above), "water[level=0]");
// A double slab holds no water: the click fills the block beside it.
let air = w.b().air();
w.put(above, air);
let double = w.id("oak_slab", &[("type", "double")]);
w.put(at, double);
assert_eq!(
w.b().bucket_empty_beside(
&mut w.level,
&mut w.ticks,
at,
above,
content,
sound,
false,
false
),
Some(above)
);
assert_eq!(w.prop(at, "type"), "double");
assert_eq!(w.name(above), "water[level=0]");
w.put(above, air);
// Powder snow only goes into empty space.
let air = w.b().air();
w.put(at, air);