Skip to main content

nms_copilot/
session.rs

1//! Session state for the interactive REPL.
2//!
3//! Tracks the user's current context: position, filters, and preferences.
4//! Commands like `find` and `route` use this state as defaults when
5//! explicit flags are not provided.
6
7use nms_core::address::GalacticAddress;
8use nms_core::biome::Biome;
9use nms_core::galaxy::Galaxy;
10use nms_graph::GalaxyModel;
11
12/// Mutable session state maintained across REPL commands.
13#[derive(Debug)]
14pub struct SessionState {
15    /// Current reference position (for distance calculations).
16    pub position: Option<PositionContext>,
17
18    /// Active biome filter (applied to find commands when --biome is not specified).
19    pub biome_filter: Option<Biome>,
20
21    /// Default warp range in light-years (for route planning).
22    pub warp_range: Option<f64>,
23
24    /// Current galaxy context.
25    pub galaxy: Galaxy,
26
27    /// Number of systems in the model.
28    pub system_count: usize,
29
30    /// Number of planets in the model.
31    pub planet_count: usize,
32}
33
34/// Where the user's reference position is anchored.
35#[derive(Debug, Clone)]
36pub enum PositionContext {
37    /// At a named base.
38    Base {
39        name: String,
40        address: GalacticAddress,
41    },
42    /// At the player's save file position.
43    PlayerPosition(GalacticAddress),
44    /// At a manually specified address.
45    Address(GalacticAddress),
46}
47
48impl PositionContext {
49    pub fn address(&self) -> &GalacticAddress {
50        match self {
51            Self::Base { address, .. } => address,
52            Self::PlayerPosition(a) | Self::Address(a) => a,
53        }
54    }
55
56    pub fn label(&self) -> String {
57        match self {
58            Self::Base { name, .. } => name.clone(),
59            Self::PlayerPosition(_) => "player position".into(),
60            Self::Address(a) => format!("0x{:012X}", a.packed()),
61        }
62    }
63}
64
65impl SessionState {
66    /// Initialize session state from the loaded model.
67    pub fn from_model(model: &GalaxyModel) -> Self {
68        let position = model
69            .player_state
70            .as_ref()
71            .map(|ps| PositionContext::PlayerPosition(ps.current_address));
72
73        let galaxy = model
74            .player_state
75            .as_ref()
76            .map(|ps| Galaxy::by_index(ps.current_address.reality_index))
77            .unwrap_or_else(|| Galaxy::by_index(0));
78
79        Self {
80            position,
81            biome_filter: None,
82            warp_range: None,
83            galaxy,
84            system_count: model.systems.len(),
85            planet_count: model.planets.len(),
86        }
87    }
88
89    /// Set the reference position to a named base.
90    pub fn set_position_base(&mut self, name: &str, model: &GalaxyModel) -> Result<String, String> {
91        let base = model
92            .base(name)
93            .ok_or_else(|| format!("Base not found: \"{name}\""))?;
94        let address = base.address;
95        let display_name = base.name.clone();
96        self.position = Some(PositionContext::Base {
97            name: display_name.clone(),
98            address,
99        });
100        Ok(format!("Position set to {display_name}"))
101    }
102
103    /// Set the reference position to an explicit address.
104    pub fn set_position_address(&mut self, address: GalacticAddress) -> String {
105        let label = format!("0x{:012X}", address.packed());
106        self.position = Some(PositionContext::Address(address));
107        format!("Position set to {label}")
108    }
109
110    /// Reset position to the player's save file position.
111    pub fn reset_position(&mut self, model: &GalaxyModel) -> String {
112        self.position = model
113            .player_state
114            .as_ref()
115            .map(|ps| PositionContext::PlayerPosition(ps.current_address));
116        "Position reset to player location".into()
117    }
118
119    /// Set the active biome filter.
120    pub fn set_biome_filter(&mut self, biome: Biome) -> String {
121        let name = format!("{biome:?}");
122        self.biome_filter = Some(biome);
123        format!("Biome filter set to {name}")
124    }
125
126    /// Clear the active biome filter.
127    pub fn clear_biome_filter(&mut self) -> &'static str {
128        self.biome_filter = None;
129        "Biome filter cleared"
130    }
131
132    /// Set the default warp range.
133    pub fn set_warp_range(&mut self, ly: f64) -> String {
134        self.warp_range = Some(ly);
135        format!("Warp range set to {} ly", ly as u64)
136    }
137
138    /// Clear the warp range.
139    pub fn clear_warp_range(&mut self) -> &'static str {
140        self.warp_range = None;
141        "Warp range cleared"
142    }
143
144    /// Reset all session state to defaults.
145    pub fn reset_all(&mut self, model: &GalaxyModel) -> &'static str {
146        self.reset_position(model);
147        self.biome_filter = None;
148        self.warp_range = None;
149        "Session state reset"
150    }
151
152    /// Format the current session state for display.
153    pub fn format_status(&self) -> String {
154        let mut lines = Vec::new();
155
156        lines.push(format!(
157            "Galaxy:      {} ({})",
158            self.galaxy.name, self.galaxy.galaxy_type
159        ));
160        lines.push(format!(
161            "Model:       {} systems, {} planets",
162            self.system_count, self.planet_count
163        ));
164
165        match &self.position {
166            Some(pos) => lines.push(format!("Position:    {}", pos.label())),
167            None => lines.push("Position:    unknown".into()),
168        }
169
170        match &self.biome_filter {
171            Some(b) => lines.push(format!("Biome:       {b:?}")),
172            None => lines.push("Biome:       (none)".into()),
173        }
174
175        match self.warp_range {
176            Some(r) => lines.push(format!("Warp range:  {} ly", r as u64)),
177            None => lines.push("Warp range:  (none)".into()),
178        }
179
180        lines.join("\n") + "\n"
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn test_model() -> GalaxyModel {
189        let json = r#"{
190            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
191            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
192            "BaseContext": {
193                "GameMode": 1,
194                "PlayerStateData": {
195                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 100, "VoxelY": 50, "VoxelZ": -200, "SolarSystemIndex": 42, "PlanetIndex": 0}},
196                    "Units": 0, "Nanites": 0, "Specials": 0,
197                    "PersistentPlayerBases": [
198                        {"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": "Home Base", "BaseType": {"PersistentBaseTypes": "HomePlanetBase"}, "LastEditedById": "", "LastEditedByUsername": ""}
199                    ]
200                }
201            },
202            "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": []}},
203            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": [
204                {"DD": {"UA": "0x050003AB8C07", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID":"","UID":"1","USN":"Explorer","PTK":"ST","TS":0}, "FL": {"U": 1}},
205                {"DD": {"UA": "0x150003AB8C07", "DT": "Planet", "VP": ["0xAB", 0]}, "DM": {}, "OWS": {"LID":"","UID":"1","USN":"Explorer","PTK":"ST","TS":0}, "FL": {"U": 1}}
206            ]}}}
207        }"#;
208        let save = nms_save::parse_save(json.as_bytes()).unwrap();
209        GalaxyModel::from_save(&save)
210    }
211
212    #[test]
213    fn test_session_from_model() {
214        let model = test_model();
215        let session = SessionState::from_model(&model);
216        assert!(session.position.is_some());
217        assert_eq!(session.galaxy.name, "Euclid");
218        assert!(session.system_count > 0);
219    }
220
221    #[test]
222    fn test_set_position_base() {
223        let model = test_model();
224        let mut session = SessionState::from_model(&model);
225        let result = session.set_position_base("Home Base", &model);
226        assert!(result.is_ok());
227        assert!(result.unwrap().contains("Home Base"));
228        match &session.position {
229            Some(PositionContext::Base { name, .. }) => assert_eq!(name, "Home Base"),
230            _ => panic!("Expected Base position"),
231        }
232    }
233
234    #[test]
235    fn test_set_position_unknown_base_errors() {
236        let model = test_model();
237        let mut session = SessionState::from_model(&model);
238        assert!(session.set_position_base("No Such Base", &model).is_err());
239    }
240
241    #[test]
242    fn test_set_biome_filter() {
243        let model = test_model();
244        let mut session = SessionState::from_model(&model);
245        session.set_biome_filter(Biome::Lush);
246        assert_eq!(session.biome_filter, Some(Biome::Lush));
247    }
248
249    #[test]
250    fn test_clear_biome_filter() {
251        let model = test_model();
252        let mut session = SessionState::from_model(&model);
253        session.set_biome_filter(Biome::Lush);
254        session.clear_biome_filter();
255        assert!(session.biome_filter.is_none());
256    }
257
258    #[test]
259    fn test_set_warp_range() {
260        let model = test_model();
261        let mut session = SessionState::from_model(&model);
262        session.set_warp_range(2500.0);
263        assert_eq!(session.warp_range, Some(2500.0));
264    }
265
266    #[test]
267    fn test_reset_all() {
268        let model = test_model();
269        let mut session = SessionState::from_model(&model);
270        session.set_biome_filter(Biome::Toxic);
271        session.set_warp_range(1000.0);
272        session.reset_all(&model);
273        assert!(session.biome_filter.is_none());
274        assert!(session.warp_range.is_none());
275    }
276
277    #[test]
278    fn test_format_status() {
279        let model = test_model();
280        let session = SessionState::from_model(&model);
281        let output = session.format_status();
282        assert!(output.contains("Euclid"));
283        assert!(output.contains("systems"));
284    }
285
286    #[test]
287    fn test_position_context_label() {
288        let addr = GalacticAddress::new(0, 0, 0, 0, 0, 0);
289        let base = PositionContext::Base {
290            name: "Test".into(),
291            address: addr,
292        };
293        assert_eq!(base.label(), "Test");
294
295        let player = PositionContext::PlayerPosition(addr);
296        assert_eq!(player.label(), "player position");
297    }
298
299    #[test]
300    fn test_set_position_address() {
301        let model = test_model();
302        let mut session = SessionState::from_model(&model);
303        let addr = GalacticAddress::new(100, 50, -200, 42, 0, 0);
304        let msg = session.set_position_address(addr);
305        assert!(msg.contains("Position set to"));
306        assert!(matches!(
307            &session.position,
308            Some(PositionContext::Address(_))
309        ));
310    }
311
312    #[test]
313    fn test_reset_position() {
314        let model = test_model();
315        let mut session = SessionState::from_model(&model);
316        session.set_position_base("Home Base", &model).unwrap();
317        session.reset_position(&model);
318        assert!(matches!(
319            &session.position,
320            Some(PositionContext::PlayerPosition(_))
321        ));
322    }
323}