Skip to main content

steel_core/inventory/menu/
behavior.rs

1//! Slots, client-sync state, and click handling shared by every menu.
2
3use crate::player::player_inventory::Prediction;
4use std::fmt;
5use std::{mem, sync::Arc};
6
7use steel_protocol::{
8    packet_traits::{ClientPacket, EncodedPacket},
9    packets::game::{
10        CContainerSetContent, CContainerSetData, CContainerSetSlot, CSetCursorItem, HashedPatchMap,
11        HashedStack,
12    },
13    utils::ConnectionProtocol,
14};
15use steel_registry::{
16    REGISTRY, RegistryEntry, RegistryExt as _, data_components::DataComponentPatch,
17    item_stack::ItemStack, menu_type::MenuTypeRef,
18};
19
20use crate::{
21    inventory::{
22        click::{DragKind, MouseButton, QuickCraft, can_item_quick_replace},
23        lock::{ContainerId, ContainerLockGuard, ContainerRef},
24        menu::builder::{FillDirection, MenuInstanceId},
25        slots::Slot,
26    },
27    player::{Player, PlayerConnection, connection::NetworkConnection},
28};
29
30/// Shared behavior and state for all menu types.
31pub struct MenuBehavior {
32    /// The slots in this menu.
33    slots: Vec<Box<dyn Slot>>,
34    /// The client's perception of the itemstacks.
35    remote_slots: Vec<RemoteSlot>,
36    /// The item being carried by the cursor.
37    carried: ItemStack,
38    /// The client's perception of the carried item.
39    remote_carried: RemoteSlot,
40    /// The container ID (0 for player inventory).
41    container_id: u8,
42    /// Incremented on every server/client mismatch.
43    state_id: u32,
44    /// None for the player inventory.
45    menu_type: Option<MenuTypeRef>,
46    /// Suppresses remote updates during click handling.
47    suppress_remote_updates: bool,
48    /// The kind of drag in progress, or `None` when idle.
49    quickcraft: Option<DragKind>,
50    /// Slots involved in the current quickcraft operation.
51    quickcraft_slots: Vec<usize>,
52    /// Data slots for furnace progress, enchanting levels and the like.
53    data_slots: Vec<i16>,
54    /// The client's perception of the data slot values.
55    remote_data_slots: Vec<i16>,
56    container_refs: Vec<ContainerRef>,
57    /// Identity stamp tying [`Section`](crate::inventory::Section) and
58    /// [`DataSlot`](crate::inventory::DataSlot) handles to this menu.
59    instance: MenuInstanceId,
60}
61
62impl fmt::Debug for MenuBehavior {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.debug_struct("MenuBehavior")
65            .field("container_id", &self.container_id)
66            .field("state_id", &self.state_id)
67            .field("slot_count", &self.slots.len())
68            .field("carried", &self.carried)
69            .field("instance", &self.instance)
70            .finish_non_exhaustive()
71    }
72}
73
74impl MenuBehavior {
75    /// Creates a menu behavior with the given slots.
76    #[must_use]
77    pub(crate) fn new(
78        instance: MenuInstanceId,
79        slots: Vec<Box<dyn Slot>>,
80        container_id: u8,
81        menu_type: Option<MenuTypeRef>,
82        container_refs: Vec<ContainerRef>,
83    ) -> Self {
84        let slot_count = slots.len();
85        Self {
86            instance,
87            slots,
88            remote_slots: vec![RemoteSlot::Unknown; slot_count],
89            carried: ItemStack::empty(),
90            remote_carried: RemoteSlot::Unknown,
91            container_id,
92            state_id: 0,
93            menu_type,
94            suppress_remote_updates: false,
95            quickcraft: None,
96            quickcraft_slots: Vec::new(),
97            data_slots: Vec::new(),
98            remote_data_slots: Vec::new(),
99            container_refs,
100        }
101    }
102
103    /// Locks all containers referenced by slots in this menu.
104    #[must_use]
105    pub fn lock_all_containers(&self) -> ContainerLockGuard {
106        ContainerLockGuard::lock_all(&self.container_refs)
107    }
108
109    /// Locks the menu containers together with one additional container.
110    #[must_use]
111    pub(crate) fn lock_all_containers_with(&self, additional: ContainerRef) -> ContainerLockGuard {
112        let mut container_refs = self.container_refs.clone();
113        container_refs.push(additional);
114        ContainerLockGuard::lock_all(&container_refs)
115    }
116
117    /// The slots of this menu, fixed at build time.
118    #[must_use]
119    pub fn slots(&self) -> &[Box<dyn Slot>] {
120        &self.slots
121    }
122
123    /// The item carried by the cursor.
124    #[must_use]
125    pub const fn carried(&self) -> &ItemStack {
126        &self.carried
127    }
128
129    /// The item carried by the cursor.
130    #[must_use]
131    pub const fn carried_mut(&mut self) -> &mut ItemStack {
132        &mut self.carried
133    }
134
135    /// The container ID (0 for the player inventory).
136    #[must_use]
137    pub const fn container_id(&self) -> u8 {
138        self.container_id
139    }
140
141    /// The menu type, or `None` for the player's own inventory.
142    #[must_use]
143    pub const fn menu_type(&self) -> Option<MenuTypeRef> {
144        self.menu_type
145    }
146
147    /// The current state ID.
148    #[must_use]
149    pub const fn state_id(&self) -> u32 {
150        self.state_id
151    }
152
153    /// The kind of drag in progress, or `None` when idle.
154    #[must_use]
155    pub const fn quickcraft(&self) -> Option<DragKind> {
156        self.quickcraft
157    }
158
159    /// The identity stamp of the menu this behavior belongs to.
160    pub(crate) const fn instance(&self) -> MenuInstanceId {
161        self.instance
162    }
163
164    /// Adds a data slot with an initial value, returning its index.
165    pub(crate) fn add_data_slot(&mut self, initial_value: i16) -> usize {
166        let index = self.data_slots.len();
167        self.data_slots.push(initial_value);
168        self.remote_data_slots.push(0);
169        index
170    }
171
172    /// Gets the value of a data slot.
173    #[must_use]
174    pub(crate) fn get_data(&self, index: usize) -> Option<i16> {
175        self.data_slots.get(index).copied()
176    }
177
178    /// Sets the value of a data slot.
179    pub(crate) fn set_data(&mut self, index: usize, value: i16) {
180        if let Some(slot) = self.data_slots.get_mut(index) {
181            *slot = value;
182        }
183    }
184
185    /// Resets the quickcraft state.
186    pub(crate) fn reset_quick_craft(&mut self) {
187        self.quickcraft = None;
188        self.quickcraft_slots.clear();
189    }
190
191    /// Writes a quick-move remainder back to its source slot. Fake slots are
192    /// recomputed, so only their change notification fires.
193    pub(crate) fn update_quick_move_source(
194        &self,
195        guard: &mut ContainerLockGuard,
196        slot_index: usize,
197        remaining: &ItemStack,
198        previous: &ItemStack,
199    ) {
200        let slot = &self.slots[slot_index];
201        if remaining.is_empty() {
202            slot.set_by_player(guard, ItemStack::empty(), previous);
203        } else {
204            if !slot.is_fake() {
205                *slot.get_item_mut(guard) = remaining.clone();
206            }
207            slot.set_changed(guard);
208        }
209    }
210
211    /// Moves items from `source_slot` into slots `[start_slot, end_slot)`, walking
212    /// the range in `direction`. Aliases of the source's physical storage are
213    /// skipped. Returns true if anything moved.
214    pub fn move_item_stack_to(
215        &self,
216        guard: &mut ContainerLockGuard,
217        source_slot: usize,
218        item_stack: &mut ItemStack,
219        start_slot: usize,
220        end_slot: usize,
221        direction: FillDirection,
222    ) -> bool {
223        if start_slot >= end_slot {
224            return false;
225        }
226
227        let backwards = direction == FillDirection::Backward;
228        let mut anything_changed = false;
229        let source_key = self.slots[source_slot].storage().physical_key();
230
231        // First pass: stack onto existing items.
232        if item_stack.is_stackable() {
233            let mut dest_slot = if backwards { end_slot - 1 } else { start_slot };
234
235            while !item_stack.is_empty() {
236                if backwards {
237                    if dest_slot < start_slot {
238                        break;
239                    }
240                } else if dest_slot >= end_slot {
241                    break;
242                }
243
244                let slot = &self.slots[dest_slot];
245                if dest_slot == source_slot
246                    || source_key.is_some_and(|key| slot.storage().physical_key() == Some(key))
247                {
248                    if backwards {
249                        if dest_slot == 0 {
250                            break;
251                        }
252                        dest_slot -= 1;
253                    } else {
254                        dest_slot += 1;
255                    }
256                    continue;
257                }
258                let target = slot.get_item(guard).clone();
259
260                if !target.is_empty()
261                    && ItemStack::is_same_item_same_components(item_stack, &target)
262                {
263                    let total_stack = target.count + item_stack.count;
264                    let max_stack_size = slot.get_max_stack_size_for_item(guard, &target);
265
266                    if total_stack <= max_stack_size {
267                        item_stack.set_count(0);
268                        slot.get_item_mut(guard).set_count(total_stack);
269                        slot.set_changed(guard);
270                        anything_changed = true;
271                    } else if target.count < max_stack_size {
272                        item_stack.shrink(max_stack_size - target.count);
273                        slot.get_item_mut(guard).set_count(max_stack_size);
274                        slot.set_changed(guard);
275                        anything_changed = true;
276                    }
277                }
278
279                if backwards {
280                    if dest_slot == 0 {
281                        break;
282                    }
283                    dest_slot -= 1;
284                } else {
285                    dest_slot += 1;
286                }
287            }
288        }
289
290        // Second pass: place into empty slots.
291        if !item_stack.is_empty() {
292            let mut dest_slot = if backwards { end_slot - 1 } else { start_slot };
293
294            while if backwards {
295                dest_slot >= start_slot
296            } else {
297                dest_slot < end_slot
298            } {
299                let slot = &self.slots[dest_slot];
300                if dest_slot == source_slot
301                    || source_key.is_some_and(|key| slot.storage().physical_key() == Some(key))
302                {
303                    if backwards {
304                        if dest_slot == 0 {
305                            break;
306                        }
307                        dest_slot -= 1;
308                    } else {
309                        dest_slot += 1;
310                    }
311                    continue;
312                }
313                let target = slot.get_item(guard).clone();
314
315                if target.is_empty() && slot.may_place(item_stack) {
316                    let max_stack_size = slot.get_max_stack_size_for_item(guard, item_stack);
317                    let to_place = item_stack.count.min(max_stack_size);
318                    slot.set_by_player(guard, item_stack.split(to_place), &ItemStack::empty());
319                    slot.set_changed(guard);
320                    anything_changed = true;
321                    break;
322                }
323
324                if backwards {
325                    if dest_slot == 0 {
326                        break;
327                    }
328                    dest_slot -= 1;
329                } else {
330                    dest_slot += 1;
331                }
332            }
333        }
334
335        anything_changed
336    }
337
338    /// Suppresses remote updates during click handling.
339    pub(crate) const fn suppress_remote_updates(&mut self) {
340        self.suppress_remote_updates = true;
341    }
342
343    /// Resumes remote updates after click handling.
344    pub(crate) const fn resume_remote_updates(&mut self) {
345        self.suppress_remote_updates = false;
346    }
347
348    /// Copies another menu's remote slot state, matched by (`container_id`,
349    /// `container_slot`), so closing a menu doesn't resync shared inventory slots.
350    pub(crate) fn transfer_state(&mut self, other: &MenuBehavior) {
351        use rustc_hash::FxHashMap;
352
353        let mut other_slots: FxHashMap<(ContainerId, usize), usize> = FxHashMap::default();
354        for (slot_index, slot) in other.slots.iter().enumerate() {
355            if slot.is_fake() {
356                continue;
357            }
358            if let Some(key) = slot.storage().physical_key() {
359                other_slots.insert(key, slot_index);
360            }
361        }
362
363        for (slot_index, slot) in self.slots.iter().enumerate() {
364            if slot.is_fake() {
365                continue;
366            }
367            if let Some(key) = slot.storage().physical_key()
368                && let Some(&other_slot_index) = other_slots.get(&key)
369            {
370                self.remote_slots[slot_index] = other.remote_slots[other_slot_index].clone();
371            }
372        }
373    }
374
375    /// The number of slots in this menu.
376    #[must_use]
377    pub const fn slot_count(&self) -> usize {
378        self.slots.len()
379    }
380
381    /// Increments and returns the new state ID.
382    const fn increment_state_id(&mut self) -> u32 {
383        self.state_id = self.state_id.wrapping_add(1) & 0x7FFF; // 15-bit wrap
384        self.state_id
385    }
386
387    /// Encodes and sends a packet through the connection.
388    fn send_packet<P: ClientPacket>(connection: &Arc<PlayerConnection>, packet: P) {
389        let encoded =
390            EncodedPacket::from_bare(packet, connection.compression(), ConnectionProtocol::Play)
391                .expect("Failed to encode packet");
392        connection.send_encoded(encoded);
393    }
394
395    /// Sends every slot, the carried item and all data slots to the client and
396    /// marks them synced.
397    pub(crate) fn send_all_data_to_remote(&mut self, connection: &Arc<PlayerConnection>) {
398        let guard = self.lock_all_containers();
399
400        let items: Vec<ItemStack> = self
401            .slots
402            .iter()
403            .map(|slot| slot.get_item(&guard).clone())
404            .collect();
405        drop(guard);
406        let state_id = self.increment_state_id();
407
408        for (remote, item) in self.remote_slots.iter_mut().zip(&items) {
409            remote.force(item);
410        }
411        self.remote_carried.force(&self.carried);
412
413        let packet = CContainerSetContent {
414            container_id: i32::from(self.container_id),
415            state_id: state_id as i32,
416            items,
417            carried_item: self.carried.clone(),
418        };
419
420        Self::send_packet(connection, packet);
421
422        for i in 0..self.data_slots.len() {
423            self.remote_data_slots[i] = self.data_slots[i];
424            let packet = CContainerSetData {
425                container_id: i32::from(self.container_id),
426                id: i as i16,
427                value: self.data_slots[i],
428            };
429            Self::send_packet(connection, packet);
430        }
431    }
432
433    /// Syncs only the changed slots, carried item and data slots to the client,
434    /// once per tick.
435    pub fn broadcast_changes(&mut self, connection: &Arc<PlayerConnection>) {
436        let guard = self.lock_all_containers();
437
438        let mut changed: Vec<(usize, ItemStack)> = Vec::new();
439        for index in 0..self.slots.len() {
440            let item = self.slots[index].get_item(&guard);
441            if self.remote_slots[index].matches(item) {
442                // Cache a matched hash as Known to avoid re-hashing next tick.
443                if matches!(self.remote_slots[index], RemoteSlot::Hashed(_)) {
444                    self.remote_slots[index] = RemoteSlot::Known(item.clone());
445                }
446            } else {
447                changed.push((index, item.clone()));
448            }
449        }
450        drop(guard);
451
452        for (index, item) in changed {
453            self.synchronize_slot_to_remote(index, item, connection);
454        }
455
456        if self.remote_carried.matches(&self.carried) {
457            if matches!(self.remote_carried, RemoteSlot::Hashed(_)) {
458                self.remote_carried = RemoteSlot::Known(self.carried.clone());
459            }
460        } else {
461            self.synchronize_carried_to_remote(connection);
462        }
463
464        for i in 0..self.data_slots.len() {
465            self.synchronize_data_slot_to_remote(i, connection);
466        }
467    }
468
469    /// Sends a data slot update to the client if it changed.
470    fn synchronize_data_slot_to_remote(
471        &mut self,
472        index: usize,
473        connection: &Arc<PlayerConnection>,
474    ) {
475        if self.suppress_remote_updates || index >= self.data_slots.len() {
476            return;
477        }
478
479        let current = self.data_slots[index];
480        let remote = self.remote_data_slots[index];
481
482        if current != remote {
483            self.remote_data_slots[index] = current;
484            let packet = CContainerSetData {
485                container_id: i32::from(self.container_id),
486                id: index as i16,
487                value: current,
488            };
489            Self::send_packet(connection, packet);
490        }
491    }
492
493    /// Sends a single slot update and records the sent stack as the client's
494    /// perception.
495    fn synchronize_slot_to_remote(
496        &mut self,
497        slot: usize,
498        item: ItemStack,
499        connection: &Arc<PlayerConnection>,
500    ) {
501        if self.suppress_remote_updates {
502            return;
503        }
504
505        let state_id = self.increment_state_id();
506
507        let packet = CContainerSetSlot {
508            container_id: i32::from(self.container_id),
509            state_id: state_id as i32,
510            slot: slot as i16,
511            item_stack: item.clone(),
512        };
513
514        Self::send_packet(connection, packet);
515        self.remote_slots[slot] = RemoteSlot::Known(item);
516    }
517
518    /// Sends the carried item (cursor) to the client.
519    fn synchronize_carried_to_remote(&mut self, connection: &Arc<PlayerConnection>) {
520        if self.suppress_remote_updates {
521            return;
522        }
523
524        let packet = CSetCursorItem {
525            item_stack: self.carried.clone(),
526        };
527
528        Self::send_packet(connection, packet);
529        self.remote_carried.force(&self.carried);
530    }
531
532    /// Sets a remote slot to a known `ItemStack` when we know exactly what the
533    /// client has.
534    pub(crate) fn set_remote_slot_known(&mut self, slot: usize, item: &ItemStack) {
535        if slot < self.remote_slots.len() {
536            self.remote_slots[slot].force(item);
537        }
538    }
539
540    /// Forgets what the client has in `slot`, forcing a resync on the next broadcast.
541    pub(crate) fn mark_remote_slot_unknown(&mut self, slot: usize) {
542        if slot < self.remote_slots.len() {
543            self.remote_slots[slot] = RemoteSlot::Unknown;
544        }
545    }
546
547    /// Records the client's reported perception of a slot.
548    pub(crate) fn set_remote_slot(&mut self, slot: usize, hash: HashedStack) {
549        if slot < self.remote_slots.len() {
550            self.remote_slots[slot].receive(hash);
551        } else {
552            log::debug!(
553                "Incorrect slot index: {} available slots: {}",
554                slot,
555                self.remote_slots.len()
556            );
557        }
558    }
559
560    /// Records the client's reported carried item.
561    pub(crate) fn set_remote_carried(&mut self, hash: HashedStack) {
562        self.remote_carried.receive(hash);
563    }
564
565    /// Handles one phase of a quickcraft (drag) operation. `can_drag_to` is
566    /// the kind's per-slot veto.
567    pub(crate) fn do_quick_craft(
568        &mut self,
569        action: QuickCraft,
570        has_infinite_materials: bool,
571        player: &Player,
572        can_drag_to: &impl Fn(usize) -> bool,
573    ) {
574        // A drag must go Start -> AddSlot* -> End.
575        let valid_transition = match action {
576            QuickCraft::Start { .. } => self.quickcraft.is_none(),
577            QuickCraft::AddSlot { .. } | QuickCraft::End => self.quickcraft.is_some(),
578        };
579        if !valid_transition {
580            self.reset_quick_craft();
581            return;
582        }
583
584        if self.carried.is_empty() {
585            self.reset_quick_craft();
586            return;
587        }
588
589        match action {
590            QuickCraft::Start { kind } => {
591                // A clone (middle-click) drag requires creative.
592                if kind == DragKind::Clone && !has_infinite_materials {
593                    self.reset_quick_craft();
594                    return;
595                }
596                self.quickcraft = Some(kind);
597                self.quickcraft_slots.clear();
598            }
599            QuickCraft::AddSlot { slot: slot_index } => {
600                let slot = &self.slots[slot_index];
601
602                let guard = self.lock_all_containers();
603                let slot_item = slot.get_item(&guard).clone();
604
605                if can_item_quick_replace(&slot_item, &self.carried, true)
606                    && slot.may_place(&self.carried)
607                    && (self.quickcraft == Some(DragKind::Clone)
608                        || self.carried.count > self.quickcraft_slots.len() as i32)
609                    && can_drag_to(slot_index)
610                    && !self.quickcraft_slots.contains(&slot_index)
611                {
612                    self.quickcraft_slots.push(slot_index);
613                }
614            }
615            QuickCraft::End => self.finish_quick_craft(player, can_drag_to),
616        }
617    }
618
619    /// Distributes the carried items over the dragged slots and resets the
620    /// drag state (the `End` phase of [`MenuBehavior::do_quick_craft`]).
621    fn finish_quick_craft(&mut self, player: &Player, can_drag_to: &impl Fn(usize) -> bool) {
622        let Some(kind) = self.quickcraft else {
623            self.reset_quick_craft();
624            return;
625        };
626        if !self.quickcraft_slots.is_empty() {
627            if self.quickcraft_slots.len() == 1 {
628                // A single slot behaves as a regular pickup click.
629                let slot = self.quickcraft_slots[0];
630                self.reset_quick_craft();
631                let button = match kind {
632                    DragKind::Left => MouseButton::Left,
633                    DragKind::Right => MouseButton::Right,
634                    // Vanilla replays the clone drag type as a pickup button.
635                    // Pickup only accepts buttons 0 and 1, so type 2 is a no-op.
636                    DragKind::Clone => return,
637                };
638                self.do_pickup(slot, button, player);
639                return;
640            }
641
642            let source = self.carried.clone();
643            if source.is_empty() {
644                self.reset_quick_craft();
645                return;
646            }
647
648            let mut remaining = self.carried.count;
649            let quickcraft_slots = self.quickcraft_slots.clone();
650
651            let mut guard = self.lock_all_containers();
652
653            for &slot_index in &quickcraft_slots {
654                let slot = &self.slots[slot_index];
655                let slot_item = slot.get_item(&guard).clone();
656
657                if can_item_quick_replace(&slot_item, &self.carried, true)
658                    && slot.may_place(&self.carried)
659                    && (kind == DragKind::Clone
660                        || self.carried.count >= quickcraft_slots.len() as i32)
661                    && can_drag_to(slot_index)
662                {
663                    let current_count = if slot_item.is_empty() {
664                        0
665                    } else {
666                        slot_item.count
667                    };
668                    let max_size = source
669                        .max_stack_size()
670                        .min(slot.get_max_stack_size_for_item(&guard, &source));
671                    let place_count = kind.place_count(quickcraft_slots.len(), &source);
672                    let new_count = (place_count + current_count).min(max_size);
673                    remaining -= new_count - current_count;
674
675                    let mut new_item = source.clone();
676                    new_item.set_count(new_count);
677                    slot.set_by_player(&mut guard, new_item, &slot_item);
678                }
679            }
680
681            let mut new_carried = source;
682            new_carried.set_count(remaining);
683            self.carried = new_carried;
684        }
685
686        self.reset_quick_craft();
687    }
688
689    /// Drops the carried stack when the player clicks outside the window. Left
690    /// drops all, right drops one.
691    pub(crate) fn drop_carried(&mut self, button: MouseButton, player: &Player) {
692        if self.carried.is_empty() {
693            return;
694        }
695        match button {
696            MouseButton::Left => {
697                let to_drop = mem::take(&mut self.carried);
698                let _ = player.drop_item(to_drop, true, Prediction::Predicted);
699            }
700            MouseButton::Right => {
701                let _ = player.drop_item(self.carried.split(1), true, Prediction::Predicted);
702            }
703        }
704    }
705
706    /// Handles a pickup click (left/right click to pick up or place items).
707    pub(crate) fn do_pickup(&mut self, slot_index: usize, button: MouseButton, player: &Player) {
708        let mut guard = self.lock_all_containers();
709
710        let slot = &self.slots[slot_index];
711
712        let slot_item = slot.get_item(&guard).clone();
713        let carried = mem::take(&mut self.carried);
714
715        if slot_item.is_empty() {
716            // Empty slot: place carried items if allowed.
717            if !carried.is_empty() && slot.may_place(&carried) {
718                let requested = if button == MouseButton::Left {
719                    carried.count
720                } else {
721                    1
722                };
723                self.carried = slot.safe_insert(&mut guard, carried, requested);
724            } else {
725                self.carried = carried;
726            }
727        } else if carried.is_empty() {
728            // Empty cursor: pick up from the slot. Result slots reject partial takes.
729            let amount = if button == MouseButton::Left {
730                slot_item.count
731            } else {
732                (slot_item.count + 1) / 2
733            };
734
735            if let Some(taken) = slot.try_remove(&mut guard, amount, i32::MAX, player) {
736                if let Some(remainder) = slot.on_take(&mut guard, &taken, player) {
737                    player.add_item_or_drop_with_guard(
738                        &mut guard,
739                        remainder,
740                        Prediction::Predicted,
741                    );
742                }
743                self.carried = taken;
744            }
745        } else if ItemStack::is_same_item_same_components(&slot_item, &carried) {
746            // Same item type: stack if the slot accepts it.
747            if slot.may_pickup(&guard, player) && slot.may_place(&carried) {
748                let requested = if button == MouseButton::Left {
749                    carried.count
750                } else {
751                    1
752                };
753                self.carried = slot.safe_insert(&mut guard, carried, requested);
754            } else {
755                // may_place failed: try to take from the slot instead.
756                if slot.may_pickup(&guard, player) {
757                    let space = carried.max_stack_size() - carried.count;
758                    if space > 0 {
759                        if let Some(taken) =
760                            slot.try_remove(&mut guard, slot_item.count, space, player)
761                        {
762                            if let Some(remainder) = slot.on_take(&mut guard, &taken, player) {
763                                player.add_item_or_drop_with_guard(
764                                    &mut guard,
765                                    remainder,
766                                    Prediction::Predicted,
767                                );
768                            }
769                            let mut new_carried = carried;
770                            new_carried.grow(taken.count);
771                            self.carried = new_carried;
772                        } else {
773                            self.carried = carried;
774                        }
775                    } else {
776                        self.carried = carried;
777                    }
778                } else {
779                    self.carried = carried;
780                }
781            }
782        } else {
783            // Different items: swap if both operations are allowed.
784            if slot.may_pickup(&guard, player) && slot.may_place(&carried) {
785                if carried.count <= slot.get_max_stack_size_for_item(&guard, &carried) {
786                    slot.set_by_player(&mut guard, carried, &slot_item);
787                    self.carried = slot_item;
788                } else {
789                    self.carried = carried;
790                }
791            } else {
792                self.carried = carried;
793            }
794        }
795
796        slot.set_changed(&mut guard);
797    }
798
799    /// Handles clone (middle-click in creative).
800    pub(crate) fn do_clone(&mut self, slot_index: usize, has_infinite_materials: bool) {
801        if !has_infinite_materials || !self.carried.is_empty() {
802            return;
803        }
804
805        let guard = self.lock_all_containers();
806        let slot = &self.slots[slot_index];
807        let slot_item = slot.get_item(&guard);
808
809        if !slot_item.is_empty() {
810            self.carried = slot_item.copy_with_count(slot_item.max_stack_size());
811        }
812    }
813
814    /// Handles throw (drop key). Q drops one item, Ctrl+Q (`whole_stack`) drops
815    /// the whole stack, repeating while the slot refills with the same item.
816    pub(crate) fn do_throw(&mut self, slot_index: usize, whole_stack: bool, player: &Player) {
817        if !self.carried.is_empty() {
818            return;
819        }
820
821        let mut guard = self.lock_all_containers();
822        let slot = &self.slots[slot_index];
823
824        if !slot.may_pickup(&guard, player) {
825            return;
826        }
827
828        if !player.can_drop_items() {
829            return;
830        }
831
832        let amount = if whole_stack {
833            slot.get_item(&guard).count
834        } else {
835            1
836        };
837
838        let dropped = slot.safe_take(&mut guard, amount, i32::MAX, player);
839        if !dropped.is_empty() {
840            let _ = guard
841                .run_unlocked(|| player.drop_item(dropped.clone(), true, Prediction::Predicted));
842        }
843
844        // Ctrl+Q: keep dropping while the slot refills with the same item.
845        if whole_stack {
846            loop {
847                if !slot.may_pickup(&guard, player) {
848                    break;
849                }
850                if !player.can_drop_items() {
851                    break;
852                }
853                let current_item = slot.get_item(&guard).clone();
854                if current_item.is_empty() || !ItemStack::is_same_item(&current_item, &dropped) {
855                    break;
856                }
857                let more_dropped = slot.safe_take(&mut guard, current_item.count, i32::MAX, player);
858                if more_dropped.is_empty() {
859                    break;
860                }
861                let _ = guard
862                    .run_unlocked(|| player.drop_item(more_dropped, true, Prediction::Predicted));
863            }
864        }
865    }
866}
867
868/// What the server believes the client is showing in one slot.
869#[derive(Debug, Clone, Default)]
870pub(crate) enum RemoteSlot {
871    /// We don't know what the client has.
872    #[default]
873    Unknown,
874    /// We know the exact `ItemStack` the client should have.
875    Known(ItemStack),
876    /// The client reported a hash of its own perception.
877    Hashed(HashedStack),
878}
879
880impl RemoteSlot {
881    /// Forces the remote slot to a known `ItemStack`.
882    pub fn force(&mut self, item: &ItemStack) {
883        *self = Self::Known(item.clone());
884    }
885
886    /// Records a hashed stack reported by the client.
887    pub fn receive(&mut self, hash: HashedStack) {
888        *self = Self::Hashed(hash);
889    }
890
891    /// Checks if the remote slot matches the local `ItemStack`.
892    #[must_use]
893    pub fn matches(&self, local: &ItemStack) -> bool {
894        match self {
895            Self::Unknown => false,
896            Self::Known(remote) => ItemStack::matches(remote, local),
897            Self::Hashed(hash) => hashed_stack_matches(hash, local),
898        }
899    }
900}
901
902/// Checks if a hashed stack matches the given `ItemStack`.
903fn hashed_stack_matches(hash: &HashedStack, item: &ItemStack) -> bool {
904    match hash {
905        HashedStack::Empty => {
906            if !item.is_empty() {
907                log::debug!("HashedStack mismatch: client sent Empty, server has {item}");
908                return false;
909            }
910            true
911        }
912        HashedStack::Item {
913            item_id,
914            count,
915            components,
916        } => {
917            if item.is_empty() {
918                log::debug!(
919                    "HashedStack mismatch: client sent item_id={item_id} count={count}, server has Empty"
920                );
921                return false;
922            }
923
924            let local_id = item.item.id() as i32;
925            if local_id != *item_id {
926                log::debug!(
927                    "HashedStack mismatch: item_id client={item_id} server={local_id} ({})",
928                    item.item.key
929                );
930                return false;
931            }
932            if item.count != *count {
933                log::debug!(
934                    "HashedStack mismatch: count client={count} server={} for {}",
935                    item.count,
936                    item.item.key
937                );
938                return false;
939            }
940
941            validate_component_hashes(components, item.patch())
942        }
943    }
944}
945
946/// Validates that the hashed component patch matches the local patch.
947fn validate_component_hashes(hashed: &HashedPatchMap, patch: &DataComponentPatch) -> bool {
948    use rustc_hash::FxHashSet;
949    use steel_registry::data_components::ComponentPatchEntry;
950
951    // Removed components must match.
952    let local_removed: FxHashSet<i32> = patch
953        .iter_removed()
954        .filter_map(|k| REGISTRY.data_components.id_from_key(k).map(|id| id as i32))
955        .collect();
956    let hashed_removed: FxHashSet<i32> = hashed.removed_components.iter().copied().collect();
957
958    if local_removed != hashed_removed {
959        log::debug!(
960            "HashedStack mismatch: removed components differ - client={hashed_removed:?} server={local_removed:?}"
961        );
962        return false;
963    }
964
965    // Each set component in our patch must carry the correct client hash.
966    for (key, entry) in patch.iter() {
967        if let ComponentPatchEntry::Set(value) = entry {
968            let Some(id) = REGISTRY.data_components.id_from_key(key) else {
969                continue;
970            };
971            let id = id as i32;
972
973            let Some(&expected_hash) = hashed.added_components.get(&id) else {
974                log::debug!(
975                    "HashedStack mismatch: client missing hash for component {key} (id={id})"
976                );
977                return false;
978            };
979
980            let Some(component_type) = REGISTRY.data_components.by_id(id as usize) else {
981                log::debug!("HashedStack mismatch: component {key} has no registry entry");
982                return false;
983            };
984            let Ok(actual_hash) = component_type.compute_hash(value) else {
985                log::debug!("HashedStack mismatch: component {key} is not persistently hashable");
986                return false;
987            };
988
989            if actual_hash != expected_hash {
990                log::debug!(
991                    "HashedStack mismatch: component {key} hash differs - client={expected_hash} server={actual_hash}"
992                );
993                return false;
994            }
995        }
996    }
997
998    // The client must not send components we don't have.
999    for &id in hashed.added_components.keys() {
1000        let Some(key) = REGISTRY.data_components.get_key_by_id(id as usize) else {
1001            log::debug!("HashedStack mismatch: client sent unknown component id={id}");
1002            return false;
1003        };
1004        if !matches!(patch.get_entry(key), Some(ComponentPatchEntry::Set(_))) {
1005            log::debug!(
1006                "HashedStack mismatch: client claims component {key} exists but server doesn't have it"
1007            );
1008            return false;
1009        }
1010    }
1011
1012    true
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use std::slice;
1018    use std::sync::Arc;
1019
1020    use steel_registry::{init_vanilla_registry, item_stack::ItemStack, vanilla_items};
1021    use steel_utils::{
1022        DowncastType, DowncastTypeKey,
1023        locks::{IntoShared, SyncMutex},
1024    };
1025
1026    use crate::inventory::{
1027        container::{Container, SimpleContainer},
1028        lock::ContainerRef,
1029        menu::{
1030            Menu,
1031            builder::{FillDirection, MenuBuilder},
1032            kinds::BasicKind,
1033        },
1034        slots::{NormalSlot, RestrictedSlot, Slot},
1035    };
1036
1037    struct RecordingContainer {
1038        item: ItemStack,
1039        set_item_calls: usize,
1040        set_changed_calls: usize,
1041    }
1042
1043    // SAFETY: This test-only key uniquely identifies `RecordingContainer`.
1044    unsafe impl DowncastType for RecordingContainer {
1045        const TYPE_KEY: DowncastTypeKey =
1046            DowncastTypeKey::new("steel:test/container/quick_move_recording");
1047    }
1048
1049    impl Container for RecordingContainer {
1050        fn items(&self) -> &[ItemStack] {
1051            slice::from_ref(&self.item)
1052        }
1053
1054        fn items_mut(&mut self) -> &mut [ItemStack] {
1055            slice::from_mut(&mut self.item)
1056        }
1057
1058        fn set_item(&mut self, _slot: usize, stack: ItemStack) {
1059            self.item = stack;
1060            self.set_item_calls += 1;
1061        }
1062
1063        fn set_changed(&mut self) {
1064            self.set_changed_calls += 1;
1065        }
1066    }
1067
1068    fn recording_menu() -> (Menu, ContainerRef) {
1069        init_vanilla_registry();
1070        let container = Arc::new(SyncMutex::new(RecordingContainer {
1071            item: ItemStack::with_count(&vanilla_items::STONE, 5),
1072            set_item_calls: 0,
1073            set_changed_calls: 0,
1074        }));
1075        let container_ref = ContainerRef::from(container);
1076        let mut builder = MenuBuilder::new(None, 1);
1077        builder.section(container_ref.clone(), 1);
1078        (builder.build(BasicKind), container_ref)
1079    }
1080
1081    #[test]
1082    fn quick_move_source_persists_cloned_remainder_with_vanilla_callbacks() {
1083        let (menu, container_ref) = recording_menu();
1084        let behavior = menu.behavior();
1085        let container_id = container_ref.container_id();
1086        let mut guard = behavior.lock_all_containers();
1087        let previous = ItemStack::with_count(&vanilla_items::STONE, 5);
1088        let remainder = ItemStack::with_count(&vanilla_items::STONE, 2);
1089
1090        behavior.update_quick_move_source(&mut guard, 0, &remainder, &previous);
1091        let state = guard
1092            .get_typed::<RecordingContainer>(container_id)
1093            .expect("recording container should remain locked");
1094        assert_eq!(state.item.count(), 2);
1095        assert_eq!(state.set_item_calls, 0);
1096        assert_eq!(state.set_changed_calls, 1);
1097
1098        behavior.update_quick_move_source(&mut guard, 0, &ItemStack::empty(), &remainder);
1099        let state = guard
1100            .get_typed::<RecordingContainer>(container_id)
1101            .expect("recording container should remain locked");
1102        assert!(state.item.is_empty());
1103        assert_eq!(state.set_item_calls, 1);
1104        assert_eq!(state.set_changed_calls, 2);
1105    }
1106
1107    #[test]
1108    fn safe_insert_uses_set_by_player_before_the_menu_notification() {
1109        let (menu, container_ref) = recording_menu();
1110        let behavior = menu.behavior();
1111        let container_id = container_ref.container_id();
1112        let mut guard = behavior.lock_all_containers();
1113
1114        let remainder = behavior.slots()[0].safe_insert(
1115            &mut guard,
1116            ItemStack::with_count(&vanilla_items::STONE, 3),
1117            3,
1118        );
1119        behavior.slots()[0].set_changed(&mut guard);
1120
1121        assert!(remainder.is_empty());
1122        let state = guard
1123            .get_typed::<RecordingContainer>(container_id)
1124            .expect("recording container should remain locked");
1125        assert_eq!(state.item.count(), 8);
1126        assert_eq!(state.set_item_calls, 1);
1127        assert_eq!(state.set_changed_calls, 2);
1128    }
1129
1130    #[test]
1131    fn quick_move_skips_aliases_of_the_source_slot() {
1132        init_vanilla_registry();
1133        let container = SimpleContainer::new(2).into_shared();
1134        container
1135            .lock()
1136            .set_item(0, ItemStack::with_count(&vanilla_items::STONE, 5));
1137        let container_ref = ContainerRef::from(container);
1138
1139        let mut builder = MenuBuilder::new(None, 1);
1140        builder.custom_boxed_section([
1141            Box::new(NormalSlot::new(container_ref.clone(), 0)) as Box<dyn Slot>,
1142            Box::new(RestrictedSlot::new(container_ref.clone(), 0, |_, _| true)),
1143            Box::new(NormalSlot::new(container_ref.clone(), 1)),
1144        ]);
1145        let menu = builder.build(BasicKind);
1146        let behavior = menu.behavior();
1147        let mut guard = behavior.lock_all_containers();
1148        let clicked = behavior.slots()[0].get_item(&guard).clone();
1149        let mut remaining = clicked.clone();
1150
1151        assert!(behavior.move_item_stack_to(
1152            &mut guard,
1153            0,
1154            &mut remaining,
1155            1,
1156            3,
1157            FillDirection::Forward,
1158        ));
1159        behavior.update_quick_move_source(&mut guard, 0, &remaining, &clicked);
1160
1161        let container = guard
1162            .get(container_ref.container_id())
1163            .expect("simple container should remain locked");
1164        assert!(container.get_item(0).is_empty());
1165        assert_eq!(container.get_item(1).count(), 5);
1166    }
1167}