From e289980c1ea486228c73d1b007f522c387cf4c28 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:37:47 +0200 Subject: [PATCH 1/4] LAN guest: their own ender chest, and double chests as 9x6 The hub's container menus now say where their slots live (lan::menu::Slots): one block entity, both halves of a double chest (CompoundContainer, the right half first) or the guest's own ender inventory (PlayerEnderChestContainer, saved as EnderItems). The host's ender inventory stays the host's while a guest acts. A Horizon client shows a server's chest-like menu with the rows and title the server sent, and keeps its slots off a block that holds no such container. Part of #435 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan_actions.rs | 78 ++++++++++++++----- crates/horizon-client/src/game/remote.rs | 36 ++++++++- crates/horizon-client/src/game/storage.rs | 8 +- crates/horizon-client/src/lan/containers.rs | 53 +++++++------ crates/horizon-client/src/lan/guest.rs | 3 + crates/horizon-client/src/lan/menu.rs | 73 ++++++++++++++++- crates/horizon-client/src/lan/playerdata.rs | 4 + tools/lan_e2e.py | 61 ++++++++++++++- 8 files changed, 264 insertions(+), 52 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 44c713efe..e52cbeb71 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -11,7 +11,10 @@ use horizon_client::{ containers::BlockEntity, entities::Experience, items::ItemStack, - lan::{guest::SPECTATOR, menu::Rules}, + lan::{ + guest::SPECTATOR, + menu::{Rules, Slots}, + }, motion::{MotionEvent, MotionWorld, PlayerMotion}, }; use horizon_core::geometry::Vec3; @@ -145,10 +148,18 @@ impl Game<'_> { self.right = play.using.is_some() || self.using.is_some(); let riding = self.riding.take(); let host_station = self.station.take(); + // The guest's own ender inventory; one the host has open goes back + // into the host's while the guest acts. + let host_ender = self.ender_open; + self.close_ender_chest(); + std::mem::swap(&mut self.ender_items, &mut g.ender_items); self.lan_acting = Some(conn); let result = f(self); self.lan_acting = None; + // An ender chest the guest opened: the menu shows their inventory + // itself (lan_open_container), not a stand-in block entity. + self.close_ender_chest(); // What the action opened: handed to the guest instead of the host. let opened = self.container.take(); @@ -172,6 +183,13 @@ impl Game<'_> { self.keys.remove(&KeyCode::ShiftLeft); } let g = &mut host.hub.guests[i]; + std::mem::swap(&mut self.ender_items, &mut g.ender_items); + if let Some(key) = host_ender { + self.world + .block_entities + .insert(key, BlockEntity::Chest(self.ender_items.clone())); + self.ender_open = Some(key); + } let new_xp = std::mem::replace(&mut self.experience, xp); g.xp_level = new_xp.level; g.xp_progress = new_xp.progress; @@ -240,30 +258,51 @@ impl Game<'_> { /// Hand a container the guest opened over (ServerPlayer.openMenu); /// false when it is not one a guest can open. fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool { - let Some(entity) = self.world.block_entities.get(&pos) else { - return false; - }; - let (menu, title) = match entity { - BlockEntity::Chest(items) => match items.len() { - 27 => ("minecraft:generic_9x3", "container.chest"), - 9 => ("minecraft:generic_3x3", "container.dispenser"), - 5 => ("minecraft:hopper", "container.hopper"), - _ => return false, - }, - _ => return false, - }; let name = &self .res .facts .get(self.world.block(pos.0, pos.1, pos.2)) .name; - let title = match &**name { - "minecraft:barrel" => "container.barrel", - "minecraft:dropper" => "container.dropper", - n if n.ends_with("shulker_box") => "container.shulkerBox", - _ => title, + let (menu, title, slots) = if &**name == "minecraft:ender_chest" { + // EnderChestBlock.useWithoutItem: the player's own inventory. + ( + "minecraft:generic_9x3", + "container.enderchest", + Slots::Ender, + ) + } else if let Some((other, right)) = self.chest_partner(pos) { + // ChestBlock.combine: the right half's slots come first. + let halves = if right { + Slots::Double(pos, other) + } else { + Slots::Double(other, pos) + }; + ("minecraft:generic_9x6", "container.chestDouble", halves) + } else { + let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else { + return false; + }; + let (menu, title) = match items.len() { + 27 => ("minecraft:generic_9x3", "container.chest"), + 9 => ("minecraft:generic_3x3", "container.dispenser"), + 5 => ("minecraft:hopper", "container.hopper"), + _ => return false, + }; + let title = match &**name { + "minecraft:barrel" => "container.barrel", + "minecraft:dropper" => "container.dropper", + n if n.ends_with("shulker_box") => "container.shulkerBox", + _ => title, + }; + (menu, title, Slots::Block(pos)) + }; + let Some(items) = host + .hub + .guest(conn) + .and_then(|g| slots.read(&self.world, g)) + else { + return false; }; - let items = entity.slots().to_vec(); // ServerPlayer.openMenu closes the menu that was open. self.guest_container_closed(host, conn); host.hub.open_container( @@ -271,6 +310,7 @@ impl Game<'_> { menu, horizon_protocol::text::translatable(title, &[]), pos, + slots, items, ); true diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5578bc71f..db2cab0d0 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -90,6 +90,8 @@ pub(super) struct Remote { loaded_sent: bool, /// Open container: (window id, menu name). menu: Option<(i32, String)>, + /// The open menu's title as the server named it. + menu_title: Option, state_id: i32, use_target: Option<[i32; 3]>, entities: HashMap, @@ -231,6 +233,7 @@ impl Game<'_> { carried: None, loaded_sent: false, menu: None, + menu_title: None, state_id: 0, use_target: None, entities: HashMap::new(), @@ -2549,6 +2552,7 @@ impl Game<'_> { fn remote_close_local(&mut self) { if let Some(r) = self.remote.as_mut() { r.menu = None; + r.menu_title = None; } if self .station @@ -2677,19 +2681,49 @@ impl Game<'_> { return; } }; + let title = plain( + &p.title, + &self + .remote + .as_ref() + .map(|r| r.lang.clone()) + .unwrap_or_default(), + ); if let Some(r) = self.remote.as_mut() { r.menu = Some((p.container, menu.clone())); + r.menu_title = Some(title); } self.craft_size = if menu == "minecraft:crafting" { 3 } else { 2 }; self.container = None; if let Some(entity) = entity { - let key = target.unwrap_or((i32::MIN, 0, 0)); + // The clicked block shows the menu when it holds that container; + // an ender chest's or a double chest's slots are no one block's, + // so they go under a key no block has. + let holds = |k: &(i32, i32, i32)| { + let name = &self.res.facts.get(self.world.block(k.0, k.1, k.2)).name; + BlockEntity::for_block(name).is_some_and(|e| { + e.id() == entity.id() && e.slots().len() == entity.slots().len() + }) + }; + let key = target.filter(holds).unwrap_or((i32::MIN, 0, 0)); self.world.block_entities.insert(key, entity); self.container = Some(key); } self.screen = Screen::Inventory; self.capture(false); } + /// 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()?; + let rows = r + .menu + .as_ref()? + .1 + .strip_prefix("minecraft:generic_9x")? + .parse() + .ok()?; + Some((rows, r.menu_title.clone().unwrap_or_default())) + } pub(super) fn remote_respawn(&mut self) { self.send(S::ClientCommand(play::ClientCommand { action: 0 })); } diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index 7017b2f5f..08793f8ed 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -103,7 +103,7 @@ fn facing_step(facing: &str) -> (i32, i32) { impl Game<'_> { /// The other half of the open double chest and whether the open one is /// the right half (`ChestBlock.getConnectedDirection`). - fn chest_partner(&self, key: (i32, i32, i32)) -> Option<((i32, i32, i32), bool)> { + pub(super) fn chest_partner(&self, key: (i32, i32, i32)) -> Option<((i32, i32, i32), bool)> { let registry = self.res.assets.registry; let state = registry.state(self.world.block(key.0, key.1, key.2))?; if !matches!( @@ -197,6 +197,9 @@ impl Game<'_> { /// Rows of the open generic 9-wide container (`ChestMenu`), or None for /// the other menus (hoppers, dispensers, shulker boxes, furnaces...). pub(super) fn chest_rows(&self) -> Option { + if let Some((rows, _)) = self.remote_chest_menu() { + return Some(rows); + } if !matches!(self.container_kind()?, BlockEntity::Chest(_)) { return None; } @@ -213,6 +216,9 @@ impl Game<'_> { } /// The title of the open chest-like menu. fn chest_title(&self, rows: usize) -> String { + if let Some((_, title)) = self.remote_chest_menu() { + return title; + } let (key, fallback) = match self.container_block().as_deref() { Some("minecraft:barrel") => ("container.barrel", "Barrel"), Some("minecraft:ender_chest") => ("container.enderchest", "Ender Chest"), diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index e024d3634..e9bfe7fbe 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -2,7 +2,7 @@ //! level events, animations and block containers with their clicks. use super::{ hub::Hub, - menu::{self, Menu, Rules, View}, + menu::{self, Menu, Rules, Slots, View}, }; use crate::{items::ItemStack, world::World}; use horizon_protocol::{ @@ -143,6 +143,7 @@ impl Hub { menu_kind: &str, title: Tag, pos: (i32, i32, i32), + slots: Slots, items: Vec>, ) { let Some(kind) = self @@ -169,6 +170,7 @@ impl Hub { window, kind, pos: Some(pos), + slots, size: items.len(), state: 0, sent: Vec::new(), @@ -265,10 +267,7 @@ impl Hub { fn view_slot(&self, i: usize, world: &World, slot: usize) -> Option { let g = &self.guests[i]; match &g.menu { - Some(m) if slot < m.size => { - let e = world.block_entities.get(&m.pos?)?; - e.slots().get(slot).cloned().flatten() - } + Some(m) if slot < m.size => m.slots.get(world, g, slot), Some(m) => g .inventory .get(player_slot(slot - m.size)) @@ -299,32 +298,32 @@ impl Hub { if !window_ok || self.guests[i].spectator() { return Vec::new(); } - let pos = self.guests[i].menu.as_ref().and_then(|m| m.pos); + let source = self.guests[i].menu.as_ref().map(|m| m.slots); let mut drag = if in_menu { self.guests[i].menu.as_mut().and_then(|m| m.drag.take()) } else { self.guests[i].drag.take() }; - let (drops, changed) = { - let guest = &mut self.guests[i]; - let mut entity = pos.and_then(|k| world.block_entities.get_mut(&k)); - let slots = entity.as_mut().map(|e| e.slots_mut()); - if in_menu && slots.is_none() { - return Vec::new(); - } - let mut view = View { - guest, - container: slots, - }; - menu::click( - &mut view, - &mut drag, - i32::from(p.slot), - i32::from(p.button), - p.input, - rules, - ) + let guest = &mut self.guests[i]; + let mut items = source.and_then(|s| s.read(world, guest)); + if in_menu && items.is_none() { + return Vec::new(); + } + let mut view = View { + guest, + container: items.as_deref_mut(), }; + let (drops, changed) = menu::click( + &mut view, + &mut drag, + i32::from(p.slot), + i32::from(p.button), + p.input, + rules, + ); + if let (Some(source), Some(items)) = (source, items) { + source.write(world, guest, items); + } if in_menu { if let Some(m) = self.guests[i].menu.as_mut() { m.drag = drag; @@ -427,8 +426,8 @@ impl Hub { else { continue; }; - let (size, window, pos) = (menu.size, menu.window, menu.pos); - if !pos.is_some_and(|k| world.block_entities.contains_key(&k)) { + let (size, window, slots) = (menu.size, menu.window, menu.slots); + if !slots.valid(world) { self.close_container_at(i); continue; } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 3967e3e83..954ffc714 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -84,6 +84,8 @@ pub struct Guest { /// (`InventoryMenu.craftSlots`, `resultSlots`), which the game computes. pub craft_grid: [Option; 4], pub craft_result: Option, + /// The ender chest inventory (`PlayerEnderChestContainer`, 27 slots). + pub ender_items: Vec>, pub health: f32, pub food: i32, pub saturation: f32, @@ -165,6 +167,7 @@ impl Guest { carried: None, craft_grid: Default::default(), craft_result: None, + ender_items: vec![None; 27], health: 20.0, food: 20, saturation: 5.0, diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index 38e0e8ff3..a2e40bf3d 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -4,7 +4,7 @@ //! clone, throw, quick craft (drag) and pickup all; items thrown out of a //! menu are returned for the game to drop. use super::guest::Guest; -use crate::items::ItemStack; +use crate::{items::ItemStack, world::World}; /// What the menus need to know about items. pub trait Rules { @@ -19,6 +19,8 @@ pub struct Menu { /// The `minecraft:menu` registry id. pub kind: i32, pub pos: Option<(i32, i32, i32)>, + /// Where the container's slots live. + pub slots: Slots, /// Slots of the container itself (without the 36 inventory slots). pub size: usize, pub state: i32, @@ -28,6 +30,75 @@ pub struct Menu { pub drag: Option<(u8, Vec)>, } +/// Where the slots of a container menu live. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Slots { + /// One block entity. + Block((i32, i32, i32)), + /// A double chest (`CompoundContainer`): the first half's slots, then + /// the second's. + Double((i32, i32, i32), (i32, i32, i32)), + /// The player's own ender chest inventory (`PlayerEnderChestContainer`). + Ender, +} + +impl Slots { + fn halves(self) -> Vec<(i32, i32, i32)> { + match self { + Slots::Block(k) => vec![k], + Slots::Double(a, b) => vec![a, b], + Slots::Ender => Vec::new(), + } + } + /// Whether the block entities behind the slots are still there + /// (`Container.stillValid`). + pub fn valid(self, world: &World) -> bool { + self.halves() + .iter() + .all(|k| world.block_entities.contains_key(k)) + } + /// Container slot `n`. + pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option { + if self == Slots::Ender { + return guest.ender_items.get(n).cloned().flatten(); + } + for k in self.halves() { + let slots = world.block_entities.get(&k)?.slots(); + if n < slots.len() { + return slots[n].clone(); + } + n -= slots.len(); + } + None + } + /// Every container slot, in menu order. + pub fn read(self, world: &World, guest: &Guest) -> Option>> { + if self == Slots::Ender { + return Some(guest.ender_items.clone()); + } + let mut items = Vec::new(); + for k in self.halves() { + items.extend_from_slice(world.block_entities.get(&k)?.slots()); + } + Some(items) + } + /// Store what `read` gave, changed. + pub fn write(self, world: &mut World, guest: &mut Guest, items: Vec>) { + if self == Slots::Ender { + guest.ender_items = items; + return; + } + let mut rest = items.into_iter(); + for k in self.halves() { + if let Some(e) = world.block_entities.get_mut(&k) { + for slot in e.slots_mut() { + *slot = rest.next().flatten(); + } + } + } + } +} + /// Slots of a menu seen as one list. pub struct View<'a> { pub guest: &'a mut Guest, diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 4fbc18c30..c0ccc41b0 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -62,6 +62,9 @@ pub fn load(guest: &mut Guest, world: &Path) -> bool { guest.game_mode = level.mode(); guest.selected = (level.selected_slot.max(0) as usize).min(8); guest.saved = Some(tag); + if level.ender_items.len() == 27 { + guest.ender_items = level.ender_items.clone(); + } for (slot, item, count, damage) in &level.inventory { let mut stack = ItemStack { item: Arc::from(item.as_str()), @@ -103,6 +106,7 @@ pub fn save(guest: &Guest, world: &Path) { on_ground: guest.on_ground, dimension: "minecraft:overworld".into(), player_extra: guest.saved.clone(), + ender_items: guest.ender_items.clone(), ..Level::default() }; let mut put = |slot: u8, stack: &Option| { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index d4ccb159e..ec6eb2a00 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -16,7 +16,9 @@ address. The script then checks, from both sides, what a guest's game used to ge - mobs: with the host in creative, a zombie and a skeleton go for the guest and hurt them; - 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. +- 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. Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of both sides go to --out (host-*.png, guest-*.png). @@ -43,7 +45,7 @@ from agent_control_e2e import Cli, Failed, game_env # noqa: E402 ROOT = Path(__file__).resolve().parents[1] GUEST_NAME = 'Guest' -CHECKS = ['tab', 'self', 'armour', 'nametags', 'chest', 'crafting', 'damage', 'mobs', 'hazards', 'pvp'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'chest', 'crafting', 'damage', 'mobs', 'hazards', 'pvp', 'containers'] class Game: @@ -389,9 +391,62 @@ def check_pvp(host, guest, out): return hits +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] + return {s['slot'] - 300: s['item'] for s in slots if s.get('item')}, len(slots) + + +def open_container(guest, pos, out, label, take=None): + """Open the container at `pos` and read it; `take` shift-clicks that container slot out first.""" + guest.raw('interact', *pos) + guest.raw('wait-for', 'screen', 'container', '--timeout', 10) + guest.wait(5) + if take is not None: + guest.raw('click-slot', 300 + take, 'shift') + guest.wait(5) + items = container_items(guest) + guest.shot(out, label) + guest.raw('key', 'esc', 'tap') + guest.wait(5) + return items + + +def check_containers(host, guest, out): + host('command', command='item replace entity @s enderchest.0 with minecraft:diamond') + guest('command', command='item replace entity @s enderchest.4 with minecraft:emerald') + x, y, z = (int(v // 1) for v in pos_of(guest)) + guest('command', command=f'setblock {x} {y} {z + 2} minecraft:ender_chest') + guest.wait(10) + ender, ender_size = open_container(guest, (x, y, z + 2), out, 'ender-chest') + # Facing north, a left half pairs with the block to its east (ChestBlock.getConnectedDirection). + guest('command', command=f'setblock {x} {y} {z - 2} minecraft:chest[facing=north,type=left]') + guest('command', command=f'setblock {x + 1} {y} {z - 2} minecraft:chest[facing=north,type=right]') + guest('command', command=f'item replace block {x + 1} {y} {z - 2} container.0 with minecraft:gold_ingot') + guest('command', command=f'item replace block {x} {y} {z - 2} container.0 with minecraft:iron_ingot') + guest.wait(10) + double, double_size = open_container(guest, (x, y, z - 2), out, 'double-chest') + # Taking from each writes back to the guest's ender inventory and to the right half. + guest('command', command='clear @s') + open_container(guest, (x, y, z + 2), out, 'ender-chest-take', take=4) + open_container(guest, (x, y, z - 2), out, 'double-chest-take', take=0) + taken = guest('inventory')['counts'] + after = (open_container(guest, (x, y, z + 2), out, 'ender-chest-after')[0], + open_container(guest, (x, y, z - 2), out, 'double-chest-after')[0]) + host_ender = host('command', command='data get entity @s EnderItems') + assert ender == {4: 'minecraft:emerald'} and ender_size == 27, f'the guest\'s ender chest: {ender} ({ender_size})' + assert double_size == 54, f'the double chest opened with {double_size} slots' + assert double == {0: 'minecraft:gold_ingot', 27: 'minecraft:iron_ingot'}, f'the double chest: {double}' + assert 'emerald' not in json.dumps(host_ender), f'the guest\'s item went to the host: {host_ender}' + assert taken.get('minecraft:emerald') == 1 and taken.get('minecraft:gold_ingot') == 1, f'taken: {taken}' + assert after == ({}, {27: 'minecraft:iron_ingot'}), f'after taking: {after}' + return {'ender': ender, 'double': double, 'taken': taken} + + CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags, 'chest': check_chest, 'crafting': check_crafting, 'damage': check_damage, - 'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp} + 'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp, + 'containers': check_containers} def run(ctl, out, resources, checks): From 64f7ecd3671cfa13a044f68c048885c02a34f021 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:27:11 +0200 Subject: [PATCH 2/4] tools/lan_e2e.py: containers takes its chests away Co-Authored-By: Claude Opus 5.5 --- tools/lan_e2e.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index ec6eb2a00..d77154c33 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -434,6 +434,8 @@ def check_containers(host, guest, out): after = (open_container(guest, (x, y, z + 2), out, 'ender-chest-after')[0], open_container(guest, (x, y, z - 2), out, 'double-chest-after')[0]) host_ender = host('command', command='data get entity @s EnderItems') + for at in ((x, y, z + 2), (x, y, z - 2), (x + 1, y, z - 2)): + guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air') assert ender == {4: 'minecraft:emerald'} and ender_size == 27, f'the guest\'s ender chest: {ender} ({ender_size})' assert double_size == 54, f'the double chest opened with {double_size} slots' assert double == {0: 'minecraft:gold_ingot', 27: 'minecraft:iron_ingot'}, f'the double chest: {double}' From 257b5e9f2d76c3d39e84a13754b2363d846cb8e9 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:36:31 +0200 Subject: [PATCH 3/4] tools/lan_e2e.py: containers waits for the server's menu before reading or taking Co-Authored-By: Claude Opus 5.5 --- tools/lan_e2e.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index d77154c33..37a220e52 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -401,6 +401,14 @@ def open_container(guest, pos, out, label, take=None): """Open the container at `pos` and read it; `take` shift-clicks that container slot out first.""" guest.raw('interact', *pos) guest.raw('wait-for', 'screen', 'container', '--timeout', 10) + # The server's menu (an ender or double chest has no stand-in block entity: key i32::MIN), + # then its contents, can come a few ticks after the screen. + for _ in range(20): + guest.wait(2) + screen = guest('screen') + items = {s['slot']: s.get('item') for s in screen.get('slots', [])} + if screen.get('container') == [-2**31, 0, 0] and (take is None or items.get(300 + take)): + break guest.wait(5) if take is not None: guest.raw('click-slot', 300 + take, 'shift') From bc0cd9cc844853472e83c780e07ec3db2cbde7ad Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:58:09 +0200 Subject: [PATCH 4/4] LAN guest: a use packet uses the item alone ServerGamePacketListenerImpl.handleUseItem takes the client's rotation and uses the held item, nothing else. The host ran the whole right click, with the block the guest had looked at before: a guest that turned from an ender chest to a double chest could get the ender chest opened again. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 19 ++++++++++++------- crates/horizon-client/src/game/lan_actions.rs | 5 +++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c9b97a198..d85489f3a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1017,6 +1017,9 @@ struct Game<'a> { saved_for_exit: bool, /// The block a guest is acting on (game/lan_actions.rs). forced_hit: Option, + /// A guest's `ServerboundUseItemPacket`: the held item alone, no block + /// or entity (`ServerGamePacketListenerImpl.handleUseItem`). + lan_item_only: bool, /// Level events of this tick for the guests, and the guest acting now. lan_events: Vec<(i32, [i32; 3], i32, Option)>, /// Block events (position, b0, b1, state) and entity events (id, event, @@ -2350,17 +2353,18 @@ impl Game<'_> { { return Ok(Some(Hand::Main)); } - if self.interact_frame() - || self.interact_armor_stand() - || self.interact_boat() - || self.interact_cart() - || self.interact_mob()? - || self.interact_knot() + if !self.lan_item_only + && (self.interact_frame() + || self.interact_armor_stand() + || self.interact_boat() + || self.interact_cart() + || self.interact_mob()? + || self.interact_knot()) { return Ok(Some(Hand::Main)); } // An EntityHitResult may fall through to Item.use, never to useItemOn. - let block_hit = if self.entity_targeted() { + let block_hit = if self.lan_item_only || self.entity_targeted() { None } else { self.pick() @@ -10169,6 +10173,7 @@ pub fn run() -> Result<()> { text_selection: Default::default(), saved_for_exit: false, forced_hit: None, + lan_item_only: false, lan_events: Vec::new(), lan_block_events: Vec::new(), lan_entity_events: Vec::new(), diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index e52cbeb71..52170de55 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -441,8 +441,13 @@ impl Game<'_> { if spectator { return; } + // 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| { + (g.yaw, g.pitch) = (p.yaw, p.pitch); + g.lan_item_only = true; let _ = g.use_item(); + g.lan_item_only = false; }); } S::Attack(p) => self.guest_attack(host, conn, p.id),