valence_inventory/
validate.rs

1use valence_server::protocol::anyhow::{self, bail, ensure};
2use valence_server::protocol::packets::play::click_slot_c2s::ClickMode;
3use valence_server::protocol::packets::play::ClickSlotC2s;
4
5use super::{CursorItem, Inventory, InventoryWindow};
6use crate::player_inventory::PlayerInventory;
7
8/// Validates a click slot packet enforcing that all fields are valid.
9pub(super) fn validate_click_slot_packet(
10    packet: &ClickSlotC2s,
11    player_inventory: &Inventory,
12    open_inventory: Option<&Inventory>,
13    cursor_item: &CursorItem,
14) -> anyhow::Result<()> {
15    ensure!(
16        (packet.window_id == 0) == open_inventory.is_none(),
17        "window id and open inventory mismatch: window_id: {} open_inventory: {}",
18        packet.window_id,
19        open_inventory.is_some()
20    );
21
22    let max_slot = if let Some(open_inv) = open_inventory {
23        // when the window is split, we can only access the main slots of player's
24        // inventory
25        PlayerInventory::MAIN_SIZE + open_inv.slot_count()
26    } else {
27        player_inventory.slot_count()
28    };
29
30    // check all slot ids and item counts are valid
31    ensure!(
32        packet.slot_changes.iter().all(|s| {
33            if !(0..=max_slot).contains(&(s.idx as u16)) {
34                return false;
35            }
36
37            if !s.stack.is_empty() {
38                let max_stack_size = s.stack.item.max_stack().max(s.stack.count);
39                if !(1..=max_stack_size).contains(&(s.stack.count)) {
40                    return false;
41                }
42            }
43
44            true
45        }),
46        "invalid slot ids or item counts"
47    );
48
49    // check carried item count is valid
50    if !packet.carried_item.is_empty() {
51        let carried_item = &packet.carried_item;
52
53        let max_stack_size = carried_item.item.max_stack().max(carried_item.count);
54        ensure!(
55            (1..=max_stack_size).contains(&carried_item.count),
56            "invalid carried item count"
57        );
58    }
59
60    match packet.mode {
61        ClickMode::Click => {
62            ensure!((0..=1).contains(&packet.button), "invalid button");
63            ensure!(
64                (0..=max_slot).contains(&(packet.slot_idx as u16))
65                    || packet.slot_idx == -999
66                    || packet.slot_idx == -1,
67                "invalid slot index"
68            )
69        }
70        ClickMode::ShiftClick => {
71            ensure!((0..=1).contains(&packet.button), "invalid button");
72            ensure!(
73                packet.carried_item.is_empty(),
74                "carried item must be empty for a hotbar swap"
75            );
76            ensure!(
77                (0..=max_slot).contains(&(packet.slot_idx as u16)),
78                "invalid slot index"
79            )
80        }
81        ClickMode::Hotbar => {
82            ensure!(matches!(packet.button, 0..=8 | 40), "invalid button");
83            ensure!(
84                packet.carried_item.is_empty(),
85                "carried item must be empty for a hotbar swap"
86            );
87        }
88        ClickMode::CreativeMiddleClick => {
89            ensure!(packet.button == 2, "invalid button");
90            ensure!(
91                (0..=max_slot).contains(&(packet.slot_idx as u16)),
92                "invalid slot index"
93            )
94        }
95        ClickMode::DropKey => {
96            ensure!((0..=1).contains(&packet.button), "invalid button");
97            ensure!(
98                packet.carried_item.is_empty(),
99                "carried item must be empty for an item drop"
100            );
101            ensure!(
102                (0..=max_slot).contains(&(packet.slot_idx as u16)) || packet.slot_idx == -999,
103                "invalid slot index"
104            )
105        }
106        ClickMode::Drag => {
107            ensure!(
108                matches!(packet.button, 0..=2 | 4..=6 | 8..=10),
109                "invalid button"
110            );
111            ensure!(
112                (0..=max_slot).contains(&(packet.slot_idx as u16)) || packet.slot_idx == -999,
113                "invalid slot index"
114            )
115        }
116        ClickMode::DoubleClick => ensure!(packet.button == 0, "invalid button"),
117    }
118
119    // Check that items aren't being duplicated, i.e. conservation of mass.
120
121    let window = InventoryWindow {
122        player_inventory,
123        open_inventory,
124    };
125
126    match packet.mode {
127        ClickMode::Click => {
128            if packet.slot_idx == -1 {
129                // Clicked outside the allowed window
130                ensure!(
131                    packet.slot_changes.is_empty(),
132                    "slot modifications must be empty"
133                );
134
135                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
136                ensure!(
137                    count_deltas == 0,
138                    "invalid item delta: expected 0, got {}",
139                    count_deltas
140                );
141            } else if packet.slot_idx == -999 {
142                // Clicked outside the window, so the client is dropping an item
143                ensure!(
144                    packet.slot_changes.is_empty(),
145                    "slot modifications must be empty"
146                );
147
148                // Clicked outside the window
149                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
150                let expected_delta = match packet.button {
151                    1 => -1,
152                    0 => {
153                        if !cursor_item.is_empty() {
154                            -i32::from(cursor_item.0.count)
155                        } else {
156                            0
157                        }
158                    }
159                    _ => unreachable!(),
160                };
161                ensure!(
162                    count_deltas == expected_delta,
163                    "invalid item delta: expected {}, got {}",
164                    expected_delta,
165                    count_deltas
166                );
167            } else {
168                // If the user clicked on an empty slot for example
169                if packet.slot_changes.is_empty() {
170                    let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
171                    ensure!(
172                        count_deltas == 0,
173                        "invalid item delta: expected 0, got {}",
174                        count_deltas
175                    );
176                } else {
177                    ensure!(
178                        packet.slot_changes.len() == 1,
179                        "click must modify one slot, got {}",
180                        packet.slot_changes.len()
181                    );
182
183                    let old_slot = window.slot(packet.slot_changes[0].idx as u16);
184                    // TODO: make sure NBT is the same.
185                    //       Sometimes, the client will add nbt data to an item if it's missing,
186                    // like       "Damage" to a sword.
187                    let should_swap: bool = packet.button == 0
188                        && match (!old_slot.is_empty(), !cursor_item.is_empty()) {
189                            (true, true) => old_slot.item != cursor_item.item,
190                            (true, false) => true,
191                            (false, true) => cursor_item.count <= cursor_item.item.max_stack(),
192                            (false, false) => false,
193                        };
194
195                    if should_swap {
196                        // assert that a swap occurs
197                        ensure!(
198                            // There are some cases where the client will add NBT data that
199                            // did not previously exist.
200                            old_slot.item == packet.carried_item.item
201                                && old_slot.count == packet.carried_item.count
202                                && cursor_item.0 == packet.slot_changes[0].stack,
203                            "swapped items must match"
204                        );
205                    } else {
206                        // assert that a merge occurs
207                        let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
208                        ensure!(
209                            count_deltas == 0,
210                            "invalid item delta for stack merge: {}",
211                            count_deltas
212                        );
213                    }
214                }
215            }
216        }
217        ClickMode::ShiftClick => {
218            // If the user clicked on an empty slot for example
219            if packet.slot_changes.is_empty() {
220                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
221                ensure!(
222                    count_deltas == 0,
223                    "invalid item delta: expected 0, got {}",
224                    count_deltas
225                );
226            } else {
227                ensure!(
228                    (2..=3).contains(&packet.slot_changes.len()),
229                    "shift click must modify 2 or 3 slots, got {}",
230                    packet.slot_changes.len()
231                );
232
233                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
234                ensure!(
235                    count_deltas == 0,
236                    "invalid item delta: expected 0, got {}",
237                    count_deltas
238                );
239
240                let Some(item_kind) = packet
241                    .slot_changes
242                    .iter()
243                    .find(|s| !s.stack.is_empty())
244                    .map(|s| s.stack.item)
245                else {
246                    bail!("shift click must move an item");
247                };
248
249                let old_slot_kind = window.slot(packet.slot_idx as u16).item;
250                ensure!(
251                    old_slot_kind == item_kind,
252                    "shift click must move the same item kind as modified slots"
253                );
254
255                // assert all moved items are the same kind
256                ensure!(
257                    packet
258                        .slot_changes
259                        .iter()
260                        .filter(|s| !s.stack.is_empty())
261                        .all(|s| s.stack.item == item_kind),
262                    "shift click must move the same item kind"
263                );
264            }
265        }
266
267        ClickMode::Hotbar => {
268            if packet.slot_changes.is_empty() {
269                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
270                ensure!(
271                    count_deltas == 0,
272                    "invalid item delta: expected 0, got {}",
273                    count_deltas
274                );
275            } else {
276                ensure!(
277                    packet.slot_changes.len() == 2,
278                    "hotbar swap must modify two slots, got {}",
279                    packet.slot_changes.len()
280                );
281
282                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
283                ensure!(
284                    count_deltas == 0,
285                    "invalid item delta: expected 0, got {}",
286                    count_deltas
287                );
288
289                // assert that a swap occurs
290                let old_slots = [
291                    window.slot(packet.slot_changes[0].idx as u16),
292                    window.slot(packet.slot_changes[1].idx as u16),
293                ];
294                // There are some cases where the client will add NBT data that did not
295                // previously exist.
296                ensure!(
297                    old_slots
298                        .iter()
299                        .any(|s| s.item == packet.slot_changes[0].stack.item
300                            && s.count == packet.slot_changes[0].stack.count)
301                        && old_slots
302                            .iter()
303                            .any(|s| s.item == packet.slot_changes[1].stack.item
304                                && s.count == packet.slot_changes[1].stack.count),
305                    "swapped items must match"
306                );
307            }
308        }
309        ClickMode::CreativeMiddleClick => {}
310        ClickMode::DropKey => {
311            if packet.slot_changes.is_empty() {
312                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
313                ensure!(
314                    count_deltas == 0,
315                    "invalid item delta: expected 0, got {}",
316                    count_deltas
317                );
318            } else {
319                ensure!(
320                    packet.slot_changes.len() == 1,
321                    "drop key must modify exactly one slot"
322                );
323                ensure!(
324                    packet.slot_idx == packet.slot_changes.first().map_or(-2, |s| s.idx),
325                    "slot index does not match modified slot"
326                );
327
328                let old_slot = window.slot(packet.slot_idx as u16);
329                let new_slot = &packet.slot_changes[0].stack;
330                let is_transmuting = match (!old_slot.is_empty(), !new_slot.is_empty()) {
331                    // TODO: make sure NBT is the same.
332                    // Sometimes, the client will add nbt data to an item if it's missing, like
333                    // "Damage" to a sword.
334                    (true, true) => old_slot.item != new_slot.item,
335                    (_, false) => false,
336                    (false, true) => true,
337                };
338                ensure!(!is_transmuting, "transmuting items is not allowed");
339
340                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
341
342                let expected_delta = match packet.button {
343                    0 => -1,
344                    1 => {
345                        if !old_slot.is_empty() {
346                            -i32::from(old_slot.count)
347                        } else {
348                            0
349                        }
350                    }
351                    _ => unreachable!(),
352                };
353                ensure!(
354                    count_deltas == expected_delta,
355                    "invalid item delta: expected {}, got {}",
356                    expected_delta,
357                    count_deltas
358                );
359            }
360        }
361        ClickMode::Drag => {
362            if matches!(packet.button, 2 | 6 | 10) {
363                let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
364                ensure!(
365                    count_deltas == 0,
366                    "invalid item delta: expected 0, got {}",
367                    count_deltas
368                );
369            } else {
370                ensure!(packet.slot_changes.is_empty() && packet.carried_item == cursor_item.0);
371            }
372        }
373        ClickMode::DoubleClick => {
374            let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
375            ensure!(
376                count_deltas == 0,
377                "invalid item delta: expected 0, got {}",
378                count_deltas
379            );
380        }
381    }
382
383    Ok(())
384}
385
386/// Calculate the total difference in item counts if the changes in this packet
387/// were to be applied.
388///
389/// Returns a positive number if items were added to the window, and a negative
390/// number if items were removed from the window.
391fn calculate_net_item_delta(
392    packet: &ClickSlotC2s,
393    window: &InventoryWindow,
394    cursor_item: &CursorItem,
395) -> i32 {
396    let mut net_item_delta: i32 = 0;
397
398    for slot in packet.slot_changes.iter() {
399        let old_slot = window.slot(slot.idx as u16);
400        let new_slot = &slot.stack;
401
402        net_item_delta += match (!old_slot.is_empty(), !new_slot.is_empty()) {
403            (true, true) => i32::from(new_slot.count) - i32::from(old_slot.count),
404            (true, false) => -i32::from(old_slot.count),
405            (false, true) => i32::from(new_slot.count),
406            (false, false) => 0,
407        };
408    }
409
410    net_item_delta += match (!cursor_item.is_empty(), !packet.carried_item.is_empty()) {
411        (true, true) => i32::from(packet.carried_item.count) - i32::from(cursor_item.count),
412        (true, false) => -i32::from(cursor_item.count),
413        (false, true) => i32::from(packet.carried_item.count),
414        (false, false) => 0,
415    };
416
417    net_item_delta
418}
419
420#[cfg(test)]
421mod tests {
422    use valence_server::nbt::Compound;
423    use valence_server::nbt::Value::Int;
424    use valence_server::protocol::packets::play::click_slot_c2s::SlotChange;
425    use valence_server::protocol::VarInt;
426    use valence_server::{ItemKind, ItemStack};
427
428    use super::*;
429    use crate::InventoryKind;
430
431    #[test]
432    fn net_item_delta_1() {
433        let drag_packet = ClickSlotC2s {
434            window_id: 2,
435            state_id: VarInt(14),
436            slot_idx: -999,
437            button: 2,
438            mode: ClickMode::Drag,
439            slot_changes: vec![
440                SlotChange {
441                    idx: 4,
442                    stack: ItemStack::new(ItemKind::Diamond, 21, None),
443                },
444                SlotChange {
445                    idx: 3,
446                    stack: ItemStack::new(ItemKind::Diamond, 21, None),
447                },
448                SlotChange {
449                    idx: 5,
450                    stack: ItemStack::new(ItemKind::Diamond, 21, None),
451                },
452            ]
453            .into(),
454            carried_item: ItemStack::new(ItemKind::Diamond, 1, None),
455        };
456
457        let player_inventory = Inventory::new(InventoryKind::Player);
458        let inventory = Inventory::new(InventoryKind::Generic9x1);
459        let window = InventoryWindow::new(&player_inventory, Some(&inventory));
460        let cursor_item = CursorItem(ItemStack::new(ItemKind::Diamond, 64, None));
461
462        assert_eq!(
463            calculate_net_item_delta(&drag_packet, &window, &cursor_item),
464            0
465        );
466    }
467
468    #[test]
469    fn net_item_delta_2() {
470        let drag_packet = ClickSlotC2s {
471            window_id: 2,
472            state_id: VarInt(14),
473            slot_idx: -999,
474            button: 2,
475            mode: ClickMode::Click,
476            slot_changes: vec![
477                SlotChange {
478                    idx: 2,
479                    stack: ItemStack::new(ItemKind::Diamond, 2, None),
480                },
481                SlotChange {
482                    idx: 3,
483                    stack: ItemStack::new(ItemKind::IronIngot, 2, None),
484                },
485                SlotChange {
486                    idx: 4,
487                    stack: ItemStack::new(ItemKind::GoldIngot, 2, None),
488                },
489                SlotChange {
490                    idx: 5,
491                    stack: ItemStack::new(ItemKind::Emerald, 2, None),
492                },
493            ]
494            .into(),
495            carried_item: ItemStack::new(ItemKind::OakWood, 2, None),
496        };
497
498        let player_inventory = Inventory::new(InventoryKind::Player);
499        let inventory = Inventory::new(InventoryKind::Generic9x1);
500        let window = InventoryWindow::new(&player_inventory, Some(&inventory));
501        let cursor_item = CursorItem::default();
502
503        assert_eq!(
504            calculate_net_item_delta(&drag_packet, &window, &cursor_item),
505            10
506        );
507    }
508
509    #[test]
510    fn click_filled_slot_with_empty_cursor_success() {
511        let player_inventory = Inventory::new(InventoryKind::Player);
512        let mut inventory = Inventory::new(InventoryKind::Generic9x1);
513        inventory.set_slot(0, ItemStack::new(ItemKind::Diamond, 20, None));
514        let cursor_item = CursorItem::default();
515        let packet = ClickSlotC2s {
516            window_id: 1,
517            button: 0,
518            mode: ClickMode::Click,
519            state_id: VarInt(0),
520            slot_idx: 0,
521            slot_changes: vec![SlotChange {
522                idx: 0,
523                stack: ItemStack::EMPTY,
524            }]
525            .into(),
526            carried_item: inventory.slot(0).clone(),
527        };
528
529        validate_click_slot_packet(&packet, &player_inventory, Some(&inventory), &cursor_item)
530            .expect("packet should be valid");
531    }
532
533    #[test]
534    fn click_filled_slot_with_incorrect_nbt_and_empty_cursor_success() {
535        let player_inventory = Inventory::new(InventoryKind::Player);
536        let cursor_item = CursorItem(ItemStack::EMPTY);
537
538        let mut inventory = Inventory::new(InventoryKind::Generic9x1);
539        // Insert an item with no NBT data that should have NBT Data.
540        inventory.set_slot(0, ItemStack::new(ItemKind::DiamondPickaxe, 1, None));
541
542        // Proper NBT Compound
543        let mut compound = Compound::new();
544        compound.insert("Damage", Int(1));
545
546        let packet = ClickSlotC2s {
547            window_id: 1,
548            state_id: VarInt(0),
549            slot_idx: 0,
550            button: 0,
551            mode: ClickMode::Click,
552            slot_changes: vec![SlotChange {
553                idx: 0,
554                stack: ItemStack::EMPTY,
555            }]
556            .into(),
557            carried_item: ItemStack {
558                item: ItemKind::DiamondPickaxe,
559                count: 1,
560                nbt: Some(compound),
561            },
562        };
563
564        validate_click_slot_packet(&packet, &player_inventory, Some(&inventory), &cursor_item)
565            .expect("packet should be valid");
566    }
567
568    #[test]
569    fn click_slot_with_filled_cursor_success() {
570        let player_inventory = Inventory::new(InventoryKind::Player);
571        let inventory1 = Inventory::new(InventoryKind::Generic9x1);
572        let mut inventory2 = Inventory::new(InventoryKind::Generic9x1);
573        inventory2.set_slot(0, ItemStack::new(ItemKind::Diamond, 10, None));
574        let cursor_item = CursorItem(ItemStack::new(ItemKind::Diamond, 20, None));
575        let packet1 = ClickSlotC2s {
576            window_id: 1,
577            button: 0,
578            mode: ClickMode::Click,
579            state_id: VarInt(0),
580            slot_idx: 0,
581            slot_changes: vec![SlotChange {
582                idx: 0,
583                stack: ItemStack::new(ItemKind::Diamond, 20, None),
584            }]
585            .into(),
586            carried_item: ItemStack::EMPTY,
587        };
588        let packet2 = ClickSlotC2s {
589            window_id: 1,
590            button: 0,
591            mode: ClickMode::Click,
592            state_id: VarInt(0),
593            slot_idx: 0,
594            slot_changes: vec![SlotChange {
595                idx: 0,
596                stack: ItemStack::new(ItemKind::Diamond, 30, None),
597            }]
598            .into(),
599            carried_item: ItemStack::EMPTY,
600        };
601
602        validate_click_slot_packet(&packet1, &player_inventory, Some(&inventory1), &cursor_item)
603            .expect("packet should be valid");
604
605        validate_click_slot_packet(&packet2, &player_inventory, Some(&inventory2), &cursor_item)
606            .expect("packet should be valid");
607    }
608
609    #[test]
610    fn click_filled_slot_with_filled_cursor_stack_overflow_success() {
611        let player_inventory = Inventory::new(InventoryKind::Player);
612        let mut inventory = Inventory::new(InventoryKind::Generic9x1);
613        inventory.set_slot(0, ItemStack::new(ItemKind::Diamond, 20, None));
614        let cursor_item = CursorItem(ItemStack::new(ItemKind::Diamond, 64, None));
615        let packet = ClickSlotC2s {
616            window_id: 1,
617            button: 0,
618            mode: ClickMode::Click,
619            state_id: VarInt(0),
620            slot_idx: 0,
621            slot_changes: vec![SlotChange {
622                idx: 0,
623                stack: ItemStack::new(ItemKind::Diamond, 64, None),
624            }]
625            .into(),
626            carried_item: ItemStack::new(ItemKind::Diamond, 20, None),
627        };
628
629        validate_click_slot_packet(&packet, &player_inventory, Some(&inventory), &cursor_item)
630            .expect("packet should be valid");
631    }
632
633    #[test]
634    fn click_filled_slot_with_filled_cursor_different_item_success() {
635        let player_inventory = Inventory::new(InventoryKind::Player);
636        let mut inventory = Inventory::new(InventoryKind::Generic9x1);
637        inventory.set_slot(0, ItemStack::new(ItemKind::IronIngot, 2, None));
638        let cursor_item = CursorItem(ItemStack::new(ItemKind::Diamond, 2, None));
639        let packet = ClickSlotC2s {
640            window_id: 1,
641            button: 0,
642            mode: ClickMode::Click,
643            state_id: VarInt(0),
644            slot_idx: 0,
645            slot_changes: vec![SlotChange {
646                idx: 0,
647                stack: ItemStack::new(ItemKind::Diamond, 2, None),
648            }]
649            .into(),
650            carried_item: ItemStack::new(ItemKind::IronIngot, 2, None),
651        };
652
653        validate_click_slot_packet(&packet, &player_inventory, Some(&inventory), &cursor_item)
654            .expect("packet should be valid");
655    }
656
657    #[test]
658    fn click_slot_with_filled_cursor_failure() {
659        let player_inventory = Inventory::new(InventoryKind::Player);
660        let inventory1 = Inventory::new(InventoryKind::Generic9x1);
661        let mut inventory2 = Inventory::new(InventoryKind::Generic9x1);
662        inventory2.set_slot(0, ItemStack::new(ItemKind::Diamond, 10, None));
663        let cursor_item = CursorItem(ItemStack::new(ItemKind::Diamond, 20, None));
664        let packet1 = ClickSlotC2s {
665            window_id: 1,
666            button: 0,
667            mode: ClickMode::Click,
668            state_id: VarInt(0),
669            slot_idx: 0,
670            slot_changes: vec![SlotChange {
671                idx: 0,
672                stack: ItemStack::new(ItemKind::Diamond, 22, None),
673            }]
674            .into(),
675            carried_item: ItemStack::EMPTY,
676        };
677        let packet2 = ClickSlotC2s {
678            window_id: 1,
679            button: 0,
680            mode: ClickMode::Click,
681            state_id: VarInt(0),
682            slot_idx: 0,
683            slot_changes: vec![SlotChange {
684                idx: 0,
685                stack: ItemStack::new(ItemKind::Diamond, 32, None),
686            }]
687            .into(),
688            carried_item: ItemStack::EMPTY,
689        };
690        let packet3 = ClickSlotC2s {
691            window_id: 1,
692            button: 0,
693            mode: ClickMode::Click,
694            state_id: VarInt(0),
695            slot_idx: 0,
696            slot_changes: vec![
697                SlotChange {
698                    idx: 0,
699                    stack: ItemStack::new(ItemKind::Diamond, 22, None),
700                },
701                SlotChange {
702                    idx: 1,
703                    stack: ItemStack::new(ItemKind::Diamond, 22, None),
704                },
705            ]
706            .into(),
707            carried_item: ItemStack::EMPTY,
708        };
709
710        validate_click_slot_packet(&packet1, &player_inventory, Some(&inventory1), &cursor_item)
711            .expect_err("packet 1 should fail item duplication check");
712
713        validate_click_slot_packet(&packet2, &player_inventory, Some(&inventory2), &cursor_item)
714            .expect_err("packet 2 should fail item duplication check");
715
716        validate_click_slot_packet(&packet3, &player_inventory, Some(&inventory1), &cursor_item)
717            .expect_err("packet 3 should fail item duplication check");
718    }
719
720    #[test]
721    fn disallow_item_transmutation() {
722        // no alchemy allowed - make sure that lead can't be turned into gold
723
724        let mut player_inventory = Inventory::new(InventoryKind::Player);
725        player_inventory.set_slot(9, ItemStack::new(ItemKind::Lead, 2, None));
726        let cursor_item = CursorItem::default();
727
728        let packets = vec![
729            ClickSlotC2s {
730                window_id: 0,
731                button: 0,
732                mode: ClickMode::ShiftClick,
733                state_id: VarInt(0),
734                slot_idx: 9,
735                slot_changes: vec![
736                    SlotChange {
737                        idx: 9,
738                        stack: ItemStack::EMPTY,
739                    },
740                    SlotChange {
741                        idx: 36,
742                        stack: ItemStack::new(ItemKind::GoldIngot, 2, None),
743                    },
744                ]
745                .into(),
746                carried_item: ItemStack::EMPTY,
747            },
748            ClickSlotC2s {
749                window_id: 0,
750                button: 0,
751                mode: ClickMode::Hotbar,
752                state_id: VarInt(0),
753                slot_idx: 9,
754                slot_changes: vec![
755                    SlotChange {
756                        idx: 9,
757                        stack: ItemStack::EMPTY,
758                    },
759                    SlotChange {
760                        idx: 36,
761                        stack: ItemStack::new(ItemKind::GoldIngot, 2, None),
762                    },
763                ]
764                .into(),
765                carried_item: ItemStack::EMPTY,
766            },
767            ClickSlotC2s {
768                window_id: 0,
769                button: 0,
770                mode: ClickMode::Click,
771                state_id: VarInt(0),
772                slot_idx: 9,
773                slot_changes: vec![SlotChange {
774                    idx: 9,
775                    stack: ItemStack::EMPTY,
776                }]
777                .into(),
778                carried_item: ItemStack::new(ItemKind::GoldIngot, 2, None),
779            },
780            ClickSlotC2s {
781                window_id: 0,
782                button: 0,
783                mode: ClickMode::DropKey,
784                state_id: VarInt(0),
785                slot_idx: 9,
786                slot_changes: vec![SlotChange {
787                    idx: 9,
788                    stack: ItemStack::new(ItemKind::GoldIngot, 1, None),
789                }]
790                .into(),
791                carried_item: ItemStack::EMPTY,
792            },
793        ];
794
795        for (i, packet) in packets.iter().enumerate() {
796            validate_click_slot_packet(packet, &player_inventory, None, &cursor_item).expect_err(
797                &format!("packet {i} passed item duplication check when it should have failed"),
798            );
799        }
800    }
801
802    #[test]
803    fn allow_shift_click_overflow_to_new_stack() {
804        let mut player_inventory = Inventory::new(InventoryKind::Player);
805        player_inventory.set_slot(9, ItemStack::new(ItemKind::Diamond, 64, None));
806        player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 32, None));
807        let cursor_item = CursorItem::default();
808
809        let packet = ClickSlotC2s {
810            window_id: 0,
811            state_id: VarInt(2),
812            slot_idx: 9,
813            button: 0,
814            mode: ClickMode::ShiftClick,
815            slot_changes: vec![
816                SlotChange {
817                    idx: 37,
818                    stack: ItemStack::new(ItemKind::Diamond, 32, None),
819                },
820                SlotChange {
821                    idx: 36,
822                    stack: ItemStack::new(ItemKind::Diamond, 64, None),
823                },
824                SlotChange {
825                    idx: 9,
826                    stack: ItemStack::EMPTY,
827                },
828            ]
829            .into(),
830            carried_item: ItemStack::EMPTY,
831        };
832
833        validate_click_slot_packet(&packet, &player_inventory, None, &cursor_item)
834            .expect("packet should be valid");
835    }
836
837    #[test]
838    fn allow_pickup_overfull_stack_click() {
839        let mut player_inventory = Inventory::new(InventoryKind::Player);
840        player_inventory.set_slot(9, ItemStack::new(ItemKind::Apple, 100, None));
841        let cursor_item = CursorItem::default();
842
843        let packet = ClickSlotC2s {
844            window_id: 0,
845            state_id: VarInt(2),
846            slot_idx: 9,
847            button: 0,
848            mode: ClickMode::Click,
849            slot_changes: vec![SlotChange {
850                idx: 9,
851                stack: ItemStack::EMPTY,
852            }]
853            .into(),
854            carried_item: ItemStack::new(ItemKind::Apple, 100, None),
855        };
856
857        validate_click_slot_packet(&packet, &player_inventory, None, &cursor_item)
858            .expect("packet should be valid");
859    }
860
861    #[test]
862    fn allow_place_overfull_stack_click() {
863        let player_inventory = Inventory::new(InventoryKind::Player);
864        let cursor_item = CursorItem(ItemStack::new(ItemKind::Apple, 100, None));
865
866        let packet = ClickSlotC2s {
867            window_id: 0,
868            state_id: VarInt(2),
869            slot_idx: 9,
870            button: 0,
871            mode: ClickMode::Click,
872            slot_changes: vec![SlotChange {
873                idx: 9,
874                stack: ItemStack::new(ItemKind::Apple, 64, None),
875            }]
876            .into(),
877            carried_item: ItemStack::new(ItemKind::Apple, 36, None),
878        };
879
880        validate_click_slot_packet(&packet, &player_inventory, None, &cursor_item)
881            .expect("packet should be valid");
882    }
883    #[test]
884    fn allow_clicking_outside_inventory_when_not_holding_anything_success() {
885        let player_inventory = Inventory::new(InventoryKind::Player);
886        let cursor_item = CursorItem(ItemStack::new(ItemKind::Air, 0, None));
887
888        let packet = ClickSlotC2s {
889            window_id: 0,
890            state_id: VarInt(2),
891            slot_idx: -999, // -999 means outside inventory
892            button: 0,
893            mode: ClickMode::DropKey, // when not holding an item and clicking outside the user
894            // interface the client sends this kind of packet
895            slot_changes: vec![].into(),
896            carried_item: ItemStack::new(ItemKind::Air, 0, None),
897        };
898
899        validate_click_slot_packet(&packet, &player_inventory, None, &cursor_item)
900            .expect("packet should be valid");
901    }
902    #[test]
903    fn allow_clicking_outside_inventory_when_holding_something_success() {
904        let player_inventory = Inventory::new(InventoryKind::Player);
905        let cursor_item = CursorItem(ItemStack::new(ItemKind::Air, 0, None));
906
907        // This is in the notchian server a stack drop
908        let packet = ClickSlotC2s {
909            window_id: 0,
910            state_id: VarInt(2),
911            slot_idx: -999, // -999 means outside inventory
912            button: 0,
913            mode: ClickMode::Click, // when holding an item its a click
914            slot_changes: vec![].into(),
915            carried_item: ItemStack::new(ItemKind::Air, 0, None),
916        };
917
918        validate_click_slot_packet(&packet, &player_inventory, None, &cursor_item)
919            .expect("packet should be valid");
920    }
921    #[test]
922    fn allow_clicking_on_the_margin_area_in_inventory_success() {
923        let player_inventory = Inventory::new(InventoryKind::Player);
924        let cursor_item = CursorItem(ItemStack::new(ItemKind::Air, 0, None));
925
926        let packet = ClickSlotC2s {
927            window_id: 0,
928            state_id: VarInt(2),
929            slot_idx: -1, // -1 here means on the margin areas
930            button: 0,
931            mode: ClickMode::Click,
932            slot_changes: vec![].into(),
933            carried_item: ItemStack::new(ItemKind::Air, 0, None),
934        };
935
936        validate_click_slot_packet(&packet, &player_inventory, None, &cursor_item)
937            .expect("packet should be valid");
938    }
939    #[test]
940    fn allow_clicking_on_an_empty_slot_with_empty_carried_item_success() {
941        let player_inventory = Inventory::new(InventoryKind::Player);
942        let cursor_item = CursorItem(ItemStack::new(ItemKind::Air, 0, None));
943
944        let packet = ClickSlotC2s {
945            window_id: 0,
946            state_id: VarInt(2),
947            slot_idx: 3,
948            button: 0,
949            mode: ClickMode::Click,
950            slot_changes: vec![].into(),
951            carried_item: ItemStack::new(ItemKind::Air, 0, None),
952        };
953
954        validate_click_slot_packet(&packet, &player_inventory, None, &cursor_item)
955            .expect("packet should be valid");
956    }
957    #[test]
958    fn allow_clicking_hotbar_keybinds_when_both_source_and_target_are_empty() {
959        let player_inventory = Inventory::new(InventoryKind::Player);
960        let cursor_item = CursorItem(ItemStack::new(ItemKind::Air, 0, None));
961
962        let packet = ClickSlotC2s {
963            window_id: 0,
964            state_id: VarInt(2),
965            slot_idx: 0,
966            button: 0,
967            mode: ClickMode::Hotbar,
968            slot_changes: vec![].into(),
969            carried_item: ItemStack::new(ItemKind::Air, 0, None),
970        };
971
972        validate_click_slot_packet(&packet, &player_inventory, None, &cursor_item)
973            .expect("packet should be valid");
974    }
975}