Skip to main content

steel_registry/recipe/
crafting.rs

1//! Crafting recipe types (shaped and shapeless).
2
3use steel_utils::Identifier;
4
5use crate::{item_stack::ItemStack, items::ItemRef};
6
7use super::ingredient::Ingredient;
8
9/// Category for crafting recipes (used by recipe book).
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CraftingCategory {
12    Building,
13    Redstone,
14    Equipment,
15    Misc,
16}
17
18impl CraftingCategory {
19    /// Parses a category from a JSON string.
20    #[must_use]
21    pub fn parse_json(s: &str) -> Self {
22        match s {
23            "building" => Self::Building,
24            "redstone" => Self::Redstone,
25            "equipment" => Self::Equipment,
26            _ => Self::Misc,
27        }
28    }
29}
30
31/// The result of a crafting recipe.
32#[derive(Debug, Clone)]
33pub struct RecipeResult {
34    pub item: ItemRef,
35    pub count: i32,
36}
37
38impl RecipeResult {
39    /// Creates an `ItemStack` from this result.
40    #[must_use]
41    pub fn to_item_stack(&self) -> ItemStack {
42        ItemStack::with_count(self.item, self.count)
43    }
44}
45
46/// A shaped crafting recipe with a specific pattern.
47#[derive(Debug)]
48pub struct ShapedRecipe {
49    pub id: Identifier,
50    pub category: CraftingCategory,
51    pub width: usize,
52    pub height: usize,
53    /// Pattern ingredients in row-major order (width * height).
54    pub pattern: &'static [Ingredient],
55    pub result: RecipeResult,
56    pub show_notification: bool,
57    /// Pre-computed: whether the pattern is horizontally symmetric.
58    pub symmetrical: bool,
59}
60
61impl ShapedRecipe {
62    /// Creates a new shaped recipe, pre-computing symmetry.
63    #[must_use]
64    pub fn new(
65        id: Identifier,
66        category: CraftingCategory,
67        width: usize,
68        height: usize,
69        pattern: &'static [Ingredient],
70        result: RecipeResult,
71        show_notification: bool,
72    ) -> Self {
73        let symmetrical = Self::compute_symmetrical(width, pattern);
74        Self {
75            id,
76            category,
77            width,
78            height,
79            pattern,
80            result,
81            show_notification,
82            symmetrical,
83        }
84    }
85
86    /// Computes whether the pattern is horizontally symmetric.
87    fn compute_symmetrical(width: usize, pattern: &[Ingredient]) -> bool {
88        if width == 0 {
89            return true;
90        }
91        let height = pattern.len() / width;
92        for y in 0..height {
93            for x in 0..width / 2 {
94                let left = &pattern[y * width + x];
95                let right = &pattern[y * width + (width - 1 - x)];
96                if !left.eq_ingredient(right) {
97                    return false;
98                }
99            }
100        }
101        true
102    }
103
104    /// Returns true if this recipe fits in a 2x2 grid.
105    #[must_use]
106    pub fn fits_in_2x2(&self) -> bool {
107        self.width <= 2 && self.height <= 2
108    }
109
110    /// Tests if the crafting input matches this recipe.
111    #[must_use]
112    pub fn matches(&self, input: &CraftingInput) -> bool {
113        // Early exit: ingredient count must match
114        if input.ingredient_count != self.pattern.iter().filter(|i| !i.is_empty()).count() {
115            return false;
116        }
117
118        // Dimensions must match
119        if input.width != self.width || input.height != self.height {
120            return false;
121        }
122
123        // Try normal orientation
124        if self.matches_at(input, false) {
125            return true;
126        }
127
128        // Only try mirrored if not symmetric
129        if !self.symmetrical && self.matches_at(input, true) {
130            return true;
131        }
132
133        false
134    }
135
136    /// Tests if the crafting input matches this recipe with optional mirroring.
137    fn matches_at(&self, input: &CraftingInput, mirrored: bool) -> bool {
138        for y in 0..self.height {
139            for x in 0..self.width {
140                let pattern_x = if mirrored { self.width - 1 - x } else { x };
141                let ingredient = &self.pattern[y * self.width + pattern_x];
142                let input_item = input.get(x, y);
143
144                if !ingredient.test(input_item) {
145                    return false;
146                }
147            }
148        }
149        true
150    }
151
152    /// Assembles the result item stack.
153    #[must_use]
154    pub fn assemble(&self) -> ItemStack {
155        self.result.to_item_stack()
156    }
157
158    /// Gets the remaining items after crafting (e.g., empty buckets).
159    #[must_use]
160    pub fn get_remaining_items(&self, input: &CraftingInput) -> Vec<ItemStack> {
161        input
162            .items
163            .iter()
164            .map(|stack| {
165                if stack.is_empty() {
166                    ItemStack::empty()
167                } else {
168                    stack.item.get_crafting_remainder()
169                }
170            })
171            .collect()
172    }
173}
174
175/// A shapeless crafting recipe where ingredient order doesn't matter.
176#[derive(Debug)]
177pub struct ShapelessRecipe {
178    pub id: Identifier,
179    pub category: CraftingCategory,
180    pub ingredients: &'static [Ingredient],
181    pub result: RecipeResult,
182}
183
184impl ShapelessRecipe {
185    /// Returns true if this recipe fits in a 2x2 grid.
186    #[must_use]
187    pub fn fits_in_2x2(&self) -> bool {
188        self.ingredients.len() <= 4
189    }
190
191    /// Tests if the crafting input matches this recipe.
192    #[must_use]
193    pub fn matches(&self, input: &CraftingInput) -> bool {
194        // Must have same number of items as ingredients
195        if input.ingredient_count != self.ingredients.len() {
196            return false;
197        }
198
199        // Fast path for single ingredient
200        if self.ingredients.len() == 1 {
201            return self.ingredients[0].test(input.items.iter().find(|s| !s.is_empty()).unwrap());
202        }
203
204        // Try to match each ingredient to an input item
205        let non_empty: Vec<&ItemStack> = input.items.iter().filter(|s| !s.is_empty()).collect();
206        let mut used = vec![false; non_empty.len()];
207
208        for ingredient in self.ingredients {
209            let mut found = false;
210            for (i, item) in non_empty.iter().enumerate() {
211                if !used[i] && ingredient.test(item) {
212                    used[i] = true;
213                    found = true;
214                    break;
215                }
216            }
217            if !found {
218                return false;
219            }
220        }
221
222        true
223    }
224
225    /// Assembles the result item stack.
226    #[must_use]
227    pub fn assemble(&self) -> ItemStack {
228        self.result.to_item_stack()
229    }
230
231    /// Gets the remaining items after crafting (e.g., empty buckets).
232    #[must_use]
233    pub fn get_remaining_items(&self, input: &CraftingInput) -> Vec<ItemStack> {
234        input
235            .items
236            .iter()
237            .map(|stack| {
238                if stack.is_empty() {
239                    ItemStack::empty()
240                } else {
241                    stack.item.get_crafting_remainder()
242                }
243            })
244            .collect()
245    }
246}
247
248/// Unified crafting recipe enum (replaces trait-based approach).
249#[derive(Debug, Clone, Copy)]
250pub enum CraftingRecipe {
251    Shaped(&'static ShapedRecipe),
252    Shapeless(&'static ShapelessRecipe),
253}
254
255impl CraftingRecipe {
256    /// Returns the recipe identifier.
257    #[must_use]
258    pub fn id(&self) -> &Identifier {
259        match self {
260            Self::Shaped(r) => &r.id,
261            Self::Shapeless(r) => &r.id,
262        }
263    }
264
265    /// Returns the recipe category.
266    #[must_use]
267    pub fn category(&self) -> CraftingCategory {
268        match self {
269            Self::Shaped(r) => r.category,
270            Self::Shapeless(r) => r.category,
271        }
272    }
273
274    /// Returns the result of this recipe.
275    #[must_use]
276    pub fn result(&self) -> &RecipeResult {
277        match self {
278            Self::Shaped(r) => &r.result,
279            Self::Shapeless(r) => &r.result,
280        }
281    }
282
283    /// Tests if the crafting input matches this recipe.
284    /// The input should already be positioned/trimmed.
285    #[must_use]
286    pub fn matches(&self, input: &CraftingInput) -> bool {
287        match self {
288            Self::Shaped(r) => r.matches(input),
289            Self::Shapeless(r) => r.matches(input),
290        }
291    }
292
293    /// Assembles the result item stack.
294    #[must_use]
295    pub fn assemble(&self) -> ItemStack {
296        match self {
297            Self::Shaped(r) => r.assemble(),
298            Self::Shapeless(r) => r.assemble(),
299        }
300    }
301
302    /// Gets the remaining items after crafting (e.g., empty buckets).
303    #[must_use]
304    pub fn get_remaining_items(&self, input: &CraftingInput) -> Vec<ItemStack> {
305        match self {
306            Self::Shaped(r) => r.get_remaining_items(input),
307            Self::Shapeless(r) => r.get_remaining_items(input),
308        }
309    }
310
311    /// Returns true if this recipe fits in a 2x2 grid.
312    #[must_use]
313    pub fn fits_in_2x2(&self) -> bool {
314        match self {
315            Self::Shaped(r) => r.fits_in_2x2(),
316            Self::Shapeless(r) => r.fits_in_2x2(),
317        }
318    }
319}
320
321/// Represents the current state of a crafting grid.
322///
323/// This should be a **positioned** (trimmed) input - containing only the
324/// bounding box of non-empty items. Use `CraftingInput::positioned()` to
325/// create one from raw grid slots.
326#[derive(Debug, Clone)]
327pub struct CraftingInput {
328    pub width: usize,
329    pub height: usize,
330    /// Items in row-major order (width * height).
331    pub items: Vec<ItemStack>,
332    /// Pre-computed count of non-empty items.
333    ingredient_count: usize,
334}
335
336impl CraftingInput {
337    /// An empty crafting input.
338    pub const EMPTY: CraftingInput = CraftingInput {
339        width: 0,
340        height: 0,
341        items: Vec::new(),
342        ingredient_count: 0,
343    };
344
345    /// Creates a new crafting input, pre-computing ingredient count.
346    #[must_use]
347    pub fn new(width: usize, height: usize, items: Vec<ItemStack>) -> Self {
348        debug_assert_eq!(items.len(), width * height);
349        let ingredient_count = items.iter().filter(|s| !s.is_empty()).count();
350        Self {
351            width,
352            height,
353            items,
354            ingredient_count,
355        }
356    }
357
358    /// Creates a positioned (trimmed) crafting input from raw grid slots.
359    ///
360    /// This is the main entry point matching Java's `CraftingInput.ofPositioned()`.
361    /// Returns the trimmed input along with the offset from the original grid.
362    #[must_use]
363    pub fn positioned(
364        width: usize,
365        height: usize,
366        items: Vec<ItemStack>,
367    ) -> PositionedCraftingInput {
368        if width == 0 || height == 0 {
369            return PositionedCraftingInput::EMPTY;
370        }
371
372        // Find bounding box
373        let mut left = width;
374        let mut right = 0;
375        let mut top = height;
376        let mut bottom = 0;
377
378        for y in 0..height {
379            for x in 0..width {
380                if !items[y * width + x].is_empty() {
381                    left = left.min(x);
382                    right = right.max(x);
383                    top = top.min(y);
384                    bottom = bottom.max(y);
385                }
386            }
387        }
388
389        // Empty grid
390        if left > right || top > bottom {
391            return PositionedCraftingInput::EMPTY;
392        }
393
394        let new_width = right - left + 1;
395        let new_height = bottom - top + 1;
396
397        // If bounds match original, use items directly
398        if new_width == width && new_height == height {
399            return PositionedCraftingInput {
400                input: CraftingInput::new(width, height, items),
401                left,
402                top,
403            };
404        }
405
406        // Create trimmed input
407        let mut new_items = Vec::with_capacity(new_width * new_height);
408        for y in 0..new_height {
409            for x in 0..new_width {
410                let index = (x + left) + (y + top) * width;
411                new_items.push(items[index].clone());
412            }
413        }
414
415        PositionedCraftingInput {
416            input: CraftingInput::new(new_width, new_height, new_items),
417            left,
418            top,
419        }
420    }
421
422    /// Gets the item at the specified position.
423    #[must_use]
424    pub fn get(&self, x: usize, y: usize) -> &ItemStack {
425        &self.items[y * self.width + x]
426    }
427
428    /// Returns the number of non-empty items (pre-computed).
429    #[must_use]
430    pub fn ingredient_count(&self) -> usize {
431        self.ingredient_count
432    }
433
434    /// Returns true if the input is empty.
435    #[must_use]
436    pub fn is_empty(&self) -> bool {
437        self.ingredient_count == 0
438    }
439}
440
441/// A crafting input with position information.
442///
443/// This represents a trimmed crafting grid (containing only the bounding box
444/// of non-empty items) along with the offset from the original grid origin.
445/// This is used when consuming ingredients to correctly map recipe slots back
446/// to the original crafting grid slots.
447#[derive(Debug, Clone)]
448pub struct PositionedCraftingInput {
449    /// The trimmed crafting input.
450    pub input: CraftingInput,
451    /// The X offset from the original grid origin.
452    pub left: usize,
453    /// The Y offset from the original grid origin.
454    pub top: usize,
455}
456
457impl PositionedCraftingInput {
458    /// An empty positioned crafting input.
459    pub const EMPTY: PositionedCraftingInput = PositionedCraftingInput {
460        input: CraftingInput::EMPTY,
461        left: 0,
462        top: 0,
463    };
464
465    /// Converts a position in the trimmed input back to the original grid slot index.
466    ///
467    /// # Arguments
468    /// * `x` - X position in the trimmed input (0 to input.width-1)
469    /// * `y` - Y position in the trimmed input (0 to input.height-1)
470    /// * `grid_width` - Width of the original crafting grid
471    ///
472    /// # Returns
473    /// The slot index in the original crafting grid.
474    #[must_use]
475    pub fn to_grid_slot(&self, x: usize, y: usize, grid_width: usize) -> usize {
476        (x + self.left) + (y + self.top) * grid_width
477    }
478}