Skip to main content

nms_copilot/map/
state.rs

1//! Map state: zoom levels, viewport math, cursor logic.
2
3use nms_core::galaxy::Galaxy;
4use nms_graph::GalaxyModel;
5
6use crate::session::SessionState;
7
8/// Zoom tier for the galaxy map.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ZoomLevel {
11    /// Full galaxy view — 4096×4096 voxel extent.
12    Galaxy,
13    /// Region view — 512×512 voxel extent.
14    Region,
15    /// Local view — 64×64 voxel extent.
16    Local,
17}
18
19impl ZoomLevel {
20    /// Voxel extent covered by one axis at this zoom level.
21    pub fn extent(self) -> f64 {
22        match self {
23            Self::Galaxy => 4096.0,
24            Self::Region => 512.0,
25            Self::Local => 64.0,
26        }
27    }
28
29    /// Zoom in one level, if possible.
30    pub fn zoom_in(self) -> Option<Self> {
31        match self {
32            Self::Galaxy => Some(Self::Region),
33            Self::Region => Some(Self::Local),
34            Self::Local => None,
35        }
36    }
37
38    /// Zoom out one level, if possible.
39    pub fn zoom_out(self) -> Option<Self> {
40        match self {
41            Self::Galaxy => None,
42            Self::Region => Some(Self::Galaxy),
43            Self::Local => Some(Self::Region),
44        }
45    }
46
47    /// Display name for the status bar.
48    pub fn label(self) -> &'static str {
49        match self {
50            Self::Galaxy => "Galaxy",
51            Self::Region => "Region",
52            Self::Local => "Local",
53        }
54    }
55}
56
57/// Label for a base on the map.
58#[derive(Debug, Clone)]
59pub struct BaseLabel {
60    pub letter: char,
61    pub name: String,
62    pub voxel_x: i16,
63    pub voxel_z: i16,
64}
65
66/// Interactive map state.
67pub struct MapState {
68    /// Current zoom level.
69    pub zoom: ZoomLevel,
70    /// Viewport center in voxel coordinates (X, Z).
71    pub center: (f64, f64),
72    /// Cursor position on the grid (col, row).
73    pub cursor: (u16, u16),
74    /// Usable map grid size (cols, rows).
75    pub grid_size: (u16, u16),
76    /// Active galaxy index.
77    pub galaxy: u8,
78    /// Galaxy name for display.
79    pub galaxy_name: String,
80    /// Base labels (A-Z).
81    pub base_labels: Vec<BaseLabel>,
82    /// Player voxel position (X, Z), if known.
83    pub player_pos: Option<(i16, i16)>,
84    /// Stack for zoom-out restoration.
85    pub zoom_stack: Vec<(ZoomLevel, f64, f64)>,
86    /// Whether to show the help overlay.
87    pub show_help: bool,
88    /// Whether the map should exit.
89    pub should_quit: bool,
90}
91
92impl MapState {
93    /// Create initial map state from the model and session.
94    pub fn new(model: &GalaxyModel, session: &SessionState) -> Self {
95        let galaxy = model.active_galaxy;
96        let galaxy_name = Galaxy::by_index(galaxy).name.to_string();
97
98        let player_pos = model.player_position().map(|a| (a.voxel_x(), a.voxel_z()));
99
100        // Center on player or origin
101        let center = player_pos
102            .map(|(x, z)| (f64::from(x), f64::from(z)))
103            .unwrap_or((0.0, 0.0));
104
105        // Build base labels (A-Z), sorted by name for stable letter assignment
106        let mut bases_in_galaxy: Vec<_> = model
107            .bases
108            .values()
109            .filter(|b| b.address.reality_index == galaxy)
110            .collect();
111        bases_in_galaxy.sort_by(|a, b| a.name.cmp(&b.name));
112        let base_labels: Vec<BaseLabel> = bases_in_galaxy
113            .into_iter()
114            .take(26)
115            .enumerate()
116            .map(|(i, b)| BaseLabel {
117                letter: (b'A' + i as u8) as char,
118                name: b.name.clone(),
119                voxel_x: b.address.voxel_x(),
120                voxel_z: b.address.voxel_z(),
121            })
122            .collect();
123
124        // Use session position if available, otherwise player position
125        let effective_center = session
126            .position
127            .as_ref()
128            .map(|p| {
129                let a = p.address();
130                (f64::from(a.voxel_x()), f64::from(a.voxel_z()))
131            })
132            .unwrap_or(center);
133
134        Self {
135            zoom: ZoomLevel::Galaxy,
136            center: effective_center,
137            cursor: (0, 0),
138            grid_size: (78, 19), // 80-2 cols, 24-3-2 rows (borders)
139            galaxy,
140            galaxy_name,
141            base_labels,
142            player_pos,
143            zoom_stack: Vec::new(),
144            show_help: false,
145            should_quit: false,
146        }
147    }
148
149    /// Update grid size (e.g., on terminal resize).
150    pub fn resize(&mut self, cols: u16, rows: u16) {
151        // Reserve 3 rows for status + legend, and 2 cols + 2 rows for map border
152        let inner_cols = cols.saturating_sub(2);
153        let inner_rows = rows.saturating_sub(3).saturating_sub(2);
154        self.grid_size = (inner_cols, inner_rows);
155        self.clamp_cursor();
156    }
157
158    /// Move cursor by (dx, dy), clamping to grid bounds.
159    pub fn move_cursor(&mut self, dx: i16, dy: i16) {
160        let (cx, cy) = self.cursor;
161        let new_x = (cx as i16 + dx).max(0) as u16;
162        let new_y = (cy as i16 + dy).max(0) as u16;
163        self.cursor = (new_x, new_y);
164        self.clamp_cursor();
165    }
166
167    /// Clamp cursor to grid bounds.
168    fn clamp_cursor(&mut self) {
169        let (cols, rows) = self.grid_size;
170        if cols > 0 {
171            self.cursor.0 = self.cursor.0.min(cols.saturating_sub(1));
172        }
173        if rows > 0 {
174            self.cursor.1 = self.cursor.1.min(rows.saturating_sub(1));
175        }
176    }
177
178    /// Zoom in on the cell under the cursor.
179    pub fn zoom_in(&mut self) {
180        if let Some(next_zoom) = self.zoom.zoom_in() {
181            // Save current state for zoom-out
182            self.zoom_stack
183                .push((self.zoom, self.center.0, self.center.1));
184            // New center = voxel coordinate of cursor cell
185            self.center = self.cursor_voxel();
186            self.zoom = next_zoom;
187            // Reset cursor to center of grid
188            self.cursor = (self.grid_size.0 / 2, self.grid_size.1 / 2);
189        }
190    }
191
192    /// Zoom out, restoring previous state.
193    pub fn zoom_out(&mut self) -> bool {
194        if let Some((prev_zoom, cx, cz)) = self.zoom_stack.pop() {
195            self.zoom = prev_zoom;
196            self.center = (cx, cz);
197            self.cursor = (self.grid_size.0 / 2, self.grid_size.1 / 2);
198            true
199        } else {
200            // At galaxy level — signal quit
201            false
202        }
203    }
204
205    /// Center the viewport on the player position.
206    pub fn center_on_player(&mut self) {
207        if let Some((px, pz)) = self.player_pos {
208            self.center = (f64::from(px), f64::from(pz));
209            self.cursor = (self.grid_size.0 / 2, self.grid_size.1 / 2);
210        }
211    }
212
213    /// Get the voxel coordinate that the cursor is pointing at.
214    pub fn cursor_voxel(&self) -> (f64, f64) {
215        let (cols, rows) = self.grid_size;
216        let extent = self.zoom.extent();
217        let cell_size_x = extent / f64::from(cols.max(1));
218        let cell_size_z = extent / f64::from(rows.max(1));
219
220        let half_cols = f64::from(cols) / 2.0;
221        let half_rows = f64::from(rows) / 2.0;
222
223        let vx = self.center.0 + (f64::from(self.cursor.0) - half_cols) * cell_size_x;
224        let vz = self.center.1 + (f64::from(self.cursor.1) - half_rows) * cell_size_z;
225        (vx, vz)
226    }
227
228    /// Convert a voxel coordinate to grid position.
229    /// Returns `None` if outside the viewport.
230    pub fn voxel_to_grid(&self, vx: f64, vz: f64) -> Option<(u16, u16)> {
231        let (cols, rows) = self.grid_size;
232        let extent = self.zoom.extent();
233        let cell_size_x = extent / f64::from(cols.max(1));
234        let cell_size_z = extent / f64::from(rows.max(1));
235
236        let half_cols = f64::from(cols) / 2.0;
237        let half_rows = f64::from(rows) / 2.0;
238
239        let col = ((vx - self.center.0) / cell_size_x + half_cols) as i32;
240        let row = ((vz - self.center.1) / cell_size_z + half_rows) as i32;
241
242        if col >= 0 && col < cols as i32 && row >= 0 && row < rows as i32 {
243            Some((col as u16, row as u16))
244        } else {
245            None
246        }
247    }
248
249    /// Get the voxel bounding box for the current viewport.
250    /// Returns ((min_x, min_z), (max_x, max_z)).
251    pub fn viewport_bounds(&self) -> ((f64, f64), (f64, f64)) {
252        let (cols, rows) = self.grid_size;
253        let extent = self.zoom.extent();
254        let cell_size_x = extent / f64::from(cols.max(1));
255        let cell_size_z = extent / f64::from(rows.max(1));
256
257        let half_w = f64::from(cols) / 2.0 * cell_size_x;
258        let half_h = f64::from(rows) / 2.0 * cell_size_z;
259
260        (
261            (self.center.0 - half_w, self.center.1 - half_h),
262            (self.center.0 + half_w, self.center.1 + half_h),
263        )
264    }
265}
266
267/// Select a density character based on system count in a cell.
268pub fn density_char(count: usize) -> char {
269    match count {
270        0 => ' ',
271        1 => '·',
272        2..=3 => '+',
273        4..=7 => '*',
274        _ => '#',
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_zoom_level_extent_galaxy() {
284        assert_eq!(ZoomLevel::Galaxy.extent(), 4096.0);
285    }
286
287    #[test]
288    fn test_zoom_level_extent_region() {
289        assert_eq!(ZoomLevel::Region.extent(), 512.0);
290    }
291
292    #[test]
293    fn test_zoom_level_extent_local() {
294        assert_eq!(ZoomLevel::Local.extent(), 64.0);
295    }
296
297    #[test]
298    fn test_zoom_in_galaxy_to_region() {
299        assert_eq!(ZoomLevel::Galaxy.zoom_in(), Some(ZoomLevel::Region));
300    }
301
302    #[test]
303    fn test_zoom_in_region_to_local() {
304        assert_eq!(ZoomLevel::Region.zoom_in(), Some(ZoomLevel::Local));
305    }
306
307    #[test]
308    fn test_zoom_in_local_returns_none() {
309        assert_eq!(ZoomLevel::Local.zoom_in(), None);
310    }
311
312    #[test]
313    fn test_zoom_out_galaxy_returns_none() {
314        assert_eq!(ZoomLevel::Galaxy.zoom_out(), None);
315    }
316
317    #[test]
318    fn test_zoom_out_region_to_galaxy() {
319        assert_eq!(ZoomLevel::Region.zoom_out(), Some(ZoomLevel::Galaxy));
320    }
321
322    #[test]
323    fn test_zoom_out_local_to_region() {
324        assert_eq!(ZoomLevel::Local.zoom_out(), Some(ZoomLevel::Region));
325    }
326
327    #[test]
328    fn test_density_char_empty() {
329        assert_eq!(density_char(0), ' ');
330    }
331
332    #[test]
333    fn test_density_char_single() {
334        assert_eq!(density_char(1), '·');
335    }
336
337    #[test]
338    fn test_density_char_few() {
339        assert_eq!(density_char(2), '+');
340        assert_eq!(density_char(3), '+');
341    }
342
343    #[test]
344    fn test_density_char_several() {
345        assert_eq!(density_char(4), '*');
346        assert_eq!(density_char(7), '*');
347    }
348
349    #[test]
350    fn test_density_char_many() {
351        assert_eq!(density_char(8), '#');
352        assert_eq!(density_char(100), '#');
353    }
354
355    #[test]
356    fn test_voxel_to_grid_center_maps_to_center() {
357        let state = MapState {
358            zoom: ZoomLevel::Galaxy,
359            center: (0.0, 0.0),
360            cursor: (40, 12),
361            grid_size: (80, 24),
362            galaxy: 0,
363            galaxy_name: "Euclid".into(),
364            base_labels: vec![],
365            player_pos: None,
366            zoom_stack: vec![],
367            show_help: false,
368            should_quit: false,
369        };
370        // Center voxel should map to center of grid
371        let pos = state.voxel_to_grid(0.0, 0.0);
372        assert_eq!(pos, Some((40, 12)));
373    }
374
375    #[test]
376    fn test_voxel_to_grid_outside_returns_none() {
377        let state = MapState {
378            zoom: ZoomLevel::Local,
379            center: (0.0, 0.0),
380            cursor: (0, 0),
381            grid_size: (80, 24),
382            galaxy: 0,
383            galaxy_name: "Euclid".into(),
384            base_labels: vec![],
385            player_pos: None,
386            zoom_stack: vec![],
387            show_help: false,
388            should_quit: false,
389        };
390        // Far away voxel should be outside viewport
391        assert!(state.voxel_to_grid(2000.0, 2000.0).is_none());
392    }
393
394    #[test]
395    fn test_move_cursor_positive() {
396        let mut state = MapState {
397            zoom: ZoomLevel::Galaxy,
398            center: (0.0, 0.0),
399            cursor: (10, 10),
400            grid_size: (80, 24),
401            galaxy: 0,
402            galaxy_name: "Euclid".into(),
403            base_labels: vec![],
404            player_pos: None,
405            zoom_stack: vec![],
406            show_help: false,
407            should_quit: false,
408        };
409        state.move_cursor(5, 3);
410        assert_eq!(state.cursor, (15, 13));
411    }
412
413    #[test]
414    fn test_move_cursor_clamps_negative() {
415        let mut state = MapState {
416            zoom: ZoomLevel::Galaxy,
417            center: (0.0, 0.0),
418            cursor: (2, 2),
419            grid_size: (80, 24),
420            galaxy: 0,
421            galaxy_name: "Euclid".into(),
422            base_labels: vec![],
423            player_pos: None,
424            zoom_stack: vec![],
425            show_help: false,
426            should_quit: false,
427        };
428        state.move_cursor(-10, -10);
429        assert_eq!(state.cursor, (0, 0));
430    }
431
432    #[test]
433    fn test_move_cursor_clamps_to_grid_bounds() {
434        let mut state = MapState {
435            zoom: ZoomLevel::Galaxy,
436            center: (0.0, 0.0),
437            cursor: (78, 22),
438            grid_size: (80, 24),
439            galaxy: 0,
440            galaxy_name: "Euclid".into(),
441            base_labels: vec![],
442            player_pos: None,
443            zoom_stack: vec![],
444            show_help: false,
445            should_quit: false,
446        };
447        state.move_cursor(10, 10);
448        assert_eq!(state.cursor, (79, 23));
449    }
450
451    #[test]
452    fn test_zoom_in_pushes_stack() {
453        let mut state = MapState {
454            zoom: ZoomLevel::Galaxy,
455            center: (100.0, -200.0),
456            cursor: (40, 12),
457            grid_size: (80, 24),
458            galaxy: 0,
459            galaxy_name: "Euclid".into(),
460            base_labels: vec![],
461            player_pos: None,
462            zoom_stack: vec![],
463            show_help: false,
464            should_quit: false,
465        };
466        state.zoom_in();
467        assert_eq!(state.zoom, ZoomLevel::Region);
468        assert_eq!(state.zoom_stack.len(), 1);
469        assert_eq!(state.zoom_stack[0].0, ZoomLevel::Galaxy);
470    }
471
472    #[test]
473    fn test_zoom_out_pops_stack() {
474        let mut state = MapState {
475            zoom: ZoomLevel::Galaxy,
476            center: (100.0, -200.0),
477            cursor: (40, 12),
478            grid_size: (80, 24),
479            galaxy: 0,
480            galaxy_name: "Euclid".into(),
481            base_labels: vec![],
482            player_pos: None,
483            zoom_stack: vec![],
484            show_help: false,
485            should_quit: false,
486        };
487        state.zoom_in();
488        assert!(state.zoom_out());
489        assert_eq!(state.zoom, ZoomLevel::Galaxy);
490        assert!(state.zoom_stack.is_empty());
491    }
492
493    #[test]
494    fn test_zoom_out_at_galaxy_returns_false() {
495        let mut state = MapState {
496            zoom: ZoomLevel::Galaxy,
497            center: (0.0, 0.0),
498            cursor: (0, 0),
499            grid_size: (80, 24),
500            galaxy: 0,
501            galaxy_name: "Euclid".into(),
502            base_labels: vec![],
503            player_pos: None,
504            zoom_stack: vec![],
505            show_help: false,
506            should_quit: false,
507        };
508        assert!(!state.zoom_out());
509    }
510
511    #[test]
512    fn test_center_on_player_updates_center() {
513        let mut state = MapState {
514            zoom: ZoomLevel::Galaxy,
515            center: (0.0, 0.0),
516            cursor: (10, 10),
517            grid_size: (80, 24),
518            galaxy: 0,
519            galaxy_name: "Euclid".into(),
520            base_labels: vec![],
521            player_pos: Some((100, -200)),
522            zoom_stack: vec![],
523            show_help: false,
524            should_quit: false,
525        };
526        state.center_on_player();
527        assert_eq!(state.center, (100.0, -200.0));
528        assert_eq!(state.cursor, (40, 12));
529    }
530
531    #[test]
532    fn test_viewport_bounds_symmetry() {
533        let state = MapState {
534            zoom: ZoomLevel::Galaxy,
535            center: (0.0, 0.0),
536            cursor: (0, 0),
537            grid_size: (80, 24),
538            galaxy: 0,
539            galaxy_name: "Euclid".into(),
540            base_labels: vec![],
541            player_pos: None,
542            zoom_stack: vec![],
543            show_help: false,
544            should_quit: false,
545        };
546        let ((min_x, min_z), (max_x, max_z)) = state.viewport_bounds();
547        // Should be symmetric around center (0, 0)
548        assert!((min_x + max_x).abs() < 0.01);
549        assert!((min_z + max_z).abs() < 0.01);
550    }
551}