Mount inventories for a server's players, and llama inventories

A server's players (single player on its server thread, LAN guests,
dedicated-server players) get the horse inventory: shift + use on a tamed
horse, donkey, mule, camel or llama, or the inventory key while riding one
(ServerboundPlayerCommandPacket OPEN_INVENTORY), opens HorseInventoryMenu
over the mount with ClientboundMountScreenOpenPacket. Clicks run on the
menu engine's new Kind::Mount (saddle and body ArmorSlots that take what
the mount may wear, the chest, AbstractMountInventoryMenu.quickMoveStack),
and the slots go back to the mount with the item's equip sound.

The client shows a server's mount screen, predicts its clicks with the
same engine and sends OPEN_INVENTORY when the inventory key is pressed
while riding. Llamas now have their inventory in the window too: the
carpet slot and a chest of `strength` columns, no saddle slot.

lan_e2e `mounts` checks it for the host and a guest, with and without
--server-thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-23 01:20:51 +02:00
1 parent 525129fbe6
commit a4e68e6a53
11 files changed
+745 -36

No files matched your search

+14
View File
@@ -72,6 +72,7 @@ mod lan_chat;
mod lan_crafting;
mod lan_entities;
mod lan_mobs;
mod lan_mount_menu;
mod lan_pvp;
mod lan_screen;
mod lan_stations;
@@ -5488,6 +5489,16 @@ impl Game<'_> {
let mut v = vec![None; 45];
v.extend((0..9).map(Some));
(Kind::ItemPicker, v)
} else if let Some(columns) = self
.mount_menu_columns()
.filter(|_| self.remote.is_some() && self.mount_active())
{
// A server's HorseInventoryMenu: the saddle, the body armour,
// the chest, then the inventory.
let mut v = vec![Some(mount_menu::SADDLE), Some(mount_menu::ARMOR)];
v.extend((0..columns * 3).map(|i| Some(mount_menu::CHEST + i)));
v.extend((9..36).chain(0..9).map(Some));
(Kind::Mount(columns), v)
} else if merchant_open {
// MerchantMenu: two payment slots, the result, then the inventory.
let mut v: Vec<Option<usize>> = vec![
@@ -5620,6 +5631,9 @@ impl Game<'_> {
None => game_of.iter().position(|x| *x == Some(g)),
};
let mut menu = Menu::new(kind);
if matches!(kind, Kind::Mount(_)) {
menu.mount = self.mount_menu_entity();
}
let total = menu.slot_count();
let before: Vec<Option<ItemStack>> = game_of
.iter()
+19 -2
View File
@@ -57,6 +57,8 @@ pub(super) struct GuestPlay {
pub(super) crafting: super::lan_crafting::GuestCrafting,
/// The workstation menu the guest has open (game/lan_stations.rs).
pub(super) station: Option<super::lan_stations::GuestStation>,
/// The mount inventory the guest has open (game/lan_mount_menu.rs).
pub(super) mount_window: Option<super::lan_mount_menu::GuestMount>,
/// The lectern the guest reads (game/lan_stations.rs).
pub(super) lectern: Option<super::lan_stations::GuestLectern>,
/// What the guest's body was given for its effects and enchantments.
@@ -493,7 +495,9 @@ impl Game<'_> {
_ => None,
};
self.command_block_screen = host_command_block;
self.mount_menu = host_mount_menu;
// A mount inventory the guest's action opened (shift + use, or the
// inventory key while riding).
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
self.keys = host_keys;
let g = &mut host.hub.guests[i];
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
@@ -616,6 +620,9 @@ impl Game<'_> {
if opened_table {
self.lan_open_crafting(host, conn);
}
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
}
/// `ServerLevel.players()` in turn: the host's own player, then each
@@ -971,6 +978,14 @@ impl Game<'_> {
S::ContainerClick(p) if self.lan_machine_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_station_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_lectern_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_mount_click(host, conn, &p) => {}
// ServerboundPlayerCommandPacket OPEN_INVENTORY: the ridden
// mount's inventory (`HasCustomInventoryScreen`).
S::PlayerCommand(p) if p.action == 5 => {
self.as_guest(host, conn, |g| {
g.open_ridden_mount_menu();
});
}
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
@@ -988,7 +1003,8 @@ impl Game<'_> {
}
S::ContainerClose(p)
if self.lan_crafting_closed(host, conn, Some(p.container))
|| self.lan_station_closed(host, conn, Some(p.container)) =>
|| self.lan_station_closed(host, conn, Some(p.container))
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
{
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
@@ -1528,6 +1544,7 @@ impl Game<'_> {
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
self.lan_merchant_tick(host);
self.lan_stations_tick(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
.hub
@@ -0,0 +1,222 @@
//! The horse inventory for a server's players (`ServerPlayer.openHorseInventory`):
//! shift + use on a tamed horse, donkey, mule, camel or llama, or the
//! inventory key while riding one (`ServerboundPlayerCommandPacket`
//! OPEN_INVENTORY), opens `HorseInventoryMenu` over the mount with
//! `ClientboundMountScreenOpenPacket`. Clicks run on the menu engine
//! (`Kind::Mount`) with the mount's saddle, body item and chest copied in and
//! written back, and the slots go to the client after each change, as
//! `broadcastChanges` does.
use super::{Game, lan::LanHost, lan_crafting::input};
use horizon_client::{
items::ItemStack,
menu::{Kind, Menu},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open mount menu.
pub(super) struct GuestMount {
window: i32,
mob: u32,
columns: usize,
state: i32,
/// The menu's slots and the carried stack the client was last sent.
sent: Option<(Vec<Option<ItemStack>>, Option<ItemStack>)>,
}
impl Game<'_> {
/// `ServerPlayer.openHorseInventory` for mount `mob`: the open menu
/// closes, the client gets the mount screen and the menu's slots.
pub(super) fn lan_open_mount(&mut self, host: &mut LanHost, conn: u32, mob: u32) {
let Some(index) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
self.lan_mount_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
return;
};
host.play.entry(conn).or_default().mount_window = Some(GuestMount {
window,
mob,
columns,
state: 0,
sent: None,
});
self.lan_send_mount(host, conn);
}
/// `HorseInventoryMenu` over mount `index`, with the player's inventory
/// loaded by the caller.
fn mount_engine_menu(&self, index: usize, columns: usize) -> Menu {
let env = self.menu_env();
let mut menu = Menu::new(Kind::Mount(columns));
menu.mount = Some(self.mobs[index].kind.id.to_string());
let (saddle, body, items) = self.mount_contents(index);
menu.set_slot(&env, 0, saddle);
menu.set_slot(&env, 1, body.flatten());
for (k, item) in items.into_iter().take(columns * 3).enumerate() {
menu.set_slot(&env, 2 + k, item);
}
menu
}
/// The menu's slots (the mount's, then the inventory and the hotbar)
/// and the carried stack, sent when they changed since the last time.
fn lan_send_mount(&mut self, host: &mut LanHost, conn: u32) {
let Some(gm) = host.play.get(&conn).and_then(|p| p.mount_window.as_ref()) else {
return;
};
let (mob, columns) = (gm.mob, gm.columns);
let Some(index) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
return;
};
let (saddle, body, items) = self.mount_contents(index);
let mut slots = vec![saddle, body.flatten()];
slots.extend(items.into_iter().take(columns * 3));
slots.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone()));
let carried = g.carried.clone();
let now = Some((slots, carried));
let Some(gm) = host
.play
.get_mut(&conn)
.and_then(|p| p.mount_window.as_mut())
else {
return;
};
if gm.sent == now {
return;
}
gm.state = (gm.state + 1) & 0x7FFF;
let (window, state) = (gm.window, gm.state);
let (slots, carried) = now.clone().unwrap_or_default();
gm.sent = now;
let content = C::ContainerSetContent(play::ContainerSetContent {
container: window,
state,
items: slots.iter().map(|s| host.hub.stack(s)).collect(),
carried: host.hub.stack(&carried),
});
host.hub.send_to(conn, &content);
}
/// A click on the guest's mount window; false for other windows.
pub(super) fn lan_mount_click(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::ContainerClick,
) -> bool {
let Some((mob, columns)) = host
.play
.get(&conn)
.and_then(|pl| pl.mount_window.as_ref())
.filter(|m| m.window == p.container)
.map(|m| (m.mob, m.columns))
else {
return false;
};
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
&& let Some(index) = self.mobs.iter().position(|m| m.id == mob)
{
let mut menu = self.mount_engine_menu(index, columns);
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
});
self.lan_mount_store(index, columns, &menu);
}
// The client is told what the server has, whatever it predicted.
if let Some(gm) = host
.play
.get_mut(&conn)
.and_then(|pl| pl.mount_window.as_mut())
{
gm.sent = None;
}
self.lan_send_mount(host, conn);
true
}
/// The engine menu's mount slots back on mount `index`.
fn lan_store_slots(menu: &Menu, columns: usize) -> Vec<Option<ItemStack>> {
(0..columns * 3)
.map(|k| menu.slot(2 + k).cloned())
.collect()
}
fn lan_mount_store(&mut self, index: usize, columns: usize, menu: &Menu) {
let (_, body, _) = self.mount_contents(index);
let body = body.map(|_| menu.slot(1).cloned());
let items = Self::lan_store_slots(menu, columns);
self.mount_store(index, menu.slot(0).cloned(), body, items);
}
/// `AbstractContainerMenu.removed` of the guest's mount menu (`window`;
/// None closes it whichever it is): the carried stack goes back to the
/// inventory. False when no such window is open.
pub(super) fn lan_mount_closed(
&mut self,
host: &mut LanHost,
conn: u32,
window: Option<i32>,
) -> bool {
let closed = host
.play
.get_mut(&conn)
.and_then(|p| {
p.mount_window
.take_if(|m| window.is_none_or(|w| m.window == w))
})
.is_some();
if closed
&& let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& let Some(carried) = host.hub.guests[gi].carried.take()
{
self.lan_place_back(host, gi, vec![carried]);
}
closed
}
/// Each tick: a mount menu whose mount died or is out of reach closes
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
/// interaction range plus 4 of the eyes), and the others follow the
/// mount's and the inventory's slots.
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
let open: Vec<(u32, u32, i32)> = host
.play
.iter()
.filter_map(|(c, p)| p.mount_window.as_ref().map(|m| (*c, m.mob, m.window)))
.collect();
for (conn, mob, window) in open {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
m.alive() && d < 7.0 * 7.0
});
if valid {
self.lan_send_mount(host, conn);
} else {
self.lan_mount_closed(host, conn, Some(window));
host.hub.send_to(
conn,
&C::ContainerClose(play::ContainerClose { container: window }),
);
}
}
}
}
+2 -1
View File
@@ -93,7 +93,8 @@ impl Game<'_> {
return false;
}
if tamed && sneaking {
// The horse inventory screen is not available yet.
// AbstractHorse.openCustomInventoryScreen.
self.open_mount_menu(index);
return true;
}
let m = &self.mobs[index];
+177 -29
View File
@@ -1,12 +1,15 @@
//! The horse inventory (`HorseInventoryMenu`, `HorseInventoryScreen`): the
//! saddle and body armour slots, the 3x5 chest of donkeys and mules and the
//! mount drawn following the cursor. It opens with E while riding a tamed
//! mount or with shift + use on a tamed one.
//! saddle and body armour slots (a llama's carpet), the chest of donkeys,
//! mules and llamas and the mount drawn following the cursor. It opens with
//! E while riding a tamed mount or with shift + use on a tamed one. On a
//! server the screen shows the server's menu (`ClientboundMountScreenOpenPacket`)
//! and its clicks go out as container clicks; a server's players get theirs
//! from game/lan_mount_menu.rs.
use super::{Game, Screen};
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
mobs::{Mob, Species, equine},
mobs::{Mob, Species, equine, llama},
};
/// Menu slot ids: the saddle, the body armour and the chest from 710.
@@ -17,11 +20,43 @@ pub(super) const CHEST: usize = 710;
const CHEST_SIZE: usize = 15;
const COLUMNS: usize = 5;
/// `AbstractHorse.getInventoryColumns`: five for a donkey's or mule's chest,
/// a llama's strength for its chest, else none.
pub(super) fn mount_columns(m: &Mob) -> usize {
let species = m.species();
if llama::is_llama(species) {
llama::inventory_columns(m).clamp(0, 5) as usize
} else if m.extra.chested && matches!(species, Species::Donkey | Species::Mule) {
COLUMNS
} else {
0
}
}
/// Whether the mount has a horse inventory (`AbstractHorse`).
pub(super) fn has_mount_menu(species: Species) -> bool {
equine::is_equine(species) || llama::is_llama(species)
}
/// The saddle slot is active (`canUseSlot(SADDLE)` and `CAN_EQUIP_SADDLE`):
/// not on llamas.
fn shows_saddle(species: Species) -> bool {
!llama::is_llama(species)
}
/// The body slot is active: horse armour, or a llama's carpet.
fn shows_body(species: Species) -> bool {
can_wear_horse_armor(species) || llama::is_llama(species)
}
/// The open mount menu: copies of the mount's slots, written back on every change.
#[derive(Default)]
pub(super) struct MountMenu {
open: Option<Open>,
}
impl MountMenu {
/// The mount whose menu is open.
pub(super) fn opened_mob(&self) -> Option<u32> {
self.open.as_ref().map(|o| o.mob)
}
}
struct Open {
mob: u32,
species: Species,
@@ -40,6 +75,9 @@ fn can_wear_horse_armor(species: Species) -> bool {
}
/// Whether `item` fits the body slot of the species (`isEquippableInSlot`).
fn body_armor_fits(species: Species, item: &str) -> bool {
if llama::is_llama(species) {
return item.ends_with("_carpet");
}
can_wear_horse_armor(species) && item.ends_with("_horse_armor")
}
@@ -55,18 +93,16 @@ impl Game<'_> {
pub(super) fn open_mount_menu(&mut self, index: usize) {
let m = &self.mobs[index];
let species = m.species();
if !equine::is_equine(species) {
if !has_mount_menu(species) {
return;
}
let chested = m.extra.chested && matches!(species, Species::Donkey | Species::Mule);
let columns = if chested { COLUMNS } else { 0 };
let mut items = m.extra.items.clone();
items.resize(CHEST_SIZE, None);
let show_armor = can_wear_horse_armor(species);
let show_armor = shows_body(species);
self.mount_menu.open = Some(Open {
mob: m.id,
species,
columns,
columns: mount_columns(m),
saddle: m.saddle_item(),
armor: m
.extra
@@ -75,10 +111,40 @@ impl Game<'_> {
.filter(|_| show_armor)
.map(|i| ItemStack::new(i, 1)),
items,
show_saddle: true,
show_saddle: shows_saddle(species),
show_armor,
});
self.carried = None;
self.show_mount_menu();
}
/// `ClientPacketListener.handleMountScreenOpen`: a server's horse
/// inventory over mount `entity`, its slots to come in the menu's content.
pub(super) fn open_remote_mount_menu(&mut self, entity: u32, columns: usize) -> bool {
let Some(species) = self
.mobs
.iter()
.find(|m| m.id == entity)
.map(Mob::species)
.filter(|s| has_mount_menu(*s))
else {
return false;
};
self.mount_menu.open = Some(Open {
mob: entity,
species,
columns: columns.min(COLUMNS),
saddle: None,
armor: None,
items: vec![None; CHEST_SIZE],
show_saddle: shows_saddle(species),
show_armor: shows_body(species),
});
self.show_mount_menu();
true
}
fn show_mount_menu(&mut self) {
if self.remote.is_none() && self.lan_acting.is_none() {
self.carried = None;
}
self.station = None;
self.container = None;
self.craft_size = 2;
@@ -94,15 +160,39 @@ impl Game<'_> {
return false;
};
let m = &self.mobs[index];
if !equine::is_equine(m.species()) || !m.extra.tame || !m.alive() {
if !has_mount_menu(m.species()) || !m.extra.tame || !m.alive() {
return false;
}
self.open_mount_menu(index);
true
}
/// Riding a mount that has a horse inventory (`HasCustomInventoryScreen`).
pub(super) fn riding_mount_with_menu(&self) -> bool {
self.riding
.and_then(|id| self.mount_mob_index(id))
.is_some_and(|i| has_mount_menu(self.mobs[i].species()))
}
/// A server closed its window: the screen's copy goes.
pub(super) fn mount_menu_reset(&mut self) {
self.mount_menu.open = None;
}
/// The entity type of the open mount menu's mount (`minecraft:horse`).
pub(super) fn mount_menu_entity(&self) -> Option<String> {
let open = self.mount_menu.open.as_ref()?;
let m = self.mobs.iter().find(|m| m.id == open.mob)?;
Some(m.kind.id.to_string())
}
/// The chest columns of the open mount menu (the server's window's).
pub(super) fn mount_menu_columns(&self) -> Option<usize> {
self.mount_menu.open.as_ref().map(|o| o.columns)
}
/// The menu ends when the mount dies or is more than 4 blocks away
/// (`AbstractMountInventoryMenu.stillValid`).
pub(super) fn tick_mount_menu(&mut self) {
// A server's menu ends when the server closes it.
if self.remote.is_some() {
return;
}
let Some(open) = self.mount_menu.open.as_ref() else {
return;
};
@@ -126,8 +216,12 @@ impl Game<'_> {
self.mount_sync();
self.mount_menu.open = None;
}
/// Writes the menu slots to the mount, with the equip sounds.
/// Writes the menu slots to the mount, with the equip sounds. A
/// server's menu is the server's to write.
fn mount_sync(&mut self) {
if self.remote.is_some() {
return;
}
let Some(open) = self.mount_menu.open.as_ref() else {
return;
};
@@ -135,25 +229,66 @@ impl Game<'_> {
return;
};
let (saddle, armor) = (open.saddle.clone(), open.armor.clone());
let body = open.show_armor.then_some(armor);
let items = open.items.clone();
let m = &mut self.mobs[index];
self.mount_store(index, saddle, body, items);
}
/// The slots of mount `index` as its menu shows them: the saddle, the
/// body item (None when the slot isn't active), the chest.
pub(super) fn mount_contents(
&self,
index: usize,
) -> (
Option<ItemStack>,
Option<Option<ItemStack>>,
Vec<Option<ItemStack>>,
) {
let m = &self.mobs[index];
let species = m.species();
let body =
shows_body(species).then(|| m.extra.head.as_deref().map(|i| ItemStack::new(i, 1)));
let mut items = m.extra.items.clone();
items.resize(mount_columns(m) * 3, None);
(m.saddle_item(), body, items)
}
/// The menu's slots back on mount `index` (`body` None: not its slot),
/// with the equip sound of what was put on (`Equippable.equipSound`).
pub(super) fn mount_store(
&mut self,
index: usize,
saddle: Option<ItemStack>,
body: Option<Option<ItemStack>>,
items: Vec<Option<ItemStack>>,
) {
let m = &self.mobs[index];
let at = m.center();
let sound = if saddle.is_some() && !m.extra.saddled {
Some("minecraft:entity.horse.saddle")
} else if armor.is_some() && m.extra.head.is_none() {
Some("minecraft:entity.horse.armor")
let put_on = if saddle.is_some() && !m.extra.saddled {
saddle.as_ref()
} else {
None
body.as_ref()
.and_then(Option::as_ref)
.filter(|_| m.extra.head.is_none())
};
let sound = put_on.map(|s| {
self.res
.items()
.get(&s.item)
.and_then(|i| i.equip_sound.clone())
.unwrap_or_else(|| "minecraft:item.armor.equip_generic".into())
});
let m = &mut self.mobs[index];
m.set_saddle(saddle);
if open.show_armor {
m.extra.head = armor.map(|a| a.item);
if let Some(body) = body {
m.extra.head = body.map(|a| a.item);
}
let mut items = items;
let keep = m.extra.items.len().max(CHEST_SIZE);
items.resize(keep, None);
m.extra.items = items;
let (x, z) = (at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4);
self.world.modified.insert((x, z));
if let Some(sound) = sound {
self.audio.play(sound, Some(at), 0.5, 1.0);
self.audio.play(&sound, Some(at), 0.5, 1.0);
}
}
pub(super) fn mount_slots(&self) -> Option<Vec<usize>> {
@@ -214,7 +349,8 @@ impl Game<'_> {
}
/// A click in the mount menu before the generic handling.
pub(super) fn mount_click(&mut self, right: bool) -> bool {
if !self.mount_active() {
// A server's menu: the click goes out like any container click.
if !self.mount_active() || self.remote.is_some() {
return false;
}
let Some(r) = self.renderer.as_ref() else {
@@ -314,12 +450,12 @@ impl Game<'_> {
if open.show_armor {
batch.sprite(atlas, "gui/sprites/container/slot", ox + 7.0, oy + 35.0);
if open.armor.is_none() {
batch.sprite(
atlas,
"gui/sprites/container/slot/horse_armor",
ox + 8.0,
oy + 36.0,
);
let sprite = if llama::is_llama(open.species) {
"gui/sprites/container/slot/llama_armor"
} else {
"gui/sprites/container/slot/horse_armor"
};
batch.sprite(atlas, sprite, ox + 8.0, oy + 36.0);
}
}
let grey = rgba(64, 64, 64, 255);
@@ -448,4 +584,16 @@ mod tests {
"minecraft:iron_chestplate"
));
}
#[test]
fn llamas_wear_carpets_and_no_saddle() {
assert!(body_armor_fits(Species::Llama, "minecraft:red_carpet"));
assert!(!body_armor_fits(
Species::Llama,
"minecraft:iron_horse_armor"
));
assert!(shows_body(Species::TraderLlama));
assert!(!shows_saddle(Species::Llama));
assert!(shows_saddle(Species::Donkey) && !shows_body(Species::Donkey));
}
}
+45
View File
@@ -183,6 +183,9 @@ const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1);
/// whatever their cost: the packets after a slow machine's chunks never
/// wait more than a tick.
const BACKLOG_LIMIT: Duration = Duration::from_millis(50);
/// The open menu name of a server's horse inventory, then its chest's
/// columns (the packet names no `MenuType`).
const MOUNT_MENU: &str = "horizon:mount";
fn face_direction(face: [i32; 3]) -> u8 {
match face {
@@ -1440,6 +1443,19 @@ impl Game<'_> {
// CrafterMenu: the grid, the inventory, then the result (the
// client's own).
"minecraft:crafter_3x3" => slots(9),
// HorseInventoryMenu: the saddle, the body armour, the chest,
// then the inventory.
m if m.starts_with(MOUNT_MENU) => {
let n = m[MOUNT_MENU.len()..].parse::<usize>().ok()? * 3;
match s {
0 => Some(super::mount_menu::SADDLE),
1 => Some(super::mount_menu::ARMOR),
_ if s < 2 + n => Some(super::mount_menu::CHEST + s - 2),
_ if s < 2 + n + 27 => Some(9 + s - 2 - n),
_ if s < 2 + n + 36 => Some(s - 2 - n - 27),
_ => None,
}
}
// A workstation: its inputs, its result, then the inventory.
m => {
let kind = Self::remote_station_kind(m)?;
@@ -2204,6 +2220,7 @@ impl Game<'_> {
}
}
C::OpenScreen(p) => self.remote_open_screen(p),
C::MountScreenOpen(p) => self.remote_open_mount(p),
C::RecipeBookAdd(p) => self.remote_recipe_book_add(p),
C::RecipeBookRemove(p) => self.remote_recipe_book_remove(p),
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
@@ -4099,6 +4116,18 @@ impl Game<'_> {
}
Ok(true)
}
// MultiPlayerGameMode.isServerControlledInventory: riding a
// horse or llama, the server opens its inventory
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
KeyCode::KeyE if self.riding_mount_with_menu() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
Ok(true)
}
KeyCode::KeyE => {
// The inventory opens without telling the server.
self.screen = if self.creative {
@@ -4343,6 +4372,7 @@ impl Game<'_> {
r.menu = None;
r.menu_title = None;
}
self.mount_menu_reset();
// The client's menu has no level access: what it holds is the server's.
self.station = None;
self.craft_size = 2;
@@ -4527,6 +4557,21 @@ impl Game<'_> {
self.screen = Screen::Inventory;
self.capture(false);
}
/// `ClientPacketListener.handleMountScreenOpen`: a horse's or llama's
/// inventory; for another entity the window is closed again.
fn remote_open_mount(&mut self, p: play::MountScreenOpen) {
let columns = usize::try_from(p.columns).unwrap_or(0).min(5);
if !self.open_remote_mount_menu(p.entity as u32, columns) {
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
return;
}
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, format!("{MOUNT_MENU}{columns}")));
r.menu_title = None;
}
}
/// 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())
@@ -234,6 +234,23 @@ impl Hub {
Some(window)
}
/// ServerPlayer.openHorseInventory: the open container closes, a new
/// window id opens mount `entity`'s inventory with its chest's columns
/// (ClientboundMountScreenOpenPacket).
pub fn open_mount_window(&mut self, conn: u32, columns: i32, entity: i32) -> Option<i32> {
let i = self.guests.iter().position(|g| g.conn == conn)?;
self.close_container_at(i);
let g = &mut self.guests[i];
g.next_window = g.next_window % 100 + 1;
let window = g.next_window;
g.send(&C::MountScreenOpen(play::MountScreenOpen {
container: window,
columns,
entity,
}));
Some(window)
}
fn send_menu_content(&mut self, i: usize, mut items: Vec<Option<ItemStack>>) {
for j in 0..36 {
items.push(self.guests[i].inventory[player_slot(j)].clone());
+4
View File
@@ -1972,6 +1972,10 @@ impl Hub {
self.guests[i].sneaking = p.flags & 32 != 0;
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
}
// OPEN_INVENTORY is the game's: the ridden mount's inventory.
S::PlayerCommand(p) if p.action == 5 => {
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
}
S::PlayerCommand(p) => match p.action {
1 => self.guests[i].sprinting = true,
2 => self.guests[i].sprinting = false,
+136 -2
View File
@@ -114,6 +114,10 @@ pub enum Kind {
Lectern,
/// `MerchantMenu`: two payment slots, the result, then the inventory.
Merchant,
/// `HorseInventoryMenu`: the saddle and body armour slots (`ArmorSlot`s
/// of the mount, whose entity type is `Menu::mount`), a chest of three
/// rows of this many columns (`getInventoryColumns`), then the inventory.
Mount(usize),
/// `CreativeModeInventoryScreen.ItemPickerMenu`: an item tab's 45
/// display slots, then the hotbar.
ItemPicker,
@@ -124,6 +128,9 @@ impl Kind {
if let Some(rows) = name.strip_prefix("chest") {
return rows.parse().ok().map(Self::Chest);
}
if let Some(columns) = name.strip_prefix("mount") {
return columns.parse().ok().map(Self::Mount);
}
Some(match name {
"inventory" => Self::Inventory,
"crafting" => Self::Crafting,
@@ -207,6 +214,9 @@ enum Role {
/// `CustomCreativeSlot`: a creative tab's display slot, which the
/// screen clicks itself; drags and PICKUP_ALL pass it by.
CreativeDisplay,
/// An `ArmorSlot` of a mount for this `EquipmentSlot` ("saddle" or
/// "body"): `isEquippableInSlot` for `Menu::mount`, one item.
MountEquip(&'static str),
}
#[derive(Clone, Copy)]
@@ -298,6 +308,9 @@ pub struct Menu {
pub enchant_button: Option<usize>,
/// `Player.mayBuild` (not adventure mode): the lectern's Take Book.
pub may_build: bool,
/// A mount menu's entity type (`minecraft:horse`): what its saddle and
/// body slots take.
pub mount: Option<String>,
}
/// `AnvilMenu`'s state besides its slots and `cost`.
@@ -560,6 +573,22 @@ impl Menu {
// The open page (LecternBlockEntity.dataAccess).
data = 1;
}
Kind::Mount(columns) => {
containers[MAIN] = vec![None; 2 + columns * 3];
for (index, slot) in ["saddle", "body"].into_iter().enumerate() {
slots.push(Slot {
container: MAIN,
index,
role: Role::MountEquip(slot),
});
}
slots.extend((2..2 + columns * 3).map(|index| Slot {
container: MAIN,
index,
role: Role::Plain,
}));
player(&mut slots);
}
Kind::ItemPicker => {
containers[MAIN] = vec![None; 45];
slots.extend(main(45, Role::CreativeDisplay));
@@ -654,6 +683,7 @@ impl Menu {
enchant_changed: false,
enchant_button: None,
may_build: true,
mount: None,
}
.with_initial_data()
}
@@ -1186,6 +1216,15 @@ impl Menu {
Some(slot == eq && allowed)
})
.unwrap_or(false),
// LivingEntity.isEquippableInSlot: the item's equippable slot,
// and the mount among its allowed entities.
Role::MountEquip(slot) => env.items.get(&stack.item).is_some_and(|i| {
i.equip_slot.as_deref() == Some(slot)
&& self
.mount
.as_deref()
.is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m)))
}),
Role::Shulker => Self::fits_inside(stack),
Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack),
Role::Output | Role::CraftResult | Role::Inert => false,
@@ -1257,7 +1296,7 @@ impl Menu {
);
o.satisfied_by(env.items, a, b) || o.satisfied_by(env.items, b, a)
}),
Role::Armor(_) => match self.slot(s) {
Role::Armor(_) | Role::MountEquip(_) => match self.slot(s) {
Some(stack) => {
self.creative || stack.enchantment_level("minecraft:binding_curse") == 0
}
@@ -1270,7 +1309,11 @@ impl Menu {
/// `Slot.getMaxStackSize(stack)`.
fn slot_max(&self, env: &Env, s: usize, stack: &ItemStack) -> u32 {
let own = match self.slots[s].role {
Role::Armor(_) | Role::Potion | Role::EnchantItem | Role::BeaconPay => 1,
Role::Armor(_)
| Role::MountEquip(_)
| Role::Potion
| Role::EnchantItem
| Role::BeaconPay => 1,
// The lectern's bookAccess holds one.
Role::Plain if self.kind == Kind::Lectern && self.slots[s].container == MAIN => 1,
Role::Fuel if Self::is_bucket(stack) => return 1,
@@ -2026,6 +2069,38 @@ impl Menu {
}
// LecternMenu.quickMoveStack moves nothing.
Kind::Lectern => return None,
Kind::Mount(columns) => {
// AbstractMountInventoryMenu.quickMoveStack: no count check
// and no onTake.
check_count = false;
let start = 2 + columns * 3;
if index < start {
if !self.move_to(env, &mut stack, start, total, true) {
return None;
}
} else if self.may_place(env, 1, &stack) && self.containers[MAIN][1].is_none() {
if !self.move_to(env, &mut stack, 1, 2, false) {
return None;
}
} else if self.may_place(env, 0, &stack) && self.containers[MAIN][0].is_none() {
if !self.move_to(env, &mut stack, 0, 1, false) {
return None;
}
} else if columns == 0 || !self.move_to(env, &mut stack, 2, start, false) {
// Between the inventory and the hotbar: the stack shrinks
// in its slot and EMPTY is returned either way.
let (main_end, hotbar_end) = (start + 27, start + 36);
if (main_end..hotbar_end).contains(&index) {
self.move_to(env, &mut stack, start, main_end, false);
} else if (start..main_end).contains(&index) {
self.move_to(env, &mut stack, main_end, hotbar_end, false);
}
let slot = self.slots[index];
self.containers[slot.container][slot.index] =
(stack.count > 0).then(|| stack.clone());
return None;
}
}
Kind::Beacon => {
// BeaconMenu.quickMoveStack.
after_take = true;
@@ -2946,6 +3021,65 @@ pub(crate) mod tests {
test(&env, &mut menu);
}
/// `HorseInventoryMenu`: a shift-click wears horse armour, then saddles,
/// then fills the chest; the body slot takes only what the mount may wear.
#[test]
fn mount_menu_slots_take_what_the_mount_wears() {
with_menu(Kind::Mount(5), |env, menu| {
menu.creative = false;
menu.mount = Some("minecraft:donkey".into());
// 2 saddle and body, 15 chest, then the inventory from 17.
assert_eq!(menu.slot_count(), 2 + 15 + 36);
let hotbar = 17 + 27;
menu.set_slot(env, hotbar, stack("iron_horse_armor", 1));
menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap();
// A donkey wears no horse armour: it goes in the chest.
assert_eq!(menu.slot(1), None);
assert_eq!(
menu.slot(2).map(|s| &*s.item),
Some("minecraft:iron_horse_armor")
);
menu.set_slot(env, hotbar, stack("saddle", 1));
menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap();
assert_eq!(menu.slot(0).map(|s| &*s.item), Some("minecraft:saddle"));
// Back out of the mount: to the inventory, from the hotbar's end.
menu.click(env, 0, 0, Input::QuickMove).unwrap();
assert_eq!(menu.slot(0), None);
assert_eq!(
menu.slot(17 + 35).map(|s| &*s.item),
Some("minecraft:saddle")
);
});
with_menu(Kind::Mount(0), |env, menu| {
menu.creative = false;
menu.mount = Some("minecraft:horse".into());
let hotbar = 2 + 27;
menu.set_slot(env, hotbar, stack("diamond_horse_armor", 1));
menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap();
assert_eq!(
menu.slot(1).map(|s| &*s.item),
Some("minecraft:diamond_horse_armor")
);
// No chest: dirt goes between the hotbar and the inventory.
menu.set_slot(env, hotbar, stack("dirt", 3));
menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap();
assert_eq!(
menu.slot(2).map(|s| (&*s.item, s.count)),
Some(("minecraft:dirt", 3))
);
assert_eq!(menu.slot(hotbar), None);
// Placing by hand: a saddle doesn't go in the body slot.
menu.carried = stack("saddle", 1);
menu.click(env, 1, 0, Input::Pickup).unwrap();
assert_eq!(
menu.slot(1).map(|s| &*s.item),
Some("minecraft:diamond_horse_armor")
);
menu.click(env, 0, 0, Input::Pickup).unwrap();
assert_eq!(menu.slot(0).map(|s| &*s.item), Some("minecraft:saddle"));
});
}
/// `onEquipItem` runs from the armour and off-hand slots' `setByPlayer`
/// when the item changes: never from `Inventory.setItem` (a swap's source).
#[test]
@@ -458,6 +458,9 @@ move_entity!(MoveEntityPos, pos: true, rot: false);
move_entity!(MoveEntityPosRot, pos: true, rot: true);
move_entity!(MoveEntityRot, pos: false, rot: true);
packet!(OpenScreen { container: i32 => VarInt, menu: i32 => VarInt, title: Tag => Nbt });
// ClientboundMountScreenOpenPacket: a horse's or llama's inventory, with its
// chest's columns (`getInventoryColumns`).
packet!(MountScreenOpen { container: i32 => VarInt, columns: i32 => VarInt, entity: i32 => Int });
packet!(PlayerAbilities { flags: i8 => Byte, flying_speed: f32 => Float, walking_speed: f32 => Float });
/// MessageSignature.Packed: an index into the last-seen cache or a full signature.
pub struct PackedSignature;
@@ -1497,6 +1500,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
MoveEntityPosRot(MoveEntityPosRot) = MOVE_ENTITY_POS_ROT,
MoveEntityRot(MoveEntityRot) = MOVE_ENTITY_ROT,
MerchantOffers(MerchantOffers) = MERCHANT_OFFERS,
MountScreenOpen(MountScreenOpen) = MOUNT_SCREEN_OPEN,
OpenScreen(OpenScreen) = OPEN_SCREEN,
Ping(Ping) = PING,
PongResponse(crate::packets::status::Ping) = PONG_RESPONSE,
@@ -2179,6 +2183,11 @@ mod tests {
menu: 2,
title: literal("Chest"),
}),
Clientbound::MountScreenOpen(MountScreenOpen {
container: 4,
columns: 5,
entity: 300,
}),
Clientbound::MerchantOffers(MerchantOffers {
container: 3,
offers: vec![NetOffer {
+100 -2
View File
@@ -123,6 +123,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
it (#1222).
- frames: the host and the guest each put a diamond in an item frame and turn it, and put a
helmet on an armor stand and take it back (#1222).
- mounts: the host and the guest each open a tamed horse's, donkey's and llama's inventory
(shift + right click, then the inventory key while riding), put on a saddle, armour
or a carpet and fill the chest by shift-clicking.
- riding: the host and the guest each saddle and ride a horse (walk, jump), a pig and a strider
(with their sticks), a camel and a donkey, sit on a llama and fly a happy ghast, and
get off by sneaking; the other side sees the animal move.
@@ -169,7 +172,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', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding']
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts']
class Game:
@@ -2638,6 +2641,101 @@ def check_riding(host, guest, out):
return result
# Mount inventories: (kind, summon NBT, what goes in by shift-click and the menu slot it lands
# in: 700 the saddle, 701 the body armour or carpet, 710 the chest's first slot).
MOUNT_MENUS = [
('horse', '{Tame:1b,NoAI:1b}', [('minecraft:saddle', 700), ('minecraft:iron_horse_armor', 701)]),
('donkey', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', [('minecraft:saddle', 700), ('minecraft:dirt', 710)]),
('llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}',
[('minecraft:red_carpet', 701), ('minecraft:dirt', 710)]),
]
def mount_screen(game, ticks=20):
"""The open mount menu's slots and items ({700: 'minecraft:saddle', ...}), waiting up to
`ticks` for it to open; None when no mount menu opened."""
for _ in range(ticks // 2):
slots = {}
for w in game.raw('ui').get('widgets', []):
wid = w.get('id', '')
if wid.startswith('slot-') and wid[5:].isdigit() and int(wid[5:]) >= 700:
slots[int(wid[5:])] = (w.get('stack') or {}).get('item')
if slots:
return slots
game.wait(2)
return None
def check_mounts(host, guest, out):
"""The host and the guest each open a tamed horse's, donkey's and llama's inventory with
shift + right click (`ClientboundMountScreenOpenPacket` for a server's player), put on a
saddle, horse armour or a carpet and fill the chest by shift-clicking, close it, then ride the
mount and open its inventory with the inventory key (OPEN_INVENTORY): it shows what was put
in, and the server took the items from the inventory."""
spots = apart(host, guest)
result = {}
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
player('command', command='gamemode survival')
result[label] = {}
for kind, nbt, puts in MOUNT_MENUS:
player('command', command='clear @s')
for item, _ in puts:
player('command', command=f'give @s {item}')
player('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + 2.5} {nbt}')
player.wait(5)
found = [m for m in player.raw('mobs', 16).get('near', [])
if m['kind'].endswith(kind) and m['health'] > 0]
assert found, f'the {label} sees no {kind}'
mob = found[0]
p = mob['pos']
aim = (f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}')
try:
player.raw('look-at', *aim)
player.raw('key', 'shift', 'down')
player.wait(3)
player.raw('mouse', 'right', 'click')
player.wait(5)
player.raw('key', 'shift', 'up')
slots = mount_screen(player)
assert slots is not None, f"the {label}'s shift + right click on the {kind} opened no inventory"
for item, _ in puts:
player.raw('click-slot', hotbar_slot(player, item), 'shift')
player.wait(5)
slots = mount_screen(player)
for item, slot in puts:
assert slots.get(slot) == item, f"the {label}'s {kind}: {item} shift-clicked, slot {slot} holds {slots.get(slot)}"
player.raw('key', 'esc', 'tap')
player.wait(5)
for item, _ in puts:
left = server_count(player, item.split(':')[1])
assert left == 0, f'the server still counts {left} {item} on the {label}'
player.raw('look-at', *aim)
player.raw('mouse', 'right', 'click')
player.wait(8)
riding = player('status').get('riding')
assert riding == mob['id'], f'the {label} right-clicked its {kind}: riding {riding}'
player.raw('key', 'e', 'tap')
again = mount_screen(player)
assert again is not None, f'the inventory key on the {label}\'s {kind} opened no inventory'
for item, slot in puts:
assert again.get(slot) == item, f"the {label}'s {kind} reopened: slot {slot} holds {again.get(slot)}, not {item}"
player.raw('key', 'esc', 'tap')
player.wait(3)
hold(player, 'shift', 10)
player.wait(5)
result[label][kind] = {slot: item for item, slot in puts}
finally:
back_to_game(player)
player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..16]')
player.wait(2)
player('command', command=f'kill @e[type=minecraft:item,x={x},y={y},z={z},distance=..16]')
player('command', command='clear @s')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
player.wait(5)
return result
def check_lightning(host, guest, out):
"""A bolt summoned beside each player: both sides get the bolt entity and flash the sky (the
host's window too on its server thread, FEATURES 4z(f))."""
@@ -2977,7 +3075,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name
'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast,
'light': check_light, 'plates': check_plates,
'swings': check_swings, 'cracks': check_cracks,
'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning, 'riding': check_riding}
'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning, 'riding': check_riding, 'mounts': check_mounts}
def run(ctl, out, resources, checks):