Skip to main content

steel_registry/recipe/crafting/
matching.rs

1//! Vanilla crafting recipe matching implementations.
2
3use crate::data_components::vanilla_components::{
4    BANNER_PATTERNS, DAMAGE, DYE, MAX_DAMAGE, WRITTEN_BOOK_CONTENT,
5};
6use crate::item_stack::ItemStack;
7
8use super::{
9    BannerDuplicateRecipe, BookCloningRecipe, CraftingInput, CraftingRecipe, DecoratedPotRecipe,
10    DyeRecipe, FireworkRocketRecipe, FireworkStarFadeRecipe, FireworkStarRecipe, ImbueRecipe,
11    Ingredient, MapExtendingRecipe, RepairItemRecipe, ShapedRecipe, ShapelessRecipe,
12    ShieldDecorationRecipe, TransmuteRecipe,
13};
14
15const CRAFTING_TABLE_GRID_SIZE: usize = 3;
16const CRAFTING_TABLE_SLOT_COUNT: usize = CRAFTING_TABLE_GRID_SIZE * CRAFTING_TABLE_GRID_SIZE;
17const CRAFTING_TABLE_CENTER: usize = CRAFTING_TABLE_GRID_SIZE / 2;
18const CRAFTING_TABLE_LAST_INDEX: usize = CRAFTING_TABLE_GRID_SIZE - 1;
19const CRAFTING_TABLE_CENTER_SLOT: usize = CRAFTING_TABLE_SLOT_COUNT / 2;
20const DECORATED_POT_INGREDIENT_COUNT: usize = 4;
21const MAX_BANNER_PATTERNS: usize = 6;
22const MIN_FIREWORK_ROCKET_FUEL_COUNT: usize = 1;
23const MAX_FIREWORK_ROCKET_FUEL_COUNT: usize = 3;
24const MAX_MAP_SCALE: u8 = 4;
25
26pub(crate) fn matches(recipe: &CraftingRecipe, input: &CraftingInput) -> bool {
27    match recipe {
28        CraftingRecipe::Shaped(recipe) => shaped(recipe, input),
29        CraftingRecipe::Shapeless(recipe) => shapeless(recipe, input),
30        CraftingRecipe::Transmute(recipe) => transmute(recipe, input),
31        CraftingRecipe::Dye(recipe) => dye(recipe, input),
32        CraftingRecipe::DecoratedPot(recipe) => decorated_pot(recipe, input),
33        CraftingRecipe::Imbue(recipe) => imbue(recipe, input),
34        CraftingRecipe::BannerDuplicate(recipe) => banner_duplicate(recipe, input),
35        CraftingRecipe::BookCloning(recipe) => book_cloning(recipe, input),
36        CraftingRecipe::FireworkRocket(recipe) => firework_rocket(recipe, input),
37        CraftingRecipe::FireworkStar(recipe) => firework_star(recipe, input),
38        CraftingRecipe::FireworkStarFade(recipe) => firework_star_fade(recipe, input),
39        CraftingRecipe::MapExtending(recipe) => map_extending(recipe, input),
40        CraftingRecipe::RepairItem(recipe) => repair_item(recipe, input),
41        CraftingRecipe::ShieldDecoration(recipe) => shield_decoration(recipe, input),
42    }
43}
44
45fn occupied_stacks(input: &CraftingInput) -> impl Iterator<Item = &ItemStack> {
46    input.items.iter().filter(|stack| !stack.is_empty())
47}
48
49fn shaped(recipe: &ShapedRecipe, input: &CraftingInput) -> bool {
50    if input.ingredient_count()
51        != recipe
52            .pattern
53            .iter()
54            .filter(|ingredient| !ingredient.is_empty())
55            .count()
56        || input.width != recipe.width
57        || input.height != recipe.height
58    {
59        return false;
60    }
61    matches_shaped_orientation(recipe, input, false)
62        || (!recipe.symmetrical && matches_shaped_orientation(recipe, input, true))
63}
64
65fn matches_shaped_orientation(
66    recipe: &ShapedRecipe,
67    input: &CraftingInput,
68    mirrored: bool,
69) -> bool {
70    for y in 0..recipe.height {
71        for x in 0..recipe.width {
72            let pattern_x = if mirrored { recipe.width - 1 - x } else { x };
73            if !recipe.pattern[y * recipe.width + pattern_x].test(input.get(x, y)) {
74                return false;
75            }
76        }
77    }
78    true
79}
80
81fn shapeless(recipe: &ShapelessRecipe, input: &CraftingInput) -> bool {
82    if input.ingredient_count() != recipe.ingredients.len() {
83        return false;
84    }
85    let items: Vec<_> = occupied_stacks(input).collect();
86    let mut used = vec![false; items.len()];
87    match_shapeless_ingredients(&recipe.ingredients, &items, &mut used)
88}
89
90fn match_shapeless_ingredients(
91    ingredients: &[Ingredient],
92    items: &[&ItemStack],
93    used: &mut [bool],
94) -> bool {
95    let Some((ingredient, remaining_ingredients)) = ingredients.split_first() else {
96        return true;
97    };
98    for (item_index, item) in items.iter().enumerate() {
99        if used[item_index] || !ingredient.test(item) {
100            continue;
101        }
102        used[item_index] = true;
103        if match_shapeless_ingredients(remaining_ingredients, items, used) {
104            return true;
105        }
106        used[item_index] = false;
107    }
108    false
109}
110
111fn transmute(recipe: &TransmuteRecipe, input: &CraftingInput) -> bool {
112    if input.ingredient_count() < recipe.min_material_count + 1
113        || input.ingredient_count() > recipe.max_material_count + 1
114    {
115        return false;
116    }
117    let mut found_input = None;
118    let mut material_count = 0;
119    for stack in occupied_stacks(input) {
120        if recipe.input.test(stack) {
121            if found_input.is_some() {
122                return false;
123            }
124            found_input = Some(stack);
125        } else if recipe.material.test(stack) {
126            material_count += 1;
127            if material_count > recipe.max_material_count {
128                return false;
129            }
130        } else {
131            return false;
132        }
133    }
134    let Some(found_input) = found_input else {
135        return false;
136    };
137    if !(recipe.min_material_count..=recipe.max_material_count).contains(&material_count) {
138        return false;
139    }
140    let result_count = if recipe.add_material_count_to_result {
141        recipe.result.count() + i32::try_from(material_count).unwrap_or(i32::MAX)
142    } else {
143        recipe.result.count()
144    };
145    if result_count != 1 {
146        return true;
147    }
148    let result = recipe.result.apply(1, found_input.components_patch());
149    !result.is_empty() && !ItemStack::is_same_item_same_components(found_input, &result)
150}
151
152fn dye(recipe: &DyeRecipe, input: &CraftingInput) -> bool {
153    if input.ingredient_count() < 2 {
154        return false;
155    }
156    let mut has_target = false;
157    let mut has_dye = false;
158    for stack in occupied_stacks(input) {
159        if recipe.target.test(stack) {
160            if has_target {
161                return false;
162            }
163            has_target = true;
164        } else if recipe.dye.test(stack) && stack.has(DYE) {
165            has_dye = true;
166        } else {
167            return false;
168        }
169    }
170    has_target && has_dye
171}
172
173fn decorated_pot(recipe: &DecoratedPotRecipe, input: &CraftingInput) -> bool {
174    input.width == CRAFTING_TABLE_GRID_SIZE
175        && input.height == CRAFTING_TABLE_GRID_SIZE
176        && input.ingredient_count() == DECORATED_POT_INGREDIENT_COUNT
177        && recipe.back.test(input.get(CRAFTING_TABLE_CENTER, 0))
178        && recipe.left.test(input.get(0, CRAFTING_TABLE_CENTER))
179        && recipe
180            .right
181            .test(input.get(CRAFTING_TABLE_LAST_INDEX, CRAFTING_TABLE_CENTER))
182        && recipe
183            .front
184            .test(input.get(CRAFTING_TABLE_CENTER, CRAFTING_TABLE_LAST_INDEX))
185}
186
187fn imbue(recipe: &ImbueRecipe, input: &CraftingInput) -> bool {
188    if input.width != CRAFTING_TABLE_GRID_SIZE
189        || input.height != CRAFTING_TABLE_GRID_SIZE
190        || input.ingredient_count() != CRAFTING_TABLE_SLOT_COUNT
191    {
192        return false;
193    }
194    for y in 0..CRAFTING_TABLE_GRID_SIZE {
195        for x in 0..CRAFTING_TABLE_GRID_SIZE {
196            let ingredient = if x == CRAFTING_TABLE_CENTER && y == CRAFTING_TABLE_CENTER {
197                &recipe.source
198            } else {
199                &recipe.material
200            };
201            if !ingredient.test(input.get(x, y)) {
202                return false;
203            }
204        }
205    }
206    true
207}
208
209fn banner_duplicate(recipe: &BannerDuplicateRecipe, input: &CraftingInput) -> bool {
210    if input.ingredient_count() != 2 {
211        return false;
212    }
213    let mut has_target = false;
214    let mut has_source = false;
215    for stack in occupied_stacks(input) {
216        if !recipe.banner.test(stack) {
217            return false;
218        }
219        let pattern_count = stack
220            .get(BANNER_PATTERNS)
221            .map_or(0, |patterns| patterns.layers().len());
222        if pattern_count > MAX_BANNER_PATTERNS {
223            return false;
224        }
225        if pattern_count > 0 {
226            if has_source {
227                return false;
228            }
229            has_source = true;
230        } else {
231            if has_target {
232                return false;
233            }
234            has_target = true;
235        }
236    }
237    has_source && has_target
238}
239
240fn book_cloning(recipe: &BookCloningRecipe, input: &CraftingInput) -> bool {
241    if input.ingredient_count() < 2 {
242        return false;
243    }
244    let mut has_source = false;
245    let mut has_material = false;
246    for stack in occupied_stacks(input) {
247        if recipe.source.test(stack) {
248            let Some(content) = stack.get(WRITTEN_BOOK_CONTENT) else {
249                return false;
250            };
251            if has_source
252                || !(recipe.min_generation..=recipe.max_generation).contains(&content.generation())
253            {
254                return false;
255            }
256            has_source = true;
257        } else if recipe.material.test(stack) {
258            has_material = true;
259        } else {
260            return false;
261        }
262    }
263    has_source && has_material
264}
265
266fn firework_rocket(recipe: &FireworkRocketRecipe, input: &CraftingInput) -> bool {
267    if input.ingredient_count() < 2 {
268        return false;
269    }
270    let mut has_shell = false;
271    let mut fuel_count = 0;
272    for stack in occupied_stacks(input) {
273        if recipe.shell.test(stack) {
274            if has_shell {
275                return false;
276            }
277            has_shell = true;
278        } else if recipe.fuel.test(stack) {
279            fuel_count += 1;
280            if fuel_count > MAX_FIREWORK_ROCKET_FUEL_COUNT {
281                return false;
282            }
283        } else if !recipe.star.test(stack) {
284            return false;
285        }
286    }
287    has_shell && fuel_count >= MIN_FIREWORK_ROCKET_FUEL_COUNT
288}
289
290fn firework_star(recipe: &FireworkStarRecipe, input: &CraftingInput) -> bool {
291    if input.ingredient_count() < 2 {
292        return false;
293    }
294    let mut has_fuel = false;
295    let mut has_dye = false;
296    let mut has_shape = false;
297    let mut has_trail = false;
298    let mut has_twinkle = false;
299    for stack in occupied_stacks(input) {
300        if recipe.twinkle.test(stack) {
301            if has_twinkle {
302                return false;
303            }
304            has_twinkle = true;
305        } else if recipe.trail.test(stack) {
306            if has_trail {
307                return false;
308            }
309            has_trail = true;
310        } else if recipe.fuel.test(stack) {
311            if has_fuel {
312                return false;
313            }
314            has_fuel = true;
315        } else if recipe.dye.test(stack) && stack.has(DYE) {
316            has_dye = true;
317        } else if recipe
318            .shapes
319            .iter()
320            .any(|(_, ingredient)| ingredient.test(stack))
321        {
322            if has_shape {
323                return false;
324            }
325            has_shape = true;
326        } else {
327            return false;
328        }
329    }
330    has_fuel && has_dye
331}
332
333fn firework_star_fade(recipe: &FireworkStarFadeRecipe, input: &CraftingInput) -> bool {
334    if input.ingredient_count() < 2 {
335        return false;
336    }
337    let mut has_target = false;
338    let mut has_dye = false;
339    for stack in occupied_stacks(input) {
340        if recipe.dye.test(stack) && stack.has(DYE) {
341            has_dye = true;
342        } else if recipe.target.test(stack) {
343            if has_target {
344                return false;
345            }
346            has_target = true;
347        } else {
348            return false;
349        }
350    }
351    has_target && has_dye
352}
353
354/// Matches the fixed 3×3 map-scaling layout and its saved-map restrictions.
355///
356/// The source map's [`super::MapRecipeData`] must be attached to the center
357/// input slot so exploration maps and maps at the maximum scale are rejected.
358fn map_extending(recipe: &MapExtendingRecipe, input: &CraftingInput) -> bool {
359    if input.width != CRAFTING_TABLE_GRID_SIZE
360        || input.height != CRAFTING_TABLE_GRID_SIZE
361        || input.ingredient_count() != CRAFTING_TABLE_SLOT_COUNT
362    {
363        return false;
364    }
365    for y in 0..CRAFTING_TABLE_GRID_SIZE {
366        for x in 0..CRAFTING_TABLE_GRID_SIZE {
367            let ingredient = if x == CRAFTING_TABLE_CENTER && y == CRAFTING_TABLE_CENTER {
368                &recipe.map
369            } else {
370                &recipe.material
371            };
372            if !ingredient.test(input.get(x, y)) {
373                return false;
374            }
375        }
376    }
377    // TODO: Populate this from saved map data once maps are implemented.
378    let Some(data) = input.map_data(CRAFTING_TABLE_CENTER_SLOT) else {
379        return false;
380    };
381    !data.exploration_map && data.scale < MAX_MAP_SCALE
382}
383
384fn repair_item(_recipe: &RepairItemRecipe, input: &CraftingInput) -> bool {
385    if input.ingredient_count() != 2 {
386        return false;
387    }
388    let mut stacks = occupied_stacks(input);
389    let Some(first) = stacks.next() else {
390        return false;
391    };
392    let Some(second) = stacks.next() else {
393        return false;
394    };
395    first.item() == second.item()
396        && first.count() == 1
397        && second.count() == 1
398        && first.has(MAX_DAMAGE)
399        && second.has(MAX_DAMAGE)
400        && first.has(DAMAGE)
401        && second.has(DAMAGE)
402}
403
404fn shield_decoration(recipe: &ShieldDecorationRecipe, input: &CraftingInput) -> bool {
405    if input.ingredient_count() != 2 {
406        return false;
407    }
408    let mut has_banner = false;
409    let mut has_target = false;
410    for stack in occupied_stacks(input) {
411        if recipe.banner.test(stack) {
412            if has_banner {
413                return false;
414            }
415            has_banner = true;
416        } else if recipe.target.test(stack) {
417            if has_target
418                || stack
419                    .get(BANNER_PATTERNS)
420                    .is_some_and(|patterns| !patterns.layers().is_empty())
421            {
422                return false;
423            }
424            has_target = true;
425        } else {
426            return false;
427        }
428    }
429    has_banner && has_target
430}
431
432#[cfg(test)]
433mod tests {
434    use crate::item_stack_template::ItemStackTemplate;
435    use crate::recipe::RecipeProperties;
436    use crate::{init_vanilla_registry, vanilla_items};
437
438    use super::*;
439
440    fn asymmetric_shaped_recipe(pattern: Vec<Ingredient>) -> ShapedRecipe {
441        ShapedRecipe::new(
442            RecipeProperties::special(),
443            pattern.len(),
444            1,
445            pattern.into_boxed_slice(),
446            ItemStackTemplate::new(&vanilla_items::PURPLE_DYE),
447        )
448    }
449
450    #[test]
451    fn asymmetric_shaped_recipe_matches_both_orientations() {
452        init_vanilla_registry();
453        let recipe = asymmetric_shaped_recipe(vec![
454            Ingredient::Item(&vanilla_items::RED_DYE),
455            Ingredient::Item(&vanilla_items::BLUE_DYE),
456        ]);
457        let forward = CraftingInput::new(
458            2,
459            1,
460            vec![
461                ItemStack::new(&vanilla_items::RED_DYE),
462                ItemStack::new(&vanilla_items::BLUE_DYE),
463            ],
464        );
465        let mirrored = CraftingInput::new(
466            2,
467            1,
468            vec![
469                ItemStack::new(&vanilla_items::BLUE_DYE),
470                ItemStack::new(&vanilla_items::RED_DYE),
471            ],
472        );
473
474        assert!(shaped(&recipe, &forward));
475        assert!(shaped(&recipe, &mirrored));
476    }
477
478    #[test]
479    fn shaped_recipe_requires_empty_pattern_slots() {
480        init_vanilla_registry();
481        let recipe = asymmetric_shaped_recipe(vec![
482            Ingredient::Item(&vanilla_items::RED_DYE),
483            Ingredient::Empty,
484            Ingredient::Item(&vanilla_items::BLUE_DYE),
485        ]);
486        let valid = CraftingInput::new(
487            3,
488            1,
489            vec![
490                ItemStack::new(&vanilla_items::RED_DYE),
491                ItemStack::empty(),
492                ItemStack::new(&vanilla_items::BLUE_DYE),
493            ],
494        );
495        let shifted = CraftingInput::new(
496            3,
497            1,
498            vec![
499                ItemStack::new(&vanilla_items::RED_DYE),
500                ItemStack::new(&vanilla_items::BLUE_DYE),
501                ItemStack::empty(),
502            ],
503        );
504
505        assert!(shaped(&recipe, &valid));
506        assert!(!shaped(&recipe, &shifted));
507    }
508
509    #[test]
510    fn firework_rocket_requires_one_to_three_fuel_items() {
511        init_vanilla_registry();
512        let recipe = FireworkRocketRecipe {
513            properties: RecipeProperties::special(),
514            shell: Ingredient::Item(&vanilla_items::PAPER),
515            fuel: Ingredient::Item(&vanilla_items::GUNPOWDER),
516            star: Ingredient::Item(&vanilla_items::FIREWORK_STAR),
517            result: ItemStackTemplate::new(&vanilla_items::FIREWORK_ROCKET),
518        };
519        let input_with_fuel = |fuel_count| {
520            let mut items = vec![ItemStack::new(&vanilla_items::PAPER)];
521            items.extend(
522                std::iter::repeat_with(|| ItemStack::new(&vanilla_items::GUNPOWDER))
523                    .take(fuel_count),
524            );
525            CraftingInput::new(items.len(), 1, items)
526        };
527
528        assert!(!firework_rocket(&recipe, &input_with_fuel(0)));
529        assert!(firework_rocket(
530            &recipe,
531            &input_with_fuel(MIN_FIREWORK_ROCKET_FUEL_COUNT)
532        ));
533        assert!(firework_rocket(
534            &recipe,
535            &input_with_fuel(MAX_FIREWORK_ROCKET_FUEL_COUNT)
536        ));
537        assert!(!firework_rocket(
538            &recipe,
539            &input_with_fuel(MAX_FIREWORK_ROCKET_FUEL_COUNT + 1)
540        ));
541    }
542}