Skip to main content

basalt_api/recipes/
mod.rs

1//! Plugin-facing wrapper around the recipe registry that dispatches
2//! registry-lifecycle events.
3//!
4//! Plugins receive a [`RecipeRegistrar`] from
5//! [`PluginRegistrar::recipes`](crate::PluginRegistrar::recipes) inside
6//! `Plugin::on_enable`. Every mutation goes through the wrapper so the
7//! 3 lifecycle events fire on the game bus:
8//!
9//! - [`RecipeRegisterEvent`](crate::events::RecipeRegisterEvent)
10//!   (Validate, cancellable) — fires before each insert.
11//! - [`RecipeRegisteredEvent`](crate::events::RecipeRegisteredEvent)
12//!   (Post) — fires after a successful insert.
13//! - [`RecipeUnregisteredEvent`](crate::events::RecipeUnregisteredEvent)
14//!   (Post) — fires after each removal.
15//!
16//! The wrapper does **not** expose the underlying registry's vanilla
17//! data through events: `RecipeRegistry::with_vanilla` runs before any
18//! handler is registered, so retroactively dispatching 1557 events
19//! would only spam handlers without serving a use case.
20
21pub mod handle;
22pub mod id;
23pub mod types;
24
25pub use handle::RecipeRegistryHandle;
26pub use id::RecipeId;
27pub use types::{OwnedShapedRecipe, OwnedShapelessRecipe, Recipe};
28
29use crate::events::{Event, EventBus};
30use crate::events::{RecipeRegisterEvent, RecipeRegisteredEvent, RecipeUnregisteredEvent};
31
32/// Plugin-facing handle to the recipe registry with event dispatch.
33///
34/// Holds mutable references to a [`RecipeRegistryHandle`] trait object
35/// and the game event bus, plus a shared reference to a dispatch
36/// context. Every mutation method dispatches the appropriate lifecycle
37/// event and respects Validate-stage cancellation.
38pub struct RecipeRegistrar<'a> {
39    registry: &'a mut dyn RecipeRegistryHandle,
40    bus: &'a mut EventBus,
41    ctx: &'a dyn crate::context::Context,
42}
43
44impl<'a> RecipeRegistrar<'a> {
45    /// Constructs a new registrar wrapper.
46    ///
47    /// Internal — called by [`PluginRegistrar::recipes`](crate::PluginRegistrar::recipes).
48    pub(crate) fn new(
49        registry: &'a mut dyn RecipeRegistryHandle,
50        bus: &'a mut EventBus,
51        ctx: &'a dyn crate::context::Context,
52    ) -> Self {
53        Self { registry, bus, ctx }
54    }
55
56    /// Registers a shaped recipe.
57    ///
58    /// Dispatches [`RecipeRegisterEvent`] at Validate. If a handler
59    /// cancels the event, the registry is left untouched and this
60    /// method returns `false`. Otherwise the recipe is inserted and
61    /// [`RecipeRegisteredEvent`] is dispatched at Post; returns `true`.
62    pub fn add_shaped(&mut self, recipe: OwnedShapedRecipe) -> bool {
63        let id = recipe.id.clone();
64        let mut event = RecipeRegisterEvent {
65            recipe: Recipe::Shaped(recipe),
66            cancelled: false,
67        };
68        self.bus.dispatch(&mut event, self.ctx);
69        if event.is_cancelled() {
70            return false;
71        }
72        match event.recipe {
73            Recipe::Shaped(r) => self.registry.add_shaped(r),
74            Recipe::Shapeless(_) => {
75                // Handlers must not change the recipe variant. Defensive
76                // fallthrough preserves invariants without panicking.
77                return false;
78            }
79        }
80        let mut post = RecipeRegisteredEvent { recipe_id: id };
81        self.bus.dispatch(&mut post, self.ctx);
82        true
83    }
84
85    /// Registers a shapeless recipe.
86    ///
87    /// Same dispatch semantics as [`add_shaped`](Self::add_shaped).
88    /// The caller is responsible for sorting `recipe.ingredients`
89    /// ascending — required for correct matching.
90    pub fn add_shapeless(&mut self, recipe: OwnedShapelessRecipe) -> bool {
91        let id = recipe.id.clone();
92        let mut event = RecipeRegisterEvent {
93            recipe: Recipe::Shapeless(recipe),
94            cancelled: false,
95        };
96        self.bus.dispatch(&mut event, self.ctx);
97        if event.is_cancelled() {
98            return false;
99        }
100        match event.recipe {
101            Recipe::Shapeless(r) => self.registry.add_shapeless(r),
102            Recipe::Shaped(_) => return false,
103        }
104        let mut post = RecipeRegisteredEvent { recipe_id: id };
105        self.bus.dispatch(&mut post, self.ctx);
106        true
107    }
108
109    /// Removes the recipe with the given id, dispatching
110    /// [`RecipeUnregisteredEvent`] at Post on success.
111    ///
112    /// Returns `true` if a recipe was removed, `false` if the id was
113    /// not registered.
114    pub fn remove_by_id(&mut self, id: &RecipeId) -> bool {
115        if self.registry.remove_by_id(id).is_some() {
116            let mut event = RecipeUnregisteredEvent {
117                recipe_id: id.clone(),
118            };
119            self.bus.dispatch(&mut event, self.ctx);
120            true
121        } else {
122            false
123        }
124    }
125
126    /// Removes every recipe (shaped and shapeless) producing the given
127    /// `result_id`. Dispatches one [`RecipeUnregisteredEvent`] per
128    /// removed entry. Returns the number of recipes removed.
129    pub fn remove_by_result(&mut self, result_id: i32) -> usize {
130        let removed = self.registry.remove_by_result(result_id);
131        let count = removed.len();
132        for recipe_id in removed {
133            let mut event = RecipeUnregisteredEvent { recipe_id };
134            self.bus.dispatch(&mut event, self.ctx);
135        }
136        count
137    }
138
139    /// Removes every recipe and dispatches one
140    /// [`RecipeUnregisteredEvent`] per removed entry.
141    pub fn clear(&mut self) {
142        let removed = self.registry.clear();
143        for recipe_id in removed {
144            let mut event = RecipeUnregisteredEvent { recipe_id };
145            self.bus.dispatch(&mut event, self.ctx);
146        }
147    }
148
149    /// Returns `true` if the registry contains a recipe with the given id.
150    pub fn contains(&self, id: &RecipeId) -> bool {
151        self.registry.contains(id)
152    }
153
154    /// Returns a clone of the recipe with the given id, or `None`.
155    pub fn get(&self, id: &RecipeId) -> Option<Recipe> {
156        self.registry.find_by_id(id)
157    }
158
159    /// Returns the number of registered shaped recipes.
160    pub fn shaped_count(&self) -> usize {
161        self.registry.shaped_count()
162    }
163
164    /// Returns the number of registered shapeless recipes.
165    pub fn shapeless_count(&self) -> usize {
166        self.registry.shapeless_count()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use std::sync::Arc;
173    use std::sync::atomic::{AtomicU32, Ordering};
174
175    use crate::events::Stage;
176    use crate::testing::{MockRecipeRegistry, NoopContext};
177
178    use super::*;
179
180    fn shaped(path: &str) -> OwnedShapedRecipe {
181        OwnedShapedRecipe {
182            id: RecipeId::new("plugin", path),
183            width: 1,
184            height: 1,
185            pattern: vec![Some(1)],
186            result_id: 42,
187            result_count: 1,
188        }
189    }
190
191    fn shapeless(path: &str) -> OwnedShapelessRecipe {
192        OwnedShapelessRecipe {
193            id: RecipeId::new("plugin", path),
194            ingredients: vec![1, 2],
195            result_id: 99,
196            result_count: 1,
197        }
198    }
199
200    #[test]
201    fn add_shaped_dispatches_register_then_registered() {
202        let mut registry = MockRecipeRegistry::new();
203        let mut bus = EventBus::new();
204        let ctx = NoopContext;
205
206        let validate_seen: Arc<AtomicU32> = Arc::new(AtomicU32::new(0));
207        let post_seen = Arc::new(AtomicU32::new(0));
208
209        {
210            let v = Arc::clone(&validate_seen);
211            bus.on::<RecipeRegisterEvent>(Stage::Validate, 0, move |_, _| {
212                v.fetch_add(1, Ordering::Relaxed);
213            });
214        }
215        {
216            let p = Arc::clone(&post_seen);
217            bus.on::<RecipeRegisteredEvent>(Stage::Post, 0, move |_, _| {
218                p.fetch_add(1, Ordering::Relaxed);
219            });
220        }
221
222        let mut registrar = RecipeRegistrar::new(
223            &mut registry as &mut dyn RecipeRegistryHandle,
224            &mut bus,
225            &ctx as &dyn crate::context::Context,
226        );
227        let inserted = registrar.add_shaped(shaped("magic_sword"));
228
229        assert!(inserted);
230        assert_eq!(validate_seen.load(Ordering::Relaxed), 1);
231        assert_eq!(post_seen.load(Ordering::Relaxed), 1);
232        assert_eq!(registry.shaped_count(), 1);
233    }
234
235    #[test]
236    fn add_shaped_cancellation_skips_insert_and_post() {
237        let mut registry = MockRecipeRegistry::new();
238        let mut bus = EventBus::new();
239        let ctx = NoopContext;
240
241        bus.on::<RecipeRegisterEvent>(Stage::Validate, 0, |event, _| {
242            event.cancel();
243        });
244
245        let post_seen = Arc::new(AtomicU32::new(0));
246        {
247            let p = Arc::clone(&post_seen);
248            bus.on::<RecipeRegisteredEvent>(Stage::Post, 0, move |_, _| {
249                p.fetch_add(1, Ordering::Relaxed);
250            });
251        }
252
253        let mut registrar = RecipeRegistrar::new(
254            &mut registry as &mut dyn RecipeRegistryHandle,
255            &mut bus,
256            &ctx as &dyn crate::context::Context,
257        );
258        let inserted = registrar.add_shaped(shaped("forbidden"));
259
260        assert!(
261            !inserted,
262            "cancellation should make add_shaped return false"
263        );
264        assert_eq!(post_seen.load(Ordering::Relaxed), 0);
265        assert_eq!(registry.shaped_count(), 0);
266    }
267
268    #[test]
269    fn add_shapeless_round_trip() {
270        let mut registry = MockRecipeRegistry::new();
271        let mut bus = EventBus::new();
272        let ctx = NoopContext;
273
274        let mut registrar = RecipeRegistrar::new(
275            &mut registry as &mut dyn RecipeRegistryHandle,
276            &mut bus,
277            &ctx as &dyn crate::context::Context,
278        );
279        assert!(registrar.add_shapeless(shapeless("bread")));
280        assert!(registrar.contains(&RecipeId::new("plugin", "bread")));
281    }
282
283    #[test]
284    fn remove_by_id_dispatches_unregistered() {
285        let mut registry = MockRecipeRegistry::new();
286        registry.add_shaped(shaped("temp"));
287
288        let mut bus = EventBus::new();
289        let ctx = NoopContext;
290        let unreg_seen = Arc::new(AtomicU32::new(0));
291        {
292            let u = Arc::clone(&unreg_seen);
293            bus.on::<RecipeUnregisteredEvent>(Stage::Post, 0, move |_, _| {
294                u.fetch_add(1, Ordering::Relaxed);
295            });
296        }
297
298        let mut registrar = RecipeRegistrar::new(
299            &mut registry as &mut dyn RecipeRegistryHandle,
300            &mut bus,
301            &ctx as &dyn crate::context::Context,
302        );
303        let id = RecipeId::new("plugin", "temp");
304        assert!(registrar.remove_by_id(&id));
305        assert_eq!(unreg_seen.load(Ordering::Relaxed), 1);
306        assert!(!registry.contains(&id));
307    }
308
309    #[test]
310    fn remove_by_id_missing_does_not_dispatch() {
311        let mut registry = MockRecipeRegistry::new();
312        let mut bus = EventBus::new();
313        let ctx = NoopContext;
314        let unreg_seen = Arc::new(AtomicU32::new(0));
315        {
316            let u = Arc::clone(&unreg_seen);
317            bus.on::<RecipeUnregisteredEvent>(Stage::Post, 0, move |_, _| {
318                u.fetch_add(1, Ordering::Relaxed);
319            });
320        }
321
322        let mut registrar = RecipeRegistrar::new(
323            &mut registry as &mut dyn RecipeRegistryHandle,
324            &mut bus,
325            &ctx as &dyn crate::context::Context,
326        );
327        assert!(!registrar.remove_by_id(&RecipeId::new("plugin", "missing")));
328        assert_eq!(unreg_seen.load(Ordering::Relaxed), 0);
329    }
330
331    #[test]
332    fn remove_by_result_dispatches_per_removed() {
333        let mut registry = MockRecipeRegistry::new();
334        registry.add_shaped(shaped("a"));
335        registry.add_shaped(shaped("b"));
336        // Both produce result_id 42 (shaped helper).
337
338        let mut bus = EventBus::new();
339        let ctx = NoopContext;
340        let unreg_seen = Arc::new(AtomicU32::new(0));
341        {
342            let u = Arc::clone(&unreg_seen);
343            bus.on::<RecipeUnregisteredEvent>(Stage::Post, 0, move |_, _| {
344                u.fetch_add(1, Ordering::Relaxed);
345            });
346        }
347
348        let mut registrar = RecipeRegistrar::new(
349            &mut registry as &mut dyn RecipeRegistryHandle,
350            &mut bus,
351            &ctx as &dyn crate::context::Context,
352        );
353        assert_eq!(registrar.remove_by_result(42), 2);
354        assert_eq!(unreg_seen.load(Ordering::Relaxed), 2);
355    }
356
357    #[test]
358    fn clear_dispatches_per_recipe() {
359        let mut registry = MockRecipeRegistry::new();
360        registry.add_shaped(shaped("a"));
361        registry.add_shapeless(shapeless("b"));
362
363        let mut bus = EventBus::new();
364        let ctx = NoopContext;
365        let unreg_seen = Arc::new(AtomicU32::new(0));
366        {
367            let u = Arc::clone(&unreg_seen);
368            bus.on::<RecipeUnregisteredEvent>(Stage::Post, 0, move |_, _| {
369                u.fetch_add(1, Ordering::Relaxed);
370            });
371        }
372
373        let mut registrar = RecipeRegistrar::new(
374            &mut registry as &mut dyn RecipeRegistryHandle,
375            &mut bus,
376            &ctx as &dyn crate::context::Context,
377        );
378        registrar.clear();
379        assert_eq!(unreg_seen.load(Ordering::Relaxed), 2);
380        assert_eq!(registry.shaped_count(), 0);
381        assert_eq!(registry.shapeless_count(), 0);
382    }
383
384    #[test]
385    fn registrar_accessors_expose_underlying_state() {
386        let mut registry = MockRecipeRegistry::new();
387        let mut bus = EventBus::new();
388        let ctx = NoopContext;
389        let mut registrar = RecipeRegistrar::new(
390            &mut registry as &mut dyn RecipeRegistryHandle,
391            &mut bus,
392            &ctx as &dyn crate::context::Context,
393        );
394        assert_eq!(registrar.shaped_count(), 0);
395        registrar.add_shaped(shaped("only"));
396        assert_eq!(registrar.shaped_count(), 1);
397    }
398}