Skip to main content

mcproto_types/recipe/
display.rs

1//! Type-safe client recipe displays.
2
3use std::{fmt, io::Read};
4
5use mcproto_codec::error::{CodecError, CodecKind, InvalidEncodingReason};
6
7use crate::{Float, PrefixedArray, SlotDisplay, TypeCodec, TypeStructCodec, VarInt};
8
9/// A shapeless crafting recipe display.
10#[derive(Debug, Clone, PartialEq, TypeStructCodec)]
11#[type_struct_codec(kind = RecipeDisplay)]
12pub struct CraftingShapelessRecipeDisplay {
13    /// Ingredient displays. The array prefix is the official ingredient count.
14    pub ingredients: PrefixedArray<SlotDisplay>,
15    /// Display for the crafted result.
16    pub result: SlotDisplay,
17    /// Crafting-station icon shown by the client.
18    pub crafting_station: SlotDisplay,
19}
20
21/// A width, height, and exactly `width * height` ingredient displays.
22///
23/// Fields are private so the length invariant cannot be broken after
24/// construction. Width, height, and ingredient count are encoded as VarInts.
25#[derive(Debug, Clone, PartialEq)]
26pub struct ShapedRecipeGrid {
27    width: u32,
28    height: u32,
29    ingredients: Vec<SlotDisplay>,
30}
31
32impl ShapedRecipeGrid {
33    /// Creates a grid when its dimensions and ingredient count are valid.
34    pub fn new(
35        width: u32,
36        height: u32,
37        ingredients: Vec<SlotDisplay>,
38    ) -> Result<Self, InvalidShapedRecipeGrid> {
39        validate_grid(width, height, ingredients.len())?;
40        Ok(Self {
41            width,
42            height,
43            ingredients,
44        })
45    }
46
47    /// Returns the grid width.
48    #[must_use]
49    pub const fn width(&self) -> u32 {
50        self.width
51    }
52
53    /// Returns the grid height.
54    #[must_use]
55    pub const fn height(&self) -> u32 {
56        self.height
57    }
58
59    /// Returns the grid dimensions as `(width, height)`.
60    #[must_use]
61    pub const fn dimensions(&self) -> (u32, u32) {
62        (self.width, self.height)
63    }
64
65    /// Returns the ingredient displays in row-major order.
66    #[must_use]
67    pub fn ingredients(&self) -> &[SlotDisplay] {
68        &self.ingredients
69    }
70
71    /// Extracts the ingredient displays in row-major order.
72    #[must_use]
73    pub fn into_ingredients(self) -> Vec<SlotDisplay> {
74        self.ingredients
75    }
76}
77
78impl TypeCodec for ShapedRecipeGrid {
79    fn encode(&self, writer: &mut impl std::io::Write) -> Result<(), CodecError> {
80        VarInt(self.width as i32)
81            .encode(writer)
82            .map_err(|error| error.with_context(CodecKind::ShapedRecipeGrid))?;
83        VarInt(self.height as i32)
84            .encode(writer)
85            .map_err(|error| error.with_context(CodecKind::ShapedRecipeGrid))?;
86        VarInt(self.ingredients.len() as i32)
87            .encode(writer)
88            .map_err(|error| error.with_context(CodecKind::ShapedRecipeGrid))?;
89        for ingredient in &self.ingredients {
90            ingredient
91                .encode(writer)
92                .map_err(|error| error.with_context(CodecKind::ShapedRecipeGrid))?;
93        }
94        Ok(())
95    }
96
97    fn decode(reader: &mut impl Read) -> Result<Self, CodecError> {
98        let width = decode_grid_dimension(reader)?;
99        let height = decode_grid_dimension(reader)?;
100        let ingredient_count = decode_ingredient_count(reader)?;
101        validate_decoded_grid(width, height, ingredient_count)?;
102
103        let mut ingredients = Vec::new();
104        for _ in 0..ingredient_count {
105            ingredients.push(
106                SlotDisplay::decode(reader)
107                    .map_err(|error| error.with_context(CodecKind::ShapedRecipeGrid))?,
108            );
109        }
110        Ok(Self {
111            width,
112            height,
113            ingredients,
114        })
115    }
116}
117
118/// A shaped crafting recipe display.
119#[derive(Debug, Clone, PartialEq, TypeStructCodec)]
120#[type_struct_codec(kind = RecipeDisplay)]
121pub struct CraftingShapedRecipeDisplay {
122    /// Validated rectangular ingredient grid.
123    pub grid: ShapedRecipeGrid,
124    /// Display for the crafted result.
125    pub result: SlotDisplay,
126    /// Crafting-station icon shown by the client.
127    pub crafting_station: SlotDisplay,
128}
129
130impl CraftingShapedRecipeDisplay {
131    /// Creates a shaped display while enforcing the ingredient-grid invariant.
132    pub fn new(
133        width: u32,
134        height: u32,
135        ingredients: Vec<SlotDisplay>,
136        result: SlotDisplay,
137        crafting_station: SlotDisplay,
138    ) -> Result<Self, InvalidShapedRecipeGrid> {
139        Ok(Self {
140            grid: ShapedRecipeGrid::new(width, height, ingredients)?,
141            result,
142            crafting_station,
143        })
144    }
145}
146
147/// A furnace-style recipe display.
148#[derive(Debug, Clone, PartialEq, TypeStructCodec)]
149#[type_struct_codec(kind = RecipeDisplay)]
150pub struct FurnaceRecipeDisplay {
151    /// Ingredient accepted by the furnace recipe.
152    pub ingredient: SlotDisplay,
153    /// Fuel display.
154    pub fuel: SlotDisplay,
155    /// Smelting result display.
156    pub result: SlotDisplay,
157    /// Furnace icon shown by the client.
158    pub crafting_station: SlotDisplay,
159    /// Cooking duration in ticks.
160    pub cooking_time: VarInt,
161    /// Experience awarded by the recipe.
162    pub experience: Float,
163}
164
165/// A stonecutter recipe display.
166#[derive(Debug, Clone, PartialEq, TypeStructCodec)]
167#[type_struct_codec(kind = RecipeDisplay)]
168pub struct StonecutterRecipeDisplay {
169    /// Input ingredient display.
170    pub ingredient: SlotDisplay,
171    /// Result display.
172    pub result: SlotDisplay,
173    /// Stonecutter icon shown by the client.
174    pub crafting_station: SlotDisplay,
175}
176
177/// A smithing recipe display.
178#[derive(Debug, Clone, PartialEq, TypeStructCodec)]
179#[type_struct_codec(kind = RecipeDisplay)]
180pub struct SmithingRecipeDisplay {
181    /// Smithing template display.
182    pub template: SlotDisplay,
183    /// Base item display.
184    pub base: SlotDisplay,
185    /// Addition material display.
186    pub addition: SlotDisplay,
187    /// Smithing result display.
188    pub result: SlotDisplay,
189    /// Smithing-table icon shown by the client.
190    pub crafting_station: SlotDisplay,
191}
192
193/// A recipe description sent for display by the client.
194///
195/// Each enum variant fixes both the ID in the `minecraft:recipe_display`
196/// registry and its payload structure, so mismatched IDs and payloads cannot
197/// be represented. The current protocol IDs are:
198///
199/// - `0`: `minecraft:crafting_shapeless`
200/// - `1`: `minecraft:crafting_shaped`
201/// - `2`: `minecraft:furnace`
202/// - `3`: `minecraft:stonecutter`
203/// - `4`: `minecraft:smithing`
204///
205/// # Examples
206///
207/// ```
208/// use mcproto_types::{
209///     CraftingShapedRecipeDisplay, RecipeDisplay, SlotDisplay, TypeCodec,
210/// };
211///
212/// let display = RecipeDisplay::CraftingShaped(
213///     CraftingShapedRecipeDisplay::new(
214///         2,
215///         1,
216///         vec![SlotDisplay::Empty, SlotDisplay::AnyFuel],
217///         SlotDisplay::Empty,
218///         SlotDisplay::AnyFuel,
219///     )?,
220/// );
221/// let mut encoded = Vec::new();
222/// display.encode(&mut encoded)?;
223/// let mut input = encoded.as_slice();
224/// assert_eq!(RecipeDisplay::decode(&mut input)?, display);
225/// assert!(input.is_empty());
226/// # Ok::<(), Box<dyn std::error::Error>>(())
227/// ```
228///
229/// See the official [Recipe Display structure] documentation.
230///
231/// [Recipe Display structure]: https://minecraft.wiki/w/Java_Edition_protocol/Recipes#Recipe_Display_structure
232#[derive(Debug, Clone, PartialEq)]
233pub enum RecipeDisplay {
234    CraftingShapeless(CraftingShapelessRecipeDisplay),
235    CraftingShaped(CraftingShapedRecipeDisplay),
236    Furnace(FurnaceRecipeDisplay),
237    Stonecutter(StonecutterRecipeDisplay),
238    Smithing(SmithingRecipeDisplay),
239}
240
241impl TypeCodec for RecipeDisplay {
242    fn encode(&self, writer: &mut impl std::io::Write) -> Result<(), CodecError> {
243        match self {
244            Self::CraftingShapeless(value) => {
245                encode_display_type(0, writer)?;
246                value.encode(writer)
247            }
248            Self::CraftingShaped(value) => {
249                encode_display_type(1, writer)?;
250                value.encode(writer)
251            }
252            Self::Furnace(value) => {
253                encode_display_type(2, writer)?;
254                value.encode(writer)
255            }
256            Self::Stonecutter(value) => {
257                encode_display_type(3, writer)?;
258                value.encode(writer)
259            }
260            Self::Smithing(value) => {
261                encode_display_type(4, writer)?;
262                value.encode(writer)
263            }
264        }
265    }
266
267    fn decode(reader: &mut impl Read) -> Result<Self, CodecError> {
268        let display_type = VarInt::decode(reader)
269            .map_err(|error| error.with_context(CodecKind::RecipeDisplay))?
270            .0;
271        match display_type {
272            0 => CraftingShapelessRecipeDisplay::decode(reader).map(Self::CraftingShapeless),
273            1 => CraftingShapedRecipeDisplay::decode(reader).map(Self::CraftingShaped),
274            2 => FurnaceRecipeDisplay::decode(reader).map(Self::Furnace),
275            3 => StonecutterRecipeDisplay::decode(reader).map(Self::Stonecutter),
276            4 => SmithingRecipeDisplay::decode(reader).map(Self::Smithing),
277            value => Err(CodecError::invalid_encoding(
278                CodecKind::RecipeDisplay,
279                0,
280                InvalidEncodingReason::InvalidEnumValue {
281                    value: i128::from(value),
282                },
283            )),
284        }
285    }
286}
287
288fn encode_display_type(
289    display_type: i32,
290    writer: &mut impl std::io::Write,
291) -> Result<(), CodecError> {
292    VarInt(display_type)
293        .encode(writer)
294        .map_err(|error| error.with_context(CodecKind::RecipeDisplay))
295}
296
297fn decode_grid_dimension(reader: &mut impl Read) -> Result<u32, CodecError> {
298    let value = VarInt::decode(reader)
299        .map_err(|error| error.with_context(CodecKind::ShapedRecipeGrid))?
300        .0;
301    u32::try_from(value).map_err(|_| {
302        CodecError::invalid_encoding(
303            CodecKind::ShapedRecipeGrid,
304            0,
305            InvalidEncodingReason::NegativeLength { value },
306        )
307    })
308}
309
310fn decode_ingredient_count(reader: &mut impl Read) -> Result<usize, CodecError> {
311    let value = VarInt::decode(reader)
312        .map_err(|error| error.with_context(CodecKind::ShapedRecipeGrid))?
313        .0;
314    usize::try_from(value).map_err(|_| {
315        CodecError::invalid_encoding(
316            CodecKind::ShapedRecipeGrid,
317            0,
318            InvalidEncodingReason::NegativeLength { value },
319        )
320    })
321}
322
323fn validate_decoded_grid(
324    width: u32,
325    height: u32,
326    ingredient_count: usize,
327) -> Result<(), CodecError> {
328    let expected = encoded_grid_area(width, height).ok_or_else(|| {
329        CodecError::invalid_encoding(
330            CodecKind::ShapedRecipeGrid,
331            0,
332            InvalidEncodingReason::LengthOutOfRange {
333                max: i32::MAX as usize,
334                actual: usize::MAX,
335            },
336        )
337    })?;
338    if ingredient_count != expected {
339        return Err(CodecError::invalid_encoding(
340            CodecKind::ShapedRecipeGrid,
341            0,
342            InvalidEncodingReason::ArrayLengthMismatch {
343                expected,
344                actual: ingredient_count,
345            },
346        ));
347    }
348    Ok(())
349}
350
351fn validate_grid(
352    width: u32,
353    height: u32,
354    ingredient_count: usize,
355) -> Result<(), InvalidShapedRecipeGrid> {
356    if width > i32::MAX as u32 {
357        return Err(InvalidShapedRecipeGrid::WidthOutOfRange { width });
358    }
359    if height > i32::MAX as u32 {
360        return Err(InvalidShapedRecipeGrid::HeightOutOfRange { height });
361    }
362    let expected = encoded_grid_area(width, height)
363        .ok_or(InvalidShapedRecipeGrid::AreaOutOfRange { width, height })?;
364    if ingredient_count != expected {
365        return Err(InvalidShapedRecipeGrid::IngredientCountMismatch {
366            expected,
367            actual: ingredient_count,
368        });
369    }
370    Ok(())
371}
372
373fn encoded_grid_area(width: u32, height: u32) -> Option<usize> {
374    let area = u64::from(width).checked_mul(u64::from(height))?;
375    if area > i32::MAX as u64 {
376        return None;
377    }
378    Some(area as usize)
379}
380
381/// Error returned when constructing an invalid shaped recipe grid.
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
383pub enum InvalidShapedRecipeGrid {
384    /// Width cannot be represented by the protocol VarInt.
385    WidthOutOfRange { width: u32 },
386    /// Height cannot be represented by the protocol VarInt.
387    HeightOutOfRange { height: u32 },
388    /// The rectangular area cannot be represented by the ingredient-count VarInt.
389    AreaOutOfRange { width: u32, height: u32 },
390    /// The supplied ingredient count is not exactly `width * height`.
391    IngredientCountMismatch { expected: usize, actual: usize },
392}
393
394impl fmt::Display for InvalidShapedRecipeGrid {
395    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
396        match self {
397            Self::WidthOutOfRange { width } => {
398                write!(
399                    formatter,
400                    "recipe grid width exceeds a positive VarInt: {width}"
401                )
402            }
403            Self::HeightOutOfRange { height } => {
404                write!(
405                    formatter,
406                    "recipe grid height exceeds a positive VarInt: {height}"
407                )
408            }
409            Self::AreaOutOfRange { width, height } => write!(
410                formatter,
411                "recipe grid area {width} * {height} exceeds a positive VarInt"
412            ),
413            Self::IngredientCountMismatch { expected, actual } => write!(
414                formatter,
415                "recipe grid requires {expected} ingredients, got {actual}"
416            ),
417        }
418    }
419}
420
421impl std::error::Error for InvalidShapedRecipeGrid {}