Merge pull request #471 from ludovic111/fix/lan-guest-containers

LAN guest: their own ender chest, and double chests as 9x6
This commit is contained in:
Ludovic Marie
2026-10-16 13:03:01 +02:00
committed by GitHub
9 changed files with 291 additions and 59 deletions
+12 -7
View File
@@ -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<Hit>,
/// 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<u32>)>,
/// 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(),
+64 -19
View File
@@ -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),
+35 -1
View File
@@ -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<String>,
state_id: i32,
use_target: Option<[i32; 3]>,
entities: HashMap<i32, Tracked>,
@@ -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 }));
}
+7 -1
View File
@@ -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<usize> {
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"),
+26 -27
View File
@@ -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<Option<ItemStack>>,
) {
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<ItemStack> {
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;
}
+3
View File
@@ -84,6 +84,8 @@ pub struct Guest {
/// (`InventoryMenu.craftSlots`, `resultSlots`), which the game computes.
pub craft_grid: [Option<ItemStack>; 4],
pub craft_result: Option<ItemStack>,
/// The ender chest inventory (`PlayerEnderChestContainer`, 27 slots).
pub ender_items: Vec<Option<ItemStack>>,
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,
+72 -1
View File
@@ -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<usize>)>,
}
/// 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<ItemStack> {
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<Vec<Option<ItemStack>>> {
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<Option<ItemStack>>) {
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,
@@ -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<ItemStack>| {
+68 -3
View File
@@ -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):