Skip to main content

steel_core/inventory/menu/
behavior.rs

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