Skip to main content

nms_cache/
serialize.rs

1//! Serialize and deserialize galaxy data to/from rkyv archives.
2
3use std::fs;
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use chrono::TimeZone;
8use rkyv::rancor::Error as RkyvError;
9
10use nms_core::system::System;
11use nms_graph::{EdgeStrategy, GalaxyModel};
12
13use crate::data::{CacheData, CachedSystem};
14use crate::error::CacheError;
15
16/// Extract cache data from a GalaxyModel.
17pub fn extract_cache_data(model: &GalaxyModel, save_version: u32) -> CacheData {
18    let systems: Vec<CachedSystem> = model
19        .systems
20        .values()
21        .map(|system| CachedSystem {
22            address: system.address,
23            name: system.name.clone(),
24            discoverer: system.discoverer.clone(),
25            timestamp_secs: system.timestamp.map(|ts| ts.timestamp()),
26            planets: system.planets.clone(),
27        })
28        .collect();
29
30    let bases = model.bases.values().cloned().collect();
31
32    let cached_at = SystemTime::now()
33        .duration_since(UNIX_EPOCH)
34        .unwrap_or_default()
35        .as_secs();
36
37    CacheData {
38        systems,
39        bases,
40        player_state: model.player_state.clone(),
41        save_version,
42        cached_at,
43    }
44}
45
46/// Serialize cache data to bytes.
47pub fn serialize(data: &CacheData) -> Result<Vec<u8>, CacheError> {
48    rkyv::to_bytes::<RkyvError>(data)
49        .map(|v| v.to_vec())
50        .map_err(|e| CacheError::Serialize(e.to_string()))
51}
52
53/// Deserialize cache data from bytes.
54pub fn deserialize(bytes: &[u8]) -> Result<CacheData, CacheError> {
55    rkyv::from_bytes::<CacheData, RkyvError>(bytes)
56        .map_err(|e| CacheError::Deserialize(e.to_string()))
57}
58
59/// Write cache data to a file.
60pub fn write_cache(data: &CacheData, path: &Path) -> Result<(), CacheError> {
61    let bytes = serialize(data)?;
62
63    // Write to a temp file first, then rename for atomicity
64    let tmp_path = path.with_extension("rkyv.tmp");
65    fs::write(&tmp_path, &bytes).map_err(CacheError::Io)?;
66    fs::rename(&tmp_path, path).map_err(CacheError::Io)?;
67
68    Ok(())
69}
70
71/// Read and deserialize cache data from a file.
72pub fn read_cache(path: &Path) -> Result<CacheData, CacheError> {
73    let bytes = fs::read(path).map_err(CacheError::Io)?;
74    deserialize(&bytes)
75}
76
77/// Rebuild a GalaxyModel from cache data.
78///
79/// Reconstructs the graph, R-tree, and all HashMap indices.
80pub fn rebuild_model(data: &CacheData) -> GalaxyModel {
81    let mut model = GalaxyModel::new();
82
83    for cached in &data.systems {
84        let timestamp = cached
85            .timestamp_secs
86            .and_then(|secs| chrono::Utc.timestamp_opt(secs, 0).single());
87
88        let system = System::new(
89            cached.address,
90            cached.name.clone(),
91            cached.discoverer.clone(),
92            timestamp,
93            cached.planets.clone(),
94        );
95        model.insert_system(system);
96    }
97
98    for base in &data.bases {
99        model.insert_base(base.clone());
100    }
101
102    model.player_state = data.player_state.clone();
103    model.build_edges(EdgeStrategy::default());
104
105    model
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn test_model() -> GalaxyModel {
113        let json = r#"{
114            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
115            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
116            "BaseContext": {
117                "GameMode": 1,
118                "PlayerStateData": {
119                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 100, "VoxelY": 50, "VoxelZ": -200, "SolarSystemIndex": 42, "PlanetIndex": 0}},
120                    "Units": 5000000, "Nanites": 10000, "Specials": 500,
121                    "PersistentPlayerBases": [
122                        {"BaseVersion": 8, "GalacticAddress": "0x050003AB8C07", "Position": [0.0,0.0,0.0], "Forward": [1.0,0.0,0.0], "LastUpdateTimestamp": 0, "Objects": [], "RID": "", "Owner": {"LID":"","UID":"1","USN":"","PTK":"ST","TS":0}, "Name": "Test Base", "BaseType": {"PersistentBaseTypes": "HomePlanetBase"}, "LastEditedById": "", "LastEditedByUsername": ""}
123                    ]
124                }
125            },
126            "ExpeditionContext": {"GameMode": 6, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
127            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": [
128                {"DD": {"UA": "0x050003AB8C07", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID":"","UID":"1","USN":"Explorer","PTK":"ST","TS":0}, "FL": {"U": 1}},
129                {"DD": {"UA": "0x150003AB8C07", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID":"","UID":"1","USN":"Explorer","PTK":"ST","TS":0}, "FL": {"U": 1}},
130                {"DD": {"UA": "0x250003AB8C07", "DT": "Planet", "VP": ["0xCD", 1]}, "DM": {}, "OWS": {"LID":"","UID":"1","USN":"Explorer","PTK":"ST","TS":0}, "FL": {"U": 1}}
131            ]}}}
132        }"#;
133        let save = nms_save::parse_save(json.as_bytes()).unwrap();
134        GalaxyModel::from_save(&save)
135    }
136
137    #[test]
138    fn test_extract_cache_data() {
139        let model = test_model();
140        let data = extract_cache_data(&model, 4720);
141        assert_eq!(data.systems.len(), model.systems.len());
142        assert_eq!(data.bases.len(), model.bases.len());
143        assert_eq!(data.save_version, 4720);
144        assert!(data.cached_at > 0);
145    }
146
147    #[test]
148    fn test_serialize_deserialize_roundtrip() {
149        let model = test_model();
150        let data = extract_cache_data(&model, 4720);
151        let bytes = serialize(&data).unwrap();
152        let restored = deserialize(&bytes).unwrap();
153        assert_eq!(restored.systems.len(), data.systems.len());
154        assert_eq!(restored.bases.len(), data.bases.len());
155    }
156
157    #[test]
158    fn test_rebuild_model_preserves_counts() {
159        let model = test_model();
160        let data = extract_cache_data(&model, 4720);
161        let bytes = serialize(&data).unwrap();
162        let restored_data = deserialize(&bytes).unwrap();
163        let rebuilt = rebuild_model(&restored_data);
164
165        assert_eq!(rebuilt.systems.len(), model.systems.len());
166        assert_eq!(rebuilt.planets.len(), model.planets.len());
167        assert_eq!(rebuilt.bases.len(), model.bases.len());
168    }
169
170    #[test]
171    fn test_write_and_read_cache_file() {
172        let model = test_model();
173        let data = extract_cache_data(&model, 4720);
174
175        let dir = tempfile::tempdir().unwrap();
176        let path = dir.path().join("test.rkyv");
177
178        write_cache(&data, &path).unwrap();
179        assert!(path.exists());
180
181        let restored = read_cache(&path).unwrap();
182        assert_eq!(restored.systems.len(), data.systems.len());
183    }
184
185    #[test]
186    fn test_read_nonexistent_cache_errors() {
187        let result = read_cache(Path::new("/tmp/nonexistent_nms_cache.rkyv"));
188        assert!(result.is_err());
189    }
190
191    #[test]
192    fn test_rebuild_preserves_player_state() {
193        let model = test_model();
194        let data = extract_cache_data(&model, 4720);
195        let rebuilt = rebuild_model(&data);
196        assert!(rebuilt.player_state.is_some());
197        assert_eq!(
198            rebuilt.player_state.as_ref().unwrap().current_address,
199            model.player_state.as_ref().unwrap().current_address
200        );
201    }
202
203    #[test]
204    fn test_rebuild_preserves_base_lookup() {
205        let model = test_model();
206        let data = extract_cache_data(&model, 4720);
207        let rebuilt = rebuild_model(&data);
208        assert!(rebuilt.base("Test Base").is_some());
209    }
210}