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