Skip to main content

openstranded_common_crafting/
recipe_def.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5/// A single ingredient required by a crafting recipe.
6///
7/// Each requirement corresponds to one `req=` line inside a `combi=start/end`
8/// block.
9///
10/// # `.inf` format
11///
12/// ```text
13/// req=item_id[,count[,stay]]
14/// ```
15///
16/// * `item_id` — numeric ID of the item required.
17/// * `count` (optional, default 1) — how many of this item are needed.
18/// * `stay` (optional) — if present, the ingredient is **not** consumed.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct RecipeRequirement {
21    /// Item type ID (matches `ItemDef.id`).
22    pub item_id: u32,
23
24    /// Number of items required (default 1).
25    #[serde(default = "default_count")]
26    pub count: u32,
27
28    /// If `true`, the ingredient is not consumed when crafting.
29    #[serde(default)]
30    pub stay: bool,
31}
32
33const fn default_count() -> u32 {
34    1
35}
36
37impl RecipeRequirement {
38    /// Parse a requirement from a raw `req=` value string.
39    ///
40    /// Accepts `"itemId"`, `"itemId,count"`, or `"itemId,count,stay"`.
41    #[must_use]
42    pub fn from_inf_value(s: &str) -> Self {
43        let parts: Vec<&str> = s.splitn(3, ',').collect();
44        let item_id = parts.first().and_then(|p| p.trim().parse().ok()).unwrap_or(0);
45        let count = parts
46            .get(1)
47            .and_then(|p| p.trim().parse().ok())
48            .unwrap_or(1);
49        let stay = parts.get(2).map_or(false, |p| p.trim() == "stay");
50        Self { item_id, count, stay }
51    }
52}
53
54/// An item produced by a crafting recipe.
55///
56/// Each result corresponds to one `gen=` line inside a `combi=start/end`
57/// block.
58///
59/// # `.inf` format
60///
61/// ```text
62/// gen=item_id[,count]
63/// ```
64///
65/// * `item_id` — numeric ID of the item produced.
66/// * `count` (optional, default 1) — how many are produced.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct RecipeResult {
69    /// Item type ID (matches `ItemDef.id`).
70    pub item_id: u32,
71
72    /// Number of items produced (default 1).
73    #[serde(default = "default_count")]
74    pub count: u32,
75}
76
77impl RecipeResult {
78    /// Parse a result from a raw `gen=` value string.
79    ///
80    /// Accepts `"itemId"` or `"itemId,count"`.
81    #[must_use]
82    pub fn from_inf_value(s: &str) -> Self {
83        let parts: Vec<&str> = s.splitn(2, ',').collect();
84        let item_id = parts.first().and_then(|p| p.trim().parse().ok()).unwrap_or(0);
85        let count = parts
86            .get(1)
87            .and_then(|p| p.trim().parse().ok())
88            .unwrap_or(1);
89        Self { item_id, count }
90    }
91}
92
93/// A single crafting recipe definition.
94///
95/// Represents one `combi=start`…`combi=end` block from a `combinations*.inf`
96/// file.
97///
98/// # `.inf` structure
99///
100/// ```text
101/// combi=start
102///     id=recipe_name            (optional string identifier)
103///     req=item_id[,count[,stay]]
104///     req=item_id[,count[,stay]]
105///     gen=item_id[,count]
106///     genname=override_name     (optional result name override)
107///     script=start
108///         ... script commands ...
109///     script=end
110/// combi=end
111/// ```
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct RecipeDef {
114    /// Numeric serial ID assigned by the loader (1‑based, sequential).
115    pub serial_id: u32,
116
117    /// Optional string identifier (from `id=` field, e.g. `"bendablebranch"`).
118    #[serde(default)]
119    pub id: Option<String>,
120
121    /// Ingredients required by this recipe.
122    #[serde(default)]
123    pub requirements: Vec<RecipeRequirement>,
124
125    /// Items produced by this recipe.
126    #[serde(default)]
127    pub results: Vec<RecipeResult>,
128
129    /// Optional override for the generated item's display name.
130    #[serde(default)]
131    pub gen_name: Option<String>,
132
133    /// Optional script executed when the recipe is crafted.
134    #[serde(default)]
135    pub script: Option<String>,
136}
137
138// ── Helper: parse a Vec<RecipeRequirement> from multivalue "req" ────
139
140fn parse_requirements(fields: &HashMap<String, Vec<String>>) -> Vec<RecipeRequirement> {
141    fields
142        .get("req")
143        .map(|vals| vals.iter().map(|v| RecipeRequirement::from_inf_value(v)).collect())
144        .unwrap_or_default()
145}
146
147fn parse_results(fields: &HashMap<String, Vec<String>>) -> Vec<RecipeResult> {
148    fields
149        .get("gen")
150        .map(|vals| vals.iter().map(|v| RecipeResult::from_inf_value(v)).collect())
151        .unwrap_or_default()
152}
153
154// ── Field parsing helpers ──────────────────────────────────────────
155
156fn first_str<'a>(fields: &'a HashMap<String, Vec<String>>, key: &str) -> Option<&'a str> {
157    fields.get(key)?.first().map(String::as_str)
158}
159
160fn first_string(fields: &HashMap<String, Vec<String>>, key: &str) -> Option<String> {
161    first_str(fields, key).map(ToOwned::to_owned)
162}
163
164fn first_u32(fields: &HashMap<String, Vec<String>>, key: &str) -> Option<u32> {
165    first_str(fields, key)?.parse().ok()
166}
167
168impl RecipeDef {
169    /// Construct a `RecipeDef` from the raw fields **inside** a `combi`
170    /// block (i.e. a `Structured` block content's fields).
171    ///
172    /// The `serial_id` should be supplied by the caller (sequential
173    /// counter across all combi blocks).
174    ///
175    /// Unknown or unparseable fields are silently ignored; missing
176    /// required fields (`gen`) return `None`.
177    #[must_use]
178    pub fn from_combi_fields(
179        serial_id: u32,
180        fields: &HashMap<String, Vec<String>>,
181    ) -> Option<Self> {
182        // A valid recipe must produce at least one item.
183        let results = parse_results(fields);
184        if results.is_empty() {
185            return None;
186        }
187
188        let id = first_string(fields, "id");
189        let requirements = parse_requirements(fields);
190        let gen_name = first_string(fields, "genname");
191
192        // Script is stored as a block in the original data, not as a
193        // field in the combi block fields. Callers should pass it in
194        // separately; we leave it as None here.
195        let script = None;
196
197        Some(Self {
198            serial_id,
199            id,
200            requirements,
201            results,
202            gen_name,
203            script,
204        })
205    }
206
207    /// Construct a `RecipeDef` from the entry-level fields of a parsed
208    /// `.inf` entry that contains `combi` blocks.
209    ///
210    /// This is useful when the caller has the outer `InfEntry` fields
211    /// (which are usually empty for combinations files) and wants to
212    /// pass in the script separately.
213    ///
214    /// Prefer [`from_combi_fields`](Self::from_combi_fields) when you
215    /// already have the structured combi block fields.
216    #[must_use]
217    pub fn from_inf_fields(serial_id: u32, fields: &HashMap<String, Vec<String>>) -> Option<Self> {
218        Self::from_combi_fields(serial_id, fields)
219    }
220
221    /// Attach a script to this recipe (builder-style).
222    #[must_use]
223    pub fn with_script(mut self, script: Option<String>) -> Self {
224        self.script = script;
225        self
226    }
227}
228
229// ── Tests ──────────────────────────────────────────────────────────
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    // ── RecipeRequirement ────────────────────────────────────────────
236
237    #[test]
238    fn test_req_from_inf_minimal() {
239        let req = RecipeRequirement::from_inf_value("24");
240        assert_eq!(req.item_id, 24);
241        assert_eq!(req.count, 1);
242        assert!(!req.stay);
243    }
244
245    #[test]
246    fn test_req_from_inf_with_count() {
247        let req = RecipeRequirement::from_inf_value("24,5");
248        assert_eq!(req.item_id, 24);
249        assert_eq!(req.count, 5);
250        assert!(!req.stay);
251    }
252
253    #[test]
254    fn test_req_from_inf_with_stay() {
255        let req = RecipeRequirement::from_inf_value("24,1,stay");
256        assert_eq!(req.item_id, 24);
257        assert_eq!(req.count, 1);
258        assert!(req.stay);
259    }
260
261    #[test]
262    fn test_req_from_inf_with_count_and_stay() {
263        let req = RecipeRequirement::from_inf_value("42,3,stay");
264        assert_eq!(req.item_id, 42);
265        assert_eq!(req.count, 3);
266        assert!(req.stay);
267    }
268
269    #[test]
270    fn test_req_from_inf_empty() {
271        let req = RecipeRequirement::from_inf_value("");
272        assert_eq!(req.item_id, 0);
273        assert_eq!(req.count, 1);
274        assert!(!req.stay);
275    }
276
277    // ── RecipeResult ─────────────────────────────────────────────────
278
279    #[test]
280    fn test_gen_from_inf_minimal() {
281        let result = RecipeResult::from_inf_value("25");
282        assert_eq!(result.item_id, 25);
283        assert_eq!(result.count, 1);
284    }
285
286    #[test]
287    fn test_gen_from_inf_with_count() {
288        let result = RecipeResult::from_inf_value("25,10");
289        assert_eq!(result.item_id, 25);
290        assert_eq!(result.count, 10);
291    }
292
293    #[test]
294    fn test_gen_from_inf_empty() {
295        let result = RecipeResult::from_inf_value("");
296        assert_eq!(result.item_id, 0);
297        assert_eq!(result.count, 1);
298    }
299
300    // ── RecipeDef ────────────────────────────────────────────────────
301
302    #[test]
303    fn test_recipe_from_combi_fields_basic() {
304        let mut fields = HashMap::new();
305        fields.insert("id".into(), vec!["bendablebranch".into()]);
306        fields.insert("req".into(), vec!["24".into(), "38".into()]);
307        fields.insert("gen".into(), vec!["25".into()]);
308
309        let recipe = RecipeDef::from_combi_fields(1, &fields).unwrap();
310        assert_eq!(recipe.serial_id, 1);
311        assert_eq!(recipe.id.as_deref(), Some("bendablebranch"));
312        assert_eq!(recipe.requirements.len(), 2);
313        assert_eq!(recipe.requirements[0].item_id, 24);
314        assert_eq!(recipe.requirements[1].item_id, 38);
315        assert_eq!(recipe.results.len(), 1);
316        assert_eq!(recipe.results[0].item_id, 25);
317    }
318
319    #[test]
320    fn test_recipe_missing_gen_returns_none() {
321        let fields = HashMap::new();
322        assert!(RecipeDef::from_combi_fields(1, &fields).is_none());
323    }
324
325    #[test]
326    fn test_recipe_with_genname() {
327        let mut fields = HashMap::new();
328        fields.insert("gen".into(), vec!["82".into()]);
329        fields.insert("genname".into(), vec!["Dough".into()]);
330
331        let recipe = RecipeDef::from_combi_fields(2, &fields).unwrap();
332        assert_eq!(recipe.gen_name.as_deref(), Some("Dough"));
333    }
334
335    #[test]
336    fn test_recipe_with_script() {
337        let mut fields = HashMap::new();
338        fields.insert("gen".into(), vec!["43".into()]);
339        fields.insert("req".into(), vec!["42,9".into(), "23,1,stay".into()]);
340
341        let recipe = RecipeDef::from_combi_fields(3, &fields)
342            .unwrap()
343            .with_script(Some("play \"grind.wav\";".into()));
344        assert_eq!(
345            recipe.script.as_deref(),
346            Some("play \"grind.wav\";")
347        );
348    }
349
350    #[test]
351    fn test_recipe_serde_derives_compile() {
352        // The derives are checked at compile time.
353        // This test ensures the struct is constructible.
354        let recipe = RecipeDef {
355            serial_id: 1,
356            id: Some("test_recipe".into()),
357            requirements: vec![RecipeRequirement {
358                item_id: 10,
359                count: 2,
360                stay: false,
361            }],
362            results: vec![RecipeResult {
363                item_id: 20,
364                count: 1,
365            }],
366            gen_name: None,
367            script: None,
368        };
369        assert_eq!(recipe.serial_id, 1);
370        assert_eq!(recipe.results.len(), 1);
371    }
372}