azalea/container.rs
1use std::{fmt, fmt::Debug};
2
3use azalea_chat::FormattedText;
4use azalea_client::{
5 inventory::{CloseContainerEvent, ContainerClickEvent},
6 packet::game::ReceiveGamePacketEvent,
7};
8use azalea_core::position::BlockPos;
9use azalea_entity::inventory::Inventory;
10use azalea_inventory::{
11 ItemStack, Menu,
12 operations::{ClickOperation, PickupClick, QuickMoveClick},
13};
14use azalea_physics::collision::BlockWithShape;
15use azalea_protocol::packets::game::ClientboundGamePacket;
16use bevy_app::{App, Plugin, Update};
17use bevy_ecs::{component::Component, prelude::MessageReader, system::Commands};
18use derive_more::Deref;
19
20use crate::Client;
21
22pub struct ContainerPlugin;
23impl Plugin for ContainerPlugin {
24 fn build(&self, app: &mut App) {
25 app.add_systems(Update, handle_menu_opened_event);
26 }
27}
28
29impl Client {
30 /// Open a container in the world, like a chest.
31 ///
32 /// Use [`Client::open_inventory`] to open your own inventory.
33 ///
34 /// This function times out after 5 seconds (100 ticks). Use
35 /// [`Self::open_container_at_with_timeout_ticks`] if you would like to
36 /// configure this.
37 ///
38 /// ```
39 /// # use azalea::{prelude::*, registry::builtin::BlockKind};
40 /// # async fn example(mut bot: azalea::Client) {
41 /// let target_pos = bot
42 /// .world()
43 /// .read()
44 /// .find_block(bot.position(), &BlockKind::Chest.into());
45 /// let Some(target_pos) = target_pos else {
46 /// bot.chat("no chest found");
47 /// return;
48 /// };
49 /// let container = bot.open_container_at(target_pos).await;
50 /// # }
51 /// ```
52 pub async fn open_container_at(&self, pos: BlockPos) -> Option<ContainerHandle> {
53 self.open_container_at_with_timeout_ticks(pos, Some(20 * 5))
54 .await
55 }
56
57 /// Open a container in the world, or time out after a specified amount of
58 /// ticks.
59 ///
60 /// See [`Self::open_container_at`] for more information. That function
61 /// defaults to a timeout of 5 seconds (100 ticks), which is usually good
62 /// enough. However to detect failures faster or to account for server
63 /// lag, you may find it useful to adjust the timeout to a different
64 /// value.
65 ///
66 /// The timeout is measured in game ticks (on the client, not the server),
67 /// i.e. 1/20th of a second.
68 pub async fn open_container_at_with_timeout_ticks(
69 &self,
70 pos: BlockPos,
71 timeout_ticks: Option<usize>,
72 ) -> Option<ContainerHandle> {
73 let mut ticks = self.get_tick_broadcaster();
74 // wait until it's not air (up to 10 ticks)
75 for _ in 0..10 {
76 let block = self.world().read().get_block_state(pos).unwrap_or_default();
77 if !block.is_collision_shape_empty() {
78 break;
79 }
80 let _ = ticks.recv().await;
81 }
82
83 self.ecs
84 .write()
85 .entity_mut(self.entity)
86 .insert(WaitingForInventoryOpen);
87 self.block_interact(pos);
88
89 self.wait_for_container_open(timeout_ticks).await
90 }
91
92 /// Wait until a container is open, up to the specified number of ticks.
93 ///
94 /// Returns `None` if the container was immediately opened and closed, or if
95 /// the timeout expired.
96 ///
97 /// If `timeout_ticks` is None, there will be no timeout.
98 pub async fn wait_for_container_open(
99 &self,
100 timeout_ticks: Option<usize>,
101 ) -> Option<ContainerHandle> {
102 let mut ticks = self.get_tick_broadcaster();
103 let mut elapsed_ticks = 0;
104 while ticks.recv().await.is_ok() {
105 let ecs = self.ecs.read();
106 if ecs.get::<WaitingForInventoryOpen>(self.entity).is_none() {
107 break;
108 }
109
110 elapsed_ticks += 1;
111 if let Some(timeout_ticks) = timeout_ticks
112 && elapsed_ticks >= timeout_ticks
113 {
114 return None;
115 }
116 }
117
118 let ecs = self.ecs.read();
119 let inventory = ecs.get::<Inventory>(self.entity).expect("no inventory");
120 if inventory.id == 0 {
121 None
122 } else {
123 Some(ContainerHandle::new(inventory.id, self.clone()))
124 }
125 }
126
127 /// Open the player's inventory.
128 ///
129 /// This will return None if another container is open.
130 ///
131 /// Note that this will send a packet to the server once it's dropped. Also,
132 /// due to how it's implemented, you could call this function multiple times
133 /// while another inventory handle already exists (but you shouldn't).
134 ///
135 /// If you just want to get the items in the player's inventory without
136 /// sending any packets, use [`Client::menu`], [`Menu::player_slots_range`],
137 /// and [`Menu::slots`].
138 pub fn open_inventory(&self) -> Option<ContainerHandle> {
139 let inventory = self.component::<Inventory>();
140 if inventory.id == 0 {
141 Some(ContainerHandle::new(0, self.clone()))
142 } else {
143 None
144 }
145 }
146
147 /// Returns a [`ContainerHandleRef`] to the client's currently open
148 /// container, or their inventory.
149 ///
150 /// This will not send a packet to close the container when it's dropped,
151 /// which may cause anticheat compatibility issues if you modify your
152 /// inventory without closing it afterwards.
153 ///
154 /// To simulate opening your own inventory (like pressing 'e') in a way that
155 /// won't trigger anticheats, use [`Client::open_inventory`].
156 ///
157 /// To open a container in the world, use [`Client::open_container_at`].
158 pub fn get_inventory(&self) -> ContainerHandleRef {
159 ContainerHandleRef::new(self.component::<Inventory>().id, self.clone())
160 }
161
162 /// Get the item in the bot's hotbar that is currently being held in its
163 /// main hand.
164 pub fn get_held_item(&self) -> ItemStack {
165 self.component::<Inventory>().held_item().clone()
166 }
167}
168
169/// A handle to a container that may be open.
170///
171/// This does not close the container when it's dropped. See [`ContainerHandle`]
172/// if that behavior is desired.
173pub struct ContainerHandleRef {
174 id: i32,
175 client: Client,
176}
177impl Debug for ContainerHandleRef {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.debug_struct("ContainerHandle")
180 .field("id", &self.id())
181 .finish()
182 }
183}
184impl ContainerHandleRef {
185 pub fn new(id: i32, client: Client) -> Self {
186 Self { id, client }
187 }
188
189 pub fn close(&self) {
190 self.client.ecs.write().trigger(CloseContainerEvent {
191 entity: self.client.entity,
192 id: self.id,
193 });
194 }
195
196 /// Get the ID of the container.
197 ///
198 /// If this is 0, that means it's the player's inventory. Otherwise, the
199 /// number isn't really meaningful since only one container can be open
200 /// at a time.
201 pub fn id(&self) -> i32 {
202 self.id
203 }
204
205 /// Returns the menu of the container.
206 ///
207 /// If the container is closed, this will return `None`.
208 ///
209 /// Note that any modifications you make to the `Menu` you're given will not
210 /// actually cause any packets to be sent. If you're trying to modify your
211 /// inventory, use [`Self::click`] instead
212 pub fn menu(&self) -> Option<Menu> {
213 self.map_inventory(|inv| {
214 if self.id == 0 {
215 inv.inventory_menu.clone()
216 } else {
217 inv.container_menu.clone().unwrap()
218 }
219 })
220 }
221
222 fn map_inventory<R>(&self, f: impl FnOnce(&Inventory) -> R) -> Option<R> {
223 self.client.query_self::<&Inventory, _>(|inv| {
224 if inv.id == self.id {
225 Some(f(inv))
226 } else {
227 // a different inventory is open
228 None
229 }
230 })
231 }
232
233 /// Returns the item slots in the container, not including the player's
234 /// inventory.
235 ///
236 /// If the container is closed, this will return `None`.
237 pub fn contents(&self) -> Option<Vec<ItemStack>> {
238 self.menu().map(|menu| menu.contents())
239 }
240
241 /// Return the contents of the menu, including the player's inventory.
242 ///
243 /// If the container is closed, this will return `None`.
244 pub fn slots(&self) -> Option<Vec<ItemStack>> {
245 self.menu().map(|menu| menu.slots())
246 }
247
248 /// Returns the title of the container, or `None` if no container is open.
249 ///
250 /// ```no_run
251 /// # use azalea::prelude::*;
252 /// # fn example(bot: &Client) {
253 /// let inventory = bot.get_inventory();
254 /// let inventory_title = inventory.title().unwrap_or_default().to_string();
255 /// // would be true if an unnamed chest is open
256 /// assert_eq!(inventory_title, "Chest");
257 /// # }
258 /// ```
259 pub fn title(&self) -> Option<FormattedText> {
260 self.map_inventory(|inv| inv.container_menu_title.clone())
261 .flatten()
262 }
263
264 /// A shortcut for [`Self::click`] with `PickupClick::Left`.
265 pub fn left_click(&self, slot: impl Into<usize>) {
266 self.click(PickupClick::Left {
267 slot: Some(slot.into() as u16),
268 });
269 }
270 /// A shortcut for [`Self::click`] with `QuickMoveClick::Left`.
271 pub fn shift_click(&self, slot: impl Into<usize>) {
272 self.click(QuickMoveClick::Left {
273 slot: slot.into() as u16,
274 });
275 }
276 /// A shortcut for [`Self::click`] with `PickupClick::Right`.
277 pub fn right_click(&self, slot: impl Into<usize>) {
278 self.click(PickupClick::Right {
279 slot: Some(slot.into() as u16),
280 });
281 }
282
283 /// Simulate a click in the container and send the packet to perform the
284 /// action.
285 pub fn click(&self, operation: impl Into<ClickOperation>) {
286 let operation = operation.into();
287 self.client.ecs.write().trigger(ContainerClickEvent {
288 entity: self.client.entity,
289 window_id: self.id,
290 operation,
291 });
292 }
293}
294
295/// A handle to the open container.
296///
297/// The container will be closed once this is dropped.
298#[derive(Deref)]
299pub struct ContainerHandle(ContainerHandleRef);
300
301impl Drop for ContainerHandle {
302 fn drop(&mut self) {
303 self.0.close();
304 }
305}
306impl Debug for ContainerHandle {
307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308 f.debug_struct("ContainerHandle")
309 .field("id", &self.id())
310 .finish()
311 }
312}
313impl ContainerHandle {
314 fn new(id: i32, client: Client) -> Self {
315 Self(ContainerHandleRef { id, client })
316 }
317
318 /// Closes the inventory by dropping the handle.
319 pub fn close(self) {
320 // implicitly calls drop
321 }
322}
323
324#[derive(Component, Debug)]
325pub struct WaitingForInventoryOpen;
326
327pub fn handle_menu_opened_event(
328 mut commands: Commands,
329 mut events: MessageReader<ReceiveGamePacketEvent>,
330) {
331 for event in events.read() {
332 if let ClientboundGamePacket::ContainerSetContent { .. } = event.packet.as_ref() {
333 commands
334 .entity(event.entity)
335 .remove::<WaitingForInventoryOpen>();
336 }
337 }
338}