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}