Compare commits

...
Author SHA1 Message Date
Ludovic Marie 8674aa784d Merge remote-tracking branch 'origin/main' into mm1-1426 2026-10-23 20:38:10 +02:00
Ludovic Marie f08a1016fd Merge remote-tracking branch 'origin/main' into mm1-1426 2026-10-23 20:21:26 +02:00
Ludovic MarieandClaude Opus 5.5 4aa1f8e14a World border on a server's clients: its state, its walls, its damage
The server told joining players of a default border and never of a
change, so on single player's server thread and for LAN guests the wall
neither showed nor stopped anyone. Players now get the real border on
entering a level and each change as BorderChangeListener sends it.

The player's movement collides with the border's walls
(CollisionGetter.borderCollision) instead of being clamped after moving,
which a sprint-jump got past. A guest (and single player's own player on
its server thread) outside the border takes its damage on the server, as
the host's player did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
16 changed files with 271 additions and 30 deletions

No files matched your search

@@ -121,6 +121,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
tick_frozen: false,
tick_steps: (0, 0),
collision: None,
border: &horizon_client::worldborder::WorldBorder::default(),
};
for event in hub.tick(&mut ctx) {
match event {
@@ -78,6 +78,7 @@ fn main() -> Result<()> {
let mut selected = collisions::EntityCollisions {
blocks: scene,
entities: &entities,
border: None,
};
let movement = world_movement::resolve_world_movement(
&mut selected,
+13 -2
View File
@@ -3,7 +3,7 @@
//! Entity-dependent rules use the defaults for players and the supported mobs.
use crate::{Result, world::World};
use horizon_core::{
geometry::{Aabb, Vec3, VoxelShape},
geometry::{Aabb, Vec3, VoxelShape, border::BorderGeometry},
position::{BlockPos, ChunkPos},
};
use horizon_world::{
@@ -64,6 +64,9 @@ pub struct SceneCollisions<'w, 'r> {
pub struct EntityCollisions<'a, W> {
pub blocks: W,
pub entities: &'a [Aabb],
/// The world border and the moving entity's x and z
/// (`CollisionGetter.borderCollision`); None: the blocks' answer.
pub border: Option<(&'a BorderGeometry, [f64; 2])>,
}
impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
type Error = W::Error;
@@ -114,12 +117,20 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
}
Ok(shapes)
}
/// `WorldBorder.isInsideCloseToBorder`: an entity inside the border and
/// near it collides with its walls.
fn movement_border(
&mut self,
entity: EntityIdentity,
bounds: Aabb,
) -> io::Result<Option<VoxelShape>> {
self.blocks.movement_border(entity, bounds)
let Some((border, [x, z])) = self.border else {
return self.blocks.movement_border(entity, bounds);
};
if !border.is_inside_close_to_border(x, z, bounds) {
return Ok(None);
}
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
impl SceneCollisions<'_, '_> {
@@ -248,8 +248,32 @@ impl Game<'_> {
/// Every tick: the size change, the wall the player cannot pass and
/// damage outside the border.
/// `BorderChangeListener.DelegateBorderChangeListener`'s packets: what
/// changed on the border since the guests were last told of it.
pub(in crate::game) fn lan_sync_border(&mut self, host: &mut crate::game::lan::LanHost) {
let now = horizon_client::lan::border::Sent::of(&self.border);
if let Some(sent) = host.border_sent.replace(now) {
for packet in sent.changes(&self.border) {
host.hub.broadcast(&packet);
}
}
}
pub(in crate::game) fn tick_worldborder(&mut self) {
self.border.tick();
self.player_border();
self.border_damage();
}
/// The player's movement collides with the border as it is now
/// (`CollisionGetter.borderCollision`): from inside, its walls stop it.
pub(in crate::game) fn player_border(&mut self) {
if let Some(p) = self.player.as_mut() {
p.border = Some(self.border.geometry().clone());
}
}
/// `LivingEntity.baseTick`: a player outside the border, further than
/// its safe zone, is hurt by the blocks beyond it. The game's player, or
/// a guest's in their turn (`as_guest`).
pub(in crate::game) fn border_damage(&mut self) {
let Some((px, pz, half)) = self.player.as_ref().map(|p| {
(
p.position.x,
@@ -269,20 +293,6 @@ impl Game<'_> {
let amount = (-dist * self.border.damage_per_block).floor().max(1.0) as f32;
self.hurt_player_by("minecraft:outside_border", amount);
}
// From inside the border the wall stops movement.
if self.border.distance(px, pz) >= 0.0 {
let [x, z] = self.border.clamp(px, pz, half);
if let Some(p) = self.player.as_mut() {
if x != px {
p.velocity.x = 0.0;
}
if z != pz {
p.velocity.z = 0.0;
}
p.position.x = x;
p.position.z = z;
}
}
}
pub(super) fn cmd_worldborder(&mut self, id: &str, ctx: &Context) -> R<i32> {
let float2 = |v: f64| format!("{v:.2}");
@@ -979,6 +979,8 @@ impl Game<'_> {
status["projectiles"] = json!(self.projectiles_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
// The world border this side knows of.
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
// The scoreboard sidebar the HUD shows.
status["sidebar"] = self.sidebar_status();
status
+6
View File
@@ -66,6 +66,8 @@ pub(super) struct LanHost {
/// whose respawn replaces their waypoint.
pub waypoints: std::collections::HashMap<String, lan::waypoints::Waypoints>,
pub waypoint_dead: std::collections::HashSet<u128>,
/// The world border as the guests were last told of it (lan/border.rs).
pub border_sent: Option<lan::border::Sent>,
/// Guests' commands waiting for the hub to be back in `self.lan`, where
/// a command can reach every player (game/lan_actions.rs).
pub commands: Vec<super::lan_actions::GuestCommand>,
@@ -561,6 +563,7 @@ impl Game<'_> {
owner: None,
waypoints: Default::default(),
waypoint_dead: Default::default(),
border_sent: None,
commands: Vec::new(),
states: Default::default(),
});
@@ -891,6 +894,7 @@ impl Game<'_> {
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
};
host.hub.movement_checks = (
self.ui.rules.bool("player_movement_check"),
@@ -998,6 +1002,7 @@ impl Game<'_> {
self.lan_update_models(&mut host);
self.lan_update_waypoints(&mut host, state.as_ref());
self.lan_sync_scoreboard(&mut host);
self.lan_sync_border(&mut host);
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
@@ -1074,6 +1079,7 @@ impl Game<'_> {
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
};
host.hub.place_new_player(conn, &mut ctx);
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
@@ -1581,6 +1581,7 @@ impl Game<'_> {
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
};
host.hub.respawn(i, spawn, &ctx);
if let Some(config) = elsewhere {
@@ -1918,7 +1919,8 @@ impl Game<'_> {
for d in travelled {
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
}
// LivingEntity.baseTick ends with tickEffects.
// LivingEntity.baseTick: outside the world border, then tickEffects.
game.border_damage();
game.tick_mob_effects();
let Some(player) = game.player.as_mut() else {
return Vec::new();
+1
View File
@@ -3043,6 +3043,7 @@ impl Game<'_> {
self.remote_handle_input();
// ClientLevel.tick: the border's size change.
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells.
self.tick_block_entity_anims();
@@ -455,6 +455,7 @@ impl Game<'_> {
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
}
}
}
+130
View File
@@ -0,0 +1,130 @@
//! The world border a server's players see: all of it when they enter a
//! level (`PlayerList.sendLevelInfo`), then each change as
//! `BorderChangeListener` broadcasts it: a new centre, a new size, a size
//! change in progress, a new warning time or distance.
use crate::worldborder::WorldBorder;
use horizon_protocol::packets::play::{self, Clientbound as C};
/// `ClientboundInitializeBorderPacket`.
pub fn initialize(b: &WorldBorder) -> play::InitializeBorder {
let [center_x, center_z] = b.center();
play::InitializeBorder {
center_x,
center_z,
old_size: b.size(),
new_size: b.target_size(),
lerp_time: b.remaining_ticks(),
absolute_max_size: b.absolute_max_size(),
warning_blocks: b.warning_distance,
warning_time: b.warning_time,
}
}
/// The border as the players were last told of it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sent {
center: [f64; 2],
size: f64,
target: f64,
remaining: i64,
warning_distance: i32,
warning_time: i32,
}
impl Sent {
pub fn of(b: &WorldBorder) -> Self {
Self {
center: b.center(),
size: b.size(),
target: b.target_size(),
remaining: b.remaining_ticks(),
warning_distance: b.warning_distance,
warning_time: b.warning_time,
}
}
/// The packets that tell the players what changed since `self`, other
/// than a size change going on (which each client runs itself).
pub fn changes(&self, b: &WorldBorder) -> Vec<C> {
let now = Self::of(b);
let mut out = Vec::new();
if now.center != self.center {
out.push(C::SetBorderCenter(play::SetBorderCenter {
x: now.center[0],
z: now.center[1],
}));
}
// A change going on counts down a tick at a time (or not at all
// while the game is frozen), towards the same size.
let going_on = self.remaining > 0
&& now.target == self.target
&& (now.remaining == self.remaining || now.remaining == self.remaining - 1);
if now.remaining > 0 {
if !going_on {
out.push(C::SetBorderLerpSize(play::SetBorderLerpSize {
old_size: now.size,
new_size: now.target,
lerp_time: now.remaining,
}));
}
} else if now.size != self.size && !(self.remaining > 0 && now.size == self.target) {
out.push(C::SetBorderSize(play::SetBorderSize { size: now.size }));
}
if now.warning_time != self.warning_time {
out.push(C::SetBorderWarningDelay(play::SetBorderWarningDelay {
delay: now.warning_time,
}));
}
if now.warning_distance != self.warning_distance {
out.push(C::SetBorderWarningDistance(
play::SetBorderWarningDistance {
blocks: now.warning_distance,
},
));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(packets: &[C]) -> Vec<&'static str> {
packets
.iter()
.map(|p| match p {
C::SetBorderCenter(_) => "center",
C::SetBorderSize(_) => "size",
C::SetBorderLerpSize(_) => "lerp",
C::SetBorderWarningDelay(_) => "delay",
C::SetBorderWarningDistance(_) => "distance",
_ => "other",
})
.collect()
}
#[test]
fn each_change_is_sent_once() {
let mut b = WorldBorder::default();
let mut sent = Sent::of(&b);
assert!(sent.changes(&b).is_empty());
b.set_center([4.0, -2.0]);
b.set_size(10.0);
b.warning_time = 30;
assert_eq!(kinds(&sent.changes(&b)), ["center", "size", "delay"]);
sent = Sent::of(&b);
// A change over 3 ticks: sent once, then each client runs it.
b.lerp_to(20.0, 3);
assert_eq!(kinds(&sent.changes(&b)), ["lerp"]);
for _ in 0..4 {
sent = Sent::of(&b);
b.tick();
assert!(sent.changes(&b).is_empty(), "{:?}", sent.changes(&b));
}
assert_eq!(b.size(), 20.0);
b.warning_distance = 1;
assert_eq!(kinds(&Sent::of(&b).changes(&b)), [] as [&str; 0]);
assert_eq!(kinds(&sent.changes(&b)), ["distance"]);
}
}
+3 -11
View File
@@ -111,6 +111,8 @@ pub struct TickCtx<'a> {
/// Block collisions, for ServerGamePacketListenerImpl's moved-wrongly
/// check (none: moves are not checked against blocks).
pub collision: Option<&'a BlockCollisionRegistry<'a>>,
/// The level's world border (PlayerList.sendLevelInfo sends it).
pub border: &'a crate::worldborder::WorldBorder,
}
/// What the gameplay code has to do for a guest.
@@ -891,17 +893,7 @@ impl Hub {
fn send_level_info(&mut self, i: usize, ctx: &TickCtx) {
let spawn = ctx.spawn;
let g = &self.guests[i];
g.send(&C::InitializeBorder(play::InitializeBorder {
center_x: 0.0,
center_z: 0.0,
old_size: 59_999_968.0,
new_size: 59_999_968.0,
lerp_time: 0,
absolute_max_size: 29_999_984,
warning_blocks: 5,
// WorldBorder.DEFAULT_WARNING_TIME, in ticks.
warning_time: 300,
}));
g.send(&C::InitializeBorder(super::border::initialize(ctx.border)));
g.send(&self.time_packet(ctx.world, ctx.clock_rate));
g.send(&self.default_spawn(spawn));
let w = &ctx.world.weather;
+1
View File
@@ -6,6 +6,7 @@
//! world that is not open to the network pays nothing.
mod advancements;
pub mod attributes;
pub mod border;
pub mod chunks;
mod command_order;
pub mod command_suggestions;
+6
View File
@@ -307,6 +307,9 @@ pub struct PlayerMotion {
pub hurt_log: Vec<crate::health::HurtRecord>,
/// The last melee attacks made on mobs, newest last.
pub attack_log: Vec<crate::health::AttackRecord>,
/// The level's world border, whose walls stop the player's movement
/// from inside (the game keeps it current; None: no border).
pub border: Option<horizon_core::geometry::border::BorderGeometry>,
}
fn attribute_error(error: impl std::fmt::Debug) -> io::Error {
io::Error::other(format!("movement attribute: {error:?}"))
@@ -461,6 +464,7 @@ impl PlayerMotion {
powder_snow_entered: Vec::new(),
hurt_log: Vec::new(),
attack_log: Vec::new(),
border: None,
})
}
/// `Entity.getTicksRequiredToFreeze`.
@@ -678,9 +682,11 @@ impl MotionWorld<'_, '_, '_> {
fn movement_collisions(
&self,
) -> crate::collisions::EntityCollisions<'_, SceneCollisions<'_, '_>> {
let at = [self.player.position.x, self.player.position.z];
crate::collisions::EntityCollisions {
blocks: self.collisions(),
entities: self.entities,
border: self.player.border.as_ref().map(|b| (b, at)),
}
}
/// `Level.noCollision(player, bounds)`: blocks and the host's entity boxes.
+4
View File
@@ -80,6 +80,10 @@ impl PartialEq for WorldBorder {
}
}
impl WorldBorder {
/// Its extent, for collisions.
pub fn geometry(&self) -> &BorderGeometry {
&self.geometry
}
pub fn center(&self) -> [f64; 2] {
self.geometry.center()
}
+1
View File
@@ -200,6 +200,7 @@ impl Server {
tick_frozen: false,
tick_steps: (0, 0),
collision: None,
border: &horizon_client::worldborder::WorldBorder::default(),
};
let events = self.hub.tick(&mut ctx);
for event in events {
+74 -2
View File
@@ -213,7 +213,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', 'looks', 'leads', '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', 'thrown', 'sparks', 'hud', 'sidebar', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', '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', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
class Game:
@@ -2794,6 +2794,77 @@ def check_sidebar(host, guest, out):
return result
def check_border(host, guest, out):
spots = apart(host, guest)
result = {}
try:
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
# A whole-block centre: the wall's collision box is the border's, rounded out to blocks.
cx, cz = float(x), float(z)
# The other player keeps out of harm outside the border meanwhile.
other('command', command='gamemode creative')
host('command', command=f'fill {x - 3} {y - 1} {z} {x + 4} {y - 1} {z + 10} minecraft:stone')
host('command', command=f'fill {x - 3} {y} {z} {x + 4} {y + 3} {z + 10} minecraft:air')
# A border 10 wide around the player, its wall 5 blocks ahead: both sides know it.
host('command', command=f'worldborder center {cx} {cz}')
host('command', command='worldborder set 10')
seen = None
for _ in range(10):
player.wait(2)
seen = [g('status').get('border') for g in (player, other)]
if all(b and b['size'] == 10 and b['center'] == [cx, cz] for b in seen):
break
result[f'{label} border'] = seen
# Sprint-jumping into the wall: it stops the player at the border.
player('command', command=f'tp @s {cx} {y} {cz} 0 0')
player.wait(10)
player.raw('look-at', f'{cx:.2f}', f'{y + 1.6:.2f}', f'{cz + 20:.2f}')
for k in ('w', 'ctrl', 'space'):
player.raw('key', k, 'down')
player.wait(40)
for k in ('space', 'ctrl', 'w'):
player.raw('key', k, 'up')
player.wait(10)
result[f'{label} reached'] = round(pos_of(player)[2] - cz, 2)
player.shot(out, f'border-{label}')
# Two blocks outside, with no safe zone: the border hurts.
host('command', command='worldborder damage buffer 0')
host('command', command='worldborder damage amount 1')
player('command', command='effect give @s minecraft:instant_health 1 5')
player.wait(20)
player('command', command=f'tp @s {cx} {y} {cz + 7}')
player.wait(5)
before = player('status')['player']['health']
player.wait(40)
result[f'{label} hurt'] = [before, player('status')['player']['health']]
player('command', command=f'tp @s {cx} {y} {cz}')
player('command', command='effect give @s minecraft:instant_health 1 5')
host('command', command='worldborder damage amount 0.2')
host('command', command='worldborder damage buffer 5')
other('command', command='gamemode survival')
finally:
guest('command', command='gamemode survival')
host('command', command='gamemode survival')
host('command', command='worldborder damage amount 0.2')
host('command', command='worldborder damage buffer 5')
host('command', command='worldborder center 0 0')
host('command', command='worldborder set 59999968')
wrong = {}
for label in ('host', 'guest'):
x, y, z = spots[label]
known = result[f'{label} border']
if not all(b and b['size'] == 10 and b['center'] == [x, z] for b in known):
wrong[f'{label} border'] = known
if not 4.0 < result[f'{label} reached'] <= 4.71:
wrong[f'{label} reached'] = result[f'{label} reached']
before, after = result[f'{label} hurt']
if not after < before:
wrong[f'{label} hurt'] = result[f'{label} hurt']
assert not wrong, f'border: {wrong}\n{result}'
return result
def check_blast(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
@@ -4131,7 +4202,8 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
'thrown': check_thrown,
'sparks': check_sparks,
'hud': check_hud,
'sidebar': check_sidebar}
'sidebar': check_sidebar,
'border': check_border}
def run(ctl, out, resources, checks):