1use 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#[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 #[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
65pub 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
108pub 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 #[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 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
147pub 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
176pub 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 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 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 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 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 #[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 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 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 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 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}