diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index fc8ebb549a..d41ff11b8a 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -63,6 +63,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m --attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test); the report gives each bot's last health --attack-after S start attacking S seconds after joining; default 5 + --no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server + ignores the player's moves and actions for its first 3 seconds --no-read stop reading from the server once spawned (a client that lets the server's replies pile up) --join-every SECONDS default 1 @@ -135,6 +137,7 @@ struct Options { attack_after: f64, flood: usize, no_read: bool, + no_loaded: bool, height: f64, ground: bool, /// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count). @@ -162,6 +165,7 @@ fn options() -> Result { attack_after: 5.0, flood: 100, no_read: false, + no_loaded: false, height: 24.0, ground: false, give: Vec::new(), @@ -180,6 +184,10 @@ fn options() -> Result { o.no_read = true; continue; } + if arg == "--no-loaded" { + o.no_loaded = true; + continue; + } if arg == "--ground" { o.ground = true; continue; @@ -732,7 +740,9 @@ fn run_bot( bot.joined_after_ms .store(asked.elapsed().as_millis() as u64, Ordering::SeqCst); let send = |body: Vec| lock(&writer).send(&body).is_ok(); - send(raw(sb::PLAYER_LOADED, |_| {})); + if !o.no_loaded { + send(raw(sb::PLAYER_LOADED, |_| {})); + } bot.deaf.store(o.no_read, Ordering::Relaxed); // What the costly floods need in hand (creative: the server takes the bot's word for it). let tool = match behaviour { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 4b29b7b0c6..a90daeaaf0 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2519,7 +2519,9 @@ impl Game<'_> { return; } let g = &mut host.hub.guests[i]; + // restartClientLoadTimerAfterRespawn. g.dead = false; + g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT; g.death_time = 0; g.health = 20.0; g.food = 20; diff --git a/crates/horizon-client/src/game/lan_vehicles.rs b/crates/horizon-client/src/game/lan_vehicles.rs index ebb7aeecd6..11899898b6 100644 --- a/crates/horizon-client/src/game/lan_vehicles.rs +++ b/crates/horizon-client/src/game/lan_vehicles.rs @@ -270,7 +270,7 @@ impl Game<'_> { return; } let g = &host.hub.guests[gi]; - if g.awaiting_teleport.is_some() || !g.loaded { + if g.awaiting_teleport.is_some() || !g.client_loaded() { return; } let owner = host.hub.singleplayer_owner == Some(g.uuid); diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 017904d7dd..4b274af7e5 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -57,6 +57,9 @@ pub struct SentEffect { pub tick_count: i32, } +/// ServerGamePacketListenerImpl.CLIENT_LOADED_TIMEOUT_TIME. +pub const CLIENT_LOADED_TIMEOUT: u8 = 60; + pub struct Guest { pub conn: u32, /// The client's address (IP bans, `list`). @@ -161,6 +164,10 @@ pub struct Guest { pub moved: bool, /// The client finished loading terrain (ServerboundPlayerLoadedPacket). pub loaded: bool, + /// Ticks left before the server stops waiting for that packet + /// (ServerGamePacketListenerImpl.clientLoadedTimeoutTimer): 60 at join and + /// after a respawn, 0 once it comes. + pub load_timeout: u8, // Chunks. pub center: Option<(i32, i32)>, pub sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>, @@ -347,6 +354,7 @@ impl Guest { anticheat: Default::default(), moved: false, loaded: false, + load_timeout: CLIENT_LOADED_TIMEOUT, center: None, sent_chunks: Default::default(), chunk_queue: Vec::new(), @@ -447,6 +455,11 @@ impl Guest { pub fn creative(&self) -> bool { self.game_mode == CREATIVE } + /// ServerGamePacketListenerImpl.hasClientLoaded: not dead (waitingForRespawn) and + /// loaded, or waited for long enough. Until then the player's actions are ignored. + pub fn client_loaded(&self) -> bool { + !self.dead && self.load_timeout == 0 + } pub fn spectator(&self) -> bool { self.game_mode == SPECTATOR } @@ -1043,6 +1056,18 @@ mod tests { ) } + #[test] + fn the_client_has_loaded_once_it_says_so_or_after_60_ticks_and_not_while_dead() { + let mut g = guest_at([0.0; 3]); + assert!(!g.client_loaded()); + g.load_timeout -= 59; + assert!(!g.client_loaded()); + g.load_timeout -= 1; + assert!(g.client_loaded()); + g.dead = true; + assert!(!g.client_loaded()); + } + #[test] fn a_dead_player_leaves_its_vehicle_when_its_body_is_removed() { let mut g = guest_at([0.5, -120.0, 0.5]); diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b1db5b0933..365b6ed78d 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -2361,6 +2361,8 @@ impl Hub { let g = &mut self.guests[i]; g.first_good = g.position; g.known_moves = g.received_moves; + // ServerPlayer.tick: tickClientLoadTimeout. + g.load_timeout = g.load_timeout.saturating_sub(1); let mut budget = PACKETS_PER_TICK; while budget > 0 { let Some((packet, bytes)) = self.guests[i].inbox.pop_front() else { @@ -2385,13 +2387,24 @@ impl Hub { | S::KeepAlive(_) => false, S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]), S::MovePlayerPosRot(p) => self.moved_far(i, [p.x, p.y, p.z]), - S::PlayerInput(_) => self.guests[i].loaded, + S::PlayerInput(_) => self.guests[i].client_loaded(), _ => true, }; if acted { self.guests[i].last_action_tick = self.ticks; } match packet { + // hasClientLoaded: these handlers ignore the player's actions until + // the client says the level around it is loaded (or 60 ticks pass), + // and while it is dead. + S::PlayerAction(_) + | S::UseItemOn(_) + | S::UseItem(_) + | S::Attack(_) + | S::Interact(_) + | S::SpectatorAction(_) + | S::PlayerCommand(_) + if !self.guests[i].client_loaded() => {} // handleAcceptTeleportPacket. S::AcceptTeleportation(p) => { let g = &mut self.guests[i]; @@ -2431,7 +2444,12 @@ impl Hub { S::MovePlayerStatus(p) => { self.move_player(i, None, None, p.flags, ctx, events); } - S::PlayerLoaded(_) => self.guests[i].loaded = true, + // handleAcceptPlayerLoad: markClientLoaded. + S::PlayerLoaded(_) => { + let g = &mut self.guests[i]; + g.loaded = true; + g.load_timeout = 0; + } // PlayerChunkSender.onChunkBatchReceivedByClient. S::ChunkBatchReceived(p) => { let g = &mut self.guests[i]; @@ -2487,8 +2505,11 @@ impl Hub { } } S::PlayerInput(p) => { - self.guests[i].input = p.flags; - self.guests[i].sneaking = p.flags & 32 != 0; + let g = &mut self.guests[i]; + g.input = p.flags; + if g.client_loaded() { + g.sneaking = p.flags & 32 != 0; + } // handlePlayerInput only records keys and updates crouching. // Sprinting persists after the sprint key is released (or // starts with a double tap); PlayerCommand changes it. @@ -2567,7 +2588,8 @@ impl Hub { } g.position_this_tick = true; } - if g.landing || !g.loaded && g.ticks == 0 && pos.is_none() { + // handleMovePlayer: nothing moves before the client has loaded. + if g.landing || !g.client_loaded() { return; } // updateAwaitingTeleport: moves wait for the client to accept the diff --git a/tools/server_client_loaded_e2e.py b/tools/server_client_loaded_e2e.py new file mode 100644 index 0000000000..d409262e35 --- /dev/null +++ b/tools/server_client_loaded_e2e.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""ServerGamePacketListenerImpl.hasClientLoaded on a dedicated server, end to end. + + python3 tools/server_client_loaded_e2e.py --bin-dir target/fast + +Until the client says the level around it is loaded (ServerboundPlayerLoadedPacket), or 60 ticks +pass, vanilla ignores the player's moves and actions. A bot that never sends that packet climbs from +spawn (0.5 blocks a tick): after 1.5 s the server still has it where it spawned; after 6 s its moves +are taken. A bot that sends it moves at once. + +horizon-bots stand in for the clients (`--no-loaded`). The 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 os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lab # noqa: E402 +from server_horizon_only_e2e import Failed, server # noqa: E402 + +EXE = '.exe' if os.name == 'nt' else '' + + +def bot(bins, port, name, *extra): + return subprocess.Popen( + [bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1', '--behaviour', 'idle', + '--seconds', '10', '--name', name, *extra], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def height(s, player): + """The player's y as the server has it (`data get entity Pos[1]`).""" + mark = s.count() + s.console(f'data get entity {player} Pos[1]') + deadline = time.time() + 30 + while time.time() < deadline: + for line in s.since(mark): + m = re.search(rf'{player} has the following entity data: (-?[\d.]+)d', line) + if m: + return float(m[1]) + if 'No entity was found' in line: + raise Failed(f'{player} is not on the server') + time.sleep(0.1) + raise Failed(f'no answer to `data get entity {player} Pos[1]`') + + +def watch(s, port, bins, name, *extra): + """The bot's height on the server when it joins, 1.5 s later and 6 s later.""" + b = bot(bins, port, name, *extra) + try: + s.wait_for(rf'{name}0 joined the game', 60) + start = height(s, f'{name}0') + time.sleep(1.5 - 0.1) + early = height(s, f'{name}0') + time.sleep(4.5) + late = height(s, f'{name}0') + finally: + b.kill() + b.wait() + return start, early, late + + +def check(bins, scratch, out): + s, port = server(bins, scratch / 'server', out, 'server', {}) + try: + late = watch(s, port, bins, 'Late', '--no-loaded') + loaded = watch(s, port, bins, 'Loaded') + finally: + s.stop() + start, early, after = late + if early != start: + raise Failed(f'a client that has not loaded moved from y {start} to {early} within 1.5 s') + if after <= start + 1.0: + raise Failed(f'after 6 s the server still ignores its moves (y {start} → {after})') + print(f'not loaded: still at y {early} after 1.5 s, moved to y {after} after 6 s (the 60-tick timeout)') + start, early, _ = loaded + if early <= start + 1.0: + raise Failed(f'a loaded client did not move within 1.5 s (y {start} → {early})') + print(f'loaded: moved from y {start} to {early} within 1.5 s') + + +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-client-loaded-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-client-loaded-e2e-') as scratch: + try: + check(bins, Path(scratch), out) + except (Failed, RuntimeError) as e: + sys.exit(f'server_client_loaded_e2e failed (logs in {out}): {e}') + + +if __name__ == '__main__': + main()