Riders: a rider whose eyes are in water gets off a mount that can't swim with one

LivingEntity.baseTick stops a rider whose eyes are in water (not in a bubble column) from riding a
vehicle of the dismounts_underwater tag: camel, chicken, donkey, happy ghast, horse, llama, mule,
pig, ravager, spider, strider, trader llama and zombie horse. Horizon only did it for boats. The
window's player and a server's player (through the same tick as the mount's input) and the mobs
that ride a mob (a baby zombie on a chicken) now get off; a skeleton horse keeps its rider.
tools/test_mount_submerged_parity.py compares 14 mounts with a vanilla server, passenger packet by
passenger packet.

Fixes #6152

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
AkirabaneandClaude Sonnet 5.5 committed 2026-11-01 19:52:01 +01:00
1 parent 975c07d915
commit 8ecf339e5b
3 files changed
+176 -1

No files matched your search

+4 -1
View File
@@ -858,7 +858,10 @@ impl Game<'_> {
self.tick_village_siege();
}
self.tick_raids();
horizon_client::spans::time("tick_creature_entities", || self.tick_creature_entities())
let ticked =
horizon_client::spans::time("tick_creature_entities", || self.tick_creature_entities());
self.dismount_underwater_mob_riders();
ticked
}
/// `DistanceManager.inEntityTickingRange` in an entity-ticking chunk
/// (block coordinates), from every player's tickets (`update_chunk_tickets`).
+83
View File
@@ -54,6 +54,27 @@ impl MountState {
self.jump_scale = 0.0;
}
}
/// `EntityTypeTags.DISMOUNTS_UNDERWATER` (`Entity.dismountsUnderwater`): the mounts that throw their
/// riders off when the rider's eyes are in water.
pub(super) fn dismounts_underwater(species: Species) -> bool {
matches!(
species,
Species::Camel
| Species::Chicken
| Species::Donkey
| Species::HappyGhast
| Species::Horse
| Species::Llama
| Species::Mule
| Species::Pig
| Species::Ravager
| Species::Spider
| Species::Strider
| Species::TraderLlama
| Species::ZombieHorse
)
}
impl Game<'_> {
fn mount_index(&self) -> Option<usize> {
let id = self.riding?;
@@ -364,6 +385,19 @@ impl Game<'_> {
}
jump
}
/// Whether the rider's eyes are in water (`Entity.updateFluidOnEyes` then `isEyeInFluid(WATER)`),
/// and not in a bubble column, as `LivingEntity.baseTick` asks before it lets a rider stay.
fn rider_eyes_in_water(&self) -> bool {
let Some([x, y, z]) = self.eye_position() else {
return false;
};
let (bx, by, bz) = (x.floor() as i32, y.floor() as i32, z.floor() as i32);
if &*self.res.facts().get(self.world.block(bx, by, bz)).name == "minecraft:bubble_column" {
return false;
}
self.fluid_height_of(bx, by, bz, Some(Fluid::Water))
.is_some_and(|(height, _)| f64::from(by) + f64::from(height) > y)
}
/// The rider's input for this tick; returns false when not on a mount.
pub(super) fn tick_mount_riding(&mut self, keys: Keys) -> bool {
let Some(i) = self.mount_index() else {
@@ -373,6 +407,12 @@ impl Game<'_> {
self.dismount_mount();
return true;
}
// `LivingEntity.baseTick`: a rider whose eyes are in water gets off a mount of the
// `dismounts_underwater` tag (a server's player too: `leave_guest` tells their client).
if dismounts_underwater(self.mobs[i].species()) && self.rider_eyes_in_water() {
self.dismount_mount();
return true;
}
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
// LocalPlayer.aiStep: the riding jump charges while space is held.
@@ -422,6 +462,23 @@ impl Game<'_> {
});
true
}
/// `LivingEntity.baseTick` for the mobs that ride a mount of the `dismounts_underwater` tag (a baby
/// zombie on a chicken): with their eyes in water they get off it.
pub(super) fn dismount_underwater_mob_riders(&mut self) {
let swimmers: Vec<u32> = self
.mobs
.iter()
.filter(|m| m.alive() && m.fluids.eye_in_water)
.filter_map(|m| {
let mount = m.extra.mount?;
let under = self.mobs.iter().find(|v| v.id == mount)?;
dismounts_underwater(under.species()).then_some(m.id)
})
.collect();
for rider in swimmers {
self.dismount_mob(rider);
}
}
/// `positionRider`: the player sits on the mount after it moves.
pub(super) fn position_rider(&mut self) {
let Some(i) = self.mount_index() else {
@@ -847,6 +904,32 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn the_mounts_that_throw_a_swimmer_off_are_the_dismounts_underwater_tag() {
// data/minecraft/tags/entity_type/dismounts_underwater.json of 26.3.
for species in [
Species::Camel,
Species::Chicken,
Species::Donkey,
Species::HappyGhast,
Species::Horse,
Species::Llama,
Species::Mule,
Species::Pig,
Species::Ravager,
Species::Spider,
Species::Strider,
Species::TraderLlama,
Species::ZombieHorse,
] {
assert!(dismounts_underwater(species), "{species:?}");
}
// Not in the tag: a skeleton horse swims with its rider, a camel husk is not the camel.
for species in [Species::SkeletonHorse, Species::CamelHusk, Species::Cow] {
assert!(!dismounts_underwater(species), "{species:?}");
}
}
#[test]
fn steering_accepts_the_correct_item_in_either_hand() {
let carrot = "minecraft:carrot_on_a_stick";
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""A rider whose eyes are in water gets off a mount of the `dismounts_underwater` tag, on real vanilla and
Horizon servers.
LivingEntity.baseTick: a rider whose eyes are in water (not in a bubble column) stops riding a vehicle whose
type is in data/minecraft/tags/entity_type/dismounts_underwater.json (camel, chicken, donkey, horse, llama,
mule, pig, ravager, spider, strider, trader llama, zombie horse, happy ghast). A vanilla-codec client rides
each mount with `/ride`; water that stops below the rider's eyes keeps it on, deeper water throws it off, and
a mount outside the tag (a skeleton horse, a cow) keeps its rider underwater.
"""
try:
from . import test_locator_bar_parity as net
except ImportError:
import test_locator_bar_parity as net
SCRIPT = [('setup', ['cmd Alice gamerule spawn_mobs false'])]
EXPECTED = []
PROBES = []
# (entity type, the tag throws its rider off in deep water)
MOUNTS = [
('pig', True), ('horse', True), ('donkey', True), ('mule', True), ('llama', True),
('trader_llama', True), ('camel', True), ('chicken', True), ('spider', True),
('strider', True), ('ravager', True), ('zombie_horse', True),
('skeleton_horse', False), ('cow', False),
]
def probe(label, mounted, player='Alice'):
PROBES.append((label, player))
EXPECTED.append(f'{label} mounted={str(mounted).lower()}')
SCRIPT.append((label, []))
for kind, ejects in MOUNTS:
SCRIPT.append((kind + '-setup', [
'cmd Alice kill @e[type=!player]',
'cmd Alice fill -6 79 -6 6 95 6 minecraft:air',
'cmd Alice fill -6 79 -6 6 95 6 minecraft:stone hollow',
'cmd Alice fill -6 95 -6 6 95 6 minecraft:air',
'cmd Alice tp @s 0.5 80 0.5',
f'cmd Alice summon minecraft:{kind} 0.5 80 0.5 {{NoAI:1b,Silent:1b}}',
f'cmd Alice ride @s mount @e[type=minecraft:{kind},limit=1]',
]))
for n in range(2):
SCRIPT.append((f'{kind}-dry-wait-{n}', []))
probe(kind + '-dry', True)
# Water to the mount's back: the rider's eyes are above it.
SCRIPT.append((kind + '-shallow-fill', ['cmd Alice fill -5 80 -5 5 80 5 minecraft:water']))
for n in range(2):
SCRIPT.append((f'{kind}-shallow-wait-{n}', []))
probe(kind + '-shallow', True)
# Water over the rider's head.
SCRIPT.append((kind + '-deep-fill', ['cmd Alice fill -5 80 -5 5 92 5 minecraft:water']))
for n in range(3):
SCRIPT.append((f'{kind}-deep-wait-{n}', []))
probe(kind + '-deep', not ejects)
def shown(lines):
# Each client's transcript is grouped under its name, so replay that
# player's real passenger packets up to each observation step separately.
order = {label: i for i, (label, _) in enumerate(SCRIPT)}
observations = []
for label, player in PROBES:
mounted = None
for line in lines:
fields = line.split()
if (len(fields) == 4 and fields[0] == player and fields[2] == 'passenger'
and order.get(fields[1].removeprefix('step='), -1) <= order[label]):
mounted = fields[3] == 'true'
value = str(mounted).lower() if mounted is not None else 'missing mount'
observations.append(f'{label} mounted={value}')
return observations
def check(native, all_lines):
if native != EXPECTED:
return f'vanilla did not exercise the expected immersion outcomes: {native!r}'
return None
def main():
lines = net.one_player('mount-submerged-parity', SCRIPT, shown, check, __doc__, step_seconds=1.0)
print(f'mount submersion: {len(lines)} passenger observations match vanilla')
if __name__ == '__main__':
main()