Skip to main content

basalt_api/events/
crafting.rs

1//! Crafting events: grid changes, recipe matching, craft execution,
2//! and recipe-registry lifecycle.
3
4use crate::context::UnlockReason;
5use crate::recipes::{Recipe, RecipeId};
6use basalt_types::Slot;
7
8/// The contents of a crafting grid have changed.
9///
10/// Fired at the **Post** stage on the **game** bus whenever a player
11/// places, removes, or rearranges an item in any crafting slot. This
12/// is a pure notification — the result of the new grid is computed
13/// separately and surfaced through [`CraftingRecipeMatchedEvent`] /
14/// [`CraftingRecipeClearedEvent`].
15///
16/// The crafting player is available via `ctx.player()`.
17#[derive(Debug, Clone)]
18pub struct CraftingGridChangedEvent {
19    /// Item IDs in the 9 grid slots (`None` for empty slots).
20    /// For a 2x2 grid, only indices 0-3 are populated.
21    pub grid: [Option<i32>; 9],
22    /// Grid dimension: 2 for inventory crafting, 3 for crafting table.
23    pub grid_size: u8,
24}
25crate::game_event!(CraftingGridChangedEvent);
26
27/// A recipe was matched against the current crafting grid contents.
28///
29/// Fired at **Process + Post** stages on the **game** bus after the
30/// server has resolved a matching recipe for the grid. Plugins can
31/// **mutate `result`** at the Process stage (priority-ordered) to:
32/// - augment the result (bonus count, custom NBT, applied enchantments)
33/// - **deny the craft** by setting `result` to [`Slot::empty()`] —
34///   the player will see no result appear in slot 0
35///
36/// After dispatch the server reads back `event.result` and writes it
37/// to the player's `CraftingGrid.output`, then syncs slot 0 to the
38/// client. Post listeners observe the final (post-mutation) result.
39#[derive(Debug, Clone)]
40pub struct CraftingRecipeMatchedEvent {
41    /// Item IDs in the 9 grid slots that produced the match
42    /// (`None` for empty slots).
43    pub grid: [Option<i32>; 9],
44    /// Grid dimension: 2 for inventory crafting, 3 for crafting table.
45    pub grid_size: u8,
46    /// The crafting result. **Mutable at Process** — plugins layer
47    /// modifications by handler priority. Setting this to
48    /// [`Slot::empty()`] hides the result from the player.
49    pub result: Slot,
50}
51crate::game_event!(CraftingRecipeMatchedEvent);
52
53/// The current crafting grid no longer matches any recipe.
54///
55/// Fired at the **Post** stage on the **game** bus only on the
56/// transition `matched → unmatched` (i.e. the previous tick had a
57/// non-empty result, this tick has none). Useful for plugins that
58/// want to react when a result disappears (UI hints, achievements
59/// for "almost crafted X").
60#[derive(Debug, Clone)]
61pub struct CraftingRecipeClearedEvent {
62    /// Grid dimension: 2 for inventory crafting, 3 for crafting table.
63    pub grid_size: u8,
64}
65crate::game_event!(CraftingRecipeClearedEvent);
66
67/// A player is about to take a crafting result (cancellable).
68///
69/// Fired at the **Validate** stage on the **game** bus when a player
70/// clicks the crafting output slot — both for normal clicks and the
71/// initial click of a shift-click batch. Cancelling the event aborts
72/// the craft entirely (no consumption, no result transfer).
73///
74/// For shift-click batches, [`CraftingShiftClickBatchEvent`] fires
75/// immediately after this event (if not cancelled here) to allow
76/// plugins to cap the batch size.
77///
78/// The crafting player is available via `ctx.player()`.
79#[derive(Debug, Clone)]
80pub struct CraftingPreCraftEvent {
81    /// The result the player is about to receive.
82    pub result: Slot,
83    /// Whether the player shift-clicked (batch craft).
84    pub is_shift_click: bool,
85    /// Whether this event has been cancelled by a Validate handler.
86    pub cancelled: bool,
87}
88crate::game_cancellable_event!(CraftingPreCraftEvent);
89
90/// A successful craft has been performed.
91///
92/// Fired at the **Post** stage on the **game** bus exactly **once
93/// per crafted unit**. For a normal click, fires once. For a
94/// shift-click batch, fires N times (one per loop iteration). The
95/// canonical hook for stats / achievements / logging.
96#[derive(Debug, Clone)]
97pub struct CraftingCraftedEvent {
98    /// Snapshot of the grid contents **before** ingredient
99    /// consumption for this craft. Index 0..9 corresponds to grid
100    /// slot indices.
101    pub consumed: [Slot; 9],
102    /// The result that was delivered to the player.
103    pub produced: Slot,
104}
105crate::game_event!(CraftingCraftedEvent);
106
107/// A shift-click batch craft is about to begin (cancellable).
108///
109/// Fired at the **Validate** stage on the **game** bus immediately
110/// after [`CraftingPreCraftEvent`] when the player shift-clicks the
111/// crafting output. Plugins can cancel the entire batch, or **lower
112/// `max_count`** to cap the number of iterations (e.g. anti-grief
113/// limit "max 16 crafted per shift-click"). Increasing `max_count`
114/// has no effect — the natural inventory-space cap still applies.
115#[derive(Debug, Clone)]
116pub struct CraftingShiftClickBatchEvent {
117    /// The result the player will receive on each iteration.
118    pub result: Slot,
119    /// Maximum number of crafts to perform. **Mutable at Validate**
120    /// — plugins lower this to cap the batch. Initial value is
121    /// `u32::MAX` (the loop is naturally capped by available
122    /// inventory space).
123    pub max_count: u32,
124    /// Whether this event has been cancelled by a Validate handler.
125    pub cancelled: bool,
126}
127crate::game_cancellable_event!(CraftingShiftClickBatchEvent);
128
129/// A plugin is about to register a recipe (cancellable).
130///
131/// Fired at the **Validate** stage on the **game** bus when a plugin
132/// calls `RecipeRegistrar::add_shaped` / `add_shapeless` from inside
133/// `Plugin::on_enable`. Cancellation aborts the registration —
134/// [`RecipeRegisteredEvent`] is **not** fired and the registry is
135/// left untouched.
136///
137/// Useful for permission gating ("only `recipe-admin` may register
138/// recipes") and compatibility checks ("a recipe with this id
139/// already exists, refuse").
140///
141/// Fires during plugin loading, **before** any player exists. The
142/// dispatch context (`ctx.player()`) returns sentinel data — handlers
143/// must rely on the event payload, not the context.
144#[derive(Debug, Clone)]
145pub struct RecipeRegisterEvent {
146    /// The recipe being registered.
147    pub recipe: Recipe,
148    /// Whether this event has been cancelled by a Validate handler.
149    pub cancelled: bool,
150}
151crate::game_cancellable_event!(RecipeRegisterEvent);
152
153/// A recipe has been registered with the runtime registry.
154///
155/// Fired at the **Post** stage on the **game** bus after a successful
156/// (i.e. non-cancelled) call to `RecipeRegistrar::add_shaped` or
157/// `add_shapeless`. Useful for plugins that index recipes (recipe
158/// book UI, search, dependency tracking, analytics).
159///
160/// Fires during plugin loading; see [`RecipeRegisterEvent`] for the
161/// context contract.
162#[derive(Debug, Clone)]
163pub struct RecipeRegisteredEvent {
164    /// Stable identifier of the newly registered recipe.
165    pub recipe_id: RecipeId,
166}
167crate::game_event!(RecipeRegisteredEvent);
168
169/// A recipe has been removed from the runtime registry.
170///
171/// Fired at the **Post** stage on the **game** bus once per removed
172/// recipe — including each entry removed by a single
173/// `remove_by_result` call or by `clear`. Useful for plugins that
174/// maintain a derived index of the registry.
175///
176/// Fires during plugin loading; see [`RecipeRegisterEvent`] for the
177/// context contract.
178#[derive(Debug, Clone)]
179pub struct RecipeUnregisteredEvent {
180    /// Stable identifier of the recipe that was removed.
181    pub recipe_id: RecipeId,
182}
183crate::game_event!(RecipeUnregisteredEvent);
184
185/// A recipe has been unlocked for the current player.
186///
187/// Fired at the **Post** stage on the **game** bus after the player's
188/// `KnownRecipes` component records the recipe and the
189/// `Recipe Book Add` packet has been queued. The crafting player is
190/// available via `ctx.player()`.
191#[derive(Debug, Clone)]
192pub struct RecipeUnlockedEvent {
193    /// Stable identifier of the unlocked recipe.
194    pub recipe_id: RecipeId,
195    /// Why the unlock happened — auto-discovery, manual grant, or
196    /// initial-join starter set.
197    pub reason: UnlockReason,
198}
199crate::game_event!(RecipeUnlockedEvent);
200
201/// A recipe has been locked for the current player.
202///
203/// Fired at the **Post** stage on the **game** bus after the player's
204/// `KnownRecipes` component drops the recipe and the
205/// `Recipe Book Remove` packet has been queued. The crafting player
206/// is available via `ctx.player()`.
207#[derive(Debug, Clone)]
208pub struct RecipeLockedEvent {
209    /// Stable identifier of the locked recipe.
210    pub recipe_id: RecipeId,
211}
212crate::game_event!(RecipeLockedEvent);
213
214/// A player is about to auto-fill a recipe from the recipe book
215/// (cancellable).
216///
217/// Fired at the **Validate** stage on the **game** bus after the
218/// server has resolved the recipe and pre-checked that the player's
219/// inventory has enough ingredients, but **before** any item is
220/// moved. Cancelling the event aborts the auto-fill — the grid stays
221/// untouched and `RecipeBookFilledEvent` is not dispatched. Plugins
222/// use it for permission gating (admin-only recipes, anti-grief
223/// rate-limits, gated unlocks).
224///
225/// The crafting player is available via `ctx.player()`.
226#[derive(Debug, Clone)]
227pub struct RecipeBookFillRequestEvent {
228    /// Stable identifier of the recipe the player clicked.
229    pub recipe_id: RecipeId,
230    /// Whether the player shift-clicked (asking for the largest
231    /// possible batch). The Phase 2 server implementation always
232    /// behaves as if `false` — `true` is plumbed through but
233    /// silently degrades for now (Phase 3 will add real stacking).
234    pub make_all: bool,
235    /// Whether this event has been cancelled by a Validate handler.
236    pub cancelled: bool,
237}
238crate::game_cancellable_event!(RecipeBookFillRequestEvent);
239
240/// A player auto-filled a recipe from the recipe book.
241///
242/// Fired at the **Post** stage on the **game** bus after the
243/// inventory has been drained and the grid populated. The standard
244/// match cycle (`CraftingRecipeMatchedEvent` etc.) has already run
245/// at this point, so `ctx.player()`'s grid reflects the new state.
246#[derive(Debug, Clone)]
247pub struct RecipeBookFilledEvent {
248    /// Stable identifier of the filled recipe.
249    pub recipe_id: RecipeId,
250    /// Whether the original request was a shift-click.
251    pub make_all: bool,
252}
253crate::game_event!(RecipeBookFilledEvent);
254
255#[cfg(test)]
256mod tests {
257    use crate::events::{BusKind, Event, EventRouting};
258    use crate::recipes::OwnedShapedRecipe;
259
260    use super::*;
261
262    fn empty_grid() -> [Option<i32>; 9] {
263        [None; 9]
264    }
265
266    fn empty_slots() -> [Slot; 9] {
267        std::array::from_fn(|_| Slot::empty())
268    }
269
270    #[test]
271    fn grid_changed_not_cancellable() {
272        let mut event = CraftingGridChangedEvent {
273            grid: empty_grid(),
274            grid_size: 3,
275        };
276        event.cancel();
277        assert!(!event.is_cancelled());
278        assert_eq!(CraftingGridChangedEvent::BUS, BusKind::Game);
279    }
280
281    #[test]
282    fn recipe_matched_carries_mutable_result() {
283        let mut event = CraftingRecipeMatchedEvent {
284            grid: empty_grid(),
285            grid_size: 3,
286            result: Slot::new(1, 4),
287        };
288        event.result = Slot::empty();
289        assert!(event.result.item_id.is_none());
290        // not cancellable
291        event.cancel();
292        assert!(!event.is_cancelled());
293        assert_eq!(CraftingRecipeMatchedEvent::BUS, BusKind::Game);
294    }
295
296    #[test]
297    fn recipe_cleared_not_cancellable() {
298        let mut event = CraftingRecipeClearedEvent { grid_size: 2 };
299        event.cancel();
300        assert!(!event.is_cancelled());
301        assert_eq!(CraftingRecipeClearedEvent::BUS, BusKind::Game);
302    }
303
304    #[test]
305    fn pre_craft_cancellation() {
306        let mut event = CraftingPreCraftEvent {
307            result: Slot::new(280, 4),
308            is_shift_click: false,
309            cancelled: false,
310        };
311        assert!(!event.is_cancelled());
312        event.cancel();
313        assert!(event.is_cancelled());
314        assert_eq!(CraftingPreCraftEvent::BUS, BusKind::Game);
315    }
316
317    #[test]
318    fn crafted_carries_consumed_and_produced() {
319        let mut consumed = empty_slots();
320        consumed[0] = Slot::new(17, 1);
321        let event = CraftingCraftedEvent {
322            consumed,
323            produced: Slot::new(280, 4),
324        };
325        assert_eq!(event.consumed[0].item_id, Some(17));
326        assert_eq!(event.produced.item_count, 4);
327        assert_eq!(CraftingCraftedEvent::BUS, BusKind::Game);
328    }
329
330    #[test]
331    fn shift_click_batch_cap_and_cancel() {
332        let mut event = CraftingShiftClickBatchEvent {
333            result: Slot::new(280, 4),
334            max_count: u32::MAX,
335            cancelled: false,
336        };
337        event.max_count = 2;
338        assert_eq!(event.max_count, 2);
339        event.cancel();
340        assert!(event.is_cancelled());
341        assert_eq!(CraftingShiftClickBatchEvent::BUS, BusKind::Game);
342    }
343
344    fn sample_recipe(path: &str) -> Recipe {
345        Recipe::Shaped(OwnedShapedRecipe {
346            id: RecipeId::new("plugin", path),
347            width: 1,
348            height: 1,
349            pattern: vec![Some(1)],
350            result_id: 42,
351            result_count: 1,
352        })
353    }
354
355    #[test]
356    fn recipe_register_cancellation() {
357        let mut event = RecipeRegisterEvent {
358            recipe: sample_recipe("magic_sword"),
359            cancelled: false,
360        };
361        assert!(!event.is_cancelled());
362        event.cancel();
363        assert!(event.is_cancelled());
364        assert_eq!(RecipeRegisterEvent::BUS, BusKind::Game);
365    }
366
367    #[test]
368    fn recipe_registered_carries_id() {
369        let mut event = RecipeRegisteredEvent {
370            recipe_id: RecipeId::vanilla("crafting_table"),
371        };
372        // not cancellable
373        event.cancel();
374        assert!(!event.is_cancelled());
375        assert_eq!(event.recipe_id.namespace, "minecraft");
376        assert_eq!(RecipeRegisteredEvent::BUS, BusKind::Game);
377    }
378
379    #[test]
380    fn recipe_unregistered_carries_id() {
381        let mut event = RecipeUnregisteredEvent {
382            recipe_id: RecipeId::new("plugin", "obsolete"),
383        };
384        event.cancel();
385        assert!(!event.is_cancelled());
386        assert_eq!(event.recipe_id.path, "obsolete");
387        assert_eq!(RecipeUnregisteredEvent::BUS, BusKind::Game);
388    }
389
390    #[test]
391    fn recipe_unlocked_carries_id_and_reason() {
392        let mut event = RecipeUnlockedEvent {
393            recipe_id: RecipeId::vanilla("oak_planks"),
394            reason: UnlockReason::AutoDiscovered,
395        };
396        // not cancellable
397        event.cancel();
398        assert!(!event.is_cancelled());
399        assert_eq!(event.reason, UnlockReason::AutoDiscovered);
400        assert_eq!(RecipeUnlockedEvent::BUS, BusKind::Game);
401    }
402
403    #[test]
404    fn recipe_locked_carries_id() {
405        let mut event = RecipeLockedEvent {
406            recipe_id: RecipeId::new("plugin", "expired"),
407        };
408        event.cancel();
409        assert!(!event.is_cancelled());
410        assert_eq!(event.recipe_id.path, "expired");
411        assert_eq!(RecipeLockedEvent::BUS, BusKind::Game);
412    }
413
414    #[test]
415    fn fill_request_cancellation() {
416        let mut event = RecipeBookFillRequestEvent {
417            recipe_id: RecipeId::vanilla("oak_planks"),
418            make_all: false,
419            cancelled: false,
420        };
421        assert!(!event.is_cancelled());
422        event.cancel();
423        assert!(event.is_cancelled());
424        assert_eq!(RecipeBookFillRequestEvent::BUS, BusKind::Game);
425    }
426
427    #[test]
428    fn fill_request_carries_make_all() {
429        let event = RecipeBookFillRequestEvent {
430            recipe_id: RecipeId::vanilla("oak_planks"),
431            make_all: true,
432            cancelled: false,
433        };
434        assert!(event.make_all);
435    }
436
437    #[test]
438    fn filled_carries_id_and_make_all() {
439        let mut event = RecipeBookFilledEvent {
440            recipe_id: RecipeId::vanilla("crafting_table"),
441            make_all: false,
442        };
443        // not cancellable
444        event.cancel();
445        assert!(!event.is_cancelled());
446        assert_eq!(RecipeBookFilledEvent::BUS, BusKind::Game);
447    }
448}