Skip to main content

steel_registry/recipe/
registry.rs

1//! Heterogeneous recipe storage with typed per-type views.
2
3use rustc_hash::FxHashMap;
4use steel_utils::{Downcast as _, DowncastType, Identifier};
5
6use super::{
7    ErasedRecipe, Recipe, RecipeData, RecipeInput, RecipeMatches, RecipeType, RecipeTypeEntryRef,
8    RecipeTypeRegistry,
9};
10
11/// Type-erased recipe reference returned by all-recipe and key lookup APIs.
12#[derive(Clone, Copy)]
13pub struct UntypedRecipeRef {
14    recipe: &'static dyn ErasedRecipe,
15}
16
17impl UntypedRecipeRef {
18    #[must_use]
19    pub fn key(self) -> &'static Identifier {
20        self.recipe.key()
21    }
22
23    #[must_use]
24    pub fn recipe_type(self) -> RecipeTypeEntryRef {
25        self.recipe.recipe_type()
26    }
27
28    #[must_use]
29    pub fn data(self) -> &'static dyn RecipeData {
30        self.recipe.data()
31    }
32
33    /// Recovers concrete data after an untyped key or all-recipe lookup.
34    #[must_use]
35    pub fn downcast_data<D: RecipeData + DowncastType>(self) -> Option<&'static D> {
36        self.recipe.data().downcast_ref::<D>()
37    }
38
39    fn typed<D: RecipeMatches<I> + DowncastType, I: RecipeInput>(
40        self,
41        recipe_type: &'static RecipeType<D, I>,
42    ) -> Option<TypedRecipeRef<D, I>> {
43        if !std::ptr::eq(self.recipe.recipe_type(), recipe_type.entry()) {
44            return None;
45        }
46        Some(TypedRecipeRef {
47            key: self.recipe.key(),
48            data: self.recipe.data().downcast_ref::<D>()?,
49            recipe_type,
50        })
51    }
52}
53
54impl std::fmt::Debug for UntypedRecipeRef {
55    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        formatter
57            .debug_struct("UntypedRecipeRef")
58            .field("key", &self.recipe.key())
59            .field("recipe_type", &self.recipe.recipe_type().key)
60            .field("data", &self.recipe.data())
61            .finish()
62    }
63}
64
65/// Recipe reference with its concrete data and input types restored.
66pub struct TypedRecipeRef<D: RecipeMatches<I> + DowncastType, I: RecipeInput> {
67    key: &'static Identifier,
68    data: &'static D,
69    recipe_type: &'static RecipeType<D, I>,
70}
71
72impl<D: RecipeMatches<I> + DowncastType, I: RecipeInput> Copy for TypedRecipeRef<D, I> {}
73
74impl<D: RecipeMatches<I> + DowncastType, I: RecipeInput> Clone for TypedRecipeRef<D, I> {
75    fn clone(&self) -> Self {
76        *self
77    }
78}
79
80impl<D: RecipeMatches<I> + DowncastType, I: RecipeInput> TypedRecipeRef<D, I> {
81    #[must_use]
82    pub const fn key(self) -> &'static Identifier {
83        self.key
84    }
85
86    #[must_use]
87    pub const fn data(self) -> &'static D {
88        self.data
89    }
90
91    #[must_use]
92    pub const fn recipe_type(self) -> &'static RecipeType<D, I> {
93        self.recipe_type
94    }
95}
96
97impl<D: RecipeMatches<I> + DowncastType, I: RecipeInput> std::fmt::Debug for TypedRecipeRef<D, I> {
98    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        formatter
100            .debug_struct("TypedRecipeRef")
101            .field("key", &self.key)
102            .field("recipe_type", &self.recipe_type.key())
103            .field("data", &self.data)
104            .finish()
105    }
106}
107
108/// Caches the last successful recipe for one operational recipe type.
109///
110/// This mirrors Vanilla's `RecipeManager.CachedCheck`: the cached recipe is
111/// tested first, then matching falls back to the registry's deterministic scan.
112pub struct CachedRecipeCheck<D: RecipeMatches<I> + DowncastType, I: RecipeInput> {
113    recipe_type: &'static RecipeType<D, I>,
114    last_recipe: Option<TypedRecipeRef<D, I>>,
115}
116
117impl<D: RecipeMatches<I> + DowncastType, I: RecipeInput> CachedRecipeCheck<D, I> {
118    /// Creates an empty cache for one recipe type.
119    #[must_use]
120    pub const fn new(recipe_type: &'static RecipeType<D, I>) -> Self {
121        Self {
122            recipe_type,
123            last_recipe: None,
124        }
125    }
126
127    /// Finds a matching recipe, testing the last successful recipe first.
128    pub fn find_match(
129        &mut self,
130        registry: &RecipeRegistry,
131        input: &I,
132    ) -> Option<TypedRecipeRef<D, I>> {
133        if input.is_empty() {
134            return None;
135        }
136        if let Some(recipe) = self.last_recipe
137            && recipe.data.matches(input)
138        {
139            return Some(recipe);
140        }
141        let recipe = registry.find_match(self.recipe_type, input)?;
142        self.last_recipe = Some(recipe);
143        Some(recipe)
144    }
145}
146
147/// Typed recipes belonging to one operational recipe type.
148pub struct TypedRecipeSet<'a, D: RecipeMatches<I> + DowncastType, I: RecipeInput> {
149    registry: &'a RecipeRegistry,
150    recipe_type: &'static RecipeType<D, I>,
151    indices: &'a [usize],
152}
153
154impl<D: RecipeMatches<I> + DowncastType, I: RecipeInput> TypedRecipeSet<'_, D, I> {
155    pub fn iter(&self) -> impl Iterator<Item = TypedRecipeRef<D, I>> + '_ {
156        self.indices.iter().filter_map(|index| {
157            self.registry
158                .recipes
159                .get(*index)
160                .copied()
161                .and_then(|recipe| recipe.typed(self.recipe_type))
162        })
163    }
164
165    #[must_use]
166    pub const fn len(&self) -> usize {
167        self.indices.len()
168    }
169
170    #[must_use]
171    pub const fn is_empty(&self) -> bool {
172        self.indices.is_empty()
173    }
174}
175
176/// Central storage for recipes of every registered concrete type.
177pub struct RecipeRegistry {
178    recipes: Vec<UntypedRecipeRef>,
179    by_key: FxHashMap<Identifier, usize>,
180    by_type: FxHashMap<Identifier, Vec<usize>>,
181    allows_registering: bool,
182}
183
184impl RecipeRegistry {
185    #[must_use]
186    pub fn new() -> Self {
187        Self {
188            recipes: Vec::new(),
189            by_key: FxHashMap::default(),
190            by_type: FxHashMap::default(),
191            allows_registering: true,
192        }
193    }
194
195    /// Registers a concrete static recipe.
196    pub fn register<D: RecipeMatches<I> + DowncastType, I: RecipeInput>(
197        &mut self,
198        recipe: &'static Recipe<D, I>,
199    ) {
200        assert!(
201            self.allows_registering,
202            "Cannot register recipes after the registry has been frozen"
203        );
204        assert!(
205            !self.by_key.contains_key(recipe.key()),
206            "Cannot register duplicate recipe key: {}",
207            recipe.key()
208        );
209        let index = self.recipes.len();
210        let erased = UntypedRecipeRef { recipe };
211        self.recipes.push(erased);
212        self.by_key.insert(recipe.key().clone(), index);
213        self.by_type
214            .entry(recipe.recipe_type().key().clone())
215            .or_default()
216            .push(index);
217    }
218
219    /// Replaces an entry with the same persistent key before freeze.
220    pub fn replace<D: RecipeMatches<I> + DowncastType, I: RecipeInput>(
221        &mut self,
222        recipe: &'static Recipe<D, I>,
223    ) -> Option<UntypedRecipeRef> {
224        assert!(
225            self.allows_registering,
226            "Cannot replace recipes after the registry has been frozen"
227        );
228        let index = self.by_key.get(recipe.key()).copied()?;
229        let replacement = UntypedRecipeRef { recipe };
230        let previous = std::mem::replace(&mut self.recipes[index], replacement);
231        if let Some(indices) = self.by_type.get_mut(&previous.recipe_type().key) {
232            indices.retain(|stored| *stored != index);
233        }
234        self.by_type
235            .entry(recipe.recipe_type().key().clone())
236            .or_default()
237            .push(index);
238        Some(previous)
239    }
240
241    /// Freezes and sorts recipe lookup order by full identifier, matching
242    /// Vanilla's sorted resource loading before `RecipeMap` construction.
243    pub fn freeze(&mut self, recipe_types: &RecipeTypeRegistry) {
244        for recipe in &self.recipes {
245            let Some(registered_type) = recipe_types.by_key(&recipe.recipe_type().key) else {
246                panic!(
247                    "Recipe {} uses unregistered recipe type {}",
248                    recipe.key(),
249                    recipe.recipe_type().key
250                );
251            };
252            assert!(
253                std::ptr::eq(registered_type, recipe.recipe_type()),
254                "Recipe {} does not use the canonical recipe type {}",
255                recipe.key(),
256                recipe.recipe_type().key
257            );
258            assert_eq!(
259                recipe.data().downcast_type_key(),
260                registered_type.data_type_key(),
261                "Recipe {} data does not match recipe type {}",
262                recipe.key(),
263                recipe.recipe_type().key
264            );
265        }
266
267        self.recipes
268            .sort_by(|left, right| left.key().cmp(right.key()));
269        self.by_key.clear();
270        self.by_type.clear();
271        for (index, recipe) in self.recipes.iter().copied().enumerate() {
272            self.by_key.insert(recipe.key().clone(), index);
273            self.by_type
274                .entry(recipe.recipe_type().key.clone())
275                .or_default()
276                .push(index);
277        }
278        self.allows_registering = false;
279    }
280
281    #[must_use]
282    pub fn by_key(&self, key: &Identifier) -> Option<UntypedRecipeRef> {
283        self.by_key
284            .get(key)
285            .and_then(|index| self.recipes.get(*index))
286            .copied()
287    }
288
289    pub fn iter(&self) -> impl Iterator<Item = UntypedRecipeRef> + '_ {
290        self.recipes.iter().copied()
291    }
292
293    #[must_use]
294    pub fn by_type<D: RecipeMatches<I> + DowncastType, I: RecipeInput>(
295        &self,
296        recipe_type: &'static RecipeType<D, I>,
297    ) -> TypedRecipeSet<'_, D, I> {
298        TypedRecipeSet {
299            registry: self,
300            recipe_type,
301            indices: self
302                .by_type
303                .get(recipe_type.key())
304                .map_or(&[], Vec::as_slice),
305        }
306    }
307
308    /// Iterates every matching recipe in deterministic key order.
309    pub fn matching<'registry, D: RecipeMatches<I> + DowncastType, I: RecipeInput>(
310        &'registry self,
311        recipe_type: &'static RecipeType<D, I>,
312        input: &'registry I,
313    ) -> impl Iterator<Item = TypedRecipeRef<D, I>> + 'registry {
314        let indices = self
315            .by_type
316            .get(recipe_type.key())
317            .map_or(&[][..], Vec::as_slice);
318        indices.iter().filter_map(move |index| {
319            let recipe = self.recipes.get(*index).copied()?.typed(recipe_type)?;
320            (!input.is_empty() && recipe.data.matches(input)).then_some(recipe)
321        })
322    }
323
324    /// Finds the first matching recipe in deterministic registry order.
325    #[must_use]
326    pub fn find_match<D: RecipeMatches<I> + DowncastType, I: RecipeInput>(
327        &self,
328        recipe_type: &'static RecipeType<D, I>,
329        input: &I,
330    ) -> Option<TypedRecipeRef<D, I>> {
331        if input.is_empty() {
332            return None;
333        }
334        self.matching(recipe_type, input).next()
335    }
336
337    #[must_use]
338    pub const fn len(&self) -> usize {
339        self.recipes.len()
340    }
341
342    #[must_use]
343    pub const fn is_empty(&self) -> bool {
344        self.recipes.is_empty()
345    }
346}
347
348impl Default for RecipeRegistry {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use std::sync::LazyLock;
357    use std::sync::atomic::{AtomicUsize, Ordering};
358
359    use steel_utils::{DowncastType, DowncastTypeKey, Identifier};
360
361    use crate::item_stack::ItemStack;
362    use crate::recipe::{
363        CachedRecipeCheck, CraftingInput, CraftingRecipe, Ingredient, Recipe, RecipeData,
364        RecipeInput, RecipeMatches, RecipeRegistry, RecipeType, RecipeTypeRegistry,
365        vanilla_recipe_types,
366    };
367    use crate::{REGISTRY, init_vanilla_registry, vanilla_items, vanilla_recipes};
368
369    #[test]
370    fn generated_static_and_registry_lookup_share_the_same_recipe_data() {
371        init_vanilla_registry();
372
373        let direct = &*vanilla_recipes::IRON_PICKAXE;
374        let Some(registered) = REGISTRY.recipes.by_key(direct.key()) else {
375            panic!("generated iron pickaxe recipe was not registered");
376        };
377        let Some(registered_data) = registered.downcast_data::<CraftingRecipe>() else {
378            panic!("registered iron pickaxe did not retain crafting data");
379        };
380
381        assert!(std::ptr::eq(registered_data, direct.data()));
382        let CraftingRecipe::Shaped(shaped) = direct.data() else {
383            panic!("iron pickaxe must remain a shaped recipe");
384        };
385        assert_eq!((shaped.width, shaped.height), (3, 3));
386    }
387
388    #[test]
389    fn generated_recipe_result_preserves_extracted_component_patch() {
390        use crate::data_components::vanilla_components::SUSPICIOUS_STEW_EFFECTS;
391
392        init_vanilla_registry();
393        let CraftingRecipe::Shapeless(recipe) = vanilla_recipes::SUSPICIOUS_STEW_FROM_ALLIUM.data()
394        else {
395            panic!("allium suspicious stew must remain shapeless");
396        };
397
398        let result = recipe.result.create();
399        let Some(effects) = result.get(SUSPICIOUS_STEW_EFFECTS) else {
400            panic!("suspicious stew result lost its extracted effects");
401        };
402        assert_eq!(effects.effects().len(), 1);
403        assert_eq!(effects.effects()[0].duration(), 60);
404    }
405
406    #[test]
407    fn typed_matching_finds_the_generated_iron_pickaxe_recipe() {
408        init_vanilla_registry();
409        let empty = ItemStack::empty;
410        let input = CraftingInput::new(
411            3,
412            3,
413            vec![
414                ItemStack::new(&vanilla_items::IRON_INGOT),
415                ItemStack::new(&vanilla_items::IRON_INGOT),
416                ItemStack::new(&vanilla_items::IRON_INGOT),
417                empty(),
418                ItemStack::new(&vanilla_items::STICK),
419                empty(),
420                empty(),
421                ItemStack::new(&vanilla_items::STICK),
422                empty(),
423            ],
424        );
425
426        let Some(found) = REGISTRY
427            .recipes
428            .find_match(&vanilla_recipe_types::CRAFTING, &input)
429        else {
430            panic!("iron pickaxe input should match a crafting recipe");
431        };
432
433        assert_eq!(found.key(), vanilla_recipes::IRON_PICKAXE.key());
434    }
435
436    #[test]
437    fn every_extracted_recipe_is_present_in_its_operational_type_bucket() {
438        init_vanilla_registry();
439
440        assert_eq!(REGISTRY.recipe_types.len(), 7);
441        assert_eq!(REGISTRY.recipes.len(), 1_585);
442        assert_eq!(
443            REGISTRY
444                .recipes
445                .by_type(&vanilla_recipe_types::CRAFTING)
446                .len(),
447            1_120
448        );
449        assert_eq!(
450            REGISTRY
451                .recipes
452                .by_type(&vanilla_recipe_types::SMELTING)
453                .len(),
454            73
455        );
456        assert_eq!(
457            REGISTRY
458                .recipes
459                .by_type(&vanilla_recipe_types::BLASTING)
460                .len(),
461            25
462        );
463        assert_eq!(
464            REGISTRY
465                .recipes
466                .by_type(&vanilla_recipe_types::SMOKING)
467                .len(),
468            9
469        );
470        assert_eq!(
471            REGISTRY
472                .recipes
473                .by_type(&vanilla_recipe_types::CAMPFIRE_COOKING)
474                .len(),
475            9
476        );
477        assert_eq!(
478            REGISTRY
479                .recipes
480                .by_type(&vanilla_recipe_types::STONECUTTING)
481                .len(),
482            319
483        );
484        assert_eq!(
485            REGISTRY
486                .recipes
487                .by_type(&vanilla_recipe_types::SMITHING)
488                .len(),
489            30
490        );
491    }
492
493    #[derive(Debug)]
494    struct PluginData {
495        required: i32,
496    }
497
498    // SAFETY: This test-only key uniquely identifies the plugin-like recipe data.
499    unsafe impl DowncastType for PluginData {
500        const TYPE_KEY: DowncastTypeKey =
501            DowncastTypeKey::new("steel:test/recipe_data/plugin_machine");
502    }
503
504    impl RecipeData for PluginData {}
505
506    #[derive(Debug)]
507    struct PluginInput(i32);
508
509    // SAFETY: This test-only key uniquely identifies the plugin-like input snapshot.
510    unsafe impl DowncastType for PluginInput {
511        const TYPE_KEY: DowncastTypeKey =
512            DowncastTypeKey::new("steel:test/recipe_input/plugin_machine");
513    }
514
515    impl RecipeInput for PluginInput {
516        fn is_empty(&self) -> bool {
517            false
518        }
519    }
520
521    impl RecipeMatches<PluginInput> for PluginData {
522        fn matches(&self, input: &PluginInput) -> bool {
523            self.required == input.0
524        }
525    }
526
527    static PLUGIN_TYPE: RecipeType<PluginData, PluginInput> =
528        RecipeType::new(Identifier::new_static("test_plugin", "pulverizing"));
529    static PLUGIN_RECIPE: LazyLock<Recipe<PluginData, PluginInput>> = LazyLock::new(|| {
530        Recipe::new(
531            Identifier::new_static("test_plugin", "pulverize_ore"),
532            &PLUGIN_TYPE,
533            PluginData { required: 7 },
534        )
535    });
536
537    #[test]
538    fn plugin_type_keeps_custom_data_typed_through_matching_and_key_lookup() {
539        let mut types = RecipeTypeRegistry::new();
540        types.register(&PLUGIN_TYPE);
541        types.freeze();
542        let mut recipes = RecipeRegistry::new();
543        recipes.register(&PLUGIN_RECIPE);
544        recipes.freeze(&types);
545
546        let Some(found) = recipes.find_match(&PLUGIN_TYPE, &PluginInput(7)) else {
547            panic!("plugin recipe should match its custom input");
548        };
549        assert_eq!(found.data().required, 7);
550
551        let Some(untyped) = recipes.by_key(PLUGIN_RECIPE.key()) else {
552            panic!("plugin recipe should be available by persistent key");
553        };
554        assert_eq!(
555            untyped
556                .downcast_data::<PluginData>()
557                .map(|data| data.required),
558            Some(7)
559        );
560    }
561
562    static CACHE_MATCH_CALLS: AtomicUsize = AtomicUsize::new(0);
563
564    #[derive(Debug)]
565    struct CacheData {
566        required: i32,
567    }
568
569    // SAFETY: This test-only key uniquely identifies cached recipe data.
570    unsafe impl DowncastType for CacheData {
571        const TYPE_KEY: DowncastTypeKey =
572            DowncastTypeKey::new("steel:test/recipe_data/cached_check");
573    }
574
575    impl RecipeData for CacheData {}
576
577    #[derive(Debug)]
578    struct CacheInput(i32);
579
580    // SAFETY: This test-only key uniquely identifies cached recipe input.
581    unsafe impl DowncastType for CacheInput {
582        const TYPE_KEY: DowncastTypeKey =
583            DowncastTypeKey::new("steel:test/recipe_input/cached_check");
584    }
585
586    impl RecipeInput for CacheInput {
587        fn is_empty(&self) -> bool {
588            false
589        }
590    }
591
592    impl RecipeMatches<CacheInput> for CacheData {
593        fn matches(&self, input: &CacheInput) -> bool {
594            CACHE_MATCH_CALLS.fetch_add(1, Ordering::Relaxed);
595            self.required == input.0
596        }
597    }
598
599    static CACHE_TYPE: RecipeType<CacheData, CacheInput> =
600        RecipeType::new(Identifier::new_static("test_plugin", "cached_machine"));
601    static CACHE_NON_MATCH: LazyLock<Recipe<CacheData, CacheInput>> = LazyLock::new(|| {
602        Recipe::new(
603            Identifier::new_static("test_plugin", "a_non_match"),
604            &CACHE_TYPE,
605            CacheData { required: 3 },
606        )
607    });
608    static CACHE_MATCH: LazyLock<Recipe<CacheData, CacheInput>> = LazyLock::new(|| {
609        Recipe::new(
610            Identifier::new_static("test_plugin", "b_match"),
611            &CACHE_TYPE,
612            CacheData { required: 7 },
613        )
614    });
615
616    #[test]
617    fn cached_check_tests_the_last_successful_recipe_before_scanning() {
618        let mut types = RecipeTypeRegistry::new();
619        types.register(&CACHE_TYPE);
620        types.freeze();
621        let mut recipes = RecipeRegistry::new();
622        recipes.register(&CACHE_NON_MATCH);
623        recipes.register(&CACHE_MATCH);
624        recipes.freeze(&types);
625        let mut cache = CachedRecipeCheck::new(&CACHE_TYPE);
626        CACHE_MATCH_CALLS.store(0, Ordering::Relaxed);
627
628        assert_eq!(
629            cache
630                .find_match(&recipes, &CacheInput(7))
631                .map(super::TypedRecipeRef::key),
632            Some(CACHE_MATCH.key())
633        );
634        assert_eq!(CACHE_MATCH_CALLS.load(Ordering::Relaxed), 2);
635
636        assert_eq!(
637            cache
638                .find_match(&recipes, &CacheInput(7))
639                .map(super::TypedRecipeRef::key),
640            Some(CACHE_MATCH.key())
641        );
642        assert_eq!(CACHE_MATCH_CALLS.load(Ordering::Relaxed), 3);
643
644        assert!(cache.find_match(&recipes, &CacheInput(9)).is_none());
645        assert_eq!(CACHE_MATCH_CALLS.load(Ordering::Relaxed), 6);
646        assert_eq!(
647            cache
648                .find_match(&recipes, &CacheInput(7))
649                .map(super::TypedRecipeRef::key),
650            Some(CACHE_MATCH.key())
651        );
652        assert_eq!(CACHE_MATCH_CALLS.load(Ordering::Relaxed), 7);
653    }
654
655    #[test]
656    fn shapeless_matching_backtracks_when_ingredients_overlap() {
657        init_vanilla_registry();
658        let choice = Ingredient::Choice(Box::leak(
659            vec![&*vanilla_items::RED_DYE, &*vanilla_items::BLUE_DYE].into_boxed_slice(),
660        ));
661        let exact_red = Ingredient::Item(&vanilla_items::RED_DYE);
662        let input = CraftingInput::new(
663            2,
664            1,
665            vec![
666                ItemStack::new(&vanilla_items::RED_DYE),
667                ItemStack::new(&vanilla_items::BLUE_DYE),
668            ],
669        );
670        let recipe = CraftingRecipe::Shapeless(crate::recipe::ShapelessRecipe::new(
671            crate::recipe::RecipeProperties::special(),
672            vec![choice, exact_red].into_boxed_slice(),
673            crate::item_stack_template::ItemStackTemplate::new(&vanilla_items::PURPLE_DYE),
674        ));
675
676        assert!(recipe.matches(&input));
677    }
678}