Skip to main content

basalt_api/events/
container.rs

1//! Container events: open/close lifecycle, clicks, drags, block entities.
2
3use crate::components::BlockPosition;
4use crate::container::{ContainerBacking, InventoryType};
5use crate::world::block_entity::BlockEntity;
6use basalt_types::Slot;
7
8// ---------------------------------------------------------------------------
9// Helper enums
10// ---------------------------------------------------------------------------
11
12/// Why a container window is closing.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum CloseReason {
15    /// Player pressed E / ESC.
16    Manual,
17    /// Player disconnected from the server.
18    Disconnect,
19    /// Server-initiated close (e.g., admin command).
20    ForceClose,
21}
22
23/// Categorises which slot was clicked, independent of window type.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum WindowSlotKind {
26    /// Crafting output slot (in crafting table or player inventory 2x2).
27    CraftOutput,
28    /// Crafting grid input slot.
29    CraftGrid,
30    /// Armor slot.
31    Armor,
32    /// Main player inventory slot (rows under hotbar).
33    MainInventory,
34    /// Hotbar slot.
35    Hotbar,
36    /// Offhand slot.
37    Offhand,
38    /// Container slot (chest slot, hopper slot, etc.).
39    Container,
40}
41
42/// Type of drag operation (paint mode).
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum DragType {
45    /// Left-click drag: distribute cursor items evenly across slots.
46    LeftDrag,
47    /// Right-click drag: place 1 item per slot.
48    RightDrag,
49    /// Middle-click drag: creative fill.
50    MiddleDrag,
51}
52
53/// Mirror of server's click action that plugins can safely consume.
54///
55/// Excludes transient click phases (DropCursor, StartDrag, AddDragSlot,
56/// EndDrag) which are internal details of the server's click processing.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ContainerClickType {
59    /// Normal left click on a slot.
60    LeftClick,
61    /// Normal right click on a slot.
62    RightClick,
63    /// Shift-click (quick-move).
64    ShiftClick,
65    /// Double-click (collect matching items).
66    DoubleClick,
67    /// Q-key drop from a slot.
68    DropSlot {
69        /// When true the entire stack is dropped (Ctrl+Q).
70        drop_all: bool,
71    },
72    /// Number key swap with hotbar slot (0-8).
73    HotbarSwap {
74        /// Hotbar slot index (0-8).
75        hotbar: u8,
76    },
77    /// Swap with offhand slot (F key).
78    OffhandSwap,
79}
80
81pub use crate::world::block_entity::BlockEntityKind;
82
83// ---------------------------------------------------------------------------
84// Container lifecycle events
85// ---------------------------------------------------------------------------
86
87/// Fires BEFORE a container window opens.
88///
89/// Cancellable -- plugins can deny the open (e.g., permission checks).
90/// Fires during the Validate stage of the game bus.
91#[derive(Debug, Clone)]
92pub struct ContainerOpenRequestEvent {
93    /// The inventory type being requested.
94    pub inventory_type: InventoryType,
95    /// How the container is backed (block or virtual).
96    pub backing: ContainerBacking,
97    /// Title that will be shown to the player.
98    pub title: String,
99    /// Whether the event has been cancelled.
100    pub cancelled: bool,
101}
102crate::game_cancellable_event!(ContainerOpenRequestEvent);
103
104/// Fires AFTER a container window has been opened for a player.
105///
106/// Non-cancellable. Fires during the Post stage.
107#[derive(Debug, Clone)]
108pub struct ContainerOpenedEvent {
109    /// Protocol window ID assigned at open time (1-127).
110    pub window_id: u8,
111    /// The inventory type that was opened.
112    pub inventory_type: InventoryType,
113    /// How the container is backed.
114    pub backing: ContainerBacking,
115    /// Number of players viewing the same block-backed container,
116    /// **including** the player who just opened it. Always at least 1
117    /// for `Block` backings, 0 for `Virtual` backings (each virtual
118    /// container is per-player).
119    ///
120    /// Used by `ContainerPlugin` to broadcast the chest-lid open
121    /// animation with the right viewer count.
122    pub viewer_count: u32,
123}
124crate::game_event!(ContainerOpenedEvent);
125
126/// Fires BEFORE the `OpenContainer` component is removed from the player.
127///
128/// Non-cancellable. Fires during the Post stage.
129#[derive(Debug, Clone)]
130pub struct ContainerClosedEvent {
131    /// Window ID that is being closed.
132    pub window_id: u8,
133    /// The inventory type.
134    pub inventory_type: InventoryType,
135    /// How the container was backed.
136    pub backing: ContainerBacking,
137    /// Why the container is closing.
138    pub reason: CloseReason,
139    /// Number of remaining viewers on the same block-backed container
140    /// **excluding** the closing player. 0 for `Virtual` backings.
141    ///
142    /// Used by `ContainerPlugin` to broadcast the chest-lid close
143    /// animation with the right remaining-viewer count (action
144    /// param 0 closes the lid completely).
145    pub viewer_count: u32,
146    /// Snapshot of the player's `CraftingGrid` slots at the moment of
147    /// close, populated **only** when `inventory_type ==
148    /// InventoryType::Crafting` (3x3 crafting table). The server has
149    /// already reset the grid to 2x2 by the time this event fires —
150    /// plugins use the snapshot to spawn dropped items.
151    ///
152    /// `None` for any non-crafting close.
153    pub crafting_grid_state: Option<[Slot; 9]>,
154}
155crate::game_event!(ContainerClosedEvent);
156
157// ---------------------------------------------------------------------------
158// Click / drag events
159// ---------------------------------------------------------------------------
160
161/// Fires BEFORE a click inside an open container is applied.
162///
163/// Cancellable -- plugins use this to implement GUI menus where
164/// slots act as buttons. Fires during the Validate stage.
165///
166/// Only fires when a container is open (NOT for player inventory
167/// clicks with no open container).
168#[derive(Debug, Clone)]
169pub struct ContainerClickEvent {
170    /// Window ID of the open container.
171    pub window_id: u8,
172    /// How the container is backed.
173    pub backing: ContainerBacking,
174    /// Protocol slot index that was clicked.
175    pub slot_index: i16,
176    /// Logical categorisation of the clicked slot.
177    pub window_slot_kind: WindowSlotKind,
178    /// Type of click action (left, right, shift, etc.).
179    pub click_type: ContainerClickType,
180    /// Cursor item state immediately before the click.
181    pub cursor_before: Slot,
182    /// Whether the event has been cancelled.
183    pub cancelled: bool,
184}
185crate::game_cancellable_event!(ContainerClickEvent);
186
187/// Fires BEFORE a drag (paint mode) distribution is applied.
188///
189/// Cancellable -- plugins can prevent drag within GUIs.
190/// Fires during the Validate stage.
191#[derive(Debug, Clone)]
192pub struct ContainerDragEvent {
193    /// Window ID of the open container.
194    pub window_id: u8,
195    /// How the container is backed.
196    pub backing: ContainerBacking,
197    /// Slots affected by the drag (protocol slot index + planned result).
198    pub affected_slots: Vec<(i16, Slot)>,
199    /// Drag type (left/right/middle).
200    pub drag_type: DragType,
201    /// Cursor item before distribution.
202    pub cursor: Slot,
203    /// Whether the event has been cancelled.
204    pub cancelled: bool,
205}
206crate::game_cancellable_event!(ContainerDragEvent);
207
208/// Fires AFTER a container slot has changed.
209///
210/// Non-cancellable. Fires during the Post stage, once per changed
211/// slot. Only fires for [`WindowSlotKind::Container`] -- not for craft
212/// grid / inventory slots inside a container window.
213#[derive(Debug, Clone)]
214pub struct ContainerSlotChangedEvent {
215    /// Window ID of the container.
216    pub window_id: u8,
217    /// How the container is backed.
218    pub backing: ContainerBacking,
219    /// Protocol slot index that changed.
220    pub slot_index: i16,
221    /// Slot state before the change.
222    pub old: Slot,
223    /// Slot state after the change.
224    pub new: Slot,
225}
226crate::game_event!(ContainerSlotChangedEvent);
227
228// ---------------------------------------------------------------------------
229// Block entity events
230// ---------------------------------------------------------------------------
231
232/// Fires AFTER a block entity is created at a position that had none.
233///
234/// Non-cancellable. Fires during the Post stage.
235#[derive(Debug, Clone)]
236pub struct BlockEntityCreatedEvent {
237    /// World position of the new block entity.
238    pub position: BlockPosition,
239    /// Kind of block entity that was created.
240    pub kind: BlockEntityKind,
241}
242crate::game_event!(BlockEntityCreatedEvent);
243
244/// Fires AFTER a block entity's data is modified.
245///
246/// Non-cancellable. Fires during the Post stage. Triggered by
247/// slot writes, tick processing, or explicit replacements.
248#[derive(Debug, Clone)]
249pub struct BlockEntityModifiedEvent {
250    /// World position of the block entity.
251    pub position: BlockPosition,
252    /// Kind of block entity that was modified.
253    pub kind: BlockEntityKind,
254}
255crate::game_event!(BlockEntityModifiedEvent);
256
257/// Fires AFTER a block entity is removed from the world.
258///
259/// Non-cancellable. Fires during the Post stage. Carries the last
260/// state so plugins can drop contents, backup data, etc.
261#[derive(Debug, Clone)]
262pub struct BlockEntityDestroyedEvent {
263    /// World position of the destroyed block entity.
264    pub position: BlockPosition,
265    /// Kind of block entity that was destroyed.
266    pub kind: BlockEntityKind,
267    /// Block entity state immediately before destruction.
268    pub last_state: BlockEntity,
269}
270crate::game_event!(BlockEntityDestroyedEvent);
271
272#[cfg(test)]
273mod tests {
274    use crate::events::{BusKind, Event, EventRouting};
275
276    use super::*;
277
278    // -- Helper enum construction and equality --------------------------------
279
280    #[test]
281    fn close_reason_variants() {
282        assert_eq!(CloseReason::Manual, CloseReason::Manual);
283        assert_eq!(CloseReason::Disconnect, CloseReason::Disconnect);
284        assert_eq!(CloseReason::ForceClose, CloseReason::ForceClose);
285        assert_ne!(CloseReason::Manual, CloseReason::Disconnect);
286    }
287
288    #[test]
289    fn window_slot_kind_variants() {
290        let variants = [
291            WindowSlotKind::CraftOutput,
292            WindowSlotKind::CraftGrid,
293            WindowSlotKind::Armor,
294            WindowSlotKind::MainInventory,
295            WindowSlotKind::Hotbar,
296            WindowSlotKind::Offhand,
297            WindowSlotKind::Container,
298        ];
299        for (i, a) in variants.iter().enumerate() {
300            assert_eq!(a, a, "variant should equal itself");
301            for b in variants.iter().skip(i + 1) {
302                assert_ne!(a, b, "distinct variants should differ");
303            }
304        }
305    }
306
307    #[test]
308    fn drag_type_variants() {
309        assert_eq!(DragType::LeftDrag, DragType::LeftDrag);
310        assert_eq!(DragType::RightDrag, DragType::RightDrag);
311        assert_eq!(DragType::MiddleDrag, DragType::MiddleDrag);
312        assert_ne!(DragType::LeftDrag, DragType::RightDrag);
313    }
314
315    #[test]
316    fn container_click_type_variants() {
317        assert_eq!(ContainerClickType::LeftClick, ContainerClickType::LeftClick);
318        assert_eq!(
319            ContainerClickType::RightClick,
320            ContainerClickType::RightClick
321        );
322        assert_eq!(
323            ContainerClickType::ShiftClick,
324            ContainerClickType::ShiftClick
325        );
326        assert_eq!(
327            ContainerClickType::DoubleClick,
328            ContainerClickType::DoubleClick
329        );
330        assert_eq!(
331            ContainerClickType::DropSlot { drop_all: true },
332            ContainerClickType::DropSlot { drop_all: true }
333        );
334        assert_ne!(
335            ContainerClickType::DropSlot { drop_all: false },
336            ContainerClickType::DropSlot { drop_all: true }
337        );
338        assert_eq!(
339            ContainerClickType::HotbarSwap { hotbar: 3 },
340            ContainerClickType::HotbarSwap { hotbar: 3 }
341        );
342        assert_ne!(
343            ContainerClickType::HotbarSwap { hotbar: 0 },
344            ContainerClickType::HotbarSwap { hotbar: 1 }
345        );
346        assert_eq!(
347            ContainerClickType::OffhandSwap,
348            ContainerClickType::OffhandSwap
349        );
350    }
351
352    #[test]
353    fn block_entity_kind_variants() {
354        assert_eq!(BlockEntityKind::Chest, BlockEntityKind::Chest);
355    }
356
357    // -- Container lifecycle events -------------------------------------------
358
359    #[test]
360    fn container_open_request_cancellation() {
361        let mut event = ContainerOpenRequestEvent {
362            inventory_type: InventoryType::Generic9x3,
363            backing: ContainerBacking::Virtual,
364            title: "Test".to_string(),
365            cancelled: false,
366        };
367        assert!(!event.is_cancelled());
368        event.cancel();
369        assert!(event.is_cancelled());
370    }
371
372    #[test]
373    fn container_opened_construction() {
374        let event = ContainerOpenedEvent {
375            window_id: 1,
376            inventory_type: InventoryType::Generic9x3,
377            backing: ContainerBacking::Block {
378                position: BlockPosition { x: 5, y: 64, z: 3 },
379            },
380            viewer_count: 1,
381        };
382        assert_eq!(event.window_id, 1);
383        assert_eq!(event.inventory_type, InventoryType::Generic9x3);
384        assert_eq!(event.viewer_count, 1);
385    }
386
387    #[test]
388    fn container_opened_not_cancellable() {
389        let mut event = ContainerOpenedEvent {
390            window_id: 1,
391            inventory_type: InventoryType::Generic9x3,
392            backing: ContainerBacking::Virtual,
393            viewer_count: 0,
394        };
395        event.cancel(); // no-op
396        assert!(!event.is_cancelled());
397    }
398
399    #[test]
400    fn container_closed_construction() {
401        let event = ContainerClosedEvent {
402            window_id: 2,
403            inventory_type: InventoryType::Hopper,
404            backing: ContainerBacking::Block {
405                position: BlockPosition {
406                    x: 10,
407                    y: 32,
408                    z: -5,
409                },
410            },
411            reason: CloseReason::Manual,
412            viewer_count: 0,
413            crafting_grid_state: None,
414        };
415        assert_eq!(event.window_id, 2);
416        assert_eq!(event.reason, CloseReason::Manual);
417    }
418
419    #[test]
420    fn container_closed_not_cancellable() {
421        let mut event = ContainerClosedEvent {
422            window_id: 1,
423            inventory_type: InventoryType::Generic9x3,
424            backing: ContainerBacking::Virtual,
425            reason: CloseReason::Disconnect,
426            viewer_count: 0,
427            crafting_grid_state: None,
428        };
429        event.cancel();
430        assert!(!event.is_cancelled());
431    }
432
433    // -- Click / drag events --------------------------------------------------
434
435    #[test]
436    fn container_click_cancellation() {
437        let mut event = ContainerClickEvent {
438            window_id: 1,
439            backing: ContainerBacking::Virtual,
440            slot_index: 5,
441            window_slot_kind: WindowSlotKind::Container,
442            click_type: ContainerClickType::LeftClick,
443            cursor_before: Slot::empty(),
444            cancelled: false,
445        };
446        assert!(!event.is_cancelled());
447        event.cancel();
448        assert!(event.is_cancelled());
449    }
450
451    #[test]
452    fn container_click_field_access() {
453        let event = ContainerClickEvent {
454            window_id: 3,
455            backing: ContainerBacking::Block {
456                position: BlockPosition { x: 0, y: 64, z: 0 },
457            },
458            slot_index: 12,
459            window_slot_kind: WindowSlotKind::Hotbar,
460            click_type: ContainerClickType::HotbarSwap { hotbar: 2 },
461            cursor_before: Slot::new(1, 32),
462            cancelled: false,
463        };
464        assert_eq!(event.slot_index, 12);
465        assert_eq!(event.window_slot_kind, WindowSlotKind::Hotbar);
466        assert_eq!(
467            event.click_type,
468            ContainerClickType::HotbarSwap { hotbar: 2 }
469        );
470    }
471
472    #[test]
473    fn container_drag_cancellation() {
474        let mut event = ContainerDragEvent {
475            window_id: 1,
476            backing: ContainerBacking::Virtual,
477            affected_slots: vec![(0, Slot::new(1, 16)), (1, Slot::new(1, 16))],
478            drag_type: DragType::LeftDrag,
479            cursor: Slot::new(1, 32),
480            cancelled: false,
481        };
482        assert!(!event.is_cancelled());
483        event.cancel();
484        assert!(event.is_cancelled());
485    }
486
487    #[test]
488    fn container_drag_field_access() {
489        let event = ContainerDragEvent {
490            window_id: 2,
491            backing: ContainerBacking::Virtual,
492            affected_slots: vec![(5, Slot::new(3, 1))],
493            drag_type: DragType::RightDrag,
494            cursor: Slot::new(3, 5),
495            cancelled: false,
496        };
497        assert_eq!(event.affected_slots.len(), 1);
498        assert_eq!(event.drag_type, DragType::RightDrag);
499    }
500
501    #[test]
502    fn container_slot_changed_construction() {
503        let event = ContainerSlotChangedEvent {
504            window_id: 1,
505            backing: ContainerBacking::Block {
506                position: BlockPosition { x: 5, y: 64, z: 3 },
507            },
508            slot_index: 7,
509            old: Slot::empty(),
510            new: Slot::new(1, 1),
511        };
512        assert_eq!(event.slot_index, 7);
513    }
514
515    #[test]
516    fn container_slot_changed_not_cancellable() {
517        let mut event = ContainerSlotChangedEvent {
518            window_id: 1,
519            backing: ContainerBacking::Virtual,
520            slot_index: 0,
521            old: Slot::empty(),
522            new: Slot::empty(),
523        };
524        event.cancel();
525        assert!(!event.is_cancelled());
526    }
527
528    // -- Block entity events --------------------------------------------------
529
530    #[test]
531    fn block_entity_created_construction() {
532        let event = BlockEntityCreatedEvent {
533            position: BlockPosition { x: 5, y: 64, z: 3 },
534            kind: BlockEntityKind::Chest,
535        };
536        assert_eq!(event.position, BlockPosition { x: 5, y: 64, z: 3 });
537        assert_eq!(event.kind, BlockEntityKind::Chest);
538    }
539
540    #[test]
541    fn block_entity_created_not_cancellable() {
542        let mut event = BlockEntityCreatedEvent {
543            position: BlockPosition { x: 0, y: 0, z: 0 },
544            kind: BlockEntityKind::Chest,
545        };
546        event.cancel();
547        assert!(!event.is_cancelled());
548    }
549
550    #[test]
551    fn block_entity_modified_construction() {
552        let event = BlockEntityModifiedEvent {
553            position: BlockPosition {
554                x: -10,
555                y: 32,
556                z: 100,
557            },
558            kind: BlockEntityKind::Chest,
559        };
560        assert_eq!(event.kind, BlockEntityKind::Chest);
561    }
562
563    #[test]
564    fn block_entity_modified_not_cancellable() {
565        let mut event = BlockEntityModifiedEvent {
566            position: BlockPosition { x: 0, y: 0, z: 0 },
567            kind: BlockEntityKind::Chest,
568        };
569        event.cancel();
570        assert!(!event.is_cancelled());
571    }
572
573    #[test]
574    fn block_entity_destroyed_carries_last_state() {
575        let be = BlockEntity::empty_chest();
576        let event = BlockEntityDestroyedEvent {
577            position: BlockPosition {
578                x: 100,
579                y: 64,
580                z: -50,
581            },
582            kind: BlockEntityKind::Chest,
583            last_state: be,
584        };
585        match &event.last_state {
586            BlockEntity::Chest { slots } => {
587                assert_eq!(slots.len(), 27);
588            }
589        }
590    }
591
592    #[test]
593    fn block_entity_destroyed_not_cancellable() {
594        let mut event = BlockEntityDestroyedEvent {
595            position: BlockPosition { x: 0, y: 0, z: 0 },
596            kind: BlockEntityKind::Chest,
597            last_state: BlockEntity::empty_chest(),
598        };
599        event.cancel();
600        assert!(!event.is_cancelled());
601    }
602
603    // -- Bus kind routing -----------------------------------------------------
604
605    #[test]
606    fn all_events_route_to_game_bus() {
607        assert_eq!(ContainerOpenRequestEvent::BUS, BusKind::Game);
608        assert_eq!(ContainerOpenedEvent::BUS, BusKind::Game);
609        assert_eq!(ContainerClosedEvent::BUS, BusKind::Game);
610        assert_eq!(ContainerClickEvent::BUS, BusKind::Game);
611        assert_eq!(ContainerDragEvent::BUS, BusKind::Game);
612        assert_eq!(ContainerSlotChangedEvent::BUS, BusKind::Game);
613        assert_eq!(BlockEntityCreatedEvent::BUS, BusKind::Game);
614        assert_eq!(BlockEntityModifiedEvent::BUS, BusKind::Game);
615        assert_eq!(BlockEntityDestroyedEvent::BUS, BusKind::Game);
616    }
617
618    #[test]
619    fn bus_kind_method_matches_const() {
620        let events_game: Vec<Box<dyn Event>> = vec![
621            Box::new(ContainerOpenRequestEvent {
622                inventory_type: InventoryType::Generic9x3,
623                backing: ContainerBacking::Virtual,
624                title: String::new(),
625                cancelled: false,
626            }),
627            Box::new(ContainerOpenedEvent {
628                window_id: 1,
629                inventory_type: InventoryType::Generic9x3,
630                backing: ContainerBacking::Virtual,
631                viewer_count: 0,
632            }),
633            Box::new(ContainerClosedEvent {
634                window_id: 1,
635                inventory_type: InventoryType::Generic9x3,
636                backing: ContainerBacking::Virtual,
637                reason: CloseReason::Manual,
638                viewer_count: 0,
639                crafting_grid_state: None,
640            }),
641            Box::new(ContainerClickEvent {
642                window_id: 1,
643                backing: ContainerBacking::Virtual,
644                slot_index: 0,
645                window_slot_kind: WindowSlotKind::Container,
646                click_type: ContainerClickType::LeftClick,
647                cursor_before: Slot::empty(),
648                cancelled: false,
649            }),
650            Box::new(ContainerDragEvent {
651                window_id: 1,
652                backing: ContainerBacking::Virtual,
653                affected_slots: vec![],
654                drag_type: DragType::LeftDrag,
655                cursor: Slot::empty(),
656                cancelled: false,
657            }),
658            Box::new(ContainerSlotChangedEvent {
659                window_id: 1,
660                backing: ContainerBacking::Virtual,
661                slot_index: 0,
662                old: Slot::empty(),
663                new: Slot::empty(),
664            }),
665            Box::new(BlockEntityCreatedEvent {
666                position: BlockPosition { x: 0, y: 0, z: 0 },
667                kind: BlockEntityKind::Chest,
668            }),
669            Box::new(BlockEntityModifiedEvent {
670                position: BlockPosition { x: 0, y: 0, z: 0 },
671                kind: BlockEntityKind::Chest,
672            }),
673            Box::new(BlockEntityDestroyedEvent {
674                position: BlockPosition { x: 0, y: 0, z: 0 },
675                kind: BlockEntityKind::Chest,
676                last_state: BlockEntity::empty_chest(),
677            }),
678        ];
679        for event in &events_game {
680            assert_eq!(event.bus_kind(), BusKind::Game);
681        }
682    }
683}