Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 145aa23a72 Server: vanilla's floating kick when allow-flight is off
A dedicated server with allow-flight=false (vanilla's default) now kicks
a player who stays in the air with nothing around them for longer than
getMaximumFlyingTicks (80 ticks at normal gravity), as
ServerGamePacketListenerImpl does: clientIsFloating from each move,
counted in tickPlayer, "multiplayer.disconnect.flying" and "{} was kicked
for floating too long!". Horizon parsed allow-flight but never used it.
An integrated or LAN server allows flight, as vanilla's does.

tools/server_floating_e2e.py: a hovering survival bot is kicked, one on
the ground isn't; creative and allow-flight=true kick nobody.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:40:20 +01:00
4 changed files with 258 additions and 17 deletions

No files matched your search

@@ -787,6 +787,7 @@ impl Game<'_> {
self.sync_ops(&lists);
if let Some(host) = self.lan.as_mut() {
host.hub.spawn_protection = props.spawn_protection;
host.hub.allow_flight = props.allow_flight;
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
host.hub.command_spam_threshold = props.command_spam_threshold_seconds.wrapping_mul(20);
}
+10
View File
@@ -143,6 +143,13 @@ pub struct Guest {
/// (receivedMovePacketCount, knownMovePacketCount).
pub received_moves: i32,
pub known_moves: i32,
/// `clientIsFloating` and `aboveGroundTickCount`: the last move ended
/// in the air with nothing to explain it, and the ticks spent so.
pub floating: bool,
pub above_ground_ticks: i32,
/// `Entity.verticalCollisionBelow`: the last move was stopped by
/// something below.
pub collided_below: bool,
/// A position move arrived since the client's last ClientTickEnd
/// (receivedPositionThisTick): a second one is a protocol error.
pub position_this_tick: bool,
@@ -326,6 +333,9 @@ impl Guest {
first_good: position,
received_moves: 0,
known_moves: 0,
floating: false,
above_ground_ticks: 0,
collided_below: false,
position_this_tick: false,
moved: false,
loaded: false,
+115 -17
View File
@@ -210,6 +210,9 @@ pub struct Hub {
/// `spawn-protection` (dedicated servers): the radius around the world
/// spawn that only operators may change.
pub spawn_protection: i32,
/// `MinecraftServer.allowFlight`: true, but a dedicated server's
/// `allow-flight`. Off, a player floating too long is kicked.
pub allow_flight: bool,
/// `chat-spam-threshold-seconds` as a TickThrottler threshold (20 a second):
/// over it, a guest who is not an operator is kicked. 0 never kicks.
pub chat_spam_threshold: i32,
@@ -385,6 +388,7 @@ impl Hub {
ops: Default::default(),
entity_range_percent: 100,
spawn_protection: 0,
allow_flight: true,
chat_spam_threshold: 0,
command_spam_threshold: 0,
weather: (false, 0.0, 0.0),
@@ -2251,6 +2255,9 @@ impl Hub {
let g = &mut self.guests[i];
g.first_good = g.position;
g.known_moves = g.received_moves;
if self.tick_floating(i, events) {
return;
}
let mut budget = PACKETS_PER_TICK;
while budget > 0 {
let Some((packet, bytes)) = self.guests[i].inbox.pop_front() else {
@@ -2537,7 +2544,10 @@ impl Hub {
}
}
let start = g.position;
let (wrongly, refused) = self.collide(i, to, ctx);
// playerStandsOnSomething: the collision of the move before this one.
let (wrongly, refused, below) = self.collide(i, to, ctx);
let floating = self.client_is_floating(i, start, to, ctx);
self.guests[i].collided_below = below;
if wrongly {
let line = format!("{} moved wrongly!", self.guests[i].name);
events.push(HostEvent::Warn(line));
@@ -2549,6 +2559,7 @@ impl Hub {
return;
}
let g = &mut self.guests[i];
g.floating = floating;
g.previous_position = g.position;
g.position = to;
// ServerChunkCache.move: its pairs are checked again.
@@ -2567,16 +2578,17 @@ impl Hub {
}
/// The collision part of handlePlayerPositionChange: whether the move
/// counts as moved wrongly (logged), and whether it is refused (the
/// player is sent back where they were).
fn collide(&self, i: usize, to: [f64; 3], ctx: &TickCtx) -> (bool, bool) {
/// counts as moved wrongly (logged), whether it is refused (the player
/// is sent back where they were), and whether something below stopped
/// it (`verticalCollisionBelow`).
fn collide(&self, i: usize, to: [f64; 3], ctx: &TickCtx) -> (bool, bool, bool) {
let g = &self.guests[i];
// noPhysics (spectators) and sleepers move freely.
let Some(binding) = ctx.collision else {
return (false, false);
return (false, false, false);
};
if g.spectator() || g.sleeping {
return (false, false);
return (false, false, false);
}
// getBoundingBox: the box of the body's pose (swimming and crawling
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
@@ -2601,28 +2613,28 @@ impl Hub {
to[1] - g.position[1],
to[2] - g.position[2],
);
let done = scene.resolve(old, movement, 0.6, g.on_ground).ok();
let below = movement.y < 0.0 && done.is_some_and(|done| done.y != movement.y);
let fail = !g.creative()
&& scene
.resolve(old, movement, 0.6, g.on_ground)
.is_ok_and(|done| {
let (dx, dz) = (movement.x - done.x, movement.z - done.z);
dx * dx + dz * dz > 0.0625
});
&& done.is_some_and(|done| {
let (dx, dz) = (movement.x - done.x, movement.z - done.z);
dx * dx + dz * dz > 0.0625
});
if fail && scene.unobstructed(old).unwrap_or(true) {
return (true, true);
return (true, true, below);
}
// isEntityCollidingWithAnythingNew: a block the old box did not
// already touch.
let shrink = f64::from(1.0E-5_f32);
let shrink = Vec3::new(shrink, shrink, shrink);
let Ok(old_shape) = VoxelShape::from_box(old.deflate(shrink)) else {
return (fail, false);
return (fail, false, below);
};
let context = CollisionContext::position(g.position[1]);
let Ok(scan) =
BlockCollisionScan::new(&mut scene, context, new.deflate(shrink), false, 1 << 20)
else {
return (fail, false);
return (fail, false, below);
};
for hit in scan {
let Ok(hit) = hit else { break };
@@ -2631,10 +2643,77 @@ impl Hub {
.join_is_not_empty(&old_shape, ShapeOp::AND)
.unwrap_or(true)
{
return (fail, true);
return (fail, true, below);
}
}
(fail, false)
(fail, false, below)
}
/// handlePlayerPositionChange's `clientIsFloating`, for a move from
/// `from` to `to`: it ended in the air, not falling, with nothing to
/// stand on and nothing that lets the player fly.
fn client_is_floating(&self, i: usize, from: [f64; 3], to: [f64; 3], ctx: &TickCtx) -> bool {
let g = &self.guests[i];
// Abilities.mayfly: creative and spectator players.
let excused = to[1] - from[1] < -0.03125
|| g.collided_below
|| g.spectator()
|| self.allow_flight
|| g.creative()
|| g.effects.get("levitation").is_some()
|| g.fall_flying
|| g.motion
.as_ref()
.is_some_and(|m| m.auto_spin_attack_ticks > 0);
if excused {
return false;
}
// noBlocksAround: every block of the body's box, 1/16 wider and
// 0.55 lower, is air (outside the world counts as air).
let d = g.dimensions();
let (half, height) = (f64::from(d.width) / 2.0 + 0.0625, f64::from(d.height));
let lo = [to[0] - half, to[1] - 0.0625 - 0.55, to[2] - half].map(|v| v.floor() as i32);
let hi = [to[0] + half, to[1] + height + 0.0625, to[2] + half].map(|v| v.floor() as i32);
for x in lo[0]..=hi[0] {
for z in lo[2]..=hi[2] {
for y in lo[1]..=hi[1] {
if !self.encoder.is_air(ctx.world.block(x, y, z).0) {
return false;
}
}
}
}
true
}
/// The floating part of ServerGamePacketListenerImpl.tickPlayer: a
/// player floating longer than `getMaximumFlyingTicks` is kicked
/// (true).
fn tick_floating(&mut self, i: usize, events: &mut Vec<HostEvent>) -> bool {
let g = &mut self.guests[i];
let dying = g.dead || g.health <= 0.0;
if !g.floating || g.sleeping || g.vehicle.is_some() || dying {
g.floating = false;
g.above_ground_ticks = 0;
return false;
}
g.above_ground_ticks += 1;
let gravity = g
.motion
.as_mut()
.map_or(Ok(0.08), |m| m.attribute("minecraft:gravity"))
.unwrap_or(0.08);
if g.above_ground_ticks <= maximum_flying_ticks(gravity) {
return false;
}
(g.floating, g.above_ground_ticks) = (false, 0);
let (conn, line) = (
g.conn,
format!("{} was kicked for floating too long!", g.name),
);
events.push(HostEvent::Warn(line));
self.kick(conn, translatable("multiplayer.disconnect.flying", &[]));
true
}
/// Acknowledge a block interaction (ClientboundBlockChangedAckPacket).
@@ -3027,11 +3106,30 @@ fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 {
shared
}
/// ServerGamePacketListenerImpl.getMaximumFlyingTicks: 80 ticks, longer
/// under weaker gravity, forever without any.
fn maximum_flying_ticks(gravity: f64) -> i32 {
if gravity < f64::from(1.0E-5_f32) {
return i32::MAX;
}
(80.0 * (0.08 / gravity).max(1.0)).ceil() as i32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fast_hash::FxHashSet;
#[test]
fn floating_lasts_80_ticks_at_vanilla_gravity() {
assert_eq!(maximum_flying_ticks(0.08), 80);
// Heavier gravity doesn't shorten it; lighter lengthens it.
assert_eq!(maximum_flying_ticks(1.0), 80);
assert_eq!(maximum_flying_ticks(0.04), 160);
assert_eq!(maximum_flying_ticks(0.03), 214);
assert_eq!(maximum_flying_ticks(0.0), i32::MAX);
}
/// `requeue` as it was, scanning the queue for each chunk of the view.
fn requeue_scan(
queue: &mut Vec<(i32, i32)>,
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Vanilla's floating kick (`allow-flight=false`) end to end on a dedicated server.
python3 tools/server_floating_e2e.py --bin-dir target/fast
ServerGamePacketListenerImpl kicks a player who stays in the air, not falling, with nothing around
them, for more than 80 ticks (`multiplayer.disconnect.flying`, "{} was kicked for floating too long!").
horizon-bots stand in for the players: an `idle` bot climbs 24 blocks above spawn and hovers there
(a fly hack), a `stand` bot stays on the ground.
- survival, `allow-flight=false` (vanilla's default): the hovering bot is kicked within seconds, the
console says why; the bot on the ground plays on;
- creative (players may fly), and survival with `allow-flight=true`: nobody is kicked.
Each server runs in a fresh folder with fresh HORIZON_HOME and XDG folders (lab.game_env), on a free
port. Build first:
cargo build --profile fast -p horizon-client --bin horizon-client
cargo build --profile fast -p horizon-bots
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
from server_bench import DONE, Server # noqa: E402
EXE = '.exe' if os.name == 'nt' else ''
KICK = 'multiplayer.disconnect.flying'
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'force-gamemode': 'true',
'view-distance': '3',
'simulation-distance': '3',
'spawn-protection': '0',
}
SECONDS = 12
class Failed(Exception):
pass
def bots(bins, port, out, run):
"""A hovering bot and one on the ground, side by side for SECONDS: their report entries."""
procs = {}
for name, behaviour in (('Hover', 'idle'), ('Ground', 'stand')):
report = out / f'{run}-{name}.json'
procs[name] = (report, subprocess.Popen(
[bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
'--behaviour', behaviour, '--seconds', str(SECONDS), '--view-distance', '3', '--name', name,
'--report', report], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
entries = {}
for name, (report, proc) in procs.items():
proc.wait(timeout=240)
entries[name] = json.loads(report.read_text())['per_bot'][0]
return entries
def server(bins, folder, out, name, extra):
folder.mkdir(parents=True)
port = lab.free_port()
props = dict(PROPERTIES, **extra, **{'server-port': str(port)})
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(folder / 'eula.txt').write_text('eula=true\n')
command = [bins / f'horizon-client{EXE}', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
s = Server(command, folder, out / f'{name}.log')
s.wait_for(DONE, 300)
return s, port
def kicked(entry):
return KICK in (entry['error'] or '')
def run(bins, scratch, out, name, extra):
s, port = server(bins, scratch / name, out, name, extra)
try:
entries = bots(bins, port, out, name)
lines = s.since(0)
finally:
s.stop()
for bot, entry in entries.items():
if entry['joined_after_ms'] <= 0:
raise Failed(f'{name}: {bot} never joined: {entry["error"]}')
return entries, lines
def check(bins, scratch, out):
entries, lines = run(bins, scratch, out, 'survival', {'gamemode': 'survival'})
if not kicked(entries['Hover']):
raise Failed(f'survival: the hovering bot was not kicked: {entries["Hover"]["error"]}')
if not any('Hover0 was kicked for floating too long!' in line for line in lines):
raise Failed('survival: the console did not say why Hover0 was kicked')
if entries['Ground']['error']:
raise Failed(f'survival: the bot on the ground was disconnected: {entries["Ground"]["error"]}')
print('survival, allow-flight=false: the hovering bot is kicked for floating, the one on the ground plays on')
for name, extra in (('creative', {'gamemode': 'creative'}),
('allowed', {'gamemode': 'survival', 'allow-flight': 'true'})):
entries, _ = run(bins, scratch, out, name, extra)
for bot, entry in entries.items():
if entry['error']:
raise Failed(f'{name}: {bot} was disconnected: {entry["error"]}')
print('creative, and survival with allow-flight=true: nobody is kicked')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast'))
parser.add_argument('--out', default=str(lab.LOCAL / 'server-floating-e2e'))
args = parser.parse_args()
bins, out = Path(args.bin_dir).resolve(), Path(args.out)
shutil.rmtree(out, ignore_errors=True)
out.mkdir(parents=True)
os.environ.update(lab.game_env())
with tempfile.TemporaryDirectory(prefix='server-floating-e2e-') as scratch:
try:
check(bins, Path(scratch), out)
except (Failed, RuntimeError) as e:
sys.exit(f'server_floating_e2e failed (logs in {out}): {e}')
if __name__ == '__main__':
main()