Riders move their horses on servers, and jump them

On a vanilla or Paper server the controlling passenger's client moves a
saddled horse, donkey, mule or camel (Entity.isLocalInstanceAuthoritative,
LivingEntity.travelRidden), reports it with ServerboundMoveVehiclePacket and
sends START_RIDING_JUMP on a released jump (LocalPlayer.sendRidingJump). The
server only turns it (travelRidden without canSimulateMovement). Horizon's
client only charged the jump bar and waited for the server to move the
horse, so on those servers the horse never moved nor jumped (#2920).

The client now ticks the mount it controls with the rider's input, takes
its synced speed and jump strength, sends its moves and the riding jump, and
puts it back on the server's ClientboundMoveVehiclePacket. Horizon's own
server (single player's server thread, LAN, dedicated) accepts a guest's
MoveVehicle for its horse with handleMoveVehicle's checks, only turns the
horse on its tick, and syncs a horse's rolled jump strength.

Fixes #2920

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-30 13:37:32 +07:00
1 parent 68f036059e
commit 699fae6296
8 files changed
+374 -31

No files matched your search

@@ -8,7 +8,7 @@ use horizon_client::{
effects,
lan::attributes::{add_modifier, generic_syncable, instance, operation_named},
mobs::{
Mob,
Mob, Species,
finalize::{self, Attr, Op},
},
};
@@ -147,6 +147,25 @@ impl Game<'_> {
}
}
}
// AbstractHorse.randomizeAttributes rolls a horse's jump strength as
// its base (kept in `vector[0]`), so the instance is synced: its
// rider's client jumps it (`getCustomJump`).
let rolled = matches!(
m.species(),
Species::Horse | Species::SkeletonHorse | Species::ZombieHorse
);
let jump_base = m
.extra
.attributes
.as_deref()
.is_some_and(|s| s.bases.iter().any(|(a, _)| *a == Attr::JumpStrength));
if rolled
&& !jump_base
&& m.extra.vector[0] > 0.0
&& let Some(a) = self.synced_attribute(kind, "minecraft:jump_strength")
{
instance(out, a, m.extra.vector[0]).base = m.extra.vector[0];
}
if let (Some(a), Some((amount, Op::AddMultipliedBase))) = (
self.synced_attribute(kind, "minecraft:movement_speed"),
finalize::transient(m, Attr::MovementSpeed),
+63 -19
View File
@@ -11,6 +11,7 @@
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_client::mobs::equine;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
@@ -225,9 +226,10 @@ impl Game<'_> {
}
}
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat its rider
/// drives goes where the client says, unless it moved too quickly or
/// wrongly; then it stays, and the client is told where it is.
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat or the
/// saddled horse its rider drives goes where the client says, unless it
/// moved too quickly or wrongly; then it stays, and the client is told
/// where it is.
pub(super) fn guest_move_vehicle(
&mut self,
host: &mut LanHost,
@@ -264,9 +266,41 @@ impl Game<'_> {
};
host.hub.send_to(conn, &C::MoveVehicle(packet));
};
// Only a boat is driven by its rider (getControllingPassenger); a
// minecart that is elsewhere on the client is put back.
let Some(i) = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v)) else {
// Only a boat, and a horse its rider's client moves, are driven by
// their rider (getControllingPassenger); a minecart that is elsewhere
// on the client is put back.
let boat = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v));
let mount = vehicle.and_then(|v| {
self.mobs
.iter()
.position(|m| m.id == v && equine::moved_by_client(m))
});
// The vehicle's id, position, rotation, motion, box, step height
// and ground.
let (id, old, yaw, velocity, bounds, step, on_ground) = if let Some(i) = boat {
let b = &self.boats[i];
(
b.id,
b.position,
b.yaw,
b.velocity,
b.bounds(),
0.0,
b.on_ground,
)
} else if let Some(i) = mount {
let m = &self.mobs[i];
let step = m.kind.step_height as f32;
(
m.id,
m.position,
m.yaw,
m.velocity,
m.bounds(),
step,
m.on_ground,
)
} else {
if let Some(c) = vehicle.and_then(|v| self.carts.iter().find(|c| c.id == v))
&& c.position.distance_squared(Vec3::new(x, y, z)) > 1.0
{
@@ -274,8 +308,6 @@ impl Game<'_> {
}
return;
};
let b = &self.boats[i];
let old = b.position;
let good = good.map_or(old, |g| Vec3::new(g[0], g[1], g[2]));
let target = Vec3::new(
x.clamp(-3.0E7, 3.0E7),
@@ -283,16 +315,15 @@ impl Game<'_> {
z.clamp(-3.0E7, 3.0E7),
);
// Moved too quickly: more than 10 blocks past its own motion.
let moved = target.distance_squared(old) - b.velocity.length_squared();
let moved = target.distance_squared(old) - velocity.length_squared();
if moved > 100.0 && !owner {
log::warn!("boat {} moved too quickly! {moved}", b.id);
back(host, old, b.yaw);
log::warn!("vehicle {id} moved too quickly! {moved}");
back(host, old, yaw);
return;
}
// vehicle.move(PLAYER, target - lastGood) with the boat's collisions:
// where it can't follow the client (a quarter block off, the height
// aside), it moved wrongly.
let bounds = b.bounds();
// vehicle.move(PLAYER, target - lastGood) with the vehicle's
// collisions: where it can't follow the client (a quarter block off,
// the height aside), it moved wrongly.
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -300,7 +331,7 @@ impl Game<'_> {
};
let wanted = target.subtract(good);
let resolved = scene
.resolve(bounds, wanted, 0.0, b.on_ground)
.resolve(bounds, wanted, step, on_ground)
.unwrap_or(wanted);
let reached = old.add_vector(resolved);
let wrongly = (target.x - reached.x).powi(2) + (target.z - reached.z).powi(2) > 0.0625;
@@ -311,10 +342,24 @@ impl Game<'_> {
.unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5)))
.unwrap_or(true);
if free_before && (wrongly || !free_after) {
log::warn!("boat {} moved wrongly", b.id);
back(host, old, b.yaw);
log::warn!("vehicle {id} moved wrongly");
back(host, old, yaw);
return;
}
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
if let Some(i) = mount {
// absSnapTo: where and how its rider's client turned it; the
// rider sits on it from its own ride tick.
let m = &mut self.mobs[i];
m.position = target;
m.yaw = p.yaw;
m.head_pitch = p.pitch;
m.on_ground = p.on_ground;
return;
}
let Some(i) = boat else {
return;
};
let b = &mut self.boats[i];
b.previous = old;
b.velocity = target.subtract(old);
@@ -324,7 +369,6 @@ impl Game<'_> {
b.on_ground = p.on_ground;
let seat = self.player_boat_seat(i);
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
g.previous_position = g.position;
g.position = [seat.x, seat.y, seat.z];
}
+3
View File
@@ -396,6 +396,9 @@ impl Game<'_> {
steer,
sprint: keys.sprint || self.keys.contains(&winit::keyboard::KeyCode::ControlLeft),
jumping: keys.jump,
// A server's player (a LAN guest, single player's window) drives
// a horse from its own client (`handleMoveVehicle`).
client: self.lan_acting.is_some(),
});
true
}
+26 -1
View File
@@ -166,6 +166,9 @@ pub(super) struct Remote {
/// The happy ghast the player just got off, until the server's teleport
/// puts the player on top of it (`place_ghast_under`).
pub(super) ghast_dismount: Option<u32>,
/// Whether the player is the first passenger of its vehicle, the one
/// `getControllingPassenger` names.
pub(super) first_passenger: bool,
/// Item entities waiting for their item data: where they are and their
/// motion.
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
@@ -509,6 +512,7 @@ impl Game<'_> {
stepped: HashMap::new(),
head_lerps: HashMap::new(),
ghast_dismount: None,
first_passenger: false,
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
flash_on_set_health: false,
@@ -1961,9 +1965,27 @@ impl Game<'_> {
)
};
let (max_health, scale) = (value("minecraft:max_health"), value("minecraft:scale"));
let (speed, jump) = (
value("minecraft:movement_speed"),
value("minecraft:jump_strength"),
);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
return;
};
// A horse its rider's client moves goes at its own speed and jumps
// its own height (`getSpeed`, `getCustomJump`).
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
if let Some(speed) = speed {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MovementSpeed,
speed,
);
}
if let Some(jump) = jump {
m.extra.vector[0] = jump;
}
}
if let Some(max_health) = max_health {
horizon_client::mobs::finalize::set_base(
m,
@@ -4546,6 +4568,9 @@ impl Game<'_> {
} else {
Vec::new()
};
// The mount this client moves ticks in `remote_ride` instead, as its
// authoritative side (LivingEntity.tick with travelRidden).
let driven = self.driven_mount().map(|m| m.id);
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -4557,7 +4582,7 @@ impl Game<'_> {
let mut mob_fx = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
if (!running && !player) || driven == Some(m.id) {
continue;
}
m.previous = m.position;
@@ -455,6 +455,10 @@ pub(super) fn client_defaults(m: &mut Mob) {
// Bat's constructor sets resting only on the server; the data default is 0.
bat::set_resting(m, false);
}
// A horse's jump strength until the server syncs its own: the type's.
if equine::moved_by_rider(m.species()) {
m.extra.vector[0] = m.kind.jump_strength;
}
}
#[cfg(test)]
@@ -487,6 +491,23 @@ mod tests {
Some(m)
}
/// #2920: a server's horse is moved by its rider's client, which jumps
/// it by the type's jump strength (`createBaseHorseAttributes`,
/// `createBaseChestedHorseAttributes`) until the server syncs its own.
#[test]
fn a_servers_horse_jumps_its_types_strength() {
for (kind, strength) in [("horse", 0.7), ("donkey", 0.5), ("mule", 0.5)] {
let Some(m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert!(
(m.extra.vector[0] - strength).abs() < 1e-6,
"{kind}: {}",
m.extra.vector[0]
);
}
}
#[test]
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
use horizon_client::mobs::guardian::{beam, client_tick};
@@ -7,9 +7,12 @@
//! (`ClientPacketListener.handleSetEntityPassengersPacket`). A boat is
//! controlled by its rider's client: it ticks here and its moves go to the
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
//! the server with the rider's input, and the rider sits in it.
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). So is a saddled horse,
//! donkey, mule or camel: its rider's client moves it and reports the moves,
//! and tells the server of a riding jump (`START_RIDING_JUMP`). A minecart
//! moves on the server with the rider's input, and the rider sits in it.
use super::{Game, Keys, ray_box};
use horizon_client::mobs::{Mob, equine};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::packets::play::{self, Serverbound as S};
@@ -123,6 +126,11 @@ impl Game<'_> {
display.vehicle = None;
}
}
if passengers.contains(&me)
&& let Some(r) = self.remote.as_mut()
{
r.first_passenger = passengers.first() == Some(&me);
}
if passengers.contains(&me) {
if self.riding != Some(vehicle_id) {
self.riding = Some(vehicle_id);
@@ -134,6 +142,9 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
m.rider = None;
}
// HappyGhast.removePassenger: a still timeout of 10 (the server's
// STAYS_STILL confirms it), and the rider lands on top of the ghast
// (`place_ghast_under`).
@@ -155,10 +166,13 @@ impl Game<'_> {
return false;
};
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
// An animal moves on the server with the rider's input
// (PlayerInput); here the jump bar charges for the HUD and the
// rider sits where `positionRider` puts it.
self.charge_mount_jump(i, keys);
// LocalPlayer.aiStep: the jump bar charges while jump is held.
let jump = self.charge_mount_jump(i, keys);
if self.drives(&self.mobs[i]) {
self.drive_mount(i, keys, jump);
}
// Another animal moves on the server with the rider's input
// (PlayerInput); the rider sits where `positionRider` puts it.
self.position_rider();
return true;
}
@@ -200,6 +214,9 @@ impl Game<'_> {
/// `LocalPlayer.tick` for a passenger: the boat it controls goes to the
/// server every tick with its paddles (the rider's own position does not).
pub(super) fn remote_vehicle_packets(&self) -> Vec<S> {
if let Some(m) = self.driven_mount() {
return vec![mount_move(m)];
}
let Some(b) = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
@@ -220,20 +237,168 @@ impl Game<'_> {
]
}
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat the
/// player drives back.
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat or
/// the mount the player drives back.
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(id) = self.driven_mount().map(|m| m.id)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.previous = at;
m.position = at;
m.yaw = yaw;
m.previous_yaw = yaw;
m.velocity = Vec3::ZERO;
return;
}
let Some(b) = self
.riding
.and_then(|id| self.boats.iter_mut().find(|b| b.id == id))
else {
return;
};
let at = Vec3::new(position[0], position[1], position[2]);
b.previous = at;
b.position = at;
b.yaw = yaw;
b.previous_yaw = yaw;
b.velocity = Vec3::ZERO;
}
/// Whether this client moves `m`, the mount the player rides: as its
/// first passenger (`AbstractHorse.getControllingPassenger`), on its
/// saddle (`isLocalInstanceAuthoritative`).
fn drives(&self, m: &Mob) -> bool {
self.riding == Some(m.id)
&& client_moves(m, self.remote.as_ref().is_some_and(|r| r.first_passenger))
}
/// The mount this client moves, if the player rides one.
pub(super) fn driven_mount(&self) -> Option<&Mob> {
let id = self.riding?;
self.mobs.iter().find(|m| m.id == id && self.drives(m))
}
/// `LocalPlayer.aiStep` and `LivingEntity.travelRidden` on the
/// controlling client: a released jump goes to the server
/// (`sendRidingJump`), and the mount takes the rider's input and moves
/// here (`tickRidden`, then `travel`).
fn drive_mount(&mut self, i: usize, keys: Keys, jump: f32) {
if jump > 0.0
&& let Some(id) = self.remote.as_ref().map(|r| r.entity_id)
{
self.send(riding_jump(id, self.mount.jump_scale));
}
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));
let m = &mut self.mobs[i];
// playerJumpPendingScale waits for the ground.
let pending = m.rider.map_or(0.0, |r| r.jump);
m.rider = Some(equine::Rider {
strafe,
forward,
yaw: self.yaw,
pitch: self.pitch,
jump: if jump > 0.0 { jump } else { pending },
steer: false,
sprint: keys.sprint,
jumping: keys.jump,
client: false,
});
let mut mob = self.mobs.remove(i);
let mut random = self.world.random.clone();
let mut events = Vec::new();
{
let world = self.mob_world_base(&[]);
mob.tick(&world, &mut random, &mut events);
}
// Only the server's packets take the mount away.
mob.removed = false;
self.world.random = random;
self.mobs.insert(i, mob);
}
}
/// Whether a player's client moves `m`, the mount it rides
/// (`isLocalInstanceAuthoritative`): a saddled horse, donkey, mule or camel
/// it is the first passenger of (`AbstractHorse.getControllingPassenger`).
fn client_moves(m: &Mob, first_passenger: bool) -> bool {
first_passenger && equine::moved_by_rider(m.species()) && m.extra.saddled && m.alive()
}
/// `LocalPlayer.sendRidingJump`: START_RIDING_JUMP with
/// `floor(getJumpRidingScale() * 100)`, as `onPlayerJump` gets it.
fn riding_jump(player: i32, scale: f32) -> S {
S::PlayerCommand(play::PlayerCommand {
id: player,
action: 3,
data: (scale * 100.0).floor() as i32,
})
}
/// `ServerboundMoveVehiclePacket.fromEntity`: where the mount this client
/// moves is, and how it is turned.
fn mount_move(m: &Mob) -> S {
S::MoveVehicle(play::MoveVehicleRequest {
position: [m.position.x, m.position.y, m.position.z],
yaw: m.yaw,
pitch: m.head_pitch,
on_ground: m.on_ground,
})
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::mobs::MobTypes;
/// A mob of the prepared resources' type `kind` (None without them).
fn mob(kind: &str) -> Option<Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let types = MobTypes::load(&root).ok()?;
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
Some(Mob::new(7, t, Vec3::new(1.5, 64.0, -2.5), 0.0))
}
/// #2920: on a vanilla server the rider's client moves a saddled horse,
/// donkey, mule or camel; a pig, a llama, an unsaddled horse or a second
/// passenger's ride is the server's.
#[test]
fn the_rider_moves_a_saddled_horse_on_its_client() {
for kind in ["horse", "donkey", "mule", "camel", "pig", "llama"] {
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert!(!client_moves(&m, true), "{kind} without a saddle");
m.set_saddle(Some(horizon_client::items::ItemStack::new(
"minecraft:saddle",
1,
)));
let horse = matches!(kind, "horse" | "donkey" | "mule" | "camel");
assert_eq!(client_moves(&m, true), horse, "{kind}");
assert!(!client_moves(&m, false), "{kind} behind another rider");
}
}
/// #2920: the client reports where it moved the mount, and a released
/// jump with the bar's percentage (`sendRidingJump`).
#[test]
fn the_rider_sends_its_mounts_moves_and_jumps() {
let Some(mut m) = mob("minecraft:horse") else {
return;
};
(m.yaw, m.head_pitch, m.on_ground) = (90.0, 10.0, true);
match mount_move(&m) {
S::MoveVehicle(p) => {
assert_eq!(p.position, [1.5, 64.0, -2.5]);
assert_eq!((p.yaw, p.pitch, p.on_ground), (90.0, 10.0, true));
}
other => panic!("{other:?}"),
}
match riding_jump(42, 0.75) {
S::PlayerCommand(p) => assert_eq!((p.id, p.action, p.data), (42, 3, 75)),
other => panic!("{other:?}"),
}
}
}
+52 -1
View File
@@ -1109,7 +1109,13 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
f64::from(m.ai.yya),
f64::from(m.ai.zza),
);
m.travel(w, input, events);
if super::equine::moved_by_client(m) {
// LivingEntity.travelRidden: the rider's client moves it
// (handleMoveVehicle), so the server only stops it.
m.velocity = Vec3::ZERO;
} else {
m.travel(w, input, events);
}
push_entities(m, w, events);
// Spider.tick: climbing while pressed against a wall.
if m.kind.flags.climber {
@@ -2554,6 +2560,51 @@ mod same_vehicle_push_tests {
}
}
#[cfg(test)]
mod ridden_tests {
use super::*;
use crate::mobs::world::with_test_world;
/// #2920: a saddled horse ridden forward, ticked on the server; with
/// `client` its rider's client moves it (`travelRidden` without
/// `canSimulateMovement`): it only turns to the rider's look and stops.
fn ridden(client: bool) -> Option<Mob> {
let mut out = None;
with_test_world(&[], |w, types| {
let kind = std::sync::Arc::new(types.types["minecraft:horse"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.set_saddle(Some(crate::items::ItemStack::new("minecraft:saddle", 1)));
m.persistent = true;
m.rider = Some(super::super::equine::Rider {
forward: 1.0,
yaw: 90.0,
client,
..Default::default()
});
let (mut random, mut events) = (LegacyRandom::new(0), Vec::new());
for _ in 0..5 {
tick(&mut m, w, &mut random, &mut events);
}
out = Some(m);
});
out
}
#[test]
fn the_server_leaves_a_horse_to_its_riders_client() {
let Some(moved) = ridden(false) else {
return;
};
// The host's own ride: the horse walks west and falls through the air.
assert!(moved.position.x < 0.4, "{:?}", moved.position);
assert!(moved.position.y < 64.0, "{:?}", moved.position);
let held = ridden(true).unwrap();
assert_eq!(held.position, Vec3::new(0.5, 64.0, 0.5));
assert_eq!(held.velocity, Vec3::ZERO);
assert_eq!(held.yaw, 90.0, "tickRidden still turns it");
}
}
#[cfg(test)]
mod turtle_ambient_tests {
use super::*;
+15
View File
@@ -47,6 +47,9 @@ pub struct Rider {
pub sprint: bool,
/// Holding the jump key (a happy ghast rises).
pub jumping: bool,
/// A server's player whose own client moves the mount
/// (`Entity.isClientAuthoritative`): here it only turns (`tickRidden`).
pub client: bool,
}
pub fn is_equine(s: Species) -> bool {
@@ -215,6 +218,18 @@ pub fn controlled(m: &Mob) -> bool {
_ => m.extra.saddled,
}
}
/// `AbstractHorse`s (camels too, not llamas, which no player steers): the
/// player controlling one moves it on its own client
/// (`Entity.isLocalInstanceAuthoritative`, `LivingEntity.travelRidden`) and
/// sends the moves (`ServerboundMoveVehiclePacket`).
pub fn moved_by_rider(s: Species) -> bool {
is_equine(s) && !super::llama::is_llama(s)
}
/// A server's mount that its rider's client moves: `travelRidden` without
/// `canSimulateMovement` turns it (`tickRidden`) and stops it there.
pub fn moved_by_client(m: &Mob) -> bool {
moved_by_rider(m.species()) && m.rider.is_some_and(|r| r.client) && controlled(m)
}
/// `ItemBasedSteering.boost`: 140 to 980 ticks of extra speed.
pub fn boost(m: &mut Mob) -> bool {
if m.extra.counter2 > 0 {