Skip to main content

steel_core/inventory/
recipe_manager.rs

1//! Recipe matching and crafting-grid interpretation.
2
3use steel_registry::data_components::vanilla_components::{
4    BANNER_PATTERNS, BASE_COLOR, DYE, DYED_COLOR, ENCHANTMENTS, FIREWORK_EXPLOSION, FIREWORKS,
5    MAP_POST_PROCESSING, MAX_DAMAGE, POT_DECORATIONS, POTION_CONTENTS, WRITTEN_BOOK_CONTENT,
6};
7use steel_registry::data_components::vanilla_components::{
8    DyedItemColor, FireworkExplosion, FireworkExplosionShape, Fireworks, ItemEnchantments,
9    MapPostProcessing, PotDecorations,
10};
11use steel_registry::recipe::{
12    BookCloningRecipe, CraftingInput, CraftingRecipe, DyeRecipe, FireworkRocketRecipe,
13    FireworkStarFadeRecipe, FireworkStarRecipe, PositionedCraftingInput, ShieldDecorationRecipe,
14    TypedRecipeRef, vanilla_recipe_types,
15};
16use steel_registry::{
17    DyeColor, REGISTRY, RegistryExt as _, TaggedRegistryExt as _, item_stack::ItemStack,
18    vanilla_enchantment_tags, vanilla_items,
19};
20
21use crate::inventory::container::CraftingContainer;
22
23use super::container::Container;
24
25/// Typed reference to one registered crafting recipe.
26pub type CraftingRecipeRef = TypedRecipeRef<CraftingRecipe, CraftingInput>;
27
28/// Updates a result container from the first matching crafting recipe.
29pub fn slot_changed_crafting_grid<R: Container>(crafting: &CraftingContainer, result: &mut R) {
30    result.set_item(
31        0,
32        assemble_for_container(crafting).unwrap_or_else(ItemStack::empty),
33    );
34}
35
36/// Finds the first crafting recipe in deterministic registry order.
37#[must_use]
38pub fn find_recipe(crafting: &CraftingContainer) -> Option<CraftingRecipeRef> {
39    let positioned = crafting.as_positioned_input();
40    REGISTRY
41        .recipes
42        .find_match(&vanilla_recipe_types::CRAFTING, &positioned.input)
43}
44
45/// Finds and interprets the output for the current crafting container.
46#[must_use]
47pub fn assemble_for_container(crafting: &CraftingContainer) -> Option<ItemStack> {
48    let positioned = crafting.as_positioned_input();
49    let recipe = find_recipe(crafting)?;
50    Some(assemble_recipe(recipe, &positioned.input))
51}
52
53/// Interprets passive vanilla crafting data for a concrete input snapshot.
54#[must_use]
55pub fn assemble_recipe(recipe: CraftingRecipeRef, input: &CraftingInput) -> ItemStack {
56    match recipe.data() {
57        CraftingRecipe::Shaped(recipe) => recipe.result.create(),
58        CraftingRecipe::Shapeless(recipe) => recipe.result.create(),
59        CraftingRecipe::Transmute(recipe) => {
60            let material_count = input
61                .items
62                .iter()
63                .filter(|stack| !stack.is_empty() && recipe.material.test(stack))
64                .count();
65            let extra_count = if recipe.add_material_count_to_result {
66                i32::try_from(material_count).unwrap_or(i32::MAX)
67            } else {
68                0
69            };
70            input
71                .items
72                .iter()
73                .find(|stack| !stack.is_empty() && recipe.input.test(stack))
74                .map_or_else(ItemStack::empty, |source| {
75                    recipe.result.apply(
76                        recipe.result.count().saturating_add(extra_count),
77                        source.components_patch(),
78                    )
79                })
80        }
81        CraftingRecipe::Dye(recipe) => assemble_dye(recipe, input),
82        CraftingRecipe::DecoratedPot(recipe) => {
83            let decorations = [
84                input.get(1, 0).item(),
85                input.get(0, 1).item(),
86                input.get(2, 1).item(),
87                input.get(1, 2).item(),
88            ];
89            let Ok(decorations) = PotDecorations::from_ordered(&decorations) else {
90                return ItemStack::empty();
91            };
92            let mut result = recipe.result.create();
93            result.set(POT_DECORATIONS, decorations);
94            result
95        }
96        CraftingRecipe::Imbue(recipe) => {
97            let mut result = recipe.result.create();
98            if let Some(contents) = input.get(1, 1).get(POTION_CONTENTS) {
99                result.set(POTION_CONTENTS, contents.clone());
100            }
101            result
102        }
103        CraftingRecipe::BannerDuplicate(recipe) => input
104            .items
105            .iter()
106            .find(|stack| {
107                recipe.banner.test(stack)
108                    && stack
109                        .get(BANNER_PATTERNS)
110                        .is_some_and(|patterns| !patterns.layers().is_empty())
111            })
112            .map_or_else(ItemStack::empty, |source| {
113                recipe
114                    .result
115                    .apply(recipe.result.count(), source.components_patch())
116            }),
117        CraftingRecipe::BookCloning(recipe) => assemble_book_cloning(recipe, input),
118        CraftingRecipe::FireworkRocket(recipe) => assemble_firework_rocket(recipe, input),
119        CraftingRecipe::FireworkStar(recipe) => assemble_firework_star(recipe, input),
120        CraftingRecipe::FireworkStarFade(recipe) => assemble_firework_fade(recipe, input),
121        CraftingRecipe::MapExtending(recipe) => {
122            let source = input.get(1, 1);
123            let mut result = recipe
124                .result
125                .apply(recipe.result.count(), source.components_patch());
126            result.set(MAP_POST_PROCESSING, MapPostProcessing::Scale);
127            result
128        }
129        CraftingRecipe::RepairItem(_) => assemble_repair(input),
130        CraftingRecipe::ShieldDecoration(recipe) => assemble_shield(recipe, input),
131    }
132}
133
134/// Returns crafting remainders and the positioned input used to map them back
135/// to the original grid.
136#[must_use]
137pub fn get_remaining_items(
138    crafting: &CraftingContainer,
139) -> Option<(Vec<ItemStack>, PositionedCraftingInput)> {
140    let positioned = crafting.as_positioned_input();
141    let recipe = find_recipe(crafting)?;
142    let mut remainders: Vec<_> = positioned
143        .input
144        .items
145        .iter()
146        .map(|stack| {
147            if stack.is_empty() {
148                ItemStack::empty()
149            } else {
150                stack.item().get_crafting_remainder()
151            }
152        })
153        .collect();
154
155    match recipe.data() {
156        CraftingRecipe::BannerDuplicate(_) => {
157            for (slot, stack) in positioned.input.items.iter().enumerate() {
158                if remainders[slot].is_empty()
159                    && stack
160                        .get(BANNER_PATTERNS)
161                        .is_some_and(|patterns| !patterns.layers().is_empty())
162                {
163                    remainders[slot] = stack.copy_with_count(1);
164                }
165            }
166        }
167        CraftingRecipe::BookCloning(_) => {
168            for (slot, stack) in positioned.input.items.iter().enumerate() {
169                if remainders[slot].is_empty() && stack.has(WRITTEN_BOOK_CONTENT) {
170                    remainders[slot] = stack.copy_with_count(1);
171                    break;
172                }
173            }
174        }
175        _ => {}
176    }
177
178    Some((remainders, positioned))
179}
180
181fn assemble_dye(recipe: &DyeRecipe, input: &CraftingInput) -> ItemStack {
182    let mut target = None;
183    let mut dyes = Vec::new();
184    for stack in input.items.iter().filter(|stack| !stack.is_empty()) {
185        if recipe.target.test(stack) {
186            target = Some(stack);
187        } else if recipe.dye.test(stack) {
188            dyes.push(stack.get(DYE).copied().unwrap_or(DyeColor::White));
189        }
190    }
191    let Some(target) = target else {
192        return ItemStack::empty();
193    };
194    if dyes.is_empty() {
195        return ItemStack::empty();
196    }
197    let color = DyedItemColor::apply_dyes(target.get(DYED_COLOR).copied(), &dyes);
198    let mut result = recipe
199        .result
200        .apply(recipe.result.count(), target.components_patch());
201    result.set(DYED_COLOR, color);
202    result
203}
204
205fn assemble_book_cloning(recipe: &BookCloningRecipe, input: &CraftingInput) -> ItemStack {
206    let Some(source) = input
207        .items
208        .iter()
209        .find(|stack| !stack.is_empty() && recipe.source.test(stack))
210    else {
211        return ItemStack::empty();
212    };
213    let Some(content) = source.get(WRITTEN_BOOK_CONTENT) else {
214        return ItemStack::empty();
215    };
216    let material_count = input
217        .items
218        .iter()
219        .filter(|stack| !stack.is_empty() && recipe.material.test(stack))
220        .count();
221    let Ok(extra_count) = i32::try_from(material_count.saturating_sub(1)) else {
222        return ItemStack::empty();
223    };
224    let mut result = recipe.result.apply(
225        recipe.result.count().saturating_add(extra_count),
226        source.components_patch(),
227    );
228    result.set(WRITTEN_BOOK_CONTENT, content.craft_copy());
229    result
230}
231
232fn assemble_firework_rocket(recipe: &FireworkRocketRecipe, input: &CraftingInput) -> ItemStack {
233    let mut flight_duration = 0;
234    let mut explosions = Vec::new();
235    for stack in input.items.iter().filter(|stack| !stack.is_empty()) {
236        if recipe.fuel.test(stack) {
237            flight_duration += 1;
238        } else if recipe.star.test(stack)
239            && let Some(explosion) = stack.get(FIREWORK_EXPLOSION)
240        {
241            explosions.push(explosion.clone());
242        }
243    }
244    let Ok(fireworks) = Fireworks::new(flight_duration, explosions) else {
245        return ItemStack::empty();
246    };
247    let mut result = recipe.result.create();
248    result.set(FIREWORKS, fireworks);
249    result
250}
251
252fn assemble_firework_star(recipe: &FireworkStarRecipe, input: &CraftingInput) -> ItemStack {
253    let mut shape = FireworkExplosionShape::SmallBall;
254    let mut has_twinkle = false;
255    let mut has_trail = false;
256    let mut colors = Vec::new();
257    for stack in input.items.iter().filter(|stack| !stack.is_empty()) {
258        if let Some((found_shape, _)) = recipe
259            .shapes
260            .iter()
261            .find(|(_, ingredient)| ingredient.test(stack))
262        {
263            shape = *found_shape;
264        } else if recipe.twinkle.test(stack) {
265            has_twinkle = true;
266        } else if recipe.trail.test(stack) {
267            has_trail = true;
268        } else if recipe.dye.test(stack) {
269            colors.push(
270                stack
271                    .get(DYE)
272                    .copied()
273                    .unwrap_or(DyeColor::White)
274                    .firework_color(),
275            );
276        }
277    }
278    let mut result = recipe.result.create();
279    result.set(
280        FIREWORK_EXPLOSION,
281        FireworkExplosion::new(shape, colors, Vec::new(), has_trail, has_twinkle),
282    );
283    result
284}
285
286fn assemble_firework_fade(recipe: &FireworkStarFadeRecipe, input: &CraftingInput) -> ItemStack {
287    let Some(target) = input
288        .items
289        .iter()
290        .find(|stack| !stack.is_empty() && recipe.target.test(stack))
291    else {
292        return ItemStack::empty();
293    };
294    let fade_colors: Vec<_> = input
295        .items
296        .iter()
297        .filter(|stack| !stack.is_empty() && recipe.dye.test(stack))
298        .map(|stack| {
299            stack
300                .get(DYE)
301                .copied()
302                .unwrap_or(DyeColor::White)
303                .firework_color()
304        })
305        .collect();
306    let mut result = recipe
307        .result
308        .apply(recipe.result.count(), target.components_patch());
309    let explosion = target
310        .get(FIREWORK_EXPLOSION)
311        .cloned()
312        .unwrap_or_default()
313        .with_fade_colors(fade_colors);
314    result.set(FIREWORK_EXPLOSION, explosion);
315    result
316}
317
318fn assemble_repair(input: &CraftingInput) -> ItemStack {
319    let mut inputs = input.items.iter().filter(|stack| !stack.is_empty());
320    let Some(first) = inputs.next() else {
321        return ItemStack::empty();
322    };
323    let Some(second) = inputs.next() else {
324        return ItemStack::empty();
325    };
326    let durability = first.get_max_damage().max(second.get_max_damage());
327    let remaining = (first.get_max_damage() - first.get_damage_value())
328        + (second.get_max_damage() - second.get_damage_value())
329        + durability * 5 / 100;
330    let mut result = ItemStack::new(first.item());
331    result.set(MAX_DAMAGE, durability);
332    result.set_damage_value((durability - remaining).max(0));
333
334    let mut curses = ItemEnchantments::empty();
335    for enchantments in [
336        first.get_enchantments_for_crafting(),
337        second.get_enchantments_for_crafting(),
338    ]
339    .into_iter()
340    .flatten()
341    {
342        for (key, level) in enchantments.iter() {
343            let Some(enchantment) = REGISTRY.enchantments.by_key(key) else {
344                continue;
345            };
346            if REGISTRY.enchantments.is_in_tag(
347                enchantment,
348                &vanilla_enchantment_tags::EnchantmentTag::CURSE,
349            ) {
350                curses.upgrade(key.clone(), *level);
351            }
352        }
353    }
354    result.set(ENCHANTMENTS, curses);
355    result
356}
357
358fn assemble_shield(recipe: &ShieldDecorationRecipe, input: &CraftingInput) -> ItemStack {
359    let Some(banner) = input
360        .items
361        .iter()
362        .find(|stack| !stack.is_empty() && recipe.banner.test(stack))
363    else {
364        return ItemStack::empty();
365    };
366    let Some(target) = input
367        .items
368        .iter()
369        .find(|stack| !stack.is_empty() && recipe.target.test(stack))
370    else {
371        return ItemStack::empty();
372    };
373    let mut result = recipe
374        .result
375        .apply(recipe.result.count(), target.components_patch());
376    if let Some(patterns) = banner.get(BANNER_PATTERNS) {
377        result.set(BANNER_PATTERNS, patterns.clone());
378    }
379    result.set(BASE_COLOR, banner_color(banner));
380    result
381}
382
383fn banner_color(stack: &ItemStack) -> DyeColor {
384    let banners = [
385        (&*vanilla_items::WHITE_BANNER, DyeColor::White),
386        (&*vanilla_items::ORANGE_BANNER, DyeColor::Orange),
387        (&*vanilla_items::MAGENTA_BANNER, DyeColor::Magenta),
388        (&*vanilla_items::LIGHT_BLUE_BANNER, DyeColor::LightBlue),
389        (&*vanilla_items::YELLOW_BANNER, DyeColor::Yellow),
390        (&*vanilla_items::LIME_BANNER, DyeColor::Lime),
391        (&*vanilla_items::PINK_BANNER, DyeColor::Pink),
392        (&*vanilla_items::GRAY_BANNER, DyeColor::Gray),
393        (&*vanilla_items::LIGHT_GRAY_BANNER, DyeColor::LightGray),
394        (&*vanilla_items::CYAN_BANNER, DyeColor::Cyan),
395        (&*vanilla_items::PURPLE_BANNER, DyeColor::Purple),
396        (&*vanilla_items::BLUE_BANNER, DyeColor::Blue),
397        (&*vanilla_items::BROWN_BANNER, DyeColor::Brown),
398        (&*vanilla_items::GREEN_BANNER, DyeColor::Green),
399        (&*vanilla_items::RED_BANNER, DyeColor::Red),
400        (&*vanilla_items::BLACK_BANNER, DyeColor::Black),
401    ];
402    banners
403        .into_iter()
404        .find_map(|(item, color)| (stack.item() == item).then_some(color))
405        .unwrap_or(DyeColor::White)
406}
407
408#[cfg(test)]
409mod tests {
410    use steel_registry::data_components::vanilla_components::DYE;
411    use steel_registry::recipe::{CraftingInput, vanilla_recipe_types};
412    use steel_registry::{DyeColor, REGISTRY, init_vanilla_registry, vanilla_items};
413
414    use super::assemble_recipe;
415    use steel_registry::item_stack::ItemStack;
416
417    #[test]
418    fn external_crafting_interpreter_assembles_a_generated_dye_recipe() {
419        init_vanilla_registry();
420        let input = CraftingInput::new(
421            2,
422            1,
423            vec![
424                ItemStack::new(&vanilla_items::RED_DYE),
425                ItemStack::new(&vanilla_items::BLUE_DYE),
426            ],
427        );
428        let Some(recipe) = REGISTRY
429            .recipes
430            .find_match(&vanilla_recipe_types::CRAFTING, &input)
431        else {
432            panic!("red and blue dye should match the purple dye recipe");
433        };
434
435        let result = assemble_recipe(recipe, &input);
436
437        assert!(result.is(&vanilla_items::PURPLE_DYE));
438        assert_eq!(result.get(DYE), Some(&DyeColor::Purple));
439    }
440}