Skip to main content

openstranded_common_building/
building_def.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5/// Category of a building recipe.
6///
7/// Mapped from the `group=` field in `buildings.inf`.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum BuildingGroup {
11    /// Regular buildings (huts, fences, shelters, …).
12    Building,
13    /// Storage containers (chests, shelves, …).
14    Storage,
15    /// Production facilities (furnace, workbench, still, …).
16    Production,
17    /// Any other group not covered by the standard categories.
18    #[serde(untagged)]
19    Custom(String),
20}
21
22impl BuildingGroup {
23    /// Parse a group string from `.inf` files.
24    #[must_use]
25    pub fn from_inf(s: &str) -> Self {
26        match s.trim() {
27            "building" => Self::Building,
28            "storage" => Self::Storage,
29            "production" => Self::Production,
30            other => Self::Custom(other.to_owned()),
31        }
32    }
33
34    /// Return the canonical string representation.
35    #[must_use]
36    pub fn as_str(&self) -> &str {
37        match self {
38            Self::Building => "building",
39            Self::Storage => "storage",
40            Self::Production => "production",
41            Self::Custom(s) => s.as_str(),
42        }
43    }
44}
45
46impl Default for BuildingGroup {
47    fn default() -> Self {
48        Self::Building
49    }
50}
51
52impl std::fmt::Display for BuildingGroup {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.write_str(self.as_str())
55    }
56}
57
58/// Where a building can be placed.
59///
60/// Mapped from the `buildspace=` field in `buildings.inf`.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum BuildSpace {
64    /// Solid ground (the default).
65    Land,
66    /// Both on land and in water.
67    LandAndWater,
68    /// In deep water.
69    Water,
70    /// On the shoreline.
71    Shore,
72    /// On a hill.
73    Hill,
74    /// In shallow water.
75    ShallowWater,
76    /// Next to a specific object type (`atobject=` defines which).
77    AtObject,
78}
79
80impl BuildSpace {
81    /// Parse a build space string from `.inf` files.
82    #[must_use]
83    pub fn from_inf(s: &str) -> Option<Self> {
84        match s.trim() {
85            "land" => Some(Self::Land),
86            "land and water" => Some(Self::LandAndWater),
87            "water" => Some(Self::Water),
88            "shore" => Some(Self::Shore),
89            "hill" => Some(Self::Hill),
90            "shallow water" => Some(Self::ShallowWater),
91            "at object" => Some(Self::AtObject),
92            _ => None,
93        }
94    }
95}
96
97/// A single material requirement for a building recipe.
98///
99/// # `.inf` format
100///
101/// ```text
102/// req=item_id[,count]
103/// ```
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct BuildingRequirement {
106    /// Item type ID (matches `ItemDef.id`).
107    pub item_id: u32,
108
109    /// Number of items required (default 1).
110    #[serde(default = "default_count")]
111    pub count: u32,
112}
113
114const fn default_count() -> u32 {
115    1
116}
117
118impl BuildingRequirement {
119    /// Parse a requirement from a raw `req=` value string.
120    #[must_use]
121    pub fn from_inf_value(s: &str) -> Self {
122        let parts: Vec<&str> = s.splitn(2, ',').collect();
123        let item_id = parts.first().and_then(|p| p.trim().parse().ok()).unwrap_or(0);
124        let count = parts
125            .get(1)
126            .and_then(|p| p.trim().parse().ok())
127            .unwrap_or(1);
128        Self { item_id, count }
129    }
130}
131
132/// A single building recipe definition.
133///
134/// Represents one entry from a `buildings*.inf` file.
135///
136/// # `.inf` structure
137///
138/// ```text
139/// id=N
140/// group=...
141/// objectid=N       (or unitid=N for units)
142/// req=item_id,count
143/// buildspace=...
144/// atobject=N
145/// buildingsite=N
146/// script=start
147///     ...
148/// script=end
149/// ```
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct BuildingDef {
152    /// Unique numeric identifier for this building recipe.
153    pub id: u32,
154
155    /// Category group.
156    #[serde(default)]
157    pub group: BuildingGroup,
158
159    /// Object type ID that will be created when built.
160    /// Mutually exclusive with [`unit_id`](Self::unit_id).
161    #[serde(default)]
162    pub object_id: Option<u32>,
163
164    /// Unit type ID that will be created when built.
165    /// Mutually exclusive with [`object_id`](Self::object_id).
166    #[serde(default)]
167    pub unit_id: Option<u32>,
168
169    /// Materials required to build this structure.
170    #[serde(default)]
171    pub requirements: Vec<BuildingRequirement>,
172
173    /// Where this building can be placed.
174    #[serde(default)]
175    pub build_space: Option<BuildSpace>,
176
177    /// Object type ID of the building site (buildplace) to use.
178    #[serde(default)]
179    pub building_site: Option<u32>,
180
181    /// Object type IDs that must exist nearby for construction.
182    #[serde(default)]
183    pub at_objects: Vec<u32>,
184
185    /// Optional script executed when the building is finished.
186    #[serde(default)]
187    pub script: Option<String>,
188}
189
190// ── Field parsing helpers ──────────────────────────────────────────
191
192fn first_str<'a>(fields: &'a HashMap<String, Vec<String>>, key: &str) -> Option<&'a str> {
193    fields.get(key)?.first().map(String::as_str)
194}
195
196fn first_string(fields: &HashMap<String, Vec<String>>, key: &str) -> Option<String> {
197    first_str(fields, key).map(ToOwned::to_owned)
198}
199
200fn first_u32(fields: &HashMap<String, Vec<String>>, key: &str) -> Option<u32> {
201    first_str(fields, key)?.parse().ok()
202}
203
204fn parse_requirements(fields: &HashMap<String, Vec<String>>) -> Vec<BuildingRequirement> {
205    fields
206        .get("req")
207        .map(|vals| {
208            vals.iter()
209                .map(|v| BuildingRequirement::from_inf_value(v))
210                .collect()
211        })
212        .unwrap_or_default()
213}
214
215fn parse_u32_list(fields: &HashMap<String, Vec<String>>, key: &str) -> Vec<u32> {
216    fields
217        .get(key)
218        .map(|vals| {
219            vals.iter()
220                .filter_map(|v| v.trim().parse().ok())
221                .collect()
222        })
223        .unwrap_or_default()
224}
225
226impl BuildingDef {
227    /// Construct a `BuildingDef` from the raw fields of a parsed `.inf`
228    /// entry.
229    ///
230    /// Unknown or unparseable fields are silently ignored; missing
231    /// required fields (`id`) return `None`.
232    #[must_use]
233    pub fn from_inf_fields(fields: &HashMap<String, Vec<String>>) -> Option<Self> {
234        let id = first_u32(fields, "id")?;
235
236        let group = first_str(fields, "group")
237            .map(BuildingGroup::from_inf)
238            .unwrap_or_default();
239
240        let object_id = first_u32(fields, "objectid");
241        let unit_id = first_u32(fields, "unitid");
242
243        let requirements = parse_requirements(fields);
244
245        let build_space = first_str(fields, "buildspace").and_then(BuildSpace::from_inf);
246
247        let building_site = first_u32(fields, "buildingsite");
248
249        let at_objects = parse_u32_list(fields, "atobject");
250
251        // Script is usually in a block; we leave it as None and let
252        // the caller attach it via `with_script()`.
253        let script = None;
254
255        Some(Self {
256            id,
257            group,
258            object_id,
259            unit_id,
260            requirements,
261            build_space,
262            building_site,
263            at_objects,
264            script,
265        })
266    }
267
268    /// Attach a script to this building recipe (builder-style).
269    #[must_use]
270    pub fn with_script(mut self, script: Option<String>) -> Self {
271        self.script = script;
272        self
273    }
274}
275
276// ── Tests ──────────────────────────────────────────────────────────
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    // ── BuildingGroup ────────────────────────────────────────────────
283
284    #[test]
285    fn test_building_group_from_inf() {
286        assert_eq!(BuildingGroup::from_inf("building"), BuildingGroup::Building);
287        assert_eq!(BuildingGroup::from_inf("storage"), BuildingGroup::Storage);
288        assert_eq!(BuildingGroup::from_inf("production"), BuildingGroup::Production);
289        assert_eq!(
290            BuildingGroup::from_inf("custom"),
291            BuildingGroup::Custom("custom".into())
292        );
293    }
294
295    #[test]
296    fn test_building_group_as_str() {
297        assert_eq!(BuildingGroup::Building.as_str(), "building");
298        assert_eq!(BuildingGroup::Custom("fort".into()).as_str(), "fort");
299    }
300
301    #[test]
302    fn test_building_group_display() {
303        assert_eq!(BuildingGroup::Storage.to_string(), "storage");
304    }
305
306    // ── BuildSpace ───────────────────────────────────────────────────
307
308    #[test]
309    fn test_build_space_from_inf() {
310        assert_eq!(BuildSpace::from_inf("land"), Some(BuildSpace::Land));
311        assert_eq!(
312            BuildSpace::from_inf("land and water"),
313            Some(BuildSpace::LandAndWater)
314        );
315        assert_eq!(BuildSpace::from_inf("water"), Some(BuildSpace::Water));
316        assert_eq!(BuildSpace::from_inf("shore"), Some(BuildSpace::Shore));
317        assert_eq!(BuildSpace::from_inf("hill"), Some(BuildSpace::Hill));
318        assert_eq!(
319            BuildSpace::from_inf("shallow water"),
320            Some(BuildSpace::ShallowWater)
321        );
322        assert_eq!(
323            BuildSpace::from_inf("at object"),
324            Some(BuildSpace::AtObject)
325        );
326        assert_eq!(BuildSpace::from_inf("invalid"), None);
327    }
328
329    // ── BuildingRequirement ──────────────────────────────────────────
330
331    #[test]
332    fn test_building_req_minimal() {
333        let req = BuildingRequirement::from_inf_value("24");
334        assert_eq!(req.item_id, 24);
335        assert_eq!(req.count, 1);
336    }
337
338    #[test]
339    fn test_building_req_with_count() {
340        let req = BuildingRequirement::from_inf_value("24,5");
341        assert_eq!(req.item_id, 24);
342        assert_eq!(req.count, 5);
343    }
344
345    // ── BuildingDef ──────────────────────────────────────────────────
346
347    #[test]
348    fn test_building_from_inf_fields_minimal() {
349        let mut fields = HashMap::new();
350        fields.insert("id".into(), vec!["1".into()]);
351        fields.insert("group".into(), vec!["building".into()]);
352        fields.insert("objectid".into(), vec!["184".into()]);
353
354        let def = BuildingDef::from_inf_fields(&fields).unwrap();
355        assert_eq!(def.id, 1);
356        assert_eq!(def.group, BuildingGroup::Building);
357        assert_eq!(def.object_id, Some(184));
358        assert!(def.unit_id.is_none());
359        assert!(def.requirements.is_empty());
360    }
361
362    #[test]
363    fn test_building_with_requirements() {
364        let mut fields = HashMap::new();
365        fields.insert("id".into(), vec!["8".into()]);
366        fields.insert("group".into(), vec!["building".into()]);
367        fields.insert("objectid".into(), vec!["190".into()]);
368        fields.insert(
369            "req".into(),
370            vec!["7,10".into(), "24,25".into(), "26,30".into()],
371        );
372        fields.insert("buildspace".into(), vec!["at object".into()]);
373        fields.insert("atobject".into(), vec!["13".into(), "16".into(), "17".into()]);
374
375        let def = BuildingDef::from_inf_fields(&fields).unwrap();
376        assert_eq!(def.requirements.len(), 3);
377        assert_eq!(def.requirements[0].item_id, 7);
378        assert_eq!(def.requirements[0].count, 10);
379        assert_eq!(def.build_space, Some(BuildSpace::AtObject));
380        assert_eq!(def.at_objects, vec![13, 16, 17]);
381    }
382
383    #[test]
384    fn test_building_with_unitid() {
385        let mut fields = HashMap::new();
386        fields.insert("id".into(), vec!["5".into()]);
387        fields.insert("group".into(), vec!["production".into()]);
388        fields.insert("unitid".into(), vec!["10".into()]);
389
390        let def = BuildingDef::from_inf_fields(&fields).unwrap();
391        assert_eq!(def.unit_id, Some(10));
392        assert!(def.object_id.is_none());
393        assert_eq!(def.group, BuildingGroup::Production);
394    }
395
396    #[test]
397    fn test_building_missing_id() {
398        let fields = HashMap::new();
399        assert!(BuildingDef::from_inf_fields(&fields).is_none());
400    }
401
402    #[test]
403    fn test_building_with_script() {
404        let mut fields = HashMap::new();
405        fields.insert("id".into(), vec!["2".into()]);
406        fields.insert("objectid".into(), vec!["165".into()]);
407        fields.insert("req".into(), vec!["24,20".into(), "15,30".into()]);
408
409        let def = BuildingDef::from_inf_fields(&fields)
410            .unwrap()
411            .with_script(Some("msg \"Built!\";".into()));
412        assert_eq!(def.script.as_deref(), Some("msg \"Built!\";"));
413    }
414
415    #[test]
416    fn test_building_with_buildingsite() {
417        let mut fields = HashMap::new();
418        fields.insert("id".into(), vec!["1".into()]);
419        fields.insert("objectid".into(), vec!["150".into()]);
420        fields.insert("buildingsite".into(), vec!["150".into()]);
421
422        let def = BuildingDef::from_inf_fields(&fields).unwrap();
423        assert_eq!(def.building_site, Some(150));
424    }
425
426    #[test]
427    fn test_building_serde_derives_compile() {
428        let def = BuildingDef {
429            id: 1,
430            group: BuildingGroup::Building,
431            object_id: Some(184),
432            unit_id: None,
433            requirements: vec![],
434            build_space: None,
435            building_site: None,
436            at_objects: vec![],
437            script: None,
438        };
439        assert_eq!(def.id, 1);
440    }
441}