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}