Skip to main content

nms_graph/
extract.rs

1//! Extract System/Planet data from raw save discovery records.
2
3use std::collections::HashMap;
4
5use nms_core::address::GalacticAddress;
6use nms_core::biome::Biome;
7use nms_core::system::{Planet, System};
8use nms_save::model::SaveRoot;
9
10use crate::spatial::SystemId;
11
12/// Temporary accumulator for building a System from multiple discovery records.
13#[derive(Debug)]
14struct SystemBuilder {
15    address: GalacticAddress,
16    name: Option<String>,
17    discoverer: Option<String>,
18    timestamp: Option<chrono::DateTime<chrono::Utc>>,
19    planets: Vec<Planet>,
20}
21
22/// Extract biome and infested flag from a discovery record's VP array.
23///
24/// VP array format (for Planet discovery type):
25///   VP[0]: seed hash (hex string or integer)
26///   VP[1]: biome/flags packed integer
27///     - bits 0..15 (mask 0xFFFF): biome type index (GcBiomeType enum)
28///     - bit 16 (mask 0x10000): infested flag
29///
30/// Returns `(biome, infested)`. Returns `(None, false)` if VP is empty or
31/// the format is unrecognized.
32pub fn extract_biome_from_vp(vp: &[serde_json::Value]) -> (Option<Biome>, bool) {
33    if vp.len() < 2 {
34        return (None, false);
35    }
36
37    // VP[1] can be a hex string "0x..." or integer
38    let flags = match &vp[1] {
39        serde_json::Value::Number(n) => n.as_u64(),
40        serde_json::Value::String(s) => {
41            let hex = s
42                .strip_prefix("0x")
43                .or_else(|| s.strip_prefix("0X"))
44                .unwrap_or(s);
45            u64::from_str_radix(hex, 16).ok()
46        }
47        _ => None,
48    };
49
50    let Some(flags) = flags else {
51        return (None, false);
52    };
53
54    let infested = (flags >> 16) & 1 == 1;
55
56    // Biome type is in the lower 16 bits (mask 0xFFFF).
57    // Mapping matches GcBiomeType::BiomeEnum ordering from game data.
58    let biome_index = (flags & 0xFFFF) as u16;
59    let biome = match biome_index {
60        0 => Some(Biome::Lush),
61        1 => Some(Biome::Toxic),
62        2 => Some(Biome::Scorched),
63        3 => Some(Biome::Radioactive),
64        4 => Some(Biome::Frozen),
65        5 => Some(Biome::Barren),
66        6 => Some(Biome::Dead),
67        7 => Some(Biome::Weird),
68        8 => Some(Biome::Red),
69        9 => Some(Biome::Green),
70        10 => Some(Biome::Blue),
71        11 => None, // "Test" biome in game data -- skip
72        12 => Some(Biome::Swamp),
73        13 => Some(Biome::Lava),
74        14 => Some(Biome::Waterworld),
75        15 => Some(Biome::GasGiant),
76        _ => None,
77    };
78
79    (biome, infested)
80}
81
82/// Extract seed hash from VP[0].
83pub fn extract_seed_from_vp(vp: &[serde_json::Value]) -> Option<u64> {
84    if vp.is_empty() {
85        return None;
86    }
87    match &vp[0] {
88        serde_json::Value::Number(n) => n.as_u64(),
89        serde_json::Value::String(s) => {
90            let hex = s
91                .strip_prefix("0x")
92                .or_else(|| s.strip_prefix("0X"))
93                .unwrap_or(s);
94            u64::from_str_radix(hex, 16).ok()
95        }
96        _ => None,
97    }
98}
99
100/// Build systems and planets from a parsed save file's discovery records.
101///
102/// Groups discovery records by system address, extracts planet biome data,
103/// and returns a map of SystemId -> System.
104pub fn extract_systems(save: &SaveRoot) -> HashMap<SystemId, System> {
105    let records = &save.discovery_manager_data.discovery_data_v1.store.record;
106    let mut builders: HashMap<SystemId, SystemBuilder> = HashMap::new();
107
108    // First pass: collect SolarSystem discoveries (for system names/discoverers)
109    for rec in records {
110        if rec.dd.dt != "SolarSystem" {
111            continue;
112        }
113        let addr = GalacticAddress::from_packed(rec.dd.ua.0, 0);
114        let sys_id = SystemId::from_address(&addr);
115
116        let timestamp = if rec.ows.ts > 0 {
117            chrono::DateTime::from_timestamp(rec.ows.ts as i64, 0)
118        } else {
119            None
120        };
121
122        let discoverer = if rec.ows.usn.is_empty() {
123            None
124        } else {
125            Some(rec.ows.usn.clone())
126        };
127
128        builders.entry(sys_id).or_insert_with(|| SystemBuilder {
129            address: addr,
130            name: None, // System names aren't in discovery records
131            discoverer,
132            timestamp,
133            planets: Vec::new(),
134        });
135    }
136
137    // Second pass: collect Planet discoveries and attach to systems
138    for rec in records {
139        if rec.dd.dt != "Planet" {
140            continue;
141        }
142        let addr = GalacticAddress::from_packed(rec.dd.ua.0, 0);
143        let sys_id = SystemId::from_address(&addr);
144        let planet_index = addr.planet_index();
145
146        let (biome, infested) = extract_biome_from_vp(&rec.dd.vp);
147        let seed_hash = extract_seed_from_vp(&rec.dd.vp);
148
149        let planet = Planet::new(
150            planet_index,
151            biome,
152            None, // BiomeSubType not extractable from VP
153            infested,
154            None, // Planet names aren't in discovery records
155            seed_hash,
156        );
157
158        let builder = builders.entry(sys_id).or_insert_with(|| SystemBuilder {
159            address: addr,
160            name: None,
161            discoverer: None,
162            timestamp: None,
163            planets: Vec::new(),
164        });
165
166        // Avoid duplicate planet indices
167        if !builder.planets.iter().any(|p| p.index == planet_index) {
168            builder.planets.push(planet);
169        }
170    }
171
172    // Convert builders to Systems
173    builders
174        .into_iter()
175        .map(|(id, b)| {
176            let system = System::new(b.address, b.name, b.discoverer, b.timestamp, b.planets);
177            (id, system)
178        })
179        .collect()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_extract_biome_from_vp_empty() {
188        assert_eq!(extract_biome_from_vp(&[]), (None, false));
189    }
190
191    #[test]
192    fn test_extract_biome_from_vp_single_element() {
193        let vp = vec![serde_json::json!("0xABCD")];
194        assert_eq!(extract_biome_from_vp(&vp), (None, false));
195    }
196
197    #[test]
198    fn test_extract_biome_lush_not_infested() {
199        let vp = vec![serde_json::json!("0xABCD"), serde_json::json!(0)];
200        assert_eq!(extract_biome_from_vp(&vp), (Some(Biome::Lush), false));
201    }
202
203    #[test]
204    fn test_extract_biome_toxic_infested() {
205        // bit 16 set = infested, low byte = 1 = Toxic
206        let flags = (1u64 << 16) | 1;
207        let vp = vec![serde_json::json!("0xABCD"), serde_json::json!(flags)];
208        assert_eq!(extract_biome_from_vp(&vp), (Some(Biome::Toxic), true));
209    }
210
211    #[test]
212    fn test_extract_biome_from_hex_string() {
213        // 0x00010005 = bit 16 set (infested) + 5 (Barren)
214        let vp = vec![serde_json::json!("0xABCD"), serde_json::json!("0x10005")];
215        assert_eq!(extract_biome_from_vp(&vp), (Some(Biome::Barren), true));
216    }
217
218    #[test]
219    fn test_extract_biome_unknown_index() {
220        let vp = vec![serde_json::json!("0xABCD"), serde_json::json!(255)];
221        assert_eq!(extract_biome_from_vp(&vp), (None, false));
222    }
223
224    #[test]
225    fn test_extract_biome_test_index_skipped() {
226        // Index 11 is "Test" biome -- should return None
227        let vp = vec![serde_json::json!("0xABCD"), serde_json::json!(11)];
228        assert_eq!(extract_biome_from_vp(&vp), (None, false));
229    }
230
231    #[test]
232    fn test_extract_biome_all_valid_indices() {
233        let expected = [
234            (0, Some(Biome::Lush)),
235            (1, Some(Biome::Toxic)),
236            (2, Some(Biome::Scorched)),
237            (3, Some(Biome::Radioactive)),
238            (4, Some(Biome::Frozen)),
239            (5, Some(Biome::Barren)),
240            (6, Some(Biome::Dead)),
241            (7, Some(Biome::Weird)),
242            (8, Some(Biome::Red)),
243            (9, Some(Biome::Green)),
244            (10, Some(Biome::Blue)),
245            (12, Some(Biome::Swamp)),
246            (13, Some(Biome::Lava)),
247            (14, Some(Biome::Waterworld)),
248            (15, Some(Biome::GasGiant)),
249        ];
250        for (idx, biome) in expected {
251            let vp = vec![serde_json::json!("0x0"), serde_json::json!(idx)];
252            assert_eq!(
253                extract_biome_from_vp(&vp),
254                (biome, false),
255                "Failed for biome index {idx}"
256            );
257        }
258    }
259
260    #[test]
261    fn test_extract_biome_vp1_not_number_or_string() {
262        let vp = vec![serde_json::json!("0xABCD"), serde_json::json!(true)];
263        assert_eq!(extract_biome_from_vp(&vp), (None, false));
264    }
265
266    #[test]
267    fn test_extract_seed_from_vp_hex() {
268        let vp = vec![serde_json::json!("0xD6911E7B1D31085E")];
269        assert_eq!(extract_seed_from_vp(&vp), Some(0xD6911E7B1D31085E));
270    }
271
272    #[test]
273    fn test_extract_seed_from_vp_integer() {
274        let vp = vec![serde_json::json!(12345)];
275        assert_eq!(extract_seed_from_vp(&vp), Some(12345));
276    }
277
278    #[test]
279    fn test_extract_seed_from_vp_empty() {
280        assert_eq!(extract_seed_from_vp(&[]), None);
281    }
282
283    #[test]
284    fn test_extract_seed_from_vp_not_number_or_string() {
285        let vp = vec![serde_json::json!(null)];
286        assert_eq!(extract_seed_from_vp(&vp), None);
287    }
288}