Skip to main content

steel_core/player/player_inventory/
player_handlers.rs

1use std::{f32::consts::TAU, mem, sync::Arc};
2
3use crate::{
4    entity::{Entity, LivingEntity as _, RemovalReason, entities::ItemEntity},
5    inventory::{
6        click::Click,
7        container::{Container, CraftingContainer, clear_or_count_matching_stack},
8        lock::{ContainerId, ContainerLockGuard},
9        menu::{
10            Menu,
11            kinds::{INVENTORY_MENU_CONTAINER_ID, InventoryKind},
12        },
13        slots::CraftingHandler,
14    },
15    player::{Player, connection::NetworkConnection as _},
16};
17use glam::DVec3;
18use steel_protocol::packets::game::{
19    CContainerClose, COpenScreen, CSetPlayerInventory, ClickType, SContainerButtonClick,
20    SContainerClick, SContainerClose, SContainerSlotStateChanged, SRenameItem, SSetBeacon,
21    SSetCarriedItem, SSetCreativeModeSlot,
22};
23use steel_registry::item_stack::ItemStack;
24use steel_registry::mob_effect::MobEffectRef;
25use steel_registry::stat::vanilla_stat_types;
26use steel_registry::vanilla_custom_stats;
27use steel_registry::{REGISTRY, RegistryExt};
28use steel_utils::{
29    Downcast as _,
30    locks::Shared,
31    translations,
32    types::{GameType, InteractionHand},
33};
34use text_components::TextComponent;
35
36use super::{
37    DeferredMenuAction, MenuItemDisposition, MenuOpenContext, MenuRemovalStatus, OpenMenuDispatch,
38    OpenMenuUnavailable, PendingMenuOpen, PlayerInventory, PreparedMenu, TerminalMenuRemoval,
39};
40
41impl Player {
42    fn take_open_menu_for_callback(
43        &self,
44        expected_container_id: Option<i32>,
45    ) -> Result<Menu, OpenMenuUnavailable> {
46        let mut open_menu = self.open_menu.lock();
47        if open_menu.dispatch.is_some() {
48            return Err(OpenMenuUnavailable::Unavailable);
49        }
50
51        let Some(menu) = open_menu.menu.as_ref() else {
52            return Err(OpenMenuUnavailable::Closed);
53        };
54        if expected_container_id.is_some_and(|expected| i32::from(menu.container_id()) != expected)
55        {
56            return Err(OpenMenuUnavailable::Unavailable);
57        }
58
59        let container_id = menu.container_id();
60        let overrides_player_slots = menu.overrides_player_slots();
61        let Some(menu) = open_menu.menu.take() else {
62            return Err(OpenMenuUnavailable::Unavailable);
63        };
64        open_menu.dispatch = Some(OpenMenuDispatch {
65            container_id,
66            overrides_player_slots,
67            actions: Vec::new(),
68        });
69        Ok(menu)
70    }
71
72    fn finish_open_menu_callback(&self, menu: Menu) {
73        let actions = {
74            let mut open_menu = self.open_menu.lock();
75            let Some(dispatch) = open_menu.dispatch.take() else {
76                open_menu.menu = Some(menu);
77                return;
78            };
79            if let Some(terminal_removal) = open_menu.terminal_removal.as_mut() {
80                Self::queue_deferred_menus(terminal_removal, dispatch.actions);
81                drop(open_menu);
82                self.finish_terminal_menu_main_cleanup(Some(menu));
83                return;
84            }
85            open_menu.menu = Some(menu);
86            open_menu.active_open_operations += 1;
87            dispatch.actions
88        };
89
90        self.run_deferred_menu_actions(actions);
91    }
92
93    fn finish_open_menu_removal(&self) {
94        let actions = {
95            let mut open_menu = self.open_menu.lock();
96            let Some(dispatch) = open_menu.dispatch.take() else {
97                return;
98            };
99            if let Some(terminal_removal) = open_menu.terminal_removal.as_mut() {
100                Self::queue_deferred_menus(terminal_removal, dispatch.actions);
101                drop(open_menu);
102                self.finish_terminal_menu_main_cleanup(None);
103                return;
104            }
105            open_menu.active_open_operations += 1;
106            dispatch.actions
107        };
108
109        self.run_deferred_menu_actions(actions);
110    }
111
112    fn run_deferred_menu_actions(&self, actions: Vec<DeferredMenuAction>) {
113        for action in actions {
114            match action {
115                DeferredMenuAction::Close { send_packet } => {
116                    if send_packet {
117                        self.close_container();
118                    } else {
119                        self.do_close_container();
120                    }
121                }
122                DeferredMenuAction::Open(prepared) => {
123                    self.execute_menu_open(*prepared);
124                }
125                DeferredMenuAction::Install(prepared) => {
126                    let PreparedMenu { title, menu } = *prepared;
127                    self.open_prepared_menu(title, menu);
128                }
129            }
130        }
131
132        self.finish_menu_open_operation();
133    }
134
135    fn queue_deferred_menus(
136        terminal_removal: &mut TerminalMenuRemoval,
137        actions: Vec<DeferredMenuAction>,
138    ) {
139        terminal_removal
140            .pending_menus
141            .extend(actions.into_iter().filter_map(|action| match action {
142                DeferredMenuAction::Install(prepared) => Some(prepared.menu),
143                DeferredMenuAction::Close { .. } | DeferredMenuAction::Open(_) => None,
144            }));
145    }
146
147    fn begin_menu_open_operation(&self) -> bool {
148        let mut open_menu = self.open_menu.lock();
149        if open_menu.terminal_removal.is_some() {
150            return false;
151        }
152        open_menu.active_open_operations += 1;
153        true
154    }
155
156    fn finish_menu_open_operation(&self) {
157        {
158            let mut open_menu = self.open_menu.lock();
159            debug_assert!(open_menu.active_open_operations > 0);
160            open_menu.active_open_operations -= 1;
161        }
162        self.try_finish_terminal_menu_removal();
163    }
164
165    /// Attempts to pick up nearby item entities.
166    ///
167    /// Mirrors vanilla's `Player.aiStep()` item pickup logic:
168    /// - Calculates pickup area as bounding box inflated by (1.0, 0.5, 1.0)
169    /// - Calls `playerTouch()` on each entity in range
170    pub(in crate::player) fn touch_nearby_items(&self) {
171        if self.game_mode() == GameType::Spectator {
172            return;
173        }
174
175        let pickup_area = self.bounding_box().inflate_xyz(1.0, 0.5, 1.0);
176        let world = self.get_world();
177        let entities = world.get_entities_in_aabb(&pickup_area);
178
179        let Some(player_arc) = world.players.get_by_entity_id(self.id()) else {
180            return;
181        };
182
183        for entity in entities {
184            if entity.id() == self.id() || entity.is_removed() {
185                continue;
186            }
187
188            entity.player_touch(&player_arc);
189        }
190    }
191
192    /// Resolves an optional beacon effect ID, rejecting IDs vanilla's packet codec would reject.
193    pub(super) fn resolve_beacon_effect(id: Option<i32>) -> Result<Option<MobEffectRef>, ()> {
194        id.map(|id| {
195            let id = usize::try_from(id).map_err(|_| ())?;
196            REGISTRY.mob_effects.by_id(id).ok_or(())
197        })
198        .transpose()
199    }
200
201    /// Handles a beacon effect selection from the set-beacon packet.
202    pub fn handle_set_beacon_packet(&self, packet: SSetBeacon) {
203        let (Ok(primary), Ok(secondary)) = (
204            Self::resolve_beacon_effect(packet.primary),
205            Self::resolve_beacon_effect(packet.secondary),
206        ) else {
207            log::warn!(
208                "Player {} sent an unknown beacon effect id",
209                self.gameprofile.name
210            );
211            self.disconnect(translations::MULTIPLAYER_DISCONNECT_GENERIC.msg());
212            return;
213        };
214
215        let Ok(mut menu) = self.take_open_menu_for_callback(None) else {
216            return;
217        };
218        if !menu.still_valid(self) {
219            log::debug!(
220                "Player {} interacted with invalid menu",
221                self.gameprofile.name
222            );
223            self.finish_open_menu_callback(menu);
224            return;
225        }
226        if !menu.update_effects(primary, secondary, &self.connection) {
227            log::warn!(
228                "Player {} tried to set invalid beacon effects",
229                self.gameprofile.name
230            );
231            self.finish_open_menu_callback(menu);
232            self.disconnect(translations::MULTIPLAYER_DISCONNECT_GENERIC.msg());
233            return;
234        }
235        self.finish_open_menu_callback(menu);
236    }
237
238    /// Handles a container button click packet (e.g., enchanting table buttons).
239    pub fn handle_container_button_click(&self, packet: SContainerButtonClick) {
240        log::debug!(
241            "Player {} clicked button {} in container {}",
242            self.gameprofile.name,
243            packet.button_id,
244            packet.container_id
245        );
246        // TODO: Implement container button click handling
247        // This is used for things like:
248        // - Enchanting table level selection
249        // - Stonecutter recipe selection
250        // - Loom pattern selection
251        // - Lectern page turning
252    }
253
254    /// Handles a container click packet (slot interaction).
255    pub fn handle_container_click(&self, packet: SContainerClick) {
256        self.reset_last_action_time();
257        match self.take_open_menu_for_callback(Some(packet.container_id)) {
258            Ok(mut menu) => {
259                self.process_container_click(&mut menu, packet);
260                self.finish_open_menu_callback(menu);
261            }
262            Err(OpenMenuUnavailable::Closed) => {
263                let mut menu = self.inventory_menu.lock();
264                if i32::from(menu.behavior().container_id()) == packet.container_id {
265                    self.process_container_click(&mut menu, packet);
266                }
267            }
268            Err(OpenMenuUnavailable::Unavailable) => {}
269        }
270    }
271
272    /// Processes a container click on any menu implementing the Menu trait.
273    ///
274    /// This is the common implementation shared between inventory menu and
275    /// external menus (crafting table, chest, etc.).
276    fn process_container_click(&self, menu: &mut Menu, packet: SContainerClick) {
277        if self.game_mode() == GameType::Spectator || self.get_health() <= 0.0 {
278            menu.behavior_mut()
279                .send_all_data_to_remote(&self.connection);
280            return;
281        }
282
283        if !menu.still_valid(self) {
284            log::debug!(
285                "Player {} interacted with invalid menu",
286                self.gameprofile.name
287            );
288            return;
289        }
290
291        // Vanilla rejects positive out-of-range slots before applying client
292        // prediction hashes or resynchronizing the menu. Its validity check
293        // admits every negative slot because each is less than the slot count.
294        let slot_count = menu.behavior().slot_count();
295        let packet_slot_is_valid = packet.slot_num < 0
296            || usize::try_from(packet.slot_num).is_ok_and(|slot| slot < slot_count);
297        if !packet_slot_is_valid {
298            log::debug!(
299                "Player {} clicked invalid slot index: {}, available slots: {}",
300                self.gameprofile.name,
301                packet.slot_num,
302                slot_count
303            );
304            return;
305        }
306
307        // Parse and validate the remaining raw click fields once. A malformed
308        // button or drag encoding is not applied, but the state sync below
309        // still runs so the client's prediction gets corrected.
310        let click = Click::parse(
311            packet.slot_num,
312            packet.button_num,
313            packet.click_type,
314            slot_count,
315        );
316        if click.is_none() {
317            log::debug!(
318                "Player {} sent malformed container click (slot {}, button {}, {:?})",
319                self.gameprofile.name,
320                packet.slot_num,
321                packet.button_num,
322                packet.click_type
323            );
324            // Vanilla rejects positive out-of-range slots before `doClick`.
325            // Once admitted, any non-QuickCraft input cancels an active drag,
326            // and malformed QuickCraft headers/types reset their own state.
327            let quick_craft_header = packet.button_num & 3;
328            let quick_craft_type = (packet.button_num >> 2) & 3;
329            if packet.click_type != ClickType::QuickCraft
330                || quick_craft_header == 3
331                || (quick_craft_header == 0 && quick_craft_type == 3)
332            {
333                menu.behavior_mut().reset_quick_craft();
334            }
335        }
336
337        let full_resync_needed = packet.state_id as u32 != menu.behavior().state_id();
338
339        menu.behavior_mut().suppress_remote_updates();
340
341        if let Some(click) = click {
342            menu.clicked(click, self);
343        }
344
345        for (slot, hash) in packet.changed_slots {
346            let slot = slot as usize;
347            // Result/fake slots are server-authoritative (their contents are
348            // recomputed from a recipe). Don't let the client's prediction set
349            // our view of what it knows, or `broadcast_changes` will think the
350            // client already has the freshly-crafted result and skip syncing it
351            // — leaving the slot blank until the next click forces a resend.
352            if menu
353                .behavior()
354                .slots()
355                .get(slot)
356                .is_some_and(|slot| slot.is_fake())
357            {
358                menu.behavior_mut().mark_remote_slot_unknown(slot);
359                continue;
360            }
361            menu.behavior_mut().set_remote_slot(slot, hash);
362        }
363
364        menu.behavior_mut().set_remote_carried(packet.carried_item);
365        menu.behavior_mut().resume_remote_updates();
366
367        if full_resync_needed {
368            menu.behavior_mut()
369                .send_all_data_to_remote(&self.connection);
370        } else {
371            menu.behavior_mut().broadcast_changes(&self.connection);
372        }
373    }
374
375    /// Handles a container close packet.
376    ///
377    /// Based on Java's `ServerGamePacketListenerImpl::handleContainerClose`.
378    pub fn handle_container_close(&self, packet: SContainerClose) {
379        log::debug!(
380            "Player {} closed container {}",
381            self.gameprofile.name,
382            packet.container_id
383        );
384
385        let open_menu = self.open_menu.lock();
386        let closes_open_menu = open_menu
387            .menu
388            .as_ref()
389            .is_some_and(|menu| i32::from(menu.container_id()) == packet.container_id)
390            || open_menu
391                .dispatch
392                .as_ref()
393                .is_some_and(|dispatch| i32::from(dispatch.container_id) == packet.container_id);
394        drop(open_menu);
395
396        if closes_open_menu {
397            self.do_close_container();
398            return;
399        }
400
401        if packet.container_id == i32::from(INVENTORY_MENU_CONTAINER_ID) {
402            let mut menu = self.inventory_menu.lock();
403            menu.removed(self);
404        }
405    }
406
407    /// Handles an anvil rename packet.
408    pub fn handle_rename_item(self: &Arc<Self>, packet: SRenameItem) {
409        match self.take_open_menu_for_callback(None) {
410            Ok(mut menu) => {
411                if menu.still_valid(self) {
412                    menu.set_item_name(packet.name, self);
413                }
414                self.finish_open_menu_callback(menu);
415            }
416            Err(OpenMenuUnavailable::Closed) => {
417                log::debug!("rename item without an open menu");
418            }
419            Err(OpenMenuUnavailable::Unavailable) => {}
420        }
421    }
422
423    /// Handles a container slot state changed packet (e.g., crafter slot toggle).
424    pub fn handle_container_slot_state_changed(&self, packet: SContainerSlotStateChanged) {
425        log::debug!(
426            "Player {} changed slot {} state to {} in container {}",
427            self.gameprofile.name,
428            packet.slot_id,
429            packet.new_state,
430            packet.container_id
431        );
432        // TODO: Implement slot state change handling
433        // This is used for the crafter block to enable/disable slots
434    }
435
436    /// Handles a creative mode slot set packet.
437    pub fn handle_set_creative_mode_slot(&self, packet: SSetCreativeModeSlot) {
438        if self.game_mode() != GameType::Creative {
439            return;
440        }
441
442        let drop = packet.slot_num < 0;
443        let item_stack = packet.item_stack;
444
445        let valid_slot = packet.slot_num >= 1 && packet.slot_num <= 45;
446        let valid_data = item_stack.is_empty() || item_stack.count <= item_stack.max_stack_size();
447
448        if valid_slot && valid_data {
449            let mut menu = self.inventory_menu.lock();
450            let slot_index = packet.slot_num as usize;
451
452            {
453                let mut guard = menu.behavior().lock_all_containers();
454                if let Some(slot) = menu.behavior().slots().get(slot_index) {
455                    let previous = slot.get_item(&guard).clone();
456                    slot.set_by_player(&mut guard, item_stack.clone(), &previous);
457                }
458            }
459            if (1..=4).contains(&slot_index) {
460                menu.update_crafting_result();
461            }
462            menu.behavior_mut()
463                .set_remote_slot_known(slot_index, &item_stack);
464            menu.behavior_mut().broadcast_changes(&self.connection);
465        } else if drop && valid_data {
466            {
467                let mut throttler = self.session.drop_spam_throttler.lock();
468                if throttler.is_under_threshold() {
469                    throttler.increment();
470                } else {
471                    log::warn!(
472                        "Player {} was dropping items too fast in creative mode; ignoring",
473                        self.gameprofile.name,
474                    );
475                    return;
476                }
477            }
478            let _ = self.drop_item(item_stack, false, true);
479        }
480    }
481
482    /// Sets selected slot
483    pub fn handle_set_carried_item(&self, packet: SSetCarriedItem) {
484        if self
485            .inventory
486            .lock()
487            .try_set_selected_slot_from_packet(packet.slot)
488            .is_err()
489        {
490            log::warn!(
491                "{} tried to set an invalid carried item",
492                self.gameprofile.name
493            );
494        } else {
495            self.reset_last_action_time();
496        }
497    }
498
499    /// Sends all inventory slots to the client (full sync).
500    /// This should be called when the player first joins.
501    pub fn send_inventory_to_remote(&self) {
502        self.inventory_menu
503            .lock()
504            .behavior_mut()
505            .send_all_data_to_remote(&self.connection);
506    }
507
508    /// Generates the next container ID (1-100, wrapping around).
509    ///
510    /// Based on Java's `ServerPlayer::nextContainerCounter`.
511    fn next_container_counter(&self) -> u8 {
512        self.container_counter.lock().next()
513    }
514
515    /// Opens a menu for this player.
516    ///
517    /// Based on Java's `ServerPlayer::openMenu`.
518    ///
519    /// # Arguments
520    /// * `title` - The display title shown in the open-screen packet.
521    /// * `create` - Factory invoked with the allocated container id, player,
522    ///   and current world. If called by a menu hook, the factory runs after
523    ///   that hook releases its container locks.
524    ///
525    /// # Panics
526    /// Panics if the created menu uses a different container id than the one
527    /// allocated for it, or has no menu type (i.e. the player's own inventory
528    /// menu, which must never be opened via `open_menu`).
529    pub fn open_menu(
530        &self,
531        title: impl Into<TextComponent>,
532        create: impl for<'a> FnOnce(MenuOpenContext<'a>) -> Menu + Send + 'static,
533    ) {
534        if !self.begin_menu_open_operation() {
535            return;
536        }
537        self.open_menu_inner(PendingMenuOpen {
538            title: title.into(),
539            create: Box::new(create),
540        });
541        self.finish_menu_open_operation();
542    }
543
544    fn open_menu_inner(&self, pending: PendingMenuOpen) {
545        self.do_close_container();
546
547        let mut open_menu = self.open_menu.lock();
548        if open_menu.terminal_removal.is_some() {
549            return;
550        }
551        if let Some(dispatch) = open_menu.dispatch.as_mut() {
552            dispatch
553                .actions
554                .push(DeferredMenuAction::Open(Box::new(pending)));
555            return;
556        }
557        drop(open_menu);
558
559        self.execute_menu_open(pending);
560    }
561
562    fn execute_menu_open(&self, pending: PendingMenuOpen) {
563        {
564            let mut open_menu = self.open_menu.lock();
565            if open_menu.terminal_removal.is_some() {
566                return;
567            }
568            if let Some(dispatch) = open_menu.dispatch.as_mut() {
569                dispatch
570                    .actions
571                    .push(DeferredMenuAction::Open(Box::new(pending)));
572                return;
573            }
574        }
575
576        let PendingMenuOpen { title, create } = pending;
577        let container_id = self.next_container_counter();
578        let world = self.get_world();
579        let menu = create(MenuOpenContext {
580            container_id,
581            player: self,
582            world: &world,
583        });
584        assert_eq!(
585            menu.container_id(),
586            container_id,
587            "open_menu factory returned container id {}, but {} was allocated",
588            menu.container_id(),
589            container_id,
590        );
591        self.open_prepared_menu(title, menu);
592    }
593
594    fn open_prepared_menu(&self, title: TextComponent, mut menu: Menu) {
595        loop {
596            {
597                let mut open_menu = self.open_menu.lock();
598                if let Some(terminal_removal) = open_menu.terminal_removal.as_mut() {
599                    terminal_removal.pending_menus.push(menu);
600                    return;
601                }
602            }
603
604            // A removal hook may have opened another menu while the initiating
605            // open call was closing its predecessor.
606            self.do_close_container();
607
608            let mut open_menu = self.open_menu.lock();
609            if let Some(terminal_removal) = open_menu.terminal_removal.as_mut() {
610                terminal_removal.pending_menus.push(menu);
611                return;
612            }
613            if let Some(dispatch) = open_menu.dispatch.as_mut() {
614                dispatch
615                    .actions
616                    .push(DeferredMenuAction::Install(Box::new(PreparedMenu {
617                        title,
618                        menu,
619                    })));
620                return;
621            }
622            if open_menu.menu.is_some() {
623                continue;
624            }
625            open_menu.dispatch = Some(OpenMenuDispatch {
626                container_id: menu.container_id(),
627                overrides_player_slots: menu.overrides_player_slots(),
628                actions: Vec::new(),
629            });
630            break;
631        }
632
633        self.send_packet(COpenScreen {
634            container_id: i32::from(menu.container_id()),
635            menu_type: menu
636                .menu_type()
637                .expect("a menu opened via open_menu must declare a menu type"),
638            title,
639        });
640
641        // Fire on_open before the full sync so anything the menu populates here
642        // is included in the first render sent below.
643        menu.on_open(self);
644
645        menu.behavior_mut()
646            .send_all_data_to_remote(&self.connection);
647
648        self.finish_open_menu_callback(menu);
649    }
650
651    /// A shared handle to the 2x2 crafting grid of the always-open inventory
652    /// menu.
653    pub fn crafting_container(&self) -> Shared<CraftingContainer> {
654        let menu = self.inventory_menu.lock();
655        let Some(kind) = menu.kind().downcast_ref::<InventoryKind>() else {
656            unreachable!("a player's inventory_menu is always the Inventory kind");
657        };
658        kind.crafting_container()
659    }
660
661    /// A shared handler for the 2x2 crafting grid of the always-open inventory
662    /// menu and its result.
663    pub(crate) fn inventory_crafting_handler(&self) -> CraftingHandler {
664        let menu = self.inventory_menu.lock();
665        let Some(kind) = menu.kind().downcast_ref::<InventoryKind>() else {
666            unreachable!("a player's inventory_menu is always the Inventory kind");
667        };
668        kind.crafting_handler()
669    }
670
671    /// Closes the currently open container and returns to the inventory menu.
672    ///
673    /// Based on Java's `ServerPlayer::closeContainer`.
674    /// This sends a close packet to the client.
675    pub fn close_container(&self) {
676        self.close_open_menu(true);
677    }
678
679    /// Internal close container logic without sending a packet.
680    ///
681    /// Based on Java's `ServerPlayer::doCloseContainer`.
682    /// Called when the client sends a close packet or when opening a new menu.
683    pub fn do_close_container(&self) {
684        self.close_open_menu(false);
685    }
686
687    /// Removes both the base inventory menu and any external menu.
688    ///
689    /// This mirrors `Player::remove`: base crafting and carried items are
690    /// handled before the external menu, and menu hooks cannot install a
691    /// replacement while removal is in progress. The inventory menu remains
692    /// reusable because Steel keeps one `Player` across world changes.
693    pub fn remove_all_menus(&self) -> MenuRemovalStatus {
694        self.remove_all_menus_with_disposition(self.default_menu_item_disposition())
695    }
696
697    pub(in crate::player) fn remove_all_menus_with_disposition(
698        &self,
699        disposition: MenuItemDisposition,
700    ) -> MenuRemovalStatus {
701        let menu = {
702            let mut open_menu = self.open_menu.lock();
703            if let Some(terminal_removal) = open_menu.terminal_removal.as_mut() {
704                terminal_removal.disposition = terminal_removal.disposition.combine(disposition);
705                return MenuRemovalStatus::Pending;
706            }
707
708            open_menu.terminal_removal = Some(TerminalMenuRemoval {
709                disposition,
710                main_cleanup_complete: false,
711                pending_cleanup_in_progress: false,
712                pending_menus: Vec::new(),
713            });
714            if open_menu.dispatch.is_some() {
715                return MenuRemovalStatus::Pending;
716            }
717
718            open_menu.menu.take()
719        };
720
721        self.finish_terminal_menu_main_cleanup(menu);
722        if self.open_menu.lock().terminal_removal.is_none() {
723            MenuRemovalStatus::Complete
724        } else {
725            MenuRemovalStatus::Pending
726        }
727    }
728
729    fn finish_terminal_menu_main_cleanup(&self, mut menu: Option<Menu>) {
730        self.inventory_menu.lock().removed(self);
731        if let Some(menu) = menu.as_mut() {
732            self.remove_open_menu(menu);
733        }
734
735        {
736            let mut open_menu = self.open_menu.lock();
737            let Some(terminal_removal) = open_menu.terminal_removal.as_mut() else {
738                return;
739            };
740            terminal_removal.main_cleanup_complete = true;
741        }
742        self.try_finish_terminal_menu_removal();
743    }
744
745    fn try_finish_terminal_menu_removal(&self) {
746        loop {
747            let pending_menus = {
748                let mut open_menu = self.open_menu.lock();
749                if open_menu.active_open_operations != 0 {
750                    return;
751                }
752                let Some(terminal_removal) = open_menu.terminal_removal.as_mut() else {
753                    return;
754                };
755                if !terminal_removal.main_cleanup_complete {
756                    return;
757                }
758                if terminal_removal.pending_cleanup_in_progress {
759                    return;
760                }
761                if terminal_removal.pending_menus.is_empty() {
762                    open_menu.terminal_removal = None;
763                    debug_assert!(open_menu.menu.is_none());
764                    return;
765                }
766                terminal_removal.pending_cleanup_in_progress = true;
767                mem::take(&mut terminal_removal.pending_menus)
768            };
769
770            for mut pending_menu in pending_menus {
771                pending_menu.removed(self);
772            }
773
774            let mut open_menu = self.open_menu.lock();
775            let Some(terminal_removal) = open_menu.terminal_removal.as_mut() else {
776                return;
777            };
778            terminal_removal.pending_cleanup_in_progress = false;
779        }
780    }
781
782    #[cfg(test)]
783    pub(in crate::player) fn retry_terminal_menu_removal_for_test(&self) {
784        self.try_finish_terminal_menu_removal();
785    }
786
787    fn close_open_menu(&self, send_packet: bool) {
788        let menu = {
789            let mut open_menu = self.open_menu.lock();
790            if open_menu.terminal_removal.is_some() {
791                return;
792            }
793            if let Some(dispatch) = open_menu.dispatch.as_mut() {
794                dispatch
795                    .actions
796                    .push(DeferredMenuAction::Close { send_packet });
797                return;
798            }
799            let Some(menu) = open_menu.menu.take() else {
800                return;
801            };
802            open_menu.dispatch = Some(OpenMenuDispatch {
803                container_id: menu.container_id(),
804                overrides_player_slots: menu.overrides_player_slots(),
805                actions: Vec::new(),
806            });
807            menu
808        };
809
810        let mut menu = menu;
811        if send_packet {
812            self.send_packet(CContainerClose {
813                container_id: i32::from(menu.container_id()),
814            });
815        }
816        self.remove_open_menu(&mut menu);
817        self.finish_open_menu_removal();
818    }
819
820    fn remove_open_menu(&self, menu: &mut Menu) {
821        let overrides_player_slots = menu.overrides_player_slots();
822        menu.removed(self);
823        if overrides_player_slots {
824            self.request_inventory_resync(0..PlayerInventory::INVENTORY_SIZE);
825        } else {
826            self.inventory_menu
827                .lock()
828                .behavior_mut()
829                .transfer_state(menu.behavior());
830        }
831    }
832
833    /// Returns true if the player has an external menu open (not the inventory).
834    #[must_use]
835    pub fn has_container_open(&self) -> bool {
836        let open_menu = self.open_menu.lock();
837        open_menu.menu.is_some() || open_menu.dispatch.is_some()
838    }
839
840    /// Runs the open menu's per-tick hook, if an external menu is open.
841    ///
842    /// Scoped to the opened menu; the base inventory menu is not ticked. Called
843    /// once per player tick, before syncing inventory changes to the client.
844    pub fn tick_open_menu(&self) {
845        let Ok(mut menu) = self.take_open_menu_for_callback(None) else {
846            return;
847        };
848        if !menu.still_valid(self) {
849            self.close_container();
850            self.finish_open_menu_callback(menu);
851            return;
852        }
853        menu.on_tick(self);
854        self.finish_open_menu_callback(menu);
855    }
856
857    /// Broadcasts inventory changes to the client (incremental sync).
858    /// This is called every tick to sync only changed slots.
859    pub fn broadcast_inventory_changes(&self) {
860        let mut open_menu = self.open_menu.lock();
861        if let Some(menu) = open_menu.menu.as_mut() {
862            menu.behavior_mut().broadcast_changes(&self.connection);
863            return;
864        }
865        if open_menu.dispatch.is_none() {
866            drop(open_menu);
867            self.inventory_menu
868                .lock()
869                .behavior_mut()
870                .broadcast_changes(&self.connection);
871        }
872    }
873
874    /// Requests direct synchronization of logical player-inventory slots.
875    pub(crate) fn request_inventory_resync(&self, slots: impl IntoIterator<Item = usize>) {
876        self.inventory_sync.lock().request(slots);
877    }
878
879    /// Sends the latest values for requested logical inventory slots.
880    pub(in crate::player) fn flush_inventory_resync(&self) {
881        let overrides_player_slots = {
882            let open_menu = self.open_menu.lock();
883            open_menu
884                .menu
885                .as_ref()
886                .is_some_and(Menu::overrides_player_slots)
887                || open_menu
888                    .dispatch
889                    .as_ref()
890                    .is_some_and(|dispatch| dispatch.overrides_player_slots)
891        };
892        let slots = self
893            .inventory_sync
894            .lock()
895            .take_ready(overrides_player_slots);
896        if slots.is_empty() {
897            return;
898        }
899
900        let packets = {
901            let inventory = self.inventory.lock();
902            slots
903                .into_iter()
904                .map(|slot| CSetPlayerInventory {
905                    slot: slot as i32,
906                    item_stack: inventory.get_item(slot).clone(),
907                })
908                .collect::<Vec<_>>()
909        };
910        for packet in packets {
911            self.send_packet(packet);
912        }
913    }
914
915    /// Removes or counts matching stacks across every location used by vanilla `/clear`.
916    pub(crate) fn clear_or_count_matching_items(
917        &self,
918        predicate: &dyn Fn(&ItemStack) -> bool,
919        amount_to_remove: i32,
920    ) -> i32 {
921        let counting_only = amount_to_remove == 0;
922        let mut count = self.inventory.lock().clear_or_count_matching_items(
923            predicate,
924            amount_to_remove,
925            counting_only,
926        );
927
928        count += self.inventory_menu.lock().clear_or_count_crafting_items(
929            predicate,
930            amount_to_remove - count,
931            counting_only,
932        );
933
934        let has_open_menu = {
935            let mut open_menu = self.open_menu.lock();
936            if let Some(menu) = open_menu.menu.as_mut() {
937                let behavior = menu.behavior_mut();
938                count += clear_or_count_matching_stack(
939                    behavior.carried_mut(),
940                    predicate,
941                    amount_to_remove - count,
942                    counting_only,
943                );
944                if behavior.carried().is_empty() {
945                    *behavior.carried_mut() = ItemStack::empty();
946                }
947                true
948            } else {
949                open_menu.dispatch.is_some()
950            }
951        };
952        if !has_open_menu {
953            let mut inventory_menu = self.inventory_menu.lock();
954            let behavior = inventory_menu.behavior_mut();
955            count += clear_or_count_matching_stack(
956                behavior.carried_mut(),
957                predicate,
958                amount_to_remove - count,
959                counting_only,
960            );
961            if behavior.carried().is_empty() {
962                *behavior.carried_mut() = ItemStack::empty();
963            }
964        }
965
966        self.inventory_menu.lock().update_crafting_result();
967        self.broadcast_inventory_changes();
968        count
969    }
970
971    /// Drops an item from the player's selected hotbar slot.
972    ///
973    /// Based on Java's `ServerPlayer.drop(boolean all)`.
974    ///
975    /// - `all`: If true, drops the entire stack (Ctrl+Q). If false, drops one item (Q).
976    pub fn drop_from_selected(&self, all: bool) {
977        if !self.can_drop_items() {
978            return;
979        }
980
981        let removed = {
982            let mut inventory = self.inventory.lock();
983            let selected_count = inventory.get_selected_item().count();
984            if selected_count == 0 {
985                return;
986            }
987            inventory.split_item_in_hand(
988                InteractionHand::MainHand,
989                if all { selected_count } else { 1 },
990            )
991        };
992
993        let _ = self.drop_item(removed, false, true);
994    }
995
996    /// Drops an item into the world.
997    ///
998    /// Based on Java's `LivingEntity.drop(ItemStack, boolean randomly, boolean thrownFromHand)`.
999    ///
1000    /// - `throw_randomly`: If true, the item is thrown in a random direction.
1001    ///   If false, it's thrown in the direction the player is facing.
1002    /// - `thrown_from_hand`: If true, sets the thrower and uses a longer pickup delay.
1003    #[must_use]
1004    pub fn drop_item(
1005        &self,
1006        item: ItemStack,
1007        throw_randomly: bool,
1008        thrown_from_hand: bool,
1009    ) -> Option<Arc<ItemEntity>> {
1010        if item.is_empty() {
1011            return None;
1012        }
1013
1014        let pos = self.position();
1015        let (yaw, pitch) = self.rotation();
1016
1017        let spawn_y = self.get_eye_y() - 0.3;
1018
1019        let velocity = if throw_randomly {
1020            let power = rand::random_range(0.0..0.5);
1021            let angle = rand::random_range(0.0..TAU);
1022            DVec3::new(
1023                f64::from(-angle.sin() * power),
1024                0.2,
1025                f64::from(angle.cos() * power),
1026            )
1027        } else {
1028            let pitch_rad = pitch.to_radians();
1029            let yaw_rad = yaw.to_radians();
1030
1031            let sin_pitch = pitch_rad.sin();
1032            let cos_pitch = pitch_rad.cos();
1033            let sin_yaw = yaw_rad.sin();
1034            let cos_yaw = yaw_rad.cos();
1035
1036            let angle_offset = rand::random_range(0.0..TAU);
1037            let power_offset = rand::random_range(0.0..0.02);
1038
1039            DVec3::new(
1040                f64::from(-sin_yaw * cos_pitch * 0.3)
1041                    + f64::from(angle_offset.cos() * power_offset),
1042                f64::from(-sin_pitch * 0.3 + 0.1)
1043                    + f64::from((rand::random::<f32>() - rand::random::<f32>()) * 0.1),
1044                f64::from(cos_yaw * cos_pitch * 0.3) + f64::from(angle_offset.sin() * power_offset),
1045            )
1046        };
1047
1048        let spawn_pos = DVec3::new(pos.x, spawn_y, pos.z);
1049
1050        let item_ref = item.item;
1051        let item_count = item.count;
1052
1053        let entity = self
1054            .get_world()
1055            .spawn_item_with_velocity(spawn_pos, item, velocity)?;
1056        entity.set_pickup_delay(40);
1057        if thrown_from_hand {
1058            entity.set_thrower(self.gameprofile.id);
1059            self.award_stat_with_count(&vanilla_stat_types::ITEM_DROPPED, item_ref, item_count);
1060            self.award_custom_stat(&vanilla_custom_stats::DROP);
1061        }
1062        Some(entity)
1063    }
1064
1065    /// Returns true if the player can drop items.
1066    ///
1067    /// Based on Java's `Player.canDropItems()`.
1068    /// Returns false if the player is dead, removed, or has a flag preventing item drops.
1069    #[must_use]
1070    pub fn can_drop_items(&self) -> bool {
1071        !self.is_removed()
1072        // TODO: Check if player is alive (health > 0)
1073    }
1074
1075    /// Returns whether items from a closing menu (crafting grid, anvil inputs,
1076    /// cursor) should be placed back into the inventory instead of dropped into
1077    /// the world.
1078    ///
1079    /// Matches vanilla's `AbstractContainerMenu.dropOrPlaceInInventory`: a
1080    /// disconnected player or one removed for any reason except a world change
1081    /// drops the items.
1082    #[must_use]
1083    pub fn returns_menu_items_to_inventory(&self) -> bool {
1084        if let Some(disposition) = self
1085            .open_menu
1086            .lock()
1087            .terminal_removal
1088            .as_ref()
1089            .map(|terminal_removal| terminal_removal.disposition)
1090        {
1091            return disposition == MenuItemDisposition::ReturnToInventory;
1092        }
1093
1094        self.default_menu_item_disposition() == MenuItemDisposition::ReturnToInventory
1095    }
1096
1097    fn default_menu_item_disposition(&self) -> MenuItemDisposition {
1098        let removed_outside_world_change =
1099            self.is_removed() && self.removal_reason() != Some(RemovalReason::ChangedWorld);
1100        if removed_outside_world_change || self.connection.closed() || self.get_health() <= 0.0 {
1101            MenuItemDisposition::Drop
1102        } else {
1103            MenuItemDisposition::ReturnToInventory
1104        }
1105    }
1106
1107    /// Tries to add an item to the player's inventory, dropping it if it doesn't fit.
1108    ///
1109    /// Based on Java's `Inventory.placeItemBackInInventory`.
1110    pub fn add_item_or_drop(&self, mut item: ItemStack) {
1111        if item.is_empty() {
1112            return;
1113        }
1114
1115        let added = self.inventory.lock().add(&mut item);
1116        if !added || !item.is_empty() {
1117            let _ = self.drop_item(item, false, false);
1118        }
1119    }
1120
1121    /// Tries to add an item to the player's inventory using an existing lock guard,
1122    /// dropping it if it doesn't fit.
1123    ///
1124    /// Use this variant when you already hold a `ContainerLockGuard` that includes
1125    /// the player's inventory to avoid deadlocks.
1126    pub fn add_item_or_drop_with_guard(&self, guard: &mut ContainerLockGuard, mut item: ItemStack) {
1127        if item.is_empty() {
1128            return;
1129        }
1130
1131        let inv_id = ContainerId::from_arc(&self.inventory);
1132        let should_drop = if let Some(inv) = guard.get_mut(inv_id) {
1133            let added = inv.add(&mut item);
1134            !added || !item.is_empty()
1135        } else {
1136            true
1137        };
1138        if should_drop {
1139            let _ = guard.run_unlocked(|| self.drop_item(item, false, false));
1140        }
1141    }
1142}