Merge origin/main into feat/vanilla-menus

With #1285 Play with Friends works on the server thread: friends_view
offers sharing there, so the pause menu's Friends icon is active on both
paths and inactive only on another server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-23 00:04:42 +02:00
commit bf274d9254
18 files changed
+957 -86

No files matched your search

+5
View File
@@ -75,6 +75,7 @@ mod lan_pvp;
mod lan_screen;
mod lan_stations;
mod lan_trading;
mod lan_vehicles;
mod lan_waypoints;
mod leads;
mod level_particles;
@@ -120,6 +121,7 @@ mod remote;
mod remote_objects;
mod remote_projectiles;
mod remote_stations;
mod remote_vehicles;
mod render_control;
mod render_tick;
mod respawn_anchor;
@@ -1321,6 +1323,8 @@ struct Game<'a> {
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// Bolts that struck here and sky flashes they made, in total (`status`).
lightning_seen: [u32; 2],
/// The head of the bed while sleeping; the visual timer also survives waking.
sleeping: Option<[i32; 3]>,
sleep_timer: u32,
@@ -11554,6 +11558,7 @@ impl<'a> Game<'a> {
dragging: false,
avatar: Default::default(),
flash: 0,
lightning_seen: [0; 2],
sleeping: None,
sleep_timer: 0,
dim: Dim::Overworld,
+10 -3
View File
@@ -57,7 +57,7 @@ pub(super) const CHEST_SLOTS: usize = 27;
const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
const SEAT: f64 = 0.1875 - 0.6;
pub(super) const SEAT: f64 = 0.1875 - 0.6;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -295,7 +295,7 @@ impl Game<'_> {
}
}
/// `AbstractBoat.tick` for one boat (`controlled` when the player rides it).
fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
pub(super) fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
b.previous = b.position;
b.previous_yaw = b.yaw;
b.old_status = b.status;
@@ -448,9 +448,12 @@ impl Game<'_> {
pub(super) fn tick_boats(&mut self) {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
// A server player's boat is driven by its client (lan_vehicles.rs).
let driven = self.guest_vehicles();
for i in 0..self.boats.len() {
let p = self.boats[i].position;
if Some(self.boats[i].id) == self.riding
|| driven.contains(&self.boats[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
continue;
@@ -659,7 +662,11 @@ impl Game<'_> {
self.right = false;
return true;
}
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
if sneaking
|| self.boats[i].out_of_control >= 60.0
|| self.riding.is_some()
|| self.ridden_by_guest(self.boats[i].id)
{
return false;
}
self.riding = Some(self.boats[i].id);
@@ -929,6 +929,8 @@ impl Game<'_> {
status["heard"] = json!(self.audio.heard);
// Boats, minecarts, armor stands, item frames and paintings.
status["objects"] = json!(self.objects_status());
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
status
+16 -8
View File
@@ -58,10 +58,12 @@ impl Game<'_> {
if self.friends.is_some() {
return Ok(());
}
if self.remote.is_some() || self.title.is_some() {
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan.is_none();
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
@@ -210,14 +212,20 @@ impl Game<'_> {
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = self
.lan
.as_ref()
.map(|h| h.hub.guests.iter().map(|g| g.name.clone()).collect())
.unwrap_or_default();
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none(),
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
+7 -1
View File
@@ -807,7 +807,13 @@ impl Game<'_> {
if has_guests {
let host_entity = state.as_ref().map(|s| s.entity_id);
self.lan_assign_ids();
self.lan_riding(host_entity, &mut host.hub.riding);
let guests: Vec<(u32, i32)> = host
.hub
.guests
.iter()
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &mut host.hub, &mut states);
} else {
states.clear();
+57 -7
View File
@@ -292,7 +292,8 @@ impl Game<'_> {
let (left, right) = (self.left, self.right);
self.left = play.digging.is_some();
self.right = play.using.is_some() || self.using.is_some();
let riding = self.riding.take();
// The guest's vehicle (Entity.vehicle) for its turn.
let riding = std::mem::replace(&mut self.riding, g.vehicle);
let host_station = self.station.take();
// The guest's bed, sleep counter and respawn point; the host's
// respawn point, anchor and action bar stay theirs.
@@ -467,7 +468,7 @@ impl Game<'_> {
(self.respawn_config, self.level.spawn) = respawn;
// ServerPlayer.displayClientMessage(_, true): the guest's action bar.
let guest_message = std::mem::replace(&mut self.message, message);
self.riding = riding;
let guest_vehicle = std::mem::replace(&mut self.riding, riding);
self.left = left;
self.right = right;
play.mining = std::mem::replace(&mut self.mining, mining);
@@ -491,6 +492,11 @@ impl Game<'_> {
self.mount_menu = host_mount_menu;
self.keys = host_keys;
let g = &mut host.hub.guests[i];
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
if guest_vehicle.is_none() {
g.vehicle_good = None;
}
g.vehicle = guest_vehicle;
let fell_asleep = bed.is_some() && g.bed.is_none();
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
g.sleeping = g.bed.is_some();
@@ -549,7 +555,24 @@ impl Game<'_> {
let teleported = std::mem::take(&mut m.teleported);
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
g.motion = Some(Box::new(m));
if moved > 0.0001 || turned || teleported {
// Entity.teleportTo takes a rider out of its vehicle.
let left_vehicle = match g.vehicle.filter(|_| teleported) {
Some(v) => {
(g.vehicle, g.vehicle_good) = (None, None);
Some(v)
}
None => left_vehicle,
};
// ServerPlayer.dismountTo: the client hears it is out of the
// vehicle before the teleport beside it. A rider sits where its
// vehicle puts it (lan_vehicles.rs), with no teleport.
if let Some(vehicle) = left_vehicle {
g.send(&C::SetPassengers(play::SetPassengers {
vehicle: vehicle as i32,
passengers: Vec::new(),
}));
}
if g.vehicle.is_none() && (moved > 0.0001 || turned || teleported) {
host.hub.teleport(i, Some(at));
}
}
@@ -900,16 +923,22 @@ impl Game<'_> {
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
if let Some((position, eye)) = self.look_at_entity(host, gi, p.id) {
let _ = (position, eye);
let before = host.hub.guests[gi].vehicle;
if self
.look_at_entity(host, gi, p.id, Some(p.location))
.is_some()
{
let swung = self.as_guest(host, conn, |g| {
let swings = g.swings_started;
let _ = g.use_item();
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
self.guest_boarded(host, conn, before);
}
}
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
S::SetCreativeModeSlot(p) => {
let g = &mut host.hub.guests[gi];
if !g.creative() || !(0..46).contains(&p.slot) {
@@ -1147,19 +1176,39 @@ impl Game<'_> {
}
}
/// Aim the swapped-in player at an entity; returns its centre and the eye.
/// Aim the swapped-in player at an entity; returns where it aims and the
/// eye. A boat, minecart, armor stand, item frame or painting is aimed at
/// the point the client hit (the interact packet's location) when that
/// is on it, else at its centre.
fn look_at_entity(
&mut self,
host: &mut LanHost,
gi: usize,
id: i32,
location: Option<[f64; 3]>,
) -> Option<([f64; 3], [f64; 3])> {
let object = || {
let (lo, hi, origin) = self.object_target(id)?;
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
let Some(l) = location else {
return Some(middle);
};
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
// A little inside the box, so the ray from the eye meets it.
let inside = [0, 1, 2].map(|k| {
let margin = ((hi[k] - lo[k]) / 2.0).min(0.01);
point[k].clamp(lo[k] + margin, hi[k] - margin)
});
let on = (0..3).all(|k| (point[k] - inside[k]).abs() < 0.1);
Some(if on { inside } else { middle })
};
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
Some(m) => [
m.position.x,
m.position.y + m.kind.height * 0.5,
m.position.z,
],
None if self.object_target(id).is_some() => object()?,
// A dragon part or an end crystal (game/dragon.rs).
None => {
let b = self.tracked_special_box(id)?;
@@ -1210,7 +1259,7 @@ impl Game<'_> {
return;
}
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
if self.look_at_entity(host, gi, id).is_none() {
if self.look_at_entity(host, gi, id, None).is_none() {
return;
}
self.as_guest(host, conn, |g| {
@@ -1676,6 +1725,7 @@ impl Game<'_> {
/// regeneration and the food tick (ServerPlayer.doTick). Their hurt
/// sounds are heard at the guest.
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
self.guest_vehicle_tick(host, conn);
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
+15 -1
View File
@@ -89,8 +89,17 @@ fn stand_poses(raw: Option<&Tag>) -> Vec<(u8, i32, Value)> {
impl Game<'_> {
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
/// host in its boat or minecart.
pub(super) fn lan_riding(&self, host_entity: Option<i32>, riding: &mut Riding) {
pub(super) fn lan_riding(
&self,
host_entity: Option<i32>,
guests: &[(u32, i32)],
riding: &mut Riding,
) {
riding.clear();
// The server's players in their boats and minecarts.
for &(vehicle, guest) in guests {
riding.add(vehicle as i32, guest);
}
for m in self.mobs.iter().filter(|m| !m.removed) {
if let Some(vehicle) = m.extra.mount {
riding.add(vehicle as i32, m.id as i32);
@@ -264,6 +273,7 @@ impl Game<'_> {
self.tridents.iter_mut().for_each(|t| give(&mut t.id));
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
}
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
@@ -275,6 +285,10 @@ impl Game<'_> {
None => 0,
};
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
// LightningBolt: each client draws and flashes its own.
for b in self.bolts.iter().filter(|b| b.id != 0) {
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
}
for t in self.tnts.iter().filter(|t| t.id != 0) {
let mut e = state(t.id, "minecraft:tnt", at(t.position), 0.0, t.on_ground);
e.velocity = at(t.velocity);
@@ -0,0 +1,288 @@
//! Boats and minecarts for a server's players: LAN guests, players on a
//! dedicated server, and single player's window on its server thread.
//!
//! A player aims at a vehicle, item frame, armor stand or painting as at a
//! mob (`ServerGamePacketListenerImpl.handleInteract`), and the game's own
//! code for it runs as that player (`AbstractBoat.interact` seats it,
//! `VehicleEntity.hurtServer` breaks the vehicle). A boat is driven by its
//! rider's client (`handleMoveVehicle`, `handlePaddleBoat`); a minecart moves
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, boats::SEAT, lan::LanHost};
use horizon_client::collisions::SceneCollisions;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
text::translatable,
};
/// `PlayerInput` flags, as the server player's client last sent them.
pub(super) fn input_keys(flags: u8) -> Keys {
Keys {
forward: flags & 1 != 0,
backward: flags & 2 != 0,
left: flags & 4 != 0,
right: flags & 8 != 0,
jump: flags & 16 != 0,
shift: flags & 32 != 0,
..Keys::default()
}
}
fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
let (lo, hi) = (b.min_position(), b.max_position());
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
}
impl Game<'_> {
/// The box of a boat, minecart, armor stand, item frame or painting, and
/// the point an interact packet's location is measured from (the
/// client's `remote_object_hit` uses the same points).
pub(super) fn object_target(&self, id: i32) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
let id = u32::try_from(id).ok()?;
let at = |v: Vec3| [v.x, v.y, v.z];
let centre = |p: [i32; 3]| p.map(|v| f64::from(v) + 0.5);
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
let (lo, hi) = corners(b.bounds());
return Some((lo, hi, at(b.position)));
}
if let Some(c) = self.carts.iter().find(|c| c.id == id) {
let (lo, hi) = corners(c.bounds());
return Some((lo, hi, at(c.position)));
}
if let Some(a) = self.armor_stands.iter().find(|a| a.id == id) {
let (lo, hi) = corners(a.bounds());
return Some((lo, hi, at(a.position)));
}
if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
let (lo, hi) = f.bounds();
return Some((lo, hi, centre(f.pos)));
}
let p = self.paintings.iter().find(|p| p.id == id)?;
let (lo, hi) = p.bounds();
Some((lo, hi, centre(p.pos)))
}
/// The vehicles the server's players ride: they move with their riders,
/// not in `tick_boats` and `tick_carts`.
pub(super) fn guest_vehicles(&self) -> Vec<u32> {
self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub.guests.iter().filter_map(|g| g.vehicle).collect()
})
}
/// Whether a server player other than the one acting rides `vehicle`
/// (`Entity.canAddPassenger`: a boat or minecart seats one player here).
pub(super) fn ridden_by_guest(&self, vehicle: u32) -> bool {
self.lan.as_ref().is_some_and(|h| {
h.hub
.guests
.iter()
.any(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(vehicle))
})
}
/// `Player.rideTick` for a server player, before its moves this tick:
/// it stays in its seat, steers its minecart, or gets out.
pub(super) fn guest_vehicle_tick(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let g = &host.hub.guests[gi];
let Some(vehicle) = g.vehicle else {
return;
};
let (keys, dead) = (input_keys(g.input), g.dead || g.health <= 0.0);
let boat = self.boats.iter().position(|b| b.id == vehicle);
let cart = self.carts.iter().any(|c| c.id == vehicle);
// The vehicle is gone (broken, in another level) or the rider died:
// its client drops it with the entity or the death screen.
if dead || (boat.is_none() && !cart) {
let g = &mut host.hub.guests[gi];
(g.vehicle, g.vehicle_good) = (None, None);
return;
}
if let Some(i) = boat {
// AbstractBoat.tick on the server for a boat its rider drives:
// only the hurt animation and the damage wear off here.
let b = &mut self.boats[i];
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
}
// Getting out goes through leave_guest, which tells the client and
// teleports it (ServerPlayer.dismountTo).
let seat = self.as_guest(host, conn, |game| {
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
if keys.shift {
game.dismount(i);
return None;
}
let p = game.boats[i].position;
return Some([p.x, p.y + SEAT, p.z]);
}
// The minecart moves with the rider's input, and lets it out when
// it sneaks or the cart breaks.
game.tick_cart_riding(keys);
game.riding?;
let p = game.player.as_ref()?.position;
Some([p.x, p.y, p.z])
});
if let Some(Some(at)) = seat {
let g = &mut host.hub.guests[gi];
g.previous_position = g.position;
g.position = at;
}
}
/// After an interaction: a player that got into a vehicle sits in it,
/// and its last good vehicle position is where the vehicle is.
pub(super) fn guest_boarded(&mut self, host: &mut LanHost, conn: u32, before: Option<u32>) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let Some(vehicle) = host.hub.guests[gi].vehicle else {
return;
};
if before == Some(vehicle) {
return;
}
// The host in the window rides it already.
if self.riding == Some(vehicle) {
host.hub.guests[gi].vehicle = before;
return;
}
let at = self
.boats
.iter()
.find(|b| b.id == vehicle)
.map(|b| b.position)
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == vehicle)
.map(|c| c.position)
});
let g = &mut host.hub.guests[gi];
if let Some(p) = at {
g.vehicle_good = Some([p.x, p.y, p.z]);
g.previous_position = g.position;
g.position = [p.x, p.y + SEAT, p.z];
g.on_ground = false;
}
}
/// `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.
pub(super) fn guest_move_vehicle(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::MoveVehicleRequest,
) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let [x, y, z] = p.position;
if !(x.is_finite() && y.is_finite() && z.is_finite())
|| !p.yaw.is_finite()
|| !p.pitch.is_finite()
{
host.hub.kick(
conn,
translatable("multiplayer.disconnect.invalid_vehicle_movement", &[]),
);
return;
}
let g = &host.hub.guests[gi];
if g.awaiting_teleport.is_some() || !g.loaded {
return;
}
let owner = host.hub.singleplayer_owner == Some(g.uuid);
let (vehicle, good) = (g.vehicle, g.vehicle_good);
let back = |host: &mut LanHost, position: Vec3, yaw: f32| {
let packet = play::MoveVehicle {
position: [position.x, position.y, position.z],
yaw,
pitch: 0.0,
};
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 {
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
{
back(host, c.position, c.yaw);
}
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),
y.clamp(-2.0E7, 2.0E7),
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();
if moved > 100.0 && !owner {
log::warn!("boat {} moved too quickly! {moved}", b.id);
back(host, old, b.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();
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let wanted = target.subtract(good);
let resolved = scene
.resolve(bounds, wanted, 0.0, b.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;
let free_before = scene.unobstructed(bounds).unwrap_or(true);
// isEntityCollidingWithAnythingNew: a box that was free is not there.
let moved_box = bounds.move_by(target.subtract(old));
let free_after = scene
.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);
return;
}
let b = &mut self.boats[i];
b.previous = old;
b.velocity = target.subtract(old);
b.position = target;
b.previous_yaw = b.yaw;
b.yaw = p.yaw;
b.on_ground = p.on_ground;
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
g.previous_position = g.position;
g.position = [target.x, target.y + SEAT, target.z];
}
/// `handlePaddleBoat`: the paddles of the boat the player drives.
pub(super) fn guest_paddle_boat(&mut self, host: &LanHost, conn: u32, paddles: [bool; 2]) {
let vehicle = host
.hub
.guests
.iter()
.find(|g| g.conn == conn)
.and_then(|g| g.vehicle);
if let Some(b) = self.boats.iter_mut().find(|b| Some(b.id) == vehicle) {
b.paddling = paddles;
}
}
}
+26 -1
View File
@@ -9,7 +9,9 @@ use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{position::BlockPos, random::RandomSource};
pub(super) struct Bolt {
at: [f64; 3],
/// The entity id a server's players know it by (0 until it has one).
pub id: u32,
pub at: [f64; 3],
life: i32,
flashes: i32,
seed: i64,
@@ -131,7 +133,9 @@ impl Game<'_> {
let r = &mut self.world.random;
let seed = r.next_long();
let flashes = r.next_int_bound(3).unwrap_or(0) + 1;
self.lightning_seen[0] += 1;
self.bolts.push(Bolt {
id: 0,
at,
life: 2,
flashes,
@@ -140,6 +144,24 @@ impl Game<'_> {
converted: Vec::new(),
});
}
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
/// only flashes, sounds and draws (`LightningBolt.tick` off the server).
pub(super) fn spawn_remote_bolt(&mut self, id: u32, at: [f64; 3]) {
self.bolts.retain(|b| b.id != id);
let r = &mut self.world.random;
let seed = r.next_long();
let flashes = r.next_int_bound(3).unwrap_or(0) + 1;
self.lightning_seen[0] += 1;
self.bolts.push(Bolt {
id,
at,
life: 2,
flashes,
seed,
visual_only: true,
converted: Vec::new(),
});
}
/// `spawnFire`: fire at the strike point and `extra` random neighbours.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
if self.ui.difficulty < 2 {
@@ -216,6 +238,9 @@ impl Game<'_> {
}
}
if b.life >= 0 {
if self.flash == 0 {
self.lightning_seen[1] += 1;
}
self.flash = 2;
if !b.visual_only {
self.thunder_hit(b);
+4 -1
View File
@@ -775,10 +775,13 @@ impl Game<'_> {
self.push_carts();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
// A server player's minecart moves with its rider (lan_vehicles.rs).
let ridden = self.guest_vehicles();
let mut i = 0;
while i < self.carts.len() {
let p = self.carts[i].position;
if Some(self.carts[i].id) == self.riding
|| ridden.contains(&self.carts[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
i += 1;
@@ -986,7 +989,7 @@ impl Game<'_> {
let held = self.held().cloned();
match self.carts[i].kind {
CartKind::Rideable => {
if sneaking || self.riding.is_some() {
if sneaking || self.riding.is_some() || self.ridden_by_guest(self.carts[i].id) {
return false;
}
self.riding = Some(self.carts[i].id);
+58 -8
View File
@@ -93,7 +93,7 @@ pub(super) struct Remote {
pub(super) registries: Arc<Dynamic>,
joined: bool,
status: String,
entity_id: i32,
pub(super) entity_id: i32,
dimension: DimensionType,
biome_map: Vec<u16>,
biome_bits: u8,
@@ -1367,6 +1367,10 @@ impl Game<'_> {
self.turn_remote_projectile(id, rotation);
}
fn remove_entity(&mut self, id: i32) {
// The vehicle the player rode is gone (Entity.removeVehicle).
if self.riding == Some(id as u32) {
self.riding = None;
}
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
@@ -1753,6 +1757,8 @@ impl Game<'_> {
];
self.move_entity(p.id, position, Some((p.yaw, p.pitch)), p.on_ground);
}
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw),
// ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3).
C::RotateHead(p) => {
if let Some(r) = self.remote.as_mut() {
@@ -2932,9 +2938,9 @@ impl Game<'_> {
self.capture_loaded_world();
}
let stick = self.pad_move();
let Some(player) = self.player.as_mut() else {
if self.player.is_none() {
return Ok(());
};
}
let playing = self.screen == Screen::Playing;
let held = |k| playing && self.keys.contains(&k);
let keys = Keys {
@@ -2955,7 +2961,14 @@ impl Game<'_> {
| u8::from(keys.jump) << 4
| u8::from(keys.shift) << 5
| u8::from(keys.sprint) << 6;
self.previous_position = player.position;
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
let Some(player) = self.player.as_mut() else {
return Ok(());
};
if !riding {
self.previous_position = player.position;
}
let before = (
player.ground.on_ground,
[player.velocity.x, player.velocity.y, player.velocity.z],
@@ -2972,7 +2985,7 @@ impl Game<'_> {
f64::from(head[2]) + 0.5,
);
player.velocity = Vec3::ZERO;
} else if !dead {
} else if !dead && !riding {
player.velocity = player.velocity.add_vector(pushed);
MotionWorld {
entities: &ghast_collisions,
@@ -3258,6 +3271,9 @@ impl Game<'_> {
};
self.tick_remote_objects(&mut step);
self.tick_remote_projectiles(&mut step);
// LightningBolt.tick on a client, and the sky flash fading.
self.flash = self.flash.saturating_sub(1);
self.tick_bolts();
}
lerps.retain(|_, l| l.steps > 0);
if let Some(r) = self.remote.as_mut() {
@@ -3319,6 +3335,8 @@ impl Game<'_> {
p.sprinting,
);
let (yaw, pitch, selected) = (self.yaw, self.pitch, self.selected);
let riding = self.riding.is_some();
let vehicle = self.remote_vehicle_packets();
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3341,9 +3359,18 @@ impl Game<'_> {
data: 0,
}));
}
if let Some(packet) = r
.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
if riding {
// LocalPlayer.tick: a passenger sends its look, then the vehicle
// it controls.
out.push(S::MovePlayerRot(play::MovePlayerRot {
yaw,
pitch,
flags: u8::from(on_ground) | u8::from(collision) << 1,
}));
out.extend(vehicle);
} else if let Some(packet) =
r.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
{
out.push(packet);
}
@@ -3574,6 +3601,21 @@ impl Game<'_> {
fn remote_use_entity(&mut self) -> bool {
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
// A boat, minecart, item frame, armor stand or painting nearer than
// any mob: the server uses it (AbstractBoat.interact boards it).
if let Some(hit) = self.remote_object_hit()
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
self.send(S::Interact(play::Interact {
id: hit.id,
hand: 0,
location: hit.location,
secondary: sneaking,
}));
self.swing_arm();
return true;
}
if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
@@ -3953,6 +3995,14 @@ impl Game<'_> {
id: self.creatures.projectiles[i].id as i32,
}));
self.reset_attack_strength();
} else if let Some(hit) = self.remote_object_hit()
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
// a painting broken: the server decides.
self.send(S::Attack(play::Attack { id: hit.id }));
self.reset_attack_strength();
} else if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
@@ -52,6 +52,8 @@ impl Game<'_> {
invisible: false,
check_interval: 0,
});
} else if kind == "minecraft:lightning_bolt" {
self.spawn_remote_bolt(id, position);
} else if kind == "minecraft:painting" {
// The variant comes with the entity data; until then the
// registry's first one (VariantUtils.getAny).
@@ -188,7 +190,12 @@ impl Game<'_> {
/// A move's rotation and ground state for an object.
pub(super) fn turn_remote_object(&mut self, id: i32, yaw: Option<f32>, on_ground: bool) {
let id = id as u32;
// The boat the player drives turns here (isLocalInstanceAuthoritative).
let driven = self.riding;
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
if Some(id) == driven {
return;
}
if let Some(yaw) = yaw {
b.yaw = yaw;
}
@@ -213,7 +220,12 @@ impl Game<'_> {
c.previous_yaw = c.yaw;
c.position = step(c.id, c.position);
}
// The boat the player drives moves here, not where the server says.
let driven = self.riding;
for b in &mut self.boats {
if Some(b.id) == driven {
continue;
}
b.previous = b.position;
b.previous_yaw = b.yaw;
b.position = step(b.id, b.position);
@@ -274,5 +286,6 @@ impl Game<'_> {
self.armor_stands.retain(|a| a.id != id);
self.item_frames.retain(|f| f.id != id);
self.paintings.retain(|p| p.id != id);
self.bolts.retain(|b| b.id != id);
}
}
@@ -0,0 +1,181 @@
//! Boats, minecarts, item frames, armor stands and paintings for a server's
//! client: single player on its server thread, a LAN guest, a player on a
//! dedicated server.
//!
//! The client aims at them like at mobs (`Minecraft.hitResult`, then
//! `ServerboundInteractPacket`), and rides the vehicle the server seats it in
//! (`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.
use super::{Game, Keys, view_vector};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::{self, Serverbound as S};
/// An object under the crosshair: its entity id, how far along the view it
/// was hit, and where (`EntityHitResult.getLocation` minus the entity's
/// position, the interact packet's location).
pub(super) struct ObjectHit {
pub id: i32,
pub distance: f64,
pub location: [f64; 3],
}
impl Game<'_> {
/// The nearest boat, minecart, item frame, armor stand or painting the
/// view hits within reach.
pub(super) fn remote_object_hit(&self) -> Option<ObjectHit> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let at = |d: f64| {
[
eye[0] + dir[0] * d,
eye[1] + dir[1] * d,
eye[2] + dir[2] * d,
]
};
let block_centre = |p: [i32; 3]| {
[
f64::from(p[0]) + 0.5,
f64::from(p[1]) + 0.5,
f64::from(p[2]) + 0.5,
]
};
let mut hits: Vec<(i32, f64, [f64; 3])> = Vec::new();
if let Some((i, d)) = self.painting_hit() {
let p = &self.paintings[i];
hits.push((p.id as i32, d, block_centre(p.pos)));
}
if let Some((i, d)) = self.frame_hit() {
let f = &self.item_frames[i];
hits.push((f.id as i32, d, block_centre(f.pos)));
}
if let Some((i, d, _)) = self.armor_stand_hit() {
let a = &self.armor_stands[i];
let p = a.position;
hits.push((a.id as i32, d, [p.x, p.y, p.z]));
}
if let Some((i, d)) = self.boat_hit() {
let p = self.boats[i].position;
hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z]));
}
if let Some((i, d)) = self.cart_hit() {
let p = self.carts[i].position;
hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z]));
}
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
let point = at(distance);
Some(ObjectHit {
id,
distance,
location: [
point[0] - origin[0],
point[1] - origin[1],
point[2] - origin[2],
],
})
}
/// `ClientPacketListener.handleSetEntityPassengersPacket`: who rides
/// `vehicle` now. The local player gets in or out; mobs and other
/// players sit where the server keeps them.
pub(super) fn remote_set_passengers(&mut self, vehicle: i32, passengers: &[i32]) {
let Some(me) = self.remote.as_ref().map(|r| r.entity_id) else {
return;
};
let vehicle_id = vehicle as u32;
for m in &mut self.mobs {
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
m.extra.mount = None;
} else if passengers.contains(&(m.id as i32)) {
m.extra.mount = Some(vehicle_id);
}
}
if passengers.contains(&me) {
if self.riding != Some(vehicle_id) {
self.riding = Some(vehicle_id);
if let Some(p) = self.player.as_mut() {
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
}
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
}
}
/// The local player's tick in its vehicle, in place of its own movement:
/// false when it rides nothing.
pub(super) fn remote_ride(&mut self, keys: Keys) -> bool {
let Some(id) = self.riding else {
return false;
};
let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) {
// AbstractBoat.tick on the controlling client: steer and move.
let mut b = self.boats[i].clone();
b.input = [keys.left, keys.right, keys.forward, keys.backward];
let b = self.tick_boat(b, true);
// positionRider: the seat turns with the boat; clampRotation.
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
let seat = b.position;
self.boats[i] = b;
Some(seat)
} else {
// A minecart: the server moves it, the rider sits in it.
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
};
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
if let Some(v) = seat {
p.position = Vec3::new(v.x, v.y + super::boats::SEAT, v.z);
}
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
true
}
/// `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> {
let Some(b) = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
else {
return Vec::new();
};
vec![
S::MoveVehicle(play::MoveVehicleRequest {
position: [b.position.x, b.position.y, b.position.z],
yaw: b.yaw,
pitch: 0.0,
on_ground: b.on_ground,
}),
S::PaddleBoat(play::PaddleBoat {
left: b.paddling[0],
right: b.paddling[1],
}),
]
}
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat the
/// player drives back.
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
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;
}
}
+10
View File
@@ -71,6 +71,13 @@ pub struct Guest {
/// The permission level (0 none, 2 game masters, 4 owners).
pub permission: u8,
pub sneaking: bool,
/// The last `ServerboundPlayerInputPacket` flags (forward 1, backward 2,
/// left 4, right 8, jump 16, shift 32, sprint 64).
pub input: u8,
/// The boat or minecart the player rides (`Entity.getVehicle`).
pub vehicle: Option<u32>,
/// `ServerGamePacketListenerImpl.lastGoodX/Y/Z` of the vehicle it controls.
pub vehicle_good: Option<[f64; 3]>,
pub sprinting: bool,
pub fall_flying: bool,
pub flying: bool,
@@ -233,6 +240,9 @@ impl Guest {
game_mode: SURVIVAL,
permission: 0,
sneaking: false,
input: 0,
vehicle: None,
vehicle_good: None,
sprinting: false,
fall_flying: false,
flying: false,
+6
View File
@@ -1968,6 +1968,7 @@ impl Hub {
g.center = None;
}
S::PlayerInput(p) => {
self.guests[i].input = p.flags;
self.guests[i].sneaking = p.flags & 32 != 0;
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
}
@@ -2050,6 +2051,11 @@ impl Hub {
to[2].clamp(-3.0E7, 3.0E7),
];
let (yaw, pitch) = (wrap_degrees(yaw), wrap_degrees(pitch));
// A passenger only turns: its vehicle carries it (absSnapTo where it is).
if g.vehicle.is_some() {
(g.yaw, g.pitch) = (yaw, pitch);
return;
}
let d = [0, 1, 2].map(|k| to[k] - g.first_good[k]);
let moved = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if g.sleeping {
@@ -672,6 +672,11 @@ packet!(
/// ClientboundSetPassengersPacket: a vehicle and every entity riding it.
SetPassengers { vehicle: i32 => VarInt, passengers: Vec<i32> => List<VarInt> }
);
packet!(
/// ClientboundMoveVehiclePacket: the server puts a vehicle its rider
/// controls back where it was (PositionAndRotation: x, y, z, yRot, xRot).
MoveVehicle { position: [f64; 3] => Vec3d, yaw: f32 => Float, pitch: f32 => Float }
);
/// ClientboundSetEquipmentPacket: (EquipmentSlot ordinal, stack) pairs.
pub struct Equipment;
impl Codec<Vec<(u8, Option<Stack>)>> for Equipment {
@@ -1529,6 +1534,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
SetHeldSlot(SetHeldSlot) = SET_HELD_SLOT,
SetObjective(SetObjective) = SET_OBJECTIVE,
SetPassengers(SetPassengers) = SET_PASSENGERS,
MoveVehicle(MoveVehicle) = MOVE_VEHICLE,
ProjectilePower(ProjectilePower) = PROJECTILE_POWER,
SetPlayerInventory(SetPlayerInventory) = SET_PLAYER_INVENTORY,
SetPlayerTeam(SetPlayerTeam) = SET_PLAYER_TEAM,
@@ -1802,6 +1808,15 @@ packet!(
packet!(MovePlayerPosRot { x: f64 => Double, y: f64 => Double, z: f64 => Double, yaw: f32 => Float, pitch: f32 => Float, flags: u8 => UByte });
packet!(MovePlayerRot { yaw: f32 => Float, pitch: f32 => Float, flags: u8 => UByte });
packet!(MovePlayerStatus { flags: u8 => UByte });
packet!(
/// ServerboundMoveVehiclePacket: where the rider moved the vehicle it
/// controls (PositionAndRotation, then onGround).
MoveVehicleRequest { position: [f64; 3] => Vec3d, yaw: f32 => Float, pitch: f32 => Float, on_ground: bool => Bool }
);
packet!(
/// ServerboundPaddleBoatPacket: the boat's left and right paddles.
PaddleBoat { left: bool => Bool, right: bool => Bool }
);
packet!(PickItemFromBlock { pos: [i32; 3] => Pos, include_data: bool => Bool });
packet!(
/// ServerboundPlayerActionPacket.Action: 0 start destroy, 1 change destroy
@@ -1869,6 +1884,8 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
MovePlayerPosRot(MovePlayerPosRot) = MOVE_PLAYER_POS_ROT,
MovePlayerRot(MovePlayerRot) = MOVE_PLAYER_ROT,
MovePlayerStatus(MovePlayerStatus) = MOVE_PLAYER_STATUS_ONLY,
MoveVehicle(MoveVehicleRequest) = MOVE_VEHICLE,
PaddleBoat(PaddleBoat) = PADDLE_BOAT,
PickItemFromBlock(PickItemFromBlock) = PICK_ITEM_FROM_BLOCK,
PingRequest(crate::packets::status::Ping) = PING_REQUEST,
PlayerAbilities(PlayerAbilitiesRequest) = PLAYER_ABILITIES,
+227 -53
View File
@@ -116,8 +116,15 @@ address. The script then checks, from both sides, what a guest's game used to ge
and wheat on farmland grows (random ticks), however the host runs its world.
- ladder: the guest climbs down ten blocks of ladder and lands unhurt: the server resets its fall
distance on a ladder every tick, as LivingEntity.handleOnClimbable does (#1255).
- boats: the host (single player, on its server thread with --server-thread) right-clicks a
boat on water and rides it, sneaks out, and breaks it with its fists (#1222).
- boats: the host (single player, on its server thread or not) and the guest each right-click a
boat on water and ride it, paddle it forward (the other side sees it move), sneak out,
and break it with their fists (#1222).
- minecarts: the host and the guest each get into a minecart on rails, sneak out beside it, and break
it (#1222).
- frames: the host and the guest each put a diamond in an item frame and turn it, and put a
helmet on an armor stand and take it back (#1222).
- lightning: a bolt summoned beside the host, then the guest: both sides get the bolt and flash the
sky (on its server thread the host's window is a client of it too).
- flight: a player in creative double-taps jump and flies, stays up, and stops flying in survival:
the guest, and on the server thread the host's window too, take what may fly from the
server's abilities (ClientPacketListener.handlePlayerAbilities).
@@ -159,7 +166,7 @@ ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
# The host plays single player on its server thread (--server-thread).
SERVER_THREAD = False
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats']
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning']
class Game:
@@ -2312,50 +2319,222 @@ def check_ladder(host, guest, out):
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
def check_boats(host, guest, out):
"""#1222: the single player boards a boat on water with a right click, sneaks out, and breaks it."""
def apart(host, guest):
"""The host where it stands and the guest 12 blocks east, each on a stone floor with room
around: neither stands in the other's way (a ray from inside a player's box hits that player)."""
x, y, z = (int(v // 1) for v in pos_of(host))
bx = x + 3
host('command', command='gamemode survival')
host('command', command=f'fill {bx - 1} {y} {z - 1} {bx + 1} {y + 3} {z + 1} minecraft:air')
host('command', command=f'fill {bx - 1} {y - 1} {z - 1} {bx + 1} {y - 1} {z + 1} minecraft:water')
host('command', command=f'summon minecraft:oak_boat {bx + 0.5} {y - 0.1} {z + 0.5}')
host.wait(30)
boats = [o for o in host('status')['objects'] if o['kind'].endswith('oak_boat')
and abs(o['position'][0] - (bx + 0.5)) < 2 and abs(o['position'][2] - (z + 0.5)) < 2]
try:
assert boats, 'the host sees no boat on the water'
boat = boats[0]
bp = boat['position']
host.raw('look-at', f'{bp[0]:.2f}', f'{bp[1] + 0.3:.2f}', f'{bp[2]:.2f}')
host.raw('mouse', 'right', 'click')
host.wait(10)
riding = host('status').get('riding')
assert riding == boat['id'], f'right-clicking the boat {boat["id"]}: riding {riding}'
host.raw('key', 'shift', 'down')
host.wait(10)
host.raw('key', 'shift', 'up')
host.wait(10)
riding = host('status').get('riding')
assert riding is None, f'sneaking out of the boat: riding {riding}'
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
host.wait(5)
gone = False
for _ in range(10):
here = [o for o in host('status')['objects'] if o['id'] == boat['id']]
if not here:
gone = True
spots = {}
for label, player, dx in (('host', host, 0), ('guest', guest, 12)):
px = x + dx
host('command', command=f'fill {px - 3} {y - 1} {z - 3} {px + 4} {y - 1} {z + 6} minecraft:stone')
host('command', command=f'fill {px - 3} {y} {z - 3} {px + 4} {y + 3} {z + 6} minecraft:air')
player('command', command=f'tp @s {px + 0.5} {y} {z + 0.5} 0 0')
spots[label] = (px, y, z)
host.wait(10)
guest.wait(10)
return spots
def board(player, kind, at):
"""Right-click the `kind` object near `at` and check `player` rides it."""
found = [o for o in player('status')['objects'] if o['kind'].endswith(kind)
and abs(o['position'][0] - at[0]) < 2 and abs(o['position'][2] - at[2]) < 2]
assert found, f'no {kind} near {at}'
vehicle = found[0]
vp = vehicle['position']
player.raw('look-at', f'{vp[0]:.2f}', f'{vp[1] + 0.3:.2f}', f'{vp[2]:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(10)
riding = player('status').get('riding')
assert riding == vehicle['id'], f'right-clicking the {kind} {vehicle["id"]}: riding {riding}'
return vehicle
def leave_vehicle(player, kind):
player.raw('key', 'shift', 'down')
player.wait(10)
player.raw('key', 'shift', 'up')
player.wait(10)
riding = player('status').get('riding')
assert riding is None, f'sneaking out of the {kind}: riding {riding}'
def break_vehicle(player, vehicle_id, kind):
"""Hit it with fists until it breaks: VehicleEntity.tick takes 1 off its damage every tick."""
for _ in range(10):
here = [o for o in player('status')['objects'] if o['id'] == vehicle_id]
if not here:
return
vp = here[0]['position']
player.raw('look-at', f'{vp[0]:.2f}', f'{vp[1] + 0.3:.2f}', f'{vp[2]:.2f}')
player.raw('mouse', 'left', 'click')
player.wait(2)
raise AssertionError(f'ten hits left the {kind} in place')
def check_boats(host, guest, out):
"""#1222: the host (single player, on its server thread or not) and the guest each board a boat
on water with a right click, paddle it forward (the other side sees it move), sneak out, and
break it with their fists."""
result = {}
spots = apart(host, guest)
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
bx = x + 3
player('command', command='gamemode survival')
# A pool ten blocks long in a stone basin (the world may float), with a path beside it to
# walk back to the boat.
player('command', command=f'fill {bx - 3} {y} {z - 2} {bx + 2} {y + 3} {z + 10} minecraft:air')
player('command', command=f'fill {bx - 3} {y - 2} {z - 2} {bx + 2} {y - 1} {z + 10} minecraft:stone')
player('command', command=f'fill {bx - 1} {y - 1} {z - 1} {bx + 1} {y - 1} {z + 9} minecraft:water')
player('command', command=f'summon minecraft:oak_boat {bx + 0.5} {y - 0.1} {z + 0.5}')
player.wait(30)
try:
boat = board(player, 'oak_boat', (bx + 0.5, y, z + 0.5))
start = boat['position']
# The summoned boat faces south: forward paddles it along the pool.
player.raw('key', 'w', 'down')
player.wait(30)
player.raw('key', 'w', 'up')
player.wait(20)
mine = [o for o in player('status')['objects'] if o['id'] == boat['id']]
assert mine, f'the {label} lost its boat'
moved = math.dist(start, mine[0]['position'])
assert moved > 0.5, f'paddling forward moved the {label}\'s boat {moved:.2f} blocks'
# The other side sees the boat where its rider took it (the server accepted its moves).
theirs = [o for o in objects_near(other, mine[0]['position'], 'objects')
if o['kind'].endswith('oak_boat')]
assert theirs, f'the other side sees no boat near the {label}\'s'
far = min(math.dist(o['position'], mine[0]['position']) for o in theirs)
assert far < 1.0, f'the other side sees the {label}\'s boat {far:.2f} blocks off'
leave_vehicle(player, 'boat')
player('command', command=f'tp @s {bx - 1.5} {y} {mine[0]["position"][2]:.2f}')
player.wait(5)
break_vehicle(player, boat['id'], 'boat')
result[label] = {'boat': boat['id'], 'paddled': round(moved, 2), 'broken': True}
finally:
player('command', command=f'kill @e[type=minecraft:oak_boat,x={bx},y={y},z={z + 4},distance=..10]')
player('command', command=f'fill {bx - 1} {y - 1} {z - 1} {bx + 1} {y - 1} {z + 9} minecraft:stone')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
player.wait(5)
return result
def check_minecarts(host, guest, out):
"""#1222: the host and the guest each get into a minecart on rails with a right click, sneak out
beside it, and break it with their fists."""
result = {}
spots = apart(host, guest)
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
cx = x + 3
player('command', command='gamemode survival')
player('command', command=f'fill {cx - 2} {y} {z - 2} {cx + 2} {y + 3} {z + 6} minecraft:air')
player('command', command=f'fill {cx - 2} {y - 1} {z - 2} {cx + 2} {y - 1} {z + 6} minecraft:stone')
player('command', command=f'fill {cx} {y} {z} {cx} {y} {z + 4} minecraft:rail')
player('command', command=f'summon minecraft:minecart {cx + 0.5} {y} {z + 0.5}')
player.wait(30)
try:
cart = board(player, 'minecart', (cx + 0.5, y, z + 0.5))
seated = pos_of(player)
assert abs(seated[0] - (cx + 0.5)) < 0.6, f'the {label} sits at {seated}, not in the cart'
leave_vehicle(player, 'minecart')
out_at = pos_of(player)
assert math.dist(out_at, seated) > 0.3, f'the {label} stayed in its seat at {out_at}'
player.wait(5)
break_vehicle(player, cart['id'], 'minecart')
result[label] = {'cart': cart['id'], 'out_at': [round(v, 2) for v in out_at], 'broken': True}
finally:
player('command', command=f'kill @e[type=minecraft:minecart,x={cx},y={y},z={z},distance=..8]')
player('command', command=f'kill @e[type=minecraft:item,x={cx},y={y},z={z},distance=..8]')
player('command', command=f'fill {cx} {y} {z} {cx} {y} {z + 4} minecraft:air')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
player.wait(5)
return result
def check_frames(host, guest, out):
"""#1222: the host and the guest each put a diamond in an item frame and turn it, and put a
helmet on an armor stand and take it back, with right clicks."""
result = {}
spots = apart(host, guest)
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
player('command', command='gamemode survival')
# Within a survival player's 3 blocks of reach: a frame on a wall, an armor stand beside it.
player('command', command=f'fill {x - 2} {y} {z + 1} {x + 2} {y + 2} {z + 2} minecraft:air')
player('command', command=f'fill {x - 2} {y} {z + 3} {x + 2} {y + 2} {z + 3} minecraft:stone')
player('command', command=f'summon minecraft:item_frame {x + 0.5} {y + 1.5} {z + 2.97} '
f'{{block_pos:[I;{x},{y + 1},{z + 2}],Facing:2b}}')
player('command', command=f'summon minecraft:armor_stand {x - 1.5} {y} {z + 1.5}')
player('command', command='clear @s')
player('command', command='item replace entity @s weapon.mainhand with minecraft:diamond')
player.wait(30)
try:
frame = [o for o in player('status')['objects'] if o['kind'].endswith('item_frame')
and o['position'] == [x, y + 1, z + 2]]
assert frame, f'the {label} sees no item frame at {[x, y + 1, z + 2]}'
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.5:.2f}', f'{z + 2.95:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(10)
player.raw('mouse', 'right', 'click')
player.wait(10)
frame = [o for o in player('status')['objects'] if o['id'] == frame[0]['id']][0]
assert frame['item'] == 'minecraft:diamond', f'the {label}\'s frame holds {frame["item"]}'
assert frame['rotation'] == 1, f'the {label}\'s frame turned to {frame["rotation"]}'
theirs = [o for o in other('status')['objects'] if o['kind'].endswith('item_frame')
and o['position'] == [x, y + 1, z + 2]]
assert theirs and theirs[0]['item'] == 'minecraft:diamond' and theirs[0]['rotation'] == 1, \
f'the other side sees the {label}\'s frame as {theirs}'
player('command', command='item replace entity @s weapon.mainhand with minecraft:iron_helmet')
player.wait(5)
stand = [o for o in player('status')['objects'] if o['kind'] == 'minecraft:armor_stand'
and abs(o['position'][0] - (x - 1.5)) < 0.5 and abs(o['position'][2] - (z + 1.5)) < 0.5]
assert stand, f'the {label} sees no armor stand'
player.raw('look-at', f'{x - 1.5:.2f}', f'{y + 1.7:.2f}', f'{z + 1.5:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(10)
worn = [o for o in player('status')['objects'] if o['id'] == stand[0]['id']][0]['equipment']
assert 'minecraft:iron_helmet' in worn, f'the {label}\'s armor stand wears {worn}'
player.raw('look-at', f'{x - 1.5:.2f}', f'{y + 1.7:.2f}', f'{z + 1.5:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(10)
worn = [o for o in player('status')['objects'] if o['id'] == stand[0]['id']][0]['equipment']
assert 'minecraft:iron_helmet' not in worn, f'taking the helmet back left {worn}'
result[label] = {'frame': 'diamond, turned', 'stand': 'helmet on and off'}
finally:
player('command', command=f'kill @e[type=!minecraft:player,x={x - 3},y={y - 1},z={z},dx=6,dy=4,dz=6]')
player('command', command=f'fill {x - 2} {y} {z + 3} {x + 2} {y + 2} {z + 3} minecraft:air')
player('command', command='clear @s')
return result
def check_lightning(host, guest, out):
"""A bolt summoned beside each player: both sides get the bolt entity and flash the sky (the
host's window too on its server thread, FEATURES 4z(f))."""
result = {}
for label, player in (('host', host), ('guest', guest)):
x, y, z = (int(v // 1) for v in pos_of(player))
before = {g.name: g('status')['lightning'] for g in (host, guest)}
# No fire where it strikes: on stone with nothing around.
player('command', command=f'fill {x + 4} {y - 1} {z - 2} {x + 8} {y - 1} {z + 2} minecraft:stone')
player('command', command=f'fill {x + 4} {y} {z - 2} {x + 8} {y + 3} {z + 2} minecraft:air')
player('command', command=f'summon minecraft:lightning_bolt {x + 6.5} {y} {z + 0.5}')
seen = {}
for _ in range(40):
player.wait(1)
seen = {g.name: g('status')['lightning'] for g in (host, guest)}
if all(seen[n]['struck'] > before[n]['struck'] and seen[n]['flashes'] > before[n]['flashes']
for n in seen):
break
bp = here[0]['position']
host.raw('look-at', f'{bp[0]:.2f}', f'{bp[1] + 0.3:.2f}', f'{bp[2]:.2f}')
host.raw('mouse', 'left', 'click')
# VehicleEntity.tick takes 1 off the damage every tick: hit fast.
host.wait(2)
assert gone, 'ten hits left the boat in place'
return {'boat': boat['id'], 'rode': True, 'broken': True}
finally:
host('command', command=f'kill @e[type=minecraft:oak_boat,x={bx},y={y},z={z},distance=..6]')
host('command', command=f'fill {bx - 1} {y - 1} {z - 1} {bx + 1} {y - 1} {z + 1} minecraft:stone')
player('command', command=f'fill {x + 4} {y} {z - 2} {x + 8} {y + 3} {z + 2} minecraft:air')
for n in seen:
assert seen[n]['struck'] > before[n]['struck'], f"{label}'s bolt: the {n} got no bolt ({seen[n]})"
assert seen[n]['flashes'] > before[n]['flashes'], f"{label}'s bolt: the {n}'s sky didn't flash ({seen[n]})"
result[label] = {n: {'bolts': seen[n]['struck'] - before[n]['struck'],
'flashes': seen[n]['flashes'] - before[n]['flashes']} for n in seen}
return result
def check_growth(host, guest, out):
"""Block ticks near a guest 1000 blocks from the host: only the guest's tickets tick them."""
@@ -2669,18 +2848,13 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name
'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast,
'light': check_light, 'plates': check_plates,
'swings': check_swings, 'cracks': check_cracks,
'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats}
'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning}
def run(ctl, out, resources, checks):
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
# Without --server-thread the host runs the game's default, the world in the frame, as players
# get it (`boats` fails there if the default goes back to the server thread before it has
# vehicles, #1222).
if SERVER_THREAD:
host.d.env['HORIZON_SERVER_THREAD'] = '1'
else:
host.d.env.pop('HORIZON_SERVER_THREAD', None)
# Either way explicitly: the game's default is the server thread.
host.d.env['HORIZON_SERVER_THREAD'] = '1' if SERVER_THREAD else '0'
results = {}
try:
host.pid = host('launch', wait=False, offscreen=sys.platform == 'darwin')['pid']
+15 -3
View File
@@ -15,6 +15,9 @@ is off: nothing leaves the machine.
python3 tools/test_friends.py --client target/fast/horizon-client --relay target/fast/horizon-relay
`--server-thread` runs the host's world on its integrated server's own
thread (the window is a client of it, as Open to LAN publishes it).
`--role host|guest` runs one side only (two machines, e.g. over a tailnet):
the host prints the code, the guest takes it with `--code` (`--relay-url`
for a relay elsewhere, `--path relay` to force it). Needs a display on Linux
@@ -86,8 +89,12 @@ class Game:
# Extra words before and after the client (`--xvfb`, `--offscreen`).
prefix = []
extra = []
# The host's world on its server thread (`--server-thread`), else in the window.
server_thread = False
def __init__(self, client, resources, work, name, env):
# Either way explicitly, whatever the game's default.
env = dict(env, HORIZON_SERVER_THREAD='1' if Game.server_thread and name == 'host' else '0')
self.home = work / name
self.home.mkdir()
self.endpoint = self.home / 'c.sock'
@@ -155,7 +162,9 @@ def play_together(host, guest, name, round_index):
# The host moves; the guest sees the host's player there.
hp = host('status')['player']['position']
target = [hp[0] + 3.0, hp[1], hp[2] + 1.0]
host(f'tp {target[0]} {target[1]} {target[2]}')
# The /tp command, not the control socket's local move: on the server thread the window is a
# client, and its server has the last word on where the host stands.
host(f'command tp @s {target[0]} {target[1]} {target[2]}')
guest.wait('status', lambda v: any(math.dist(p, target) < 0.6 for p in v['server']['player_positions']), 20,
'host movement on the guest')
# A block the host places, which the guest sees and then breaks.
@@ -163,8 +172,9 @@ def play_together(host, guest, name, round_index):
feet = [math.floor(gp[0]), math.floor(gp[1]), math.floor(gp[2])]
block = [feet[0] + 2, feet[1] + 1, feet[2]]
# Clear the line of sight (the test terrain has hills), then the block at eye level.
host(f'fill {feet[0] + 1} {feet[1]} {feet[2]} {feet[0] + 1} {feet[1] + 2} {feet[2]} minecraft:air')
host(f'setblock {block[0]} {block[1]} {block[2]} minecraft:gold_block')
# As commands, for the same reason as the /tp above.
host(f'command fill {feet[0] + 1} {feet[1]} {feet[2]} {feet[0] + 1} {feet[1] + 2} {feet[2]} minecraft:air')
host(f'command setblock {block[0]} {block[1]} {block[2]} minecraft:gold_block')
guest.wait(f'getblock {block[0]} {block[1]} {block[2]}', lambda v: v['block'] == 'minecraft:gold_block', 20,
'the host block on the guest')
eye = [gp[0], gp[1] + 1.62, gp[2]]
@@ -343,10 +353,12 @@ def main():
parser.add_argument('--hold', type=float, default=0, help='guest: stay this many seconds before leaving')
parser.add_argument('--baseline', action='store_true', help='first run the same checks over plain LAN')
parser.add_argument('--offscreen', action='store_true', help='run the games without a window')
parser.add_argument('--server-thread', action='store_true', help="the host's world on its server thread")
parser.add_argument('--xvfb', action='store_true', help='run the games under xvfb-run (headless Linux)')
args = parser.parse_args()
Game.prefix = ['xvfb-run', '-a'] if args.xvfb else []
Game.extra = ['--offscreen'] if args.offscreen else []
Game.server_thread = args.server_thread
args.client, args.resources = args.client.resolve(), args.resources.resolve()
# Short: Unix socket paths are limited to about 100 bytes.
with tempfile.TemporaryDirectory(prefix='hzf-', dir='/tmp') as directory: