Skip to main content

galeon_engine/
data.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use serde::Deserialize;
7
8/// Unit stats — the numeric properties of a unit type.
9#[derive(Debug, Clone, Deserialize, PartialEq)]
10pub struct UnitStats {
11    pub hp: i32,
12    pub speed: f32,
13    #[serde(default)]
14    pub combat_rating: i32,
15    #[serde(default)]
16    pub build_time: f32,
17}
18
19/// A unit template loaded from a RON file.
20///
21/// Templates are blueprints — they define what a unit IS. At spawn time,
22/// the template stamps its data into ECS components.
23#[derive(Debug, Clone, Deserialize)]
24pub struct UnitTemplate {
25    pub name: String,
26    pub stats: UnitStats,
27}
28
29/// Registry of game data loaded from RON files.
30///
31/// Currently supports unit templates. Will expand to buildings, tech trees,
32/// weapons, etc.
33#[derive(Debug)]
34pub struct DataRegistry {
35    units: HashMap<String, UnitTemplate>,
36}
37
38impl DataRegistry {
39    /// Create an empty registry.
40    pub fn new() -> Self {
41        Self {
42            units: HashMap::new(),
43        }
44    }
45
46    /// Load all unit templates from RON files in the given directory.
47    ///
48    /// Each `.ron` file in the directory is parsed as a `UnitTemplate`.
49    /// The filename (without extension) becomes the lookup key.
50    pub fn load_units_from_dir(dir: &Path) -> Result<Self, Box<DataLoadError>> {
51        let mut units = HashMap::new();
52
53        let entries = std::fs::read_dir(dir).map_err(|e| {
54            Box::new(DataLoadError::Io {
55                path: dir.to_path_buf(),
56                source: e,
57            })
58        })?;
59
60        for entry in entries {
61            let entry = entry.map_err(|e| {
62                Box::new(DataLoadError::Io {
63                    path: dir.to_path_buf(),
64                    source: e,
65                })
66            })?;
67            let path = entry.path();
68
69            if path.extension().is_some_and(|ext| ext == "ron") {
70                let key = path
71                    .file_stem()
72                    .unwrap_or_default()
73                    .to_string_lossy()
74                    .to_string();
75
76                let contents = std::fs::read_to_string(&path).map_err(|e| {
77                    Box::new(DataLoadError::Io {
78                        path: path.clone(),
79                        source: e,
80                    })
81                })?;
82
83                let template: UnitTemplate = ron::from_str(&contents).map_err(|e| {
84                    Box::new(DataLoadError::Ron {
85                        path: path.clone(),
86                        source: e,
87                    })
88                })?;
89
90                units.insert(key, template);
91            }
92        }
93
94        Ok(Self { units })
95    }
96
97    /// Load a single unit template from a RON string.
98    pub fn load_unit_from_str(key: &str, ron_str: &str) -> Result<Self, Box<DataLoadError>> {
99        let template: UnitTemplate = ron::from_str(ron_str).map_err(|e| {
100            Box::new(DataLoadError::RonParse {
101                key: key.to_string(),
102                source: e,
103            })
104        })?;
105        let mut units = HashMap::new();
106        units.insert(key.to_string(), template);
107        Ok(Self { units })
108    }
109
110    /// Get a unit template by name.
111    pub fn unit(&self, name: &str) -> Option<&UnitTemplate> {
112        self.units.get(name)
113    }
114
115    /// Returns the number of loaded unit templates.
116    pub fn unit_count(&self) -> usize {
117        self.units.len()
118    }
119
120    /// Merge another registry into this one.
121    pub fn merge(&mut self, other: DataRegistry) {
122        self.units.extend(other.units);
123    }
124}
125
126impl Default for DataRegistry {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132/// Errors that can occur during data loading.
133#[derive(Debug)]
134pub enum DataLoadError {
135    Io {
136        path: std::path::PathBuf,
137        source: std::io::Error,
138    },
139    Ron {
140        path: std::path::PathBuf,
141        source: ron::error::SpannedError,
142    },
143    RonParse {
144        key: String,
145        source: ron::error::SpannedError,
146    },
147}
148
149impl std::fmt::Display for DataLoadError {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            DataLoadError::Io { path, source } => {
153                write!(f, "IO error reading {}: {}", path.display(), source)
154            }
155            DataLoadError::Ron { path, source } => {
156                write!(f, "RON parse error in {}: {}", path.display(), source)
157            }
158            DataLoadError::RonParse { key, source } => {
159                write!(f, "RON parse error for '{}': {}", key, source)
160            }
161        }
162    }
163}
164
165impl std::error::Error for DataLoadError {}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::io::Write;
171
172    #[test]
173    fn deserialize_unit_template_from_ron() {
174        let ron_str = r#"
175            UnitTemplate(
176                name: "Sentinel",
177                stats: UnitStats(
178                    hp: 120,
179                    speed: 45.0,
180                    combat_rating: 18,
181                    build_time: 15.0,
182                ),
183            )
184        "#;
185
186        let template: UnitTemplate = ron::from_str(ron_str).unwrap();
187        assert_eq!(template.name, "Sentinel");
188        assert_eq!(template.stats.hp, 120);
189        assert!((template.stats.speed - 45.0).abs() < f32::EPSILON);
190        assert_eq!(template.stats.combat_rating, 18);
191    }
192
193    #[test]
194    fn load_unit_from_str() {
195        let ron_str = r#"UnitTemplate(name: "Scout", stats: UnitStats(hp: 50, speed: 80.0))"#;
196        let registry = DataRegistry::load_unit_from_str("scout", ron_str).unwrap();
197        let unit = registry.unit("scout").unwrap();
198        assert_eq!(unit.name, "Scout");
199        assert_eq!(unit.stats.hp, 50);
200    }
201
202    #[test]
203    fn load_units_from_directory() {
204        let dir = tempfile::tempdir().unwrap();
205
206        let sentinel_path = dir.path().join("sentinel.ron");
207        let mut f = std::fs::File::create(&sentinel_path).unwrap();
208        writeln!(
209            f,
210            r#"UnitTemplate(name: "Sentinel", stats: UnitStats(hp: 120, speed: 45.0))"#
211        )
212        .unwrap();
213
214        let scout_path = dir.path().join("scout.ron");
215        let mut f = std::fs::File::create(&scout_path).unwrap();
216        writeln!(
217            f,
218            r#"UnitTemplate(name: "Scout", stats: UnitStats(hp: 50, speed: 80.0))"#
219        )
220        .unwrap();
221
222        let txt_path = dir.path().join("notes.txt");
223        std::fs::write(&txt_path, "not a ron file").unwrap();
224
225        let registry = DataRegistry::load_units_from_dir(dir.path()).unwrap();
226        assert_eq!(registry.unit_count(), 2);
227        assert_eq!(registry.unit("sentinel").unwrap().stats.hp, 120);
228        assert_eq!(registry.unit("scout").unwrap().stats.hp, 50);
229    }
230
231    #[test]
232    fn missing_optional_fields_default() {
233        let ron_str = r#"UnitTemplate(name: "Minimal", stats: UnitStats(hp: 10, speed: 1.0))"#;
234        let template: UnitTemplate = ron::from_str(ron_str).unwrap();
235        assert_eq!(template.stats.combat_rating, 0);
236        assert!((template.stats.build_time - 0.0).abs() < f32::EPSILON);
237    }
238
239    #[test]
240    fn bad_ron_gives_descriptive_error() {
241        let bad_ron = r#"UnitTemplate(name: "Bad", stats: UnitStats(hp: "not a number"))"#;
242        let result = DataRegistry::load_unit_from_str("bad", bad_ron);
243        assert!(result.is_err());
244        let err = result.unwrap_err();
245        assert!(err.to_string().contains("bad"));
246    }
247}