From 567cc335768b491e6bb0fd7defc3ac3db36d06d8 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:48 +0200 Subject: [PATCH 1/6] LAN: the guest's clock stops with /time pause and keeps up with the server's game time The host sent the overworld clock's rate even while /time pause held it (ServerClockInstance.packNetworkState sends 0 when paused), so a guest's day kept running. A guest also moved its clock by the rounded rate each client tick and never caught up when the server's game-time sync moved game_time on, so a guest whose game lagged fell behind for good. ClientClockManager.tick advances the clock by the game time passed, with its partial tick; the guest now does the same. lan_e2e.py time: the paused clock and a guest stalled for 4 seconds. Part of #848 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/chat.rs | 10 ++++++ crates/horizon-client/src/game/lan.rs | 10 +++--- crates/horizon-client/src/game/lan_actions.rs | 7 ++-- crates/horizon-client/src/game/remote.rs | 33 ++++++++++++++++--- .../horizon-client/src/game/server_travel.rs | 7 ++-- tools/lan_e2e.py | 32 ++++++++++++++++-- 6 files changed, 75 insertions(+), 24 deletions(-) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index cb8a3a0b5..b78b97cb5 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -689,6 +689,16 @@ impl Game<'_> { c.partial -= whole; self.world.day_time += whole as i64; } + /// `ServerClockInstance.packNetworkState`: the rate players' clients + /// advance the overworld clock at, 0 while it is paused (`/time pause`) + /// or `advance_time` is off. + pub(super) fn network_clock_rate(&self) -> f32 { + if self.ui.rules.bool("advance_time") && !self.ui.clock.paused { + self.ui.clock.rate + } else { + 0.0 + } + } /// The engine-side rule values (block behaviour and health). pub(super) fn level_rules(&self) -> horizon_world::level::GameRules { let r = &self.ui.rules; diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index d35bf787a..d39422e35 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -814,6 +814,7 @@ impl Game<'_> { }; let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); let events = { + let clock_rate = self.network_clock_rate(); let mut ctx = TickCtx { world: &mut self.world, host: state.as_ref(), @@ -822,11 +823,7 @@ impl Game<'_> { spawn, difficulty: i32::from(self.ui.difficulty), difficulty_locked: self.level.difficulty_locked, - clock_rate: if self.ui.rules.bool("advance_time") { - self.ui.clock.rate - } else { - 0.0 - }, + clock_rate, tick_rate: self.clock.rate(), tick_frozen: self.clock.frozen(), collision: Some(self.collision), @@ -991,6 +988,7 @@ impl Game<'_> { } else { entities }; + let clock_rate = self.network_clock_rate(); let mut ctx = TickCtx { world: &mut self.world, host: state, @@ -999,7 +997,7 @@ impl Game<'_> { spawn, difficulty: i32::from(self.ui.difficulty), difficulty_locked: self.level.difficulty_locked, - clock_rate: self.ui.clock.rate, + clock_rate, tick_rate: self.clock.rate(), tick_frozen: self.clock.frozen(), collision: Some(self.collision), diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index ced435d8d..42e96becc 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1072,6 +1072,7 @@ impl Game<'_> { if let Some(play) = host.play.get_mut(&conn) { play.potions = Default::default(); } + let clock_rate = self.network_clock_rate(); let ctx = TickCtx { world: &mut self.world, host: None, @@ -1080,11 +1081,7 @@ impl Game<'_> { spawn, difficulty: i32::from(self.ui.difficulty), difficulty_locked: self.level.difficulty_locked, - clock_rate: if self.ui.rules.bool("advance_time") { - self.ui.clock.rate - } else { - 0.0 - }, + clock_rate, tick_rate: self.clock.rate(), tick_frozen: self.clock.frozen(), collision: Some(self.collision), diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 680360c93..710e507d3 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -149,7 +149,12 @@ pub(super) struct Remote { synced: Vec>, decode_errors: u32, clock: Option, + /// `ClientClockManager.ClientClockInstance`'s rate and partial tick for + /// the level's clock (its total is `world.day_time`), and + /// `lastTickGameTime`. clock_rate: f32, + clock_partial: f32, + clock_game_time: i64, /// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket). chunk_center: (i32, i32), } @@ -331,6 +336,8 @@ impl Game<'_> { decode_errors: 0, clock: None, clock_rate: 1.0, + clock_partial: 0.0, + clock_game_time: 0, chunk_center: (0, 0), }); // The level's TickRateManager follows the server's ticking packets. @@ -1648,8 +1655,12 @@ impl Game<'_> { total: p.total, } } + // ClientPacketListener.handleSetTime: the game time, then + // ClientClockManager.handleUpdates: the clock catches up with the + // game time and takes the server's state. C::SetTime(p) => { self.world.game_time = p.game_time; + self.remote_clock_tick(); let clock = { let r = self.remote.as_ref(); let names = @@ -1670,6 +1681,7 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.clock = Some(c.clock); r.clock_rate = c.rate; + r.clock_partial = c.partial_tick; } } } @@ -2379,15 +2391,12 @@ impl Game<'_> { horizon_protocol::packets::configuration::ClientInformation::new(view), )); } - // Minecraft.tick: TickRateManager.tick; ClientLevel.tick advances + // Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances // the time only while the level runs normally. let running = self.clock.world_runs(); if running { self.world.game_time += 1; - let rate = self.remote.as_ref().map_or(1.0, |r| r.clock_rate); - if rate > 0.0 { - self.world.day_time += rate.round() as i64; - } + self.remote_clock_tick(); } // Weather levels drift toward their targets between server updates. let w = &mut self.world.weather; @@ -3701,6 +3710,20 @@ impl Game<'_> { .filter_map(|(id, uuid)| Some((*id as u32, r.players.get(uuid)?.clone()))) .collect() } + /// `ClientClockManager.tick`: the clock moves on by the game time that + /// passed since its last tick, at its rate. A game time the server sets + /// forward (its sync every second) moves the clock with it. + fn remote_clock_tick(&mut self) { + let Some(r) = self.remote.as_mut() else { + return; + }; + let delta = self.world.game_time - r.clock_game_time; + r.clock_game_time = self.world.game_time; + let partial = f64::from(r.clock_partial) + delta as f64 * f64::from(r.clock_rate); + let full = partial.floor(); + r.clock_partial = (partial - full) as f32; + self.world.day_time += full as i64; + } pub(super) fn remote_status(&self) -> Option { let r = self.remote.as_ref()?; Some(serde_json::json!({ diff --git a/crates/horizon-client/src/game/server_travel.rs b/crates/horizon-client/src/game/server_travel.rs index 6c0ec48e7..56fdbaca5 100644 --- a/crates/horizon-client/src/game/server_travel.rs +++ b/crates/horizon-client/src/game/server_travel.rs @@ -304,6 +304,7 @@ impl Game<'_> { /// The hub's view of the server for packets sent outside its own tick. pub(super) fn server_ctx(&mut self) -> TickCtx<'_> { let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); + let clock_rate = self.network_clock_rate(); TickCtx { world: &mut self.world, host: None, @@ -312,11 +313,7 @@ impl Game<'_> { spawn, difficulty: i32::from(self.ui.difficulty), difficulty_locked: self.level.difficulty_locked, - clock_rate: if self.ui.rules.bool("advance_time") { - self.ui.clock.rate - } else { - 0.0 - }, + clock_rate, tick_rate: self.clock.rate(), tick_frozen: self.clock.frozen(), collision: Some(self.collision), diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 4d13b1c22..b7d844de9 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -49,6 +49,8 @@ address. The script then checks, from both sides, what a guest's game used to ge guest as the host has them (kind, place, rotation, what they hold or wear, the variant). - projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where the host has them; a falling block lands on both sides. +- time: the guest's clock is the host's: it stops with `/time pause`, and catches up with the + server's game time after the guest's game stalls for a few seconds (#848). - light: the guest's light matches the host's: around a torch placed near it, under a roof and once a block of the roof is removed under the sky, and in the last chunk it tracks next to glowstone in a chunk it doesn't (only the server's light updates bring that one). @@ -69,6 +71,7 @@ import json import os import re import shutil +import signal import subprocess import sys import tempfile @@ -84,7 +87,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', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'time', 'effects', 'portals', 'distance'] class Game: @@ -1122,6 +1125,29 @@ def check_xp(host, guest, out): return {'seen': seen, 'guest': xp, 'host': host_xp} +def check_time(host, guest, out): + def times(): + return host('state')['time']['day_time'], guest('state')['time']['day_time'] + host('command', command='time set 1000') + host('command', command='time pause') + host.wait(60) + paused = times() + host('command', command='time resume') + guest.wait(20) + # A stalled game runs at most 10 missed ticks when it wakes (DeltaTracker); the server's + # game time, sent every second, moves the guest's clock on (ClientClockManager.tick). + os.kill(guest.pid, signal.SIGSTOP) + time.sleep(4) + os.kill(guest.pid, signal.SIGCONT) + guest.wait(60) + stalled = times() + detail = {'paused': paused, 'after_stall': stalled} + assert paused[0] == paused[1] and 1000 <= paused[0] < 1010, f'the clock while paused: {detail}' + # The two reads are a tick or two apart. + assert abs(stalled[0] - stalled[1]) <= 3, f'the guest\'s clock after its game stalled: {detail}' + return detail + + def objects_near(game, centre, key='objects'): """`status`'s objects within 8 blocks of centre, without their ids (each side numbers its own).""" out = [] @@ -1278,7 +1304,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name 'effects': check_effects, 'portals': check_portals, 'distance': check_distance, 'clicks': check_clicks, 'sleep': check_sleep, 'push': check_push, 'eat': check_eat, 'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, - 'light': check_light} + 'light': check_light, 'time': check_time} def run(ctl, out, resources, checks): @@ -1301,7 +1327,7 @@ def run(ctl, out, resources, checks): host('command', command='gamerule spawn_mobs false') port = host.raw('lan', 'open', 0, 'commands')['port'] host.player = host.raw('lan', 'status').get('host', 'Player') # the host's profile name - guest('launch', wait=False, offscreen=sys.platform == 'darwin') + guest.pid = guest('launch', wait=False, offscreen=sys.platform == 'darwin')['pid'] guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME) guest.raw('wait-for', 'loaded', '--timeout', 300) guest.wait(40) From a49f93f9ef342a246a77cc91565c9cdc2d14f8d0 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:48 +0200 Subject: [PATCH 2/6] LAN: guests open hoppers, dispensers and droppers The guest's client closed every menu it had no screen for, so hoppers (MenuType.HOPPER) and dispensers and droppers (GENERIC_3x3) did nothing. They open as their 5 and 9 slot screens now, drawn by the menu's type and under the server's title. lan_e2e.py machines: each opens with its slots and keeps a stack the guest shift-clicks in. Part of #848 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/machines.rs | 12 +++++++- crates/horizon-client/src/game/remote.rs | 25 +++++++++++++--- tools/lan_e2e.py | 35 ++++++++++++++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index d46928060..d40d6a3e8 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -770,6 +770,14 @@ enum Screen { impl Game<'_> { fn machine_screen(&self) -> Option { let key = self.container?; + // A server's menu shows by its type, whatever block (or minecart) holds it. + if let Some(menu) = self.remote_menu_type() { + return match menu { + "minecraft:hopper" => Some(Screen::Hopper), + "minecraft:generic_3x3" => Some(Screen::Dispenser), + _ => None, + }; + } if key.1 == super::minecarts::CART_KEY_Y { return (self.open_cart_kind() == Some(super::minecarts::CartKind::Hopper)) .then_some(Screen::Hopper); @@ -852,7 +860,9 @@ impl Game<'_> { batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX); } let grey = horizon_client::hud::rgba(64, 64, 64, 255); - let title = &self.container_title(title.to_owned()); + let title = &self + .remote_menu_title() + .unwrap_or_else(|| self.container_title(title.to_owned())); let title_x = if screen == Screen::Hopper { 8.0 } else { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 680360c93..3b7d51609 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1130,14 +1130,16 @@ impl Game<'_> { /// Map a menu slot to Horizon's inventory screen slot ids. fn menu_slot(menu: &str, slot: i32) -> Option { let s = usize::try_from(slot).ok()?; - let rows = |n: usize| -> Option { + // A container of `n` slots, then the inventory and the hotbar. + let slots = |n: usize| -> Option { match s { - _ if s < n * 9 => Some(300 + s), - _ if s < n * 9 + 27 => Some(9 + s - n * 9), - _ if s < n * 9 + 36 => Some(s - n * 9 - 27), + _ if s < n => Some(300 + s), + _ if s < n + 27 => Some(9 + s - n), + _ if s < n + 36 => Some(s - n - 27), _ => None, } }; + let rows = |n: usize| slots(n * 9); match menu { "inventory" => match s { 0 => Some(200), @@ -1175,6 +1177,9 @@ impl Game<'_> { "minecraft:generic_9x4" => rows(4), "minecraft:generic_9x5" => rows(5), "minecraft:generic_9x6" => rows(6), + // HopperMenu; DispenserMenu (dispensers and droppers). + "minecraft:hopper" => slots(5), + "minecraft:generic_3x3" => slots(9), _ => None, } } @@ -3544,6 +3549,10 @@ impl Game<'_> { speed: 1.0, })) } + // HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers + // and droppers). + "minecraft:hopper" => Some(BlockEntity::Chest(vec![None; 5])), + "minecraft:generic_3x3" => Some(BlockEntity::Chest(vec![None; 9])), m if m.starts_with("minecraft:generic_9x") || m == "minecraft:shulker_box" => { let rows = m .strip_prefix("minecraft:generic_9x") @@ -3591,6 +3600,14 @@ impl Game<'_> { self.screen = Screen::Inventory; self.capture(false); } + /// The type of the menu a server opened (`MenuType`'s id). + pub(super) fn remote_menu_type(&self) -> Option<&str> { + Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str()) + } + /// The title the server gave the menu it opened. + pub(super) fn remote_menu_title(&self) -> Option { + self.remote.as_ref()?.menu_title.clone() + } /// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title. pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> { let r = self.remote.as_ref()?; diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 4d13b1c22..a0ae2615e 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -21,6 +21,8 @@ address. The script then checks, from both sides, what a guest's game used to ge - pvp: the guest and the host hit each other with a sword; with the pvp game rule off, nothing; - containers: the guest's ender chest holds the guest's items, not the host's, and a double chest opens as 9x6 with the right half first; +- machines: a hopper, a dispenser and a dropper open on the guest's screen with their 5 and 9 + slots, and keep what the guest shift-clicks into them (#848); - feedback: the guest's command feedback names the guest and shows the numbers; - effects: a beacon's power reaches every player in range, the host and the guest; - distance: (--server-thread) the server follows the host's render distance, past vanilla's 32 too @@ -84,7 +86,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', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'machines', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] class Game: @@ -637,6 +639,35 @@ def check_containers(host, guest, out): return {'ender': ender, 'double': double, 'taken': taken} +def check_machines(host, guest, out): + # After `recipes`: the stacks given here unlock recipes, which would move the book's first one. + guest('command', command='gamemode survival') + x, y, z = (int(v // 1) for v in pos_of(guest)) + at = (x, y, z + 2) + seen = {} + for block in ('hopper', 'dispenser', 'dropper'): + guest('command', command='clear @s') + guest('command', command='give @s minecraft:cobblestone 4') + guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}') + guest.wait(10) + guest.raw('interact', *at) + guest.raw('wait-for', 'screen', 'container', '--timeout', 10) + guest.wait(10) + size = container_items(guest)[1] + guest.shot(out, f'machines-{block}') + guest.raw('click-slot', hotbar_slot(guest, 'minecraft:cobblestone'), 'shift') + guest.wait(10) + guest.raw('key', 'esc', 'tap') + guest.wait(10) + # Opened again, the menu holds what the server has. + seen[block] = (size, open_container(guest, at, out, f'machines-{block}-after')[0]) + guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air') + want = {'hopper': (5, {0: 'minecraft:cobblestone'}), 'dispenser': (9, {0: 'minecraft:cobblestone'}), + 'dropper': (9, {0: 'minecraft:cobblestone'})} + assert seen == want, f'the machines on the guest: {seen}' + return seen + + def slot_point(game, slot): """The GUI point in the middle of slot `slot` of the open screen (`ui`'s slot-N).""" for w in game.raw('ui')['widgets']: @@ -1272,7 +1303,7 @@ def check_light(host, guest, out): CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags, 'chest': check_chest, 'crafting': check_crafting, 'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp, - 'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles, + 'containers': check_containers, 'machines': check_machines, 'feedback': check_feedback, 'titles': check_titles, 'commands': check_commands, 'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls, 'effects': check_effects, 'portals': check_portals, 'distance': check_distance, 'clicks': check_clicks, From 0bc19687aa29e714f03df078b425261329b40688 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:48 +0200 Subject: [PATCH 3/6] LAN: guests see mobs flash when hit The host told guests of a hurt mob with EntityEvent 2, which 26.3's clients read as a kinetic hit, not a hurt. It now sends ClientboundDamageEventPacket (ServerLevel.broadcastDamageEvent) with the damage type and the attacker, and the guest's client restarts the hurt flash and jolts the walk animation (LivingEntity.handleDamageEvent). lan_e2e.py hurt: a pig the guest hits and one the host hits flash on both sides. Part of #848 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/agent.rs | 2 +- crates/horizon-client/src/game/creatures.rs | 2 +- crates/horizon-client/src/game/lan.rs | 26 ++++++++++++- crates/horizon-client/src/game/remote.rs | 9 +++++ crates/horizon-client/src/lan/containers.rs | 27 +++++++++++++ crates/horizon-protocol/src/packets/play.rs | 20 ++++++++++ tools/lan_e2e.py | 43 +++++++++++++++++++-- 7 files changed, 121 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index b11fdd552..5a7894ea2 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -579,7 +579,7 @@ impl Game<'_> { push( [m.position.x, m.position.y, m.position.z], m.kind.id.as_ref(), - json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby() }), + json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }), ); } for item in &self.dropped { diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3ef399e62..9f657b816 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -3016,7 +3016,7 @@ impl Game<'_> { } /// Mob.getBaseExperienceReward /// The damage type id of a Horizon damage kind. -fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str { +pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str { use horizon_client::mobs::DamageKind as K; match kind { K::PlayerAttack => "minecraft:player_attack", diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index d35bf787a..eaf4672c0 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1068,8 +1068,30 @@ impl Game<'_> { .and_then(|s| s.get(&m.id)) .copied(); let at = [m.position.x, m.position.y, m.position.z]; - if m.hurt_time > old.map_or(0, |o| o.0) && !dead { - host.hub.entity_event(m.id as i32, 2, at); + // LivingEntity.hurtServer: a hurt that restarted the hurt time. + if m.hurt_time > old.map_or(0, |o| o.0) + && !dead + && let Some((source, _)) = m.last_damage + { + use horizon_client::mobs::{DamageKind as K, Who}; + let player = source.attacker == Some(Who::Player); + let kind = super::creatures::damage_type(source.kind, player); + // The player a mob sees is the guest acting, else the host. + let cause = source.attacker.map(|who| match who { + Who::Player => host + .mob_players + .get(&m.id) + .and_then(|conn| host.hub.guest(*conn)) + .map_or(host.host_entity, |g| g.entity_id), + Who::Mob(id) => id as i32, + }); + // A projectile is the direct entity of its hit: not tracked here. + let melee = matches!( + source.kind, + K::PlayerAttack | K::MaceSmash | K::MobAttack | K::Sting | K::SonicBoom + ); + let direct = cause.filter(|_| melee); + host.hub.damage_event(m.id as i32, kind, cause, direct, at); } if dead && !old.is_some_and(|o| o.1) { host.hub.entity_event(m.id as i32, 3, at); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 680360c93..b144b55dd 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1572,6 +1572,15 @@ impl Game<'_> { pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]); } } + // ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent + // jolts the walk animation and restarts the hurt flash. Its hurt + // sound comes as a sound from Horizon's host, which plays it itself. + C::DamageEvent(p) => { + if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { + m.limb_speed = 1.5; + m.hurt_time = 10; + } + } C::HurtAnimation(p) => { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { m.hurt_time = 10; diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 3ecd58b80..9f8607bb9 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -109,6 +109,33 @@ impl Hub { ); } + /// `ServerLevel.broadcastDamageEvent`: the clients around show the hurt + /// entity's flash and hear its hurt sound. + pub fn damage_event( + &self, + id: i32, + kind: &str, + cause: Option, + direct: Option, + at: [f64; 3], + ) { + let Some(source_type) = self.sync.id("minecraft:damage_type", kind) else { + return; + }; + self.broadcast_near( + at, + 128.0, + &C::DamageEvent(play::DamageEvent { + id, + source_type, + cause, + direct, + position: None, + }), + None, + ); + } + pub fn swing(&self, id: i32, hand: i32, at: [f64; 3], except: Option) { self.broadcast_near( at, diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 895db9755..349f41ddd 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -244,6 +244,18 @@ packet!(EntityPositionSync { id: i32 => VarInt, path: PositionPath => PositionPa packet!(ForgetLevelChunk { pos: [i32; 2] => ChunkPos }); packet!(GameEvent { event: u8 => UByte, param: f32 => Float }); packet!(HurtAnimation { id: i32 => VarInt, yaw: f32 => Float }); +packet!( + /// ClientboundDamageEventPacket: the hurt entity, the damage type's + /// registry id, the causing and the direct entity (none: -1 on the wire's + /// id + 1) and the source's position. + DamageEvent { + id: i32 => VarInt, + source_type: i32 => VarInt, + cause: Option => OptVarInt, + direct: Option => OptVarInt, + position: Option<[f64; 3]> => Opt, + } +); packet!(LevelChunkWithLight { x: i32 => Int, z: i32 => Int, chunk: ChunkData => ChunkData, light: LightData => LightData }); packet!(LevelEvent { kind: i32 => Int, pos: [i32; 3] => Pos, data: i32 => Int, global: bool => Bool }); packet!(LightUpdate { x: i32 => VarInt, z: i32 => VarInt, light: LightData => LightData }); @@ -1388,6 +1400,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { GameEvent(GameEvent) = GAME_EVENT, GameRuleValues(GameRuleValues) = GAME_RULE_VALUES, HurtAnimation(HurtAnimation) = HURT_ANIMATION, + DamageEvent(DamageEvent) = DAMAGE_EVENT, KeepAlive(KeepAlive) = KEEP_ALIVE, InitializeBorder(InitializeBorder) = INITIALIZE_BORDER, LevelChunkWithLight(LevelChunkWithLight) = LEVEL_CHUNK_WITH_LIGHT, @@ -2110,6 +2123,13 @@ mod tests { id: 2, slots: vec![(0, None), (5, Some(Stack::new(1, 1)))], }), + Clientbound::DamageEvent(DamageEvent { + id: 7, + source_type: 31, + cause: Some(0), + direct: None, + position: Some([1.5, -2.0, 3.25]), + }), Clientbound::SetHealth(SetHealth { health: 17.5, food: 18, diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 4d13b1c22..85a4f9e36 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -19,6 +19,8 @@ address. The script then checks, from both sides, what a guest's game used to ge - hazards: a guest in survival gets hungry sprint-jumping and is hurt by lava, a block at the eyes, drowning and the void (then respawns); - pvp: the guest and the host hit each other with a sword; with the pvp game rule off, nothing; +- hurt: a pig the guest hits, and one the host hits, flashes red on the other side too (the + server's damage event) (#848); - containers: the guest's ender chest holds the guest's items, not the host's, and a double chest opens as 9x6 with the right half first; - feedback: the guest's command feedback names the guest and shows the numbers; @@ -84,7 +86,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', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'hurt', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] class Game: @@ -575,6 +577,33 @@ def check_pvp(host, guest, out): return hits +def check_hurt(host, guest, out): + for g in (host, guest): + g('command', command='gamemode survival') + g('command', command='clear @s') + face_each_other(host, guest) + hx, hy, hz = pos_of(host) + # Between the two, within both players' reach; bare hands leave it alive. + host('command', command=f'summon minecraft:pig {hx + 1.5:.2f} {hy:.2f} {hz + 0.8:.2f} {{NoAI:1b}}') + host.wait(30) + + def pig(game): + return game.raw('entities', 6, 'pig')['entities'][0] + seen = {} + for name, attacker, watcher in (('guest hits', guest, host), ('host hits', host, guest)): + attacker.wait(30) # a full attack charge + attacker.raw('attack', pig(attacker)['id']) + seen[name] = {'attacker': pig(attacker)['hurt_time'], 'watcher': pig(watcher)['hurt_time']} + attacker.shot(out, 'hurt-' + name.replace(' ', '-')) + watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen') + attacker.wait(20) + host('command', command='kill @e[type=minecraft:pig]') + # LivingEntity.hurtTime: 10 when hurt, one less each tick; the reads come a few ticks later. + for name, sides in seen.items(): + assert all(t > 0 for t in sides.values()), f'{name}: the pig\'s hurt flash {seen}' + return seen + + def container_items(game): """The open container's stacks by slot (Horizon's container slots are 300 and up) and its size.""" slots = [s for s in game('screen').get('slots', []) if s.get('slot', 0) >= 300] @@ -1100,7 +1129,13 @@ def check_xp(host, guest, out): g('command', command='gamemode survival') g('command', command='xp set @s 0 levels') g('command', command='xp set @s 0 points') + # Out of the orbs' way wherever an earlier check left the host (ExperienceOrb follows the + # nearest player who isn't a spectator). + host('command', command='gamemode spectator') guest.wait(10) + # `xp set` leaves Player.totalExperience (the score): experience from earlier checks stays in it. + total = guest('state')['player']['xp']['total'] + host_total = host('state')['player']['xp']['total'] x, y, z = pos_of(guest) # Three orbs of 7 points 4 blocks east of the guest (out of reach, within the 8 blocks orbs # follow from): they fly to the guest. @@ -1116,8 +1151,8 @@ def check_xp(host, guest, out): detail = {'seen': seen, 'guest': xp, 'host': host_xp, 'left': left} # ExperienceOrb.scanForMerges: orbs of one value side by side can merge into one. assert 1 <= seen <= 3, f'the guest saw {seen} orbs: {detail}' - assert xp['total'] == 21 and xp['level'] == 2, f'the guest\'s experience after three 7-point orbs: {detail}' - assert host_xp['total'] == 0, f'the host took the orbs: {detail}' + assert xp['total'] - total == 21 and xp['level'] == 2, f'the guest\'s experience after three 7-point orbs: {detail}' + assert host_xp['total'] == host_total, f'the host took the orbs: {detail}' assert left == 0, f'orbs left on the guest\'s screen: {detail}' return {'seen': seen, 'guest': xp, 'host': host_xp} @@ -1271,7 +1306,7 @@ def check_light(host, guest, out): CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags, 'chest': check_chest, 'crafting': check_crafting, 'sign': check_sign, 'damage': check_damage, - 'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp, + 'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp, 'hurt': check_hurt, 'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles, 'commands': check_commands, 'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls, From 89cbe03ebe1a6d54f4a494fcc0402f1768cdc710 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:48 +0200 Subject: [PATCH 4/6] LAN: everyone sees players' arm swings The host ignored ServerboundPunchPacket, so a guest mining or hitting swung only on its own screen; only the first swing a guest's use ever started went out, the host's own swings never did, the host drew no swing on guests' bodies and a guest's client ignored ClientboundSwingAnimationPacket. The host now swings the guest's hand on a punch (handlePunch), sends each tick's guest swings to the players around and the host's own to the guests, and swings and ticks the guests' bodies it draws. A use the server ran that swung goes back to the guest too: Horizon's client does not run uses itself, so it never predicted that swing (the hoe). The guest's client swings itself or the other entity on the packet. lan_e2e.py swings: mining swings seen from each side, and the guest's own hoe swing. Part of #848 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/agent.rs | 2 + crates/horizon-client/src/game/lan.rs | 8 ++ crates/horizon-client/src/game/lan_actions.rs | 93 ++++++++++++++++--- crates/horizon-client/src/game/remote.rs | 19 ++++ crates/horizon-client/src/mobs/ai.rs | 2 +- tools/lan_e2e.py | 60 +++++++++++- 6 files changed, 167 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index b11fdd552..71992ec3f 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -477,6 +477,8 @@ impl Game<'_> { "sleeping": self.sleeping, // The item in use: its slot and for how many ticks. "using": self.using.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })), + // The arm swings started (LivingEntity.swing). + "swings": self.swings_started, "time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) }, "weather": { "raining": self.world.weather.raining, "thundering": self.world.weather.thundering }, "paused": self.pause, diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index d35bf787a..491767836 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -42,6 +42,8 @@ pub(super) struct LanHost { pub hub: Hub, pub settings: LanSettings, pub host_entity: i32, + /// The host's swings the guests were told of (`Game::swings_started`). + pub host_swings: u32, pub host_uuid: u128, /// The host's profile name (the account's, or the offline name). pub host_name: String, @@ -395,6 +397,8 @@ impl Game<'_> { [&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet] .map(|s| s.as_deref()) }), + // The host draws this guest's arm swinging. + "model_swinging": h.models.get(&g.conn).map(|m| m.ai.swinging), })).collect::>(), }), _ => json!({ "open": false }), @@ -509,6 +513,7 @@ impl Game<'_> { hub, settings: LanSettings { port, ..settings }, host_entity, + host_swings: self.swings_started, host_uuid, host_name: self.options.player_name(), models: HashMap::new(), @@ -1158,6 +1163,9 @@ impl Game<'_> { .each_ref() .map(|s| s.as_ref().map(|s| s.item.clone())); m.extra.crouching = g.sneaking; + // LivingEntity.updateSwingTime: the arm swing lan_swings started. + m.anim.attack_anim_o = m.anim.attack_anim; + horizon_client::mobs::ai::tick_swing(m); } } diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index ced435d8d..e161517dd 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -10,6 +10,7 @@ use horizon_client::{ command::vanilla, containers::BlockEntity, entities::Experience, + first_person::{Hand, SwingAnimation}, items::ItemStack, lan::{ guest::SPECTATOR, @@ -43,8 +44,9 @@ pub(super) struct GuestPlay { /// its progress since `dig_start` is complete (ServerPlayerGameMode.hasDelayedDestroy). delayed: Option<([i32; 3], u64)>, last_progress: i32, - /// Hand movements the client announced and the host animated. - swung: bool, + /// Swings the guest's actions started this tick, for the players around + /// (`LivingEntity.swing` sends ClientboundSwingAnimationPacket). + swings: u32, /// The trading screen the guest has open. pub(super) merchant: Option, /// The inventory menu and the crafting table the guest crafts in. @@ -202,7 +204,11 @@ impl Game<'_> { self.lan_acting_name = Some(g.name.clone()); self.lan_acting_uuid = Some(g.uuid); + // The guest's swings are theirs: the host's own count stays the host's. + let swings = self.swings_started; let result = f(self); + play.swings += self.swings_started.wrapping_sub(swings); + self.swings_started = swings; for packet in std::mem::take(&mut self.guest_packets) { host.hub.send_to(conn, &packet); } @@ -542,6 +548,16 @@ impl Game<'_> { } } } + // ServerGamePacketListenerImpl.handlePunch: the main hand swings + // with the held item's attack animation (mining, attacking, a + // click on nothing), seen by the players around, and the attack + // strength starts again. + S::Punch(_) => { + self.as_guest(host, conn, |g| { + g.swing_hand(Hand::Main, SwingAnimation::DEFAULT); + g.reset_attack_strength(); + }); + } S::UseItemOn(p) => { host.hub.ack(conn, p.sequence); if spectator { @@ -557,11 +573,14 @@ impl Game<'_> { f64::from(p.pos[2]) + f64::from(p.cursor[2]), ], }; - self.as_guest(host, conn, |g| { + let swung = self.as_guest(host, conn, |g| { g.forced_hit = Some(hit); + let swings = g.swings_started; let _ = g.use_item(); g.forced_hit = None; + g.swings_started != swings }); + self.lan_swing_back(host, conn, swung); // The client predicted a result: correct it with what really is. for pos in [ p.pos, @@ -579,20 +598,26 @@ impl Game<'_> { } // handleUseItem: the client's rotation, then the item alone. What it // looked at a moment ago (a block, a mob) is UseItemOn's or Interact's. - self.as_guest(host, conn, |g| { + let swung = self.as_guest(host, conn, |g| { (g.yaw, g.pitch) = (p.yaw, p.pitch); g.lan_item_only = true; + let swings = g.swings_started; let _ = g.use_item(); g.lan_item_only = false; + g.swings_started != swings }); + self.lan_swing_back(host, conn, swung); } 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); - self.as_guest(host, conn, |g| { + 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); } } S::SetCreativeModeSlot(p) => { @@ -1619,23 +1644,63 @@ impl Game<'_> { } } - /// Guests' arm swings for everyone else. + /// A use the server ran swung the guest's hand: their own client is told + /// too (`swing(hand, true)` for a SwingSource.SERVER result). Horizon's + /// client does not run uses itself, so it does not predict the swing; a + /// client that did (a block placed) is still in the first half of its + /// swing and ignores it (`SwingState.startIfAble`). + fn lan_swing_back(&mut self, host: &mut LanHost, conn: u32, swung: Option) { + if swung != Some(true) { + return; + } + let (Some(g), Some(play)) = (host.hub.guest(conn), host.play.get(&conn)) else { + return; + }; + if let Some(c) = play.swing.current { + host.hub + .send_to(conn, &swing_packet(g.entity_id, c.hand, c.animation)); + } + } + + /// The tick's arm swings for the players around (the guests' to everyone + /// else, the host's to the guests), and `LivingEntity.updateSwingTime` + /// for the guests' bodies here. pub(super) fn lan_swings(&mut self, host: &mut LanHost) { let conns: Vec = host.play.keys().copied().collect(); for conn in conns { let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else { continue; }; - if !host.hub.here(g) { - continue; - } - let (id, at) = (g.entity_id, g.position); + let (id, at, here) = (g.entity_id, g.position, host.hub.here(g)); let play = host.play.get_mut(&conn).expect("play"); - let swinging = play.swing.swinging() && play.swing.ticks == 0; - if swinging && !play.swung { - host.hub.swing(id, 0, at, Some(conn)); + let swung = std::mem::take(&mut play.swings) > 0; + if let (true, true, Some(c)) = (swung, here, play.swing.current) { + let packet = swing_packet(id, c.hand, c.animation); + host.hub.broadcast_near(at, 128.0, &packet, Some(conn)); + if let Some(m) = host.models.get_mut(&conn) { + m.swing(); + } + } + play.swing.tick(); + } + if self.swings_started != host.host_swings { + host.host_swings = self.swings_started; + if let (Some(p), Some(c)) = (self.player.as_ref(), self.swing.current) { + let at = [p.position.x, p.position.y, p.position.z]; + let packet = swing_packet(host.host_entity, c.hand, c.animation); + host.hub.broadcast_near(at, 128.0, &packet, None); } - play.swung = swinging; } } } + +/// ClientboundSwingAnimationPacket: the hand and the animation (its type and +/// base duration; each client applies the swinger's haste and fatigue). +fn swing_packet(id: i32, hand: Hand, animation: SwingAnimation) -> C { + C::SwingAnimation(play::SwingAnimation { + id, + hand: i32::from(hand == Hand::Off), + kind: i32::from(animation.kind), + duration: animation.duration, + }) +} diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 680360c93..e5bc9b6b4 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1599,6 +1599,21 @@ impl Game<'_> { } } C::Animate(p) => self.remote_animate(p.id, p.action), + // ClientPacketListener.handleSwingAnimation: the entity swings + // (LivingEntity.swing, which a swing in its first half ignores). + C::SwingAnimation(p) => { + use horizon_client::first_person::Hand; + let hand = if p.hand == 1 { Hand::Off } else { Hand::Main }; + let animation = horizon_client::first_person::SwingAnimation { + kind: p.kind.clamp(0, 255) as u8, + duration: p.duration, + }; + if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) { + self.swing_hand(hand, animation); + } else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { + m.swing(); + } + } C::UpdateMobEffect(p) => self.remote_effect(&p), C::RemoveMobEffect(p) => { if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) @@ -2621,6 +2636,9 @@ impl Game<'_> { if !m.alive() { m.death_time += 1; } + // LivingEntity.updateSwingTime. + m.anim.attack_anim_o = m.anim.attack_anim; + horizon_client::mobs::ai::tick_swing(m); // Entity.commonTick: LinearInterpolationHandler.interpolate. if let Some(l) = lerps.get_mut(&(m.id as i32)) && l.steps > 0 @@ -3728,6 +3746,7 @@ impl Game<'_> { let armor = [&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref()); serde_json::json!({ "id": m.id, + "swinging": m.ai.swinging, "armor": armor, "main_hand": e.main_hand.as_deref(), "off_hand": e.off_hand.as_deref(), diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index f74a3c7d1..475bd7611 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -429,7 +429,7 @@ pub fn tick( true } /// LivingEntity.updateSwingTime (6-tick swing duration). -fn tick_swing(m: &mut Mob) { +pub fn tick_swing(m: &mut Mob) { if m.ai.swinging { m.ai.swing_time += 1; if m.ai.swing_time >= 6 { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 4d13b1c22..e0a696c23 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -49,6 +49,8 @@ address. The script then checks, from both sides, what a guest's game used to ge guest as the host has them (kind, place, rotation, what they hold or wear, the variant). - projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where the host has them; a falling block lands on both sides. +- swings: each side sees the other's arm swing while it mines a block, and the guest sees its own + swing when its hoe tills dirt (the server's use) (#848). - light: the guest's light matches the host's: around a torch placed near it, under a roof and once a block of the roof is removed under the sky, and in the last chunk it tracks next to glowstone in a chunk it doesn't (only the server's light updates bring that one). @@ -84,7 +86,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', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'swings', 'effects', 'portals', 'distance'] class Game: @@ -1095,6 +1097,60 @@ def check_eat(host, guest, out): return detail +def watch_mining(miner, pos, seen): + """Mine the block at `pos` (by hand, for seconds) and sample `seen()` meanwhile.""" + dig = threading.Thread(target=miner.raw, args=('break', *pos)) + dig.start() + samples = [] + for _ in range(8): + time.sleep(0.25) + samples.append(seen()) + dig.join() + return samples + + +def check_swings(host, guest, out): + for g in (host, guest): + g('command', command='gamemode survival') + g('command', command='clear @s') + face_each_other(host, guest) + hx, hy, hz = (int(v // 1) for v in pos_of(host)) + gx, gy, gz = (int(v // 1) for v in pos_of(guest)) + # Stone by hand takes 7.5 seconds: ServerboundPunchPacket every tick keeps the arm swinging. + guest('command', command=f'setblock {gx} {gy} {gz + 1} minecraft:stone') + host('command', command=f'setblock {hx} {hy} {hz + 1} minecraft:stone') + host.wait(10) + def swinging(game): + # On its server thread the host's window is a client of its server too. + if game is host and not SERVER_THREAD: + return lan_guest(host).get('model_swinging') + return any(e.get('swinging') for e in remote(game).get('player_equipment', [])) + # Most samples: a swing restarts only past its first half (SwingState.startIfAble), so a sample can + # fall between two. + host_sees = watch_mining(guest, (gx, gy, gz + 1), lambda: swinging(host)) + guest_sees = watch_mining(host, (hx, hy, hz + 1), lambda: swinging(guest)) + # A hoe on dirt: the server tills it and swings the guest's hand, on the guest's screen too. + # Where the guest stands now (an earlier check can leave it on a ledge it then drops from). + gx, gy, gz = (int(v // 1) for v in pos_of(guest)) + soil = (gx, gy - 1, gz + 1) + guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:dirt') + # HoeItem.useOn tills only under air. + guest('command', command=f'setblock {soil[0]} {soil[1] + 1} {soil[2]} minecraft:air') + guest('command', command='give @s minecraft:wooden_hoe') + guest.wait(10) + guest.raw('select', 'minecraft:wooden_hoe') + before = guest('state')['swings'] + guest.raw('interact', *soil) + guest.wait(10) + hoe = {'swings': guest('state')['swings'] - before, 'soil': host.raw('block', *soil)['block']} + guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:stone') + detail = {'host_sees_guest': host_sees, 'guest_sees_host': guest_sees, 'hoe': hoe} + assert sum(map(bool, host_sees)) >= 4, f'the host does not see the guest mine: {detail}' + assert sum(map(bool, guest_sees)) >= 4, f'the guest does not see the host mine: {detail}' + assert hoe == {'swings': 1, 'soil': 'minecraft:farmland'}, f'the guest\'s hoe: {detail}' + return detail + + def check_xp(host, guest, out): for g in (host, guest): g('command', command='gamemode survival') @@ -1278,7 +1334,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name 'effects': check_effects, 'portals': check_portals, 'distance': check_distance, 'clicks': check_clicks, 'sleep': check_sleep, 'push': check_push, 'eat': check_eat, 'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, - 'light': check_light} + 'light': check_light, 'swings': check_swings} def run(ctl, out, resources, checks): From 5b47d53dbf409b55b582212bcab2d26c0b68054d Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:32:08 +0200 Subject: [PATCH 5/6] LAN: guests use brewing stands BrewingStandBlock opens BrewingStandMenu for a guest: the host clicks it on the menu engine over the stand's slots (one bottle per slot, ingredient and fuel slots, quick moves by item) and sends its four data slots (BrewingStandBlockEntity.dataAccess). The guest opens minecraft:brewing_stand menus, maps their slots, predicts clicks on Kind::Brewing and shows the times. Reported by Tydragon00. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 3 + crates/horizon-client/src/game/lan_actions.rs | 9 ++- .../horizon-client/src/game/lan_crafting.rs | 60 ++++++++++++------- crates/horizon-client/src/game/remote.rs | 25 ++++++-- crates/horizon-client/src/game/stations.rs | 4 +- crates/horizon-client/src/lan/containers.rs | 20 ++++--- tools/lan_e2e.py | 41 ++++++++++++- 7 files changed, 121 insertions(+), 41 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 85a191832..1a295a569 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4948,6 +4948,9 @@ impl Game<'_> { }; (kind, size) } + // A server's BrewingStandMenu; a local stand's clicks are the + // station screen's. + Some(BlockEntity::Brewing(_)) if self.remote.is_some() => (Kind::Brewing, 5), Some(BlockEntity::Furnace(f)) => ( Kind::Furnace( match f.kind.recipe_type().trim_start_matches("minecraft:") { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index ced435d8d..8581093dc 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -395,6 +395,13 @@ impl Game<'_> { _ => ("minecraft:furnace", "container.furnace"), }; (menu, title, Slots::Block(pos)) + } else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) { + // BrewingStandBlock.useWithoutItem: BrewingStandMenu. + ( + "minecraft:brewing_stand", + "container.brewing", + Slots::Block(pos), + ) } else if let Some((other, right)) = self.chest_partner(pos) { // ChestBlock.combine: the right half's slots come first. let halves = if right { @@ -606,7 +613,7 @@ impl Game<'_> { S::ContainerClick(p) if self.lan_merchant_click(host, conn, &p) => {} S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {} S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {} - S::ContainerClick(p) if self.lan_furnace_click(host, conn, &p) => {} + S::ContainerClick(p) if self.lan_machine_click(host, conn, &p) => {} S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item), S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => { let g = &mut host.hub.guests[gi]; diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index 1f16a9c74..efb7f0a00 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -21,9 +21,10 @@ pub(super) struct GuestCrafting { inventory: Option>, /// The open crafting table: its window, state id and menu. table: Option<(i32, i32, Box)>, - /// The open furnace's window and menu (`AbstractFurnaceMenu`); its slots live in - /// the furnace block entity and the hub's container menu keeps the client in sync. - furnace: Option<(i32, Box)>, + /// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`, + /// `BrewingStandMenu`); its slots live in the block entity and the hub's + /// container menu keeps the client in sync. + machine: Option<(i32, Box)>, } /// ServerboundContainerClickPacket's input. @@ -157,10 +158,11 @@ impl Game<'_> { true } - /// A click on the guest's open furnace, blast furnace or smoker: the host's - /// `AbstractFurnaceMenu` (fuel slot, take-only result, quick moves by recipe and - /// fuel) over the block entity's slots. False for other windows. - pub(super) fn lan_furnace_click( + /// A click on the guest's open furnace, blast furnace, smoker or brewing stand: + /// the host's `AbstractFurnaceMenu` (fuel slot, take-only result, quick moves by + /// recipe and fuel) or `BrewingStandMenu` (one bottle per slot, ingredient and + /// blaze powder slots) over the block entity's slots. False for other windows. + pub(super) fn lan_machine_click( &mut self, host: &mut LanHost, conn: u32, @@ -180,15 +182,18 @@ impl Game<'_> { else { return false; }; - let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) else { - return false; + let (kind, items) = match self.world.block_entities.get(&pos) { + Some(BlockEntity::Furnace(f)) => { + let recipe = match f.kind.recipe_type() { + "minecraft:blasting" => "blasting", + "minecraft:smoking" => "smoking", + _ => "smelting", + }; + (Kind::Furnace(recipe), f.items.to_vec()) + } + Some(BlockEntity::Brewing(b)) => (Kind::Brewing, b.items.to_vec()), + _ => return false, }; - let recipe = match f.kind.recipe_type() { - "minecraft:blasting" => "blasting", - "minecraft:smoking" => "smoking", - _ => "smelting", - }; - let items = f.items.clone(); if host.hub.guests[gi].spectator() { return true; } @@ -197,10 +202,10 @@ impl Game<'_> { .entry(conn) .or_default() .crafting - .furnace + .machine .take() .filter(|(window, _)| *window == p.container) - .map_or_else(|| Box::new(Menu::new(Kind::Furnace(recipe))), |(_, m)| m); + .map_or_else(|| Box::new(Menu::new(kind)), |(_, m)| m); let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| { for (k, s) in items.iter().enumerate() { menu.set_slot(env, k, s.clone()); @@ -213,14 +218,23 @@ impl Game<'_> { .filter(|&s| menu.slot(s) != before[s].as_ref()) .collect::>() }); - if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) { - for k in 0..3 { - f.items[k] = menu.slot(k).cloned(); + match self.world.block_entities.get_mut(&pos) { + Some(BlockEntity::Furnace(f)) => { + for k in 0..3 { + f.items[k] = menu.slot(k).cloned(); + } + // AbstractFurnaceBlockEntity.setItem: only a new input restarts cooking. + f.input_clicked(&self.res.recipes, items[0].clone()); } - // AbstractFurnaceBlockEntity.setItem: only a new input restarts cooking. - f.input_clicked(&self.res.recipes, items[0].clone()); + // BrewingStandBlockEntity.serverTick notices a new ingredient itself. + Some(BlockEntity::Brewing(b)) => { + for k in 0..5 { + b.items[k] = menu.slot(k).cloned(); + } + } + _ => {} } - host.play.entry(conn).or_default().crafting.furnace = Some((p.container, menu)); + host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu)); host.hub.inventory_clicked(conn, p, &self.world, &changed); true } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3b7d51609..e76f2fecb 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1180,6 +1180,7 @@ impl Game<'_> { // HopperMenu; DispenserMenu (dispensers and droppers). "minecraft:hopper" => slots(5), "minecraft:generic_3x3" => slots(9), + "minecraft:brewing_stand" => slots(5), _ => None, } } @@ -1758,17 +1759,27 @@ impl Game<'_> { self.remote_synced(); } C::ContainerSetData(p) => { - if let Some(key) = self.container - && let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&key) + let v = i32::from(p.value); + match self + .container + .and_then(|key| self.world.block_entities.get_mut(&key)) { - let v = i32::from(p.value); - match p.id { + Some(BlockEntity::Furnace(f)) => match p.id { 0 => f.lit_remaining = v, 1 => f.lit_total = v, 2 => f.cooking_timer = v, 3 => f.cooking_total = v, _ => {} - } + }, + // BrewingStandBlockEntity.dataAccess. + Some(BlockEntity::Brewing(b)) => match p.id { + 0 => b.brew_time = v, + 1 => b.fuel = v, + 2 => b.total_brew_time = v, + 3 => b.total_fuel = v, + _ => {} + }, + _ => {} } } C::OpenScreen(p) => self.remote_open_screen(p), @@ -3552,6 +3563,10 @@ impl Game<'_> { // HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers // and droppers). "minecraft:hopper" => Some(BlockEntity::Chest(vec![None; 5])), + // BrewingStandMenu: its times come in ContainerSetData. + "minecraft:brewing_stand" => Some(BlockEntity::Brewing(Box::new( + horizon_client::brewing::BrewingStand::new(), + ))), "minecraft:generic_3x3" => Some(BlockEntity::Chest(vec![None; 9])), m if m.starts_with("minecraft:generic_9x") || m == "minecraft:shulker_box" => { let rows = m diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 7b70cb1f1..db64399dc 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -1051,7 +1051,8 @@ impl Game<'_> { } } pub(super) fn station_click(&mut self, right: bool) -> bool { - if self.station.is_none() && !self.brewing_open() { + // A server's brewing stand: its slots are the server's. + if self.station.is_none() && (!self.brewing_open() || self.remote.is_some()) { return false; } let Some(size) = self @@ -1396,6 +1397,7 @@ impl Game<'_> { .as_ref() .and_then(|s| self.world.block_names.get(&(s.pos[0], s.pos[1], s.pos[2]))) .cloned() + .or_else(|| self.remote_menu_title()) .unwrap_or(default); let tw = HudBatch::text_width(atlas, &title); batch.text( diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 3ecd58b80..a807fb82a 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -428,15 +428,17 @@ impl Hub { let Slots::Block(pos) = m.slots else { return; }; - let Some(BlockEntity::Furnace(f)) = world.block_entities.get(&pos) else { - return; - }; - let data = [ - f.lit_remaining, - f.lit_total, - f.cooking_timer, - f.cooking_total, - ] + // AbstractFurnaceBlockEntity.dataAccess, BrewingStandBlockEntity.dataAccess. + let data = match world.block_entities.get(&pos) { + Some(BlockEntity::Furnace(f)) => [ + f.lit_remaining, + f.lit_total, + f.cooking_timer, + f.cooking_total, + ], + Some(BlockEntity::Brewing(b)) => [b.brew_time, b.fuel, b.total_brew_time, b.total_fuel], + _ => return, + } .map(|v| v as i16); m.sent_data.resize(data.len(), i16::MIN); let window = m.window; diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index a0ae2615e..74176d705 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -23,6 +23,9 @@ address. The script then checks, from both sides, what a guest's game used to ge opens as 9x6 with the right half first; - machines: a hopper, a dispenser and a dropper open on the guest's screen with their 5 and 9 slots, and keep what the guest shift-clicks into them (#848); +- brewing: a brewing stand opens on the guest's screen; blaze powder, nether wart and a bottle the + guest shift-clicks go to the fuel, ingredient and bottle slots, and the stand's fuel shows + (#848); - feedback: the guest's command feedback names the guest and shows the numbers; - effects: a beacon's power reaches every player in range, the host and the guest; - distance: (--server-thread) the server follows the host's render distance, past vanilla's 32 too @@ -86,7 +89,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', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'machines', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'machines', 'brewing', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] class Game: @@ -668,6 +671,40 @@ def check_machines(host, guest, out): return seen +def check_brewing(host, guest, out): + guest('command', command='gamemode survival') + guest('command', command='clear @s') + loads = ('blaze_powder', 'nether_wart', 'glass_bottle') + for item in loads: + guest('command', command=f'give @s minecraft:{item}') + x, y, z = (int(v // 1) for v in pos_of(guest)) + at = (x, y, z + 2) + guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:brewing_stand') + guest.wait(10) + guest.raw('interact', *at) + guest.raw('wait-for', 'screen', 'container', '--timeout', 10) + guest.wait(10) + size = container_items(guest)[1] + # BrewingStandMenu.quickMoveStack: blaze powder to the fuel slot, an ingredient to its slot, + # a bottle to the bottle slots. + for item in loads: + guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift') + guest.wait(5) + # BrewingStandBlockEntity.serverTick burns the blaze powder into 20 uses; the guest sees them + # through the menu's data slots. + guest.wait(10) + info = guest.raw('menu')['info'] + guest.shot(out, 'brewing') + guest.raw('key', 'esc', 'tap') + guest.wait(10) + items = open_container(guest, at, out, 'brewing-after')[0] + guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air') + seen = {'size': size, 'items': items, 'fuel': info and info.get('fuel')} + want = {'size': 5, 'items': {0: 'minecraft:glass_bottle', 3: 'minecraft:nether_wart'}, 'fuel': 20} + assert seen == want, f'the brewing stand on the guest: {seen}' + return seen + + def slot_point(game, slot): """The GUI point in the middle of slot `slot` of the open screen (`ui`'s slot-N).""" for w in game.raw('ui')['widgets']: @@ -1303,7 +1340,7 @@ def check_light(host, guest, out): CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags, 'chest': check_chest, 'crafting': check_crafting, 'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp, - 'containers': check_containers, 'machines': check_machines, 'feedback': check_feedback, 'titles': check_titles, + 'containers': check_containers, 'machines': check_machines, 'brewing': check_brewing, 'feedback': check_feedback, 'titles': check_titles, 'commands': check_commands, 'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls, 'effects': check_effects, 'portals': check_portals, 'distance': check_distance, 'clicks': check_clicks, From 159a5dfa5f0c800de5fb8576c808f5e8315e64cd Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:14:48 +0200 Subject: [PATCH 6/6] LAN: pressure plates, tripwires and detector rails see the guests Block triggers counted only the host's own player and the minecarts, so a guest stepping on a plate did nothing. The guests in the level, not spectators, now trigger them through Entity.checkInsideBlocks and are counted by the plates' entity queries. On the server thread no block ticks run at all yet (#890). lan_e2e.py plates: a stone plate under the guest, on both sides. Part of #848 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 23 ++++++++++++++---- crates/horizon-client/src/game/lan_mobs.rs | 20 ++++++++++++++++ tools/lan_e2e.py | 27 ++++++++++++++++++++-- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 85a191832..0669ea4de 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -460,6 +460,8 @@ struct GameLevel<'w> { raining: bool, thundering: bool, player_box: Option<([f64; 3], [f64; 3])>, + /// The LAN guests in this level who trigger blocks (not spectators). + guest_boxes: Vec<([f64; 3], [f64; 3])>, player_collision: Option, /// Minecart boxes (detector rails). cart_boxes: Vec<([f64; 3], [f64; 3])>, @@ -673,7 +675,7 @@ impl LevelAccess for GameLevel<'_> { .unwrap_or(false) } /// Entities that trigger pressure plates, tripwires and detector rails: - /// the player (a mob) and the minecarts. + /// the players (the host and the LAN guests, mobs) and the minecarts. fn count_entities(&self, area: Aabb, query: EntityQuery) -> usize { let hit = |(lo, hi): &([f64; 3], [f64; 3])| { area.intersects(Aabb::new( @@ -682,10 +684,17 @@ impl LevelAccess for GameLevel<'_> { )) }; let carts = || self.cart_boxes.iter().filter(|b| hit(b)).count(); + let players = || { + self.player_box + .iter() + .chain(&self.guest_boxes) + .filter(|b| hit(b)) + .count() + }; match query { EntityQuery::Minecart => carts(), - EntityQuery::Mobs => self.player_box.iter().filter(|b| hit(b)).count(), - EntityQuery::Everything => self.player_box.iter().filter(|b| hit(b)).count() + carts(), + EntityQuery::Mobs => players(), + EntityQuery::Everything => players() + carts(), } } fn emit(&mut self, event: LevelEvent) { @@ -1868,6 +1877,7 @@ impl Game<'_> { ) }) .collect(); + let guest_boxes = self.lan_block_trigger_boxes(); let dimension = match self.dim { Dim::Overworld => LevelDimension::Overworld, Dim::Nether => LevelDimension::Nether, @@ -1885,6 +1895,7 @@ impl Game<'_> { raining: rain > 0.2, thundering: thunder > 0.9, player_box, + guest_boxes, player_collision, biomes: &self.res.biomes, seed: self.level.seed, @@ -1963,8 +1974,9 @@ impl Game<'_> { ticking.push((x, z, sections)); } // Entity.checkInsideBlocks for the plates, tripwires and detector rails - // under the player and the minecarts. + // under the players (the host and the LAN guests) and the minecarts. let mut inside = Vec::new(); + let guests = self.lan_block_trigger_boxes(); let boxes = self .player .iter() @@ -1983,7 +1995,8 @@ impl Game<'_> { [p.x - 0.49, p.y, p.z - 0.49], [p.x + 0.49, p.y + 0.7, p.z + 0.49], ) - })); + })) + .chain(guests); for (lo, hi) in boxes { for x in (lo[0] + 1e-7).floor() as i32..=(hi[0] - 1e-7).floor() as i32 { for y in (lo[1] + 1e-7).floor() as i32..=(hi[1] - 1e-7).floor() as i32 { diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 99888058c..0ee991737 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -131,6 +131,26 @@ impl Game<'_> { self.lan = Some(host); } + /// The guests' boxes for the blocks entities trigger (pressure plates, + /// tripwires, detector rails): `Entity.checkInsideBlocks` and + /// `Level.getEntities` see every player in the level, but not spectators + /// (`Entity.isIgnoringBlockTriggers`). + pub(super) fn lan_block_trigger_boxes(&self) -> Vec<([f64; 3], [f64; 3])> { + let Some(host) = self.lan.as_ref() else { + return Vec::new(); + }; + host.hub + .guests + .iter() + .filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator()) + .map(|g| { + let [x, y, z] = g.position; + let height = if g.sneaking { 1.5 } else { 1.8 }; + ([x - 0.3, y, z - 0.3], [x + 0.3, y + height, z + 0.3]) + }) + .collect() + } + /// The guests a projectile can hit, under their entity ids. pub(super) fn lan_projectile_targets(&self) -> Vec<(Who, Aabb)> { let Some(host) = self.lan.as_ref() else { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 4d13b1c22..f59d00b95 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -49,6 +49,8 @@ address. The script then checks, from both sides, what a guest's game used to ge guest as the host has them (kind, place, rotation, what they hold or wear, the variant). - projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where the host has them; a falling block lands on both sides. +- plates: a stone pressure plate the guest steps on is pressed on both sides, and comes up once + the guest steps off (#848). - light: the guest's light matches the host's: around a torch placed near it, under a roof and once a block of the roof is removed under the sky, and in the last chunk it tracks next to glowstone in a chunk it doesn't (only the server's light updates bring that one). @@ -84,7 +86,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', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'effects', 'portals', 'distance'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'containers', 'feedback', 'titles', 'commands', 'recipes', 'trials', 'clicks', 'sleep', 'push', 'eat', 'xp', 'objects', 'projectiles', 'light', 'plates', 'effects', 'portals', 'distance'] class Game: @@ -958,6 +960,27 @@ def wait_state(game, pos, key, want, ticks): return state_of(game, pos, key) +def check_plates(host, guest, out): + guest('command', command='gamemode survival') + x, y, z = (int(v // 1) for v in pos_of(guest)) + plate = (x + 2, y, z) + guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:stone_pressure_plate') + guest.wait(10) + guest('command', command=f'tp @s {plate[0] + 0.5} {plate[1]} {plate[2] + 0.5}') + pressed = {'host': wait_state(host, plate, 'powered', 'true', 40), + 'guest': wait_state(guest, plate, 'powered', 'true', 40)} + guest.shot(out, 'plate-pressed') + guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}') + # PressurePlateBlock.getPressedTime: a stone plate checks again every 20 ticks. + released = {'host': wait_state(host, plate, 'powered', 'false', 60), + 'guest': wait_state(guest, plate, 'powered', 'false', 60)} + guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:air') + detail = {'pressed': pressed, 'released': released} + assert pressed == {'host': 'true', 'guest': 'true'}, f'the plate under the guest: {detail}' + assert released == {'host': 'false', 'guest': 'false'}, f'the plate after the guest left: {detail}' + return detail + + def check_trials(host, guest, out): # The host watches as a spectator, whom neither block detects: only the guest can wake them. hx, hy, hz = pos_of(host) @@ -1278,7 +1301,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name 'effects': check_effects, 'portals': check_portals, 'distance': check_distance, 'clicks': check_clicks, 'sleep': check_sleep, 'push': check_push, 'eat': check_eat, 'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, - 'light': check_light} + 'light': check_light, 'plates': check_plates} def run(ctl, out, resources, checks):