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):