diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 84aea576a..03be601ee 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1031,6 +1031,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, @@ -2375,17 +2378,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() @@ -10213,6 +10217,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 5e14ac1c1..051c38e18 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 @@ -401,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), diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index adb4ea7b6..8df1c5324 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(), @@ -2551,6 +2554,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 @@ -2679,19 +2683,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 df84ec6e9..d3db63b72 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -75,7 +75,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!( @@ -169,6 +169,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; } @@ -185,6 +188,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..37a220e52 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,72 @@ 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) + # 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') + 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') + 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}' + 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):