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