diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs
index 52bbad605..72017eea7 100644
--- a/crates/horizon-client/src/game.rs
+++ b/crates/horizon-client/src/game.rs
@@ -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> = 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 > = game_of
.iter()
diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs
index 569f4937b..0d70b16f6 100644
--- a/crates/horizon-client/src/game/lan_actions.rs
+++ b/crates/horizon-client/src/game/lan_actions.rs
@@ -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,
+ /// The mount inventory the guest has open (game/lan_mount_menu.rs).
+ pub(super) mount_window: Option,
/// The lectern the guest reads (game/lan_stations.rs).
pub(super) lectern: Option,
/// 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 = host
.hub
diff --git a/crates/horizon-client/src/game/lan_mount_menu.rs b/crates/horizon-client/src/game/lan_mount_menu.rs
new file mode 100644
index 000000000..78b306252
--- /dev/null
+++ b/crates/horizon-client/src/game/lan_mount_menu.rs
@@ -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)>,
+}
+
+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> {
+ (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,
+ ) -> 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 }),
+ );
+ }
+ }
+ }
+}
diff --git a/crates/horizon-client/src/game/llamas.rs b/crates/horizon-client/src/game/llamas.rs
index b9be87e3e..1ce518f31 100644
--- a/crates/horizon-client/src/game/llamas.rs
+++ b/crates/horizon-client/src/game/llamas.rs
@@ -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];
diff --git a/crates/horizon-client/src/game/mount_menu.rs b/crates/horizon-client/src/game/mount_menu.rs
index 1db2210d3..e9e1e380b 100644
--- a/crates/horizon-client/src/game/mount_menu.rs
+++ b/crates/horizon-client/src/game/mount_menu.rs
@@ -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,
}
+impl MountMenu {
+ /// The mount whose menu is open.
+ pub(super) fn opened_mob(&self) -> Option {
+ 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 {
+ 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 {
+ 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,
+ Option>,
+ Vec >,
+ ) {
+ 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,
+ body: Option>,
+ items: Vec >,
+ ) {
+ 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> {
@@ -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));
+ }
}
diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs
index c482d9728..7ae8b9ed3 100644
--- a/crates/horizon-client/src/game/remote.rs
+++ b/crates/horizon-client/src/game/remote.rs
@@ -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::().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())
diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs
index 88f50aa3b..d43cb5c27 100644
--- a/crates/horizon-client/src/lan/containers.rs
+++ b/crates/horizon-client/src/lan/containers.rs
@@ -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 {
+ 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>) {
for j in 0..36 {
items.push(self.guests[i].inventory[player_slot(j)].clone());
diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs
index 8d7b5e29a..bf66f979b 100644
--- a/crates/horizon-client/src/lan/hub.rs
+++ b/crates/horizon-client/src/lan/hub.rs
@@ -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,
diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs
index cb804e15b..cdaf73eed 100644
--- a/crates/horizon-client/src/menu.rs
+++ b/crates/horizon-client/src/menu.rs
@@ -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,
/// `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,
}
/// `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]
diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs
index e737258c5..24eec6a05 100644
--- a/crates/horizon-protocol/src/packets/play.rs
+++ b/crates/horizon-protocol/src/packets/play.rs
@@ -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 {
diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py
index 894bb8627..c5a90150f 100755
--- a/tools/lan_e2e.py
+++ b/tools/lan_e2e.py
@@ -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):