Skip to main content

nms_core/
address.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5use crate::glyph::{Glyph, GlyphParseError, parse_next_glyph};
6
7/// Special system index: always contains a black hole.
8pub const SSI_BLACK_HOLE: u16 = 0x079;
9/// Special system index: always contains an Atlas Interface.
10pub const SSI_ATLAS_INTERFACE: u16 = 0x07A;
11/// Purple system SSI range start (inclusive).
12pub const SSI_PURPLE_START: u16 = 0x3E8;
13/// Purple system SSI range end (inclusive).
14pub const SSI_PURPLE_END: u16 = 0x429;
15
16/// Mask for the 48-bit packed galactic address.
17const PACKED_MASK: u64 = 0xFFFF_FFFF_FFFF;
18
19// Bit-field shifts within the 48-bit packed value.
20const PLANET_SHIFT: u32 = 44;
21const SSI_SHIFT: u32 = 32;
22const VOXEL_Y_SHIFT: u32 = 24;
23const VOXEL_Z_SHIFT: u32 = 12;
24
25// Bit-field masks (applied after shifting).
26const MASK_4BIT: u64 = 0xF;
27const MASK_8BIT: u64 = 0xFF;
28const MASK_12BIT: u64 = 0xFFF;
29
30// 12-bit sign extension constants.
31const SIGN_BIT_12: u16 = 0x800;
32const SIGN_EXTEND_12: u16 = 0xF000;
33
34/// Offset added to signal-booster X/Z to convert to portal-frame X/Z.
35const SB_TO_PORTAL_XZ: u16 = 0x801;
36/// Offset added to signal-booster Y to convert to portal-frame Y.
37const SB_TO_PORTAL_Y: u16 = 0x81;
38/// Offset added to portal-frame X/Z to convert to signal-booster X/Z.
39const PORTAL_TO_SB_XZ: u16 = 0x7FF;
40/// Offset added to portal-frame Y to convert to signal-booster Y.
41const PORTAL_TO_SB_Y: u16 = 0x7F;
42
43/// A packed 48-bit galactic coordinate plus a galaxy (reality) index.
44///
45/// The 48-bit value encodes fields in portal glyph order: `P-SSS-YY-ZZZ-XXX`
46/// where P=PlanetIndex, SSS=SolarSystemIndex, YY=VoxelY, ZZZ=VoxelZ, XXX=VoxelX.
47///
48/// The `reality_index` identifies the galaxy (0=Euclid, 1=Hilbert, etc.) and is
49/// stored separately from the packed value.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[cfg_attr(
52    feature = "archive",
53    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
54)]
55pub struct GalacticAddress {
56    packed: u64,
57    pub reality_index: u8,
58}
59
60impl GalacticAddress {
61    /// Create from individual field values (portal coordinate frame).
62    pub fn new(
63        voxel_x: i16,
64        voxel_y: i8,
65        voxel_z: i16,
66        solar_system_index: u16,
67        planet_index: u8,
68        reality_index: u8,
69    ) -> Self {
70        let x_bits = (voxel_x as u16 as u64) & MASK_12BIT;
71        let y_bits = (voxel_y as u8 as u64) & MASK_8BIT;
72        let z_bits = (voxel_z as u16 as u64) & MASK_12BIT;
73        let ssi_bits = (solar_system_index as u64) & MASK_12BIT;
74        let p_bits = (planet_index as u64) & MASK_4BIT;
75
76        let packed = (p_bits << PLANET_SHIFT)
77            | (ssi_bits << SSI_SHIFT)
78            | (y_bits << VOXEL_Y_SHIFT)
79            | (z_bits << VOXEL_Z_SHIFT)
80            | x_bits;
81
82        Self {
83            packed,
84            reality_index,
85        }
86    }
87
88    /// Create from raw packed 48-bit value and reality index.
89    pub fn from_packed(packed: u64, reality_index: u8) -> Self {
90        Self {
91            packed: packed & PACKED_MASK,
92            reality_index,
93        }
94    }
95
96    /// Return the raw 48-bit packed value.
97    pub fn packed(&self) -> u64 {
98        self.packed
99    }
100
101    /// Planet index (4-bit unsigned, 0-15). Bits 47-44.
102    pub fn planet_index(&self) -> u8 {
103        ((self.packed >> PLANET_SHIFT) & MASK_4BIT) as u8
104    }
105
106    /// Solar system index (12-bit unsigned, 0x000-0xFFE). Bits 43-32.
107    pub fn solar_system_index(&self) -> u16 {
108        ((self.packed >> SSI_SHIFT) & MASK_12BIT) as u16
109    }
110
111    /// VoxelY (8-bit signed, -128..127). Bits 31-24.
112    pub fn voxel_y(&self) -> i8 {
113        ((self.packed >> VOXEL_Y_SHIFT) & MASK_8BIT) as u8 as i8
114    }
115
116    /// VoxelZ (12-bit signed, -2048..2047). Bits 23-12.
117    pub fn voxel_z(&self) -> i16 {
118        let raw = ((self.packed >> VOXEL_Z_SHIFT) & MASK_12BIT) as u16;
119        if raw & SIGN_BIT_12 != 0 {
120            (raw | SIGN_EXTEND_12) as i16
121        } else {
122            raw as i16
123        }
124    }
125
126    /// VoxelX (12-bit signed, -2048..2047). Bits 11-0.
127    pub fn voxel_x(&self) -> i16 {
128        let raw = (self.packed & MASK_12BIT) as u16;
129        if raw & SIGN_BIT_12 != 0 {
130            (raw | SIGN_EXTEND_12) as i16
131        } else {
132            raw as i16
133        }
134    }
135
136    /// Voxel coordinates as (x, y, z) signed integers (center-origin).
137    pub fn voxel_position(&self) -> (i16, i8, i16) {
138        (self.voxel_x(), self.voxel_y(), self.voxel_z())
139    }
140
141    /// Parse signal booster format `XXXX:YYYY:ZZZZ:SSSS`.
142    ///
143    /// Signal booster uses corner-origin unsigned coordinates. The conversion
144    /// adds fixed offsets to translate into portal-frame (center-origin) values.
145    /// Does NOT include planet index or reality index; caller must supply those.
146    pub fn from_signal_booster(
147        s: &str,
148        planet_index: u8,
149        reality_index: u8,
150    ) -> Result<Self, AddressParseError> {
151        let parts: Vec<&str> = s.split(':').collect();
152        if parts.len() != 4 {
153            return Err(AddressParseError::InvalidFormat);
154        }
155        let sb_x = u16::from_str_radix(parts[0], 16).map_err(|_| AddressParseError::InvalidHex)?;
156        let sb_y = u16::from_str_radix(parts[1], 16).map_err(|_| AddressParseError::InvalidHex)?;
157        let sb_z = u16::from_str_radix(parts[2], 16).map_err(|_| AddressParseError::InvalidHex)?;
158        let ssi = u16::from_str_radix(parts[3], 16).map_err(|_| AddressParseError::InvalidHex)?;
159
160        let portal_x = sb_x.wrapping_add(SB_TO_PORTAL_XZ) & MASK_12BIT as u16;
161        let portal_y = (sb_y.wrapping_add(SB_TO_PORTAL_Y) & MASK_8BIT as u16) as u8;
162        let portal_z = sb_z.wrapping_add(SB_TO_PORTAL_XZ) & MASK_12BIT as u16;
163
164        let packed = ((planet_index as u64 & MASK_4BIT) << PLANET_SHIFT)
165            | ((ssi as u64 & MASK_12BIT) << SSI_SHIFT)
166            | ((portal_y as u64) << VOXEL_Y_SHIFT)
167            | ((portal_z as u64) << VOXEL_Z_SHIFT)
168            | (portal_x as u64);
169
170        Ok(Self {
171            packed,
172            reality_index,
173        })
174    }
175
176    /// Format as signal booster string `XXXX:YYYY:ZZZZ:SSSS`.
177    ///
178    /// Converts portal-frame (center-origin) coordinates back to the
179    /// corner-origin unsigned format used by the in-game signal booster.
180    pub fn to_signal_booster(&self) -> String {
181        let portal_x = (self.packed & MASK_12BIT) as u16;
182        let portal_y = ((self.packed >> VOXEL_Y_SHIFT) & MASK_8BIT) as u16;
183        let portal_z = ((self.packed >> VOXEL_Z_SHIFT) & MASK_12BIT) as u16;
184        let ssi = ((self.packed >> SSI_SHIFT) & MASK_12BIT) as u16;
185
186        let sb_x = portal_x.wrapping_add(PORTAL_TO_SB_XZ) & MASK_12BIT as u16;
187        let sb_y = portal_y.wrapping_add(PORTAL_TO_SB_Y) & MASK_8BIT as u16;
188        let sb_z = portal_z.wrapping_add(PORTAL_TO_SB_XZ) & MASK_12BIT as u16;
189
190        format!("{sb_x:04X}:{sb_y:04X}:{sb_z:04X}:{ssi:04X}")
191    }
192
193    /// Distance in light-years to another address.
194    ///
195    /// Uses Euclidean distance in voxel space multiplied by 400.
196    /// Only meaningful for addresses in the same galaxy.
197    pub fn distance_ly(&self, other: &GalacticAddress) -> f64 {
198        let (x1, y1, z1) = self.voxel_position();
199        let (x2, y2, z2) = other.voxel_position();
200        let dx = (x1 as f64) - (x2 as f64);
201        let dy = (y1 as f64) - (y2 as f64);
202        let dz = (z1 as f64) - (z2 as f64);
203        (dx * dx + dy * dy + dz * dz).sqrt() * 400.0
204    }
205
206    /// Whether two addresses are in the same region (same VoxelX/Y/Z).
207    pub fn same_region(&self, other: &GalacticAddress) -> bool {
208        self.voxel_x() == other.voxel_x()
209            && self.voxel_y() == other.voxel_y()
210            && self.voxel_z() == other.voxel_z()
211    }
212
213    /// Whether two addresses are in the same system (same region + same SSI).
214    pub fn same_system(&self, other: &GalacticAddress) -> bool {
215        self.same_region(other) && self.solar_system_index() == other.solar_system_index()
216    }
217
218    /// Whether another address is within N light-years.
219    pub fn within(&self, other: &GalacticAddress, ly: f64) -> bool {
220        self.distance_ly(other) <= ly
221    }
222
223    /// Distance in light-years from this address to the galactic core (0, 0, 0).
224    ///
225    /// Note: comparing addresses across different galaxies (reality_index)
226    /// is not physically meaningful.
227    pub fn distance_to_core_ly(&self) -> f64 {
228        let (x, y, z) = self.voxel_position();
229        let dx = x as f64;
230        let dy = y as f64;
231        let dz = z as f64;
232        (dx * dx + dy * dy + dz * dz).sqrt() * 400.0
233    }
234
235    /// Whether this address points to a black hole system (SSI 0x079).
236    pub fn is_black_hole(&self) -> bool {
237        self.solar_system_index() == SSI_BLACK_HOLE
238    }
239
240    /// Whether this address points to an Atlas Interface system (SSI 0x07A).
241    pub fn is_atlas_interface(&self) -> bool {
242        self.solar_system_index() == SSI_ATLAS_INTERFACE
243    }
244
245    /// Whether this address is in the purple system SSI range (0x3E8-0x429).
246    pub fn is_purple_system(&self) -> bool {
247        let ssi = self.solar_system_index();
248        (SSI_PURPLE_START..=SSI_PURPLE_END).contains(&ssi)
249    }
250}
251
252/// Display as `0x` followed by 12 uppercase hex digits.
253impl fmt::Display for GalacticAddress {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        write!(f, "0x{:012X}", self.packed)
256    }
257}
258
259/// Parse from `0x`/`0X` prefix + 12 hex digits, or bare 12 hex digits.
260/// Reality index defaults to 0.
261impl FromStr for GalacticAddress {
262    type Err = AddressParseError;
263
264    fn from_str(s: &str) -> Result<Self, Self::Err> {
265        let hex_str = if let Some(stripped) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
266        {
267            stripped
268        } else {
269            s
270        };
271
272        if hex_str.len() != 12 {
273            return Err(AddressParseError::InvalidLength);
274        }
275
276        let packed = u64::from_str_radix(hex_str, 16).map_err(|_| AddressParseError::InvalidHex)?;
277
278        Ok(Self {
279            packed,
280            reality_index: 0,
281        })
282    }
283}
284
285/// From raw packed u64 (reality_index defaults to 0).
286impl From<u64> for GalacticAddress {
287    fn from(packed: u64) -> Self {
288        Self {
289            packed: packed & PACKED_MASK,
290            reality_index: 0,
291        }
292    }
293}
294
295/// Into raw packed u64 (drops reality_index).
296impl From<GalacticAddress> for u64 {
297    fn from(addr: GalacticAddress) -> u64 {
298        addr.packed
299    }
300}
301
302/// Error returned when parsing a galactic address string fails.
303#[derive(Debug, Clone, PartialEq, Eq, Hash)]
304#[non_exhaustive]
305pub enum AddressParseError {
306    InvalidFormat,
307    InvalidHex,
308    InvalidLength,
309}
310
311impl fmt::Display for AddressParseError {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        match self {
314            Self::InvalidFormat => write!(f, "invalid address format"),
315            Self::InvalidHex => write!(f, "invalid hex digit in address"),
316            Self::InvalidLength => write!(f, "address has wrong number of digits"),
317        }
318    }
319}
320
321impl std::error::Error for AddressParseError {}
322
323// ---------------------------------------------------------------------------
324// PortalAddress
325// ---------------------------------------------------------------------------
326
327/// A 12-glyph portal address encoding a galactic location.
328///
329/// Each glyph is a nibble (0-15), and the 12 glyphs form a 48-bit packed
330/// value in the same layout as [`GalacticAddress`]: `P-SSS-YY-ZZZ-XXX`.
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
332pub struct PortalAddress {
333    glyphs: [u8; 12],
334}
335
336/// Error returned when parsing a portal address string fails.
337#[derive(Debug, Clone, PartialEq, Eq)]
338#[non_exhaustive]
339pub enum PortalParseError {
340    WrongLength(usize),
341    InvalidGlyph(GlyphParseError),
342}
343
344impl fmt::Display for PortalParseError {
345    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346        match self {
347            Self::WrongLength(n) => write!(f, "expected 12 glyphs, got {n}"),
348            Self::InvalidGlyph(e) => write!(f, "{e}"),
349        }
350    }
351}
352
353impl std::error::Error for PortalParseError {}
354
355impl From<GlyphParseError> for PortalParseError {
356    fn from(e: GlyphParseError) -> Self {
357        Self::InvalidGlyph(e)
358    }
359}
360
361impl PortalAddress {
362    /// Create from an array of 12 u8 values (each 0-15).
363    pub fn new(glyphs: [u8; 12]) -> Self {
364        for (i, &g) in glyphs.iter().enumerate() {
365            assert!(g < 16, "glyph[{i}] = {g} is out of range 0-15");
366        }
367        Self { glyphs }
368    }
369
370    /// Get the glyph at position `i` (0-11).
371    pub fn glyph(&self, i: usize) -> Glyph {
372        Glyph::new(self.glyphs[i])
373    }
374
375    /// Get all 12 glyphs.
376    pub fn glyphs(&self) -> [Glyph; 12] {
377        let mut out = [Glyph::new(0); 12];
378        for (i, slot) in out.iter_mut().enumerate() {
379            *slot = Glyph::new(self.glyphs[i]);
380        }
381        out
382    }
383
384    /// Format as 12 hex digits (uppercase): e.g., `01717D8A4EA2`.
385    pub fn to_hex_string(&self) -> String {
386        self.glyphs.iter().map(|g| format!("{g:X}")).collect()
387    }
388
389    /// Format as emoji string.
390    pub fn to_emoji_string(&self) -> String {
391        self.glyphs.iter().map(|&g| Glyph::new(g).emoji()).collect()
392    }
393
394    /// Parse a mixed-format string containing 12 glyphs.
395    ///
396    /// Accepts any combination of hex digits, emoji, and glyph names.
397    pub fn parse_mixed(s: &str) -> Result<Self, PortalParseError> {
398        let mut glyphs = Vec::with_capacity(12);
399        let mut remaining = s.trim();
400
401        while !remaining.is_empty() && glyphs.len() < 12 {
402            let (glyph, rest) = parse_next_glyph(remaining)?;
403            glyphs.push(glyph.index());
404            remaining = rest;
405        }
406
407        if glyphs.len() != 12 {
408            return Err(PortalParseError::WrongLength(glyphs.len()));
409        }
410
411        if !remaining.is_empty() {
412            return Err(PortalParseError::WrongLength(13));
413        }
414
415        let mut arr = [0u8; 12];
416        arr.copy_from_slice(&glyphs);
417        Ok(Self { glyphs: arr })
418    }
419
420    /// Convert to [`GalacticAddress`] (reality_index = 0).
421    pub fn to_galactic_address(&self) -> GalacticAddress {
422        GalacticAddress::from(*self)
423    }
424
425    /// Create from a [`GalacticAddress`].
426    pub fn from_galactic_address(addr: &GalacticAddress) -> Self {
427        PortalAddress::from(*addr)
428    }
429
430    /// Create from a signal booster string by first parsing to [`GalacticAddress`].
431    pub fn from_signal_booster(
432        s: &str,
433        planet_index: u8,
434        reality_index: u8,
435    ) -> Result<Self, AddressParseError> {
436        let addr = GalacticAddress::from_signal_booster(s, planet_index, reality_index)?;
437        Ok(PortalAddress::from(addr))
438    }
439}
440
441/// Default display is hex.
442impl fmt::Display for PortalAddress {
443    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444        write!(f, "{}", self.to_hex_string())
445    }
446}
447
448/// Parse from any supported format (hex, emoji, mixed).
449impl FromStr for PortalAddress {
450    type Err = PortalParseError;
451
452    fn from_str(s: &str) -> Result<Self, Self::Err> {
453        Self::parse_mixed(s)
454    }
455}
456
457impl From<PortalAddress> for GalacticAddress {
458    /// Convert portal address to galactic address.
459    ///
460    /// Portal glyph layout: `P-SSS-YY-ZZZ-XXX`
461    /// (glyph positions 0-indexed: [0]=P, [1-3]=SSS, [4-5]=YY, [6-8]=ZZZ, [9-11]=XXX).
462    /// Reality index defaults to 0.
463    fn from(pa: PortalAddress) -> Self {
464        let g = pa.glyphs;
465
466        let planet_index = g[0];
467        let ssi = ((g[1] as u16) << 8) | ((g[2] as u16) << 4) | (g[3] as u16);
468        let y_raw = (g[4] << 4) | g[5];
469        let z_raw = ((g[6] as u16) << 8) | ((g[7] as u16) << 4) | (g[8] as u16);
470        let x_raw = ((g[9] as u16) << 8) | ((g[10] as u16) << 4) | (g[11] as u16);
471
472        let packed = ((planet_index as u64) << PLANET_SHIFT)
473            | ((ssi as u64) << SSI_SHIFT)
474            | ((y_raw as u64) << VOXEL_Y_SHIFT)
475            | ((z_raw as u64) << VOXEL_Z_SHIFT)
476            | (x_raw as u64);
477
478        GalacticAddress::from_packed(packed, 0)
479    }
480}
481
482impl From<GalacticAddress> for PortalAddress {
483    /// Convert galactic address to portal address.
484    ///
485    /// Extracts each nibble from the packed 48-bit value.
486    fn from(addr: GalacticAddress) -> Self {
487        let p = addr.packed();
488        let mut glyphs = [0u8; 12];
489
490        for (i, slot) in glyphs.iter_mut().enumerate() {
491            *slot = ((p >> (44 - i * 4)) & 0xF) as u8;
492        }
493
494        PortalAddress { glyphs }
495    }
496}
497
498impl GalacticAddress {
499    /// Convert to [`PortalAddress`].
500    pub fn to_portal_address(&self) -> PortalAddress {
501        PortalAddress::from(*self)
502    }
503
504    /// Create from a portal address string (hex, emoji, or mixed).
505    /// Reality index defaults to 0.
506    pub fn from_portal_string(s: &str) -> Result<Self, PortalParseError> {
507        let pa: PortalAddress = s.parse()?;
508        Ok(GalacticAddress::from(pa))
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn pack_unpack_roundtrip() {
518        let addr = GalacticAddress::new(-350, 42, 1000, 0x123, 3, 0);
519        assert_eq!(addr.voxel_x(), -350);
520        assert_eq!(addr.voxel_y(), 42);
521        assert_eq!(addr.voxel_z(), 1000);
522        assert_eq!(addr.solar_system_index(), 0x123);
523        assert_eq!(addr.planet_index(), 3);
524    }
525
526    #[test]
527    fn from_portal_hex_string() {
528        let addr: GalacticAddress = "01717D8A4EA2".parse().unwrap();
529        assert_eq!(addr.packed(), 0x01717D8A4EA2);
530        assert_eq!(addr.planet_index(), 0);
531        assert_eq!(addr.solar_system_index(), 0x171);
532        let y_raw = ((0x01717D8A4EA2u64 >> 24) & 0xFF) as u8;
533        assert_eq!(addr.voxel_y(), y_raw as i8);
534    }
535
536    #[test]
537    fn display_as_hex() {
538        let addr = GalacticAddress::from_packed(0x01717D8A4EA2, 0);
539        assert_eq!(format!("{addr}"), "0x01717D8A4EA2");
540    }
541
542    #[test]
543    fn from_u64_and_into_u64() {
544        let packed: u64 = 0x01717D8A4EA2;
545        let addr = GalacticAddress::from(packed);
546        let back: u64 = addr.into();
547        assert_eq!(back, packed);
548    }
549
550    #[test]
551    fn parse_with_0x_prefix() {
552        let addr: GalacticAddress = "0x01717D8A4EA2".parse().unwrap();
553        assert_eq!(addr.packed(), 0x01717D8A4EA2);
554    }
555
556    #[test]
557    fn signal_booster_roundtrip() {
558        let addr = GalacticAddress::new(-350, 42, 1000, 0x123, 3, 0);
559        let sb = addr.to_signal_booster();
560        let addr2 = GalacticAddress::from_signal_booster(&sb, 3, 0).unwrap();
561        assert_eq!(addr.packed(), addr2.packed());
562    }
563
564    #[test]
565    fn signal_booster_format() {
566        let addr = GalacticAddress::from_packed(0x01717D8A4EA2, 0);
567        let sb = addr.to_signal_booster();
568        let parts: Vec<&str> = sb.split(':').collect();
569        assert_eq!(parts.len(), 4);
570        for part in &parts {
571            assert_eq!(part.len(), 4);
572            assert!(u16::from_str_radix(part, 16).is_ok());
573        }
574    }
575
576    #[test]
577    fn special_system_indices() {
578        let bh = GalacticAddress::new(0, 0, 0, 0x079, 0, 0);
579        assert!(bh.is_black_hole());
580        assert!(!bh.is_atlas_interface());
581
582        let atlas = GalacticAddress::new(0, 0, 0, 0x07A, 0, 0);
583        assert!(atlas.is_atlas_interface());
584
585        let purple = GalacticAddress::new(0, 0, 0, 0x400, 0, 0);
586        assert!(purple.is_purple_system());
587    }
588
589    #[test]
590    fn distance_same_address_is_zero() {
591        let addr = GalacticAddress::new(100, 50, 200, 0x123, 0, 0);
592        assert_eq!(addr.distance_ly(&addr), 0.0);
593    }
594
595    #[test]
596    fn distance_one_voxel_x() {
597        let a = GalacticAddress::new(0, 0, 0, 0, 0, 0);
598        let b = GalacticAddress::new(1, 0, 0, 0, 0, 0);
599        assert!((a.distance_ly(&b) - 400.0).abs() < 0.01);
600    }
601
602    #[test]
603    fn same_region_different_ssi() {
604        let a = GalacticAddress::new(100, 50, 200, 0x001, 0, 0);
605        let b = GalacticAddress::new(100, 50, 200, 0x002, 0, 0);
606        assert!(a.same_region(&b));
607        assert!(!a.same_system(&b));
608    }
609
610    #[test]
611    fn same_system_same_everything() {
612        let a = GalacticAddress::new(100, 50, 200, 0x123, 0, 0);
613        let b = GalacticAddress::new(100, 50, 200, 0x123, 5, 0);
614        assert!(a.same_system(&b));
615    }
616
617    #[test]
618    fn within_boundary() {
619        let a = GalacticAddress::new(0, 0, 0, 0, 0, 0);
620        let b = GalacticAddress::new(1, 0, 0, 0, 0, 0);
621        assert!(a.within(&b, 400.0));
622        assert!(!a.within(&b, 399.0));
623    }
624
625    #[test]
626    fn negative_voxel_roundtrip() {
627        let addr = GalacticAddress::new(-2048, -128, -2048, 0, 0, 0);
628        assert_eq!(addr.voxel_x(), -2048);
629        assert_eq!(addr.voxel_y(), -128);
630        assert_eq!(addr.voxel_z(), -2048);
631    }
632
633    #[test]
634    fn serde_roundtrip() {
635        let addr = GalacticAddress::new(-350, 42, 1000, 0x123, 3, 5);
636        let json = serde_json::to_string(&addr).unwrap();
637        let addr2: GalacticAddress = serde_json::from_str(&json).unwrap();
638        assert_eq!(addr, addr2);
639    }
640
641    // --- Portal address tests ---
642
643    #[test]
644    fn known_address_hex_to_emoji() {
645        let pa: PortalAddress = "01717D8A4EA2".parse().unwrap();
646        let emoji = pa.to_emoji_string();
647        assert_eq!(
648            emoji,
649            "\u{1F305}\u{1F54A}\u{FE0F}\u{1F41C}\u{1F54A}\u{FE0F}\u{1F41C}\u{1F680}\u{1F98B}\u{1F54B}\u{1F31C}\u{1F333}\u{1F54B}\u{1F611}"
650        );
651    }
652
653    #[test]
654    fn known_address_emoji_to_hex() {
655        let emoji_str = "\u{1F305}\u{1F54A}\u{FE0F}\u{1F41C}\u{1F54A}\u{FE0F}\u{1F41C}\u{1F680}\u{1F98B}\u{1F54B}\u{1F31C}\u{1F333}\u{1F54B}\u{1F611}";
656        let pa: PortalAddress = emoji_str.parse().unwrap();
657        assert_eq!(pa.to_hex_string(), "01717D8A4EA2");
658    }
659
660    #[test]
661    fn galactic_address_portal_roundtrip() {
662        let ga = GalacticAddress::from_packed(0x01717D8A4EA2, 0);
663        let pa = PortalAddress::from(ga);
664        assert_eq!(pa.to_hex_string(), "01717D8A4EA2");
665        let ga2 = GalacticAddress::from(pa);
666        assert_eq!(ga.packed(), ga2.packed());
667    }
668
669    #[test]
670    fn hex_string_roundtrip() {
671        let pa: PortalAddress = "01717D8A4EA2".parse().unwrap();
672        let hex = pa.to_hex_string();
673        assert_eq!(hex, "01717D8A4EA2");
674        let pa2: PortalAddress = hex.parse().unwrap();
675        assert_eq!(pa, pa2);
676    }
677
678    #[test]
679    fn full_roundtrip_ga_pa_hex_pa_ga() {
680        let ga1 = GalacticAddress::new(-350, 42, 1000, 0x123, 3, 5);
681        let pa1 = ga1.to_portal_address();
682        let hex = pa1.to_hex_string();
683        let pa2: PortalAddress = hex.parse().unwrap();
684        let ga2 = pa2.to_galactic_address();
685        // reality_index is lost in portal address conversion (defaults to 0)
686        assert_eq!(ga1.packed(), ga2.packed());
687    }
688
689    #[test]
690    fn parse_emoji_with_variation_selectors() {
691        let with_vs = "\u{1F305}\u{1F54A}\u{FE0F}\u{1F41C}\u{1F54A}\u{FE0F}\u{1F41C}\u{1F680}\u{1F98B}\u{1F54B}\u{1F31C}\u{1F333}\u{1F54B}\u{1F611}";
692        let pa_with: PortalAddress = with_vs.parse().unwrap();
693
694        let without_vs = "\u{1F305}\u{1F54A}\u{1F41C}\u{1F54A}\u{1F41C}\u{1F680}\u{1F98B}\u{1F54B}\u{1F31C}\u{1F333}\u{1F54B}\u{1F611}";
695        let pa_without: PortalAddress = without_vs.parse().unwrap();
696
697        assert_eq!(pa_with, pa_without);
698    }
699
700    #[test]
701    fn parse_mixed_input() {
702        let mixed = "\u{1F305}1\u{1F41C}Bird\u{1F41C}D8A4EA2";
703        let pa: PortalAddress = mixed.parse().unwrap();
704        assert_eq!(pa.to_hex_string(), "01717D8A4EA2");
705    }
706
707    #[test]
708    fn wrong_length_errors() {
709        assert!("0171".parse::<PortalAddress>().is_err());
710        assert!("01717D8A4EA20".parse::<PortalAddress>().is_err());
711    }
712
713    #[test]
714    fn signal_booster_to_portal_address() {
715        let ga = GalacticAddress::new(0, 0, 0, 0x100, 0, 0);
716        let sb = ga.to_signal_booster();
717        let pa = PortalAddress::from_signal_booster(&sb, 0, 0).unwrap();
718        assert_eq!(pa.to_galactic_address().packed(), ga.packed());
719    }
720
721    #[test]
722    fn portal_display_is_hex() {
723        let pa: PortalAddress = "01717D8A4EA2".parse().unwrap();
724        assert_eq!(format!("{pa}"), "01717D8A4EA2");
725    }
726
727    // --- Distance calculator tests (milestone 1.4) ---
728
729    #[test]
730    fn identical_addresses_distance_zero() {
731        let addr = GalacticAddress::new(100, 50, -200, 0x123, 3, 0);
732        assert_eq!(addr.distance_ly(&addr), 0.0);
733    }
734
735    #[test]
736    fn one_voxel_apart_y_axis() {
737        let a = GalacticAddress::new(0, 0, 0, 0x100, 0, 0);
738        let b = GalacticAddress::new(0, 1, 0, 0x100, 0, 0);
739        let dist = a.distance_ly(&b);
740        assert!((dist - 400.0).abs() < 0.001, "expected 400.0, got {}", dist);
741    }
742
743    #[test]
744    fn one_voxel_apart_z_axis() {
745        let a = GalacticAddress::new(0, 0, 0, 0x100, 0, 0);
746        let b = GalacticAddress::new(0, 0, 1, 0x100, 0, 0);
747        let dist = a.distance_ly(&b);
748        assert!((dist - 400.0).abs() < 0.001, "expected 400.0, got {}", dist);
749    }
750
751    #[test]
752    fn diagonal_distance_3_4_5_triangle() {
753        let a = GalacticAddress::new(0, 0, 0, 0x100, 0, 0);
754        let b = GalacticAddress::new(3, 4, 0, 0x100, 0, 0);
755        let dist = a.distance_ly(&b);
756        assert!(
757            (dist - 2000.0).abs() < 0.001,
758            "expected 2000.0, got {}",
759            dist
760        );
761    }
762
763    #[test]
764    fn negative_coordinates_distance() {
765        let a = GalacticAddress::new(-100, -50, -200, 0x100, 0, 0);
766        let b = GalacticAddress::new(100, 50, 200, 0x100, 0, 0);
767        let dist = a.distance_ly(&b);
768        let expected = (210000.0_f64).sqrt() * 400.0;
769        assert!(
770            (dist - expected).abs() < 0.01,
771            "expected {}, got {}",
772            expected,
773            dist
774        );
775    }
776
777    #[test]
778    fn max_distance_across_galaxy() {
779        let a = GalacticAddress::new(-2048, -128, -2048, 0x000, 0, 0);
780        let b = GalacticAddress::new(2047, 127, 2047, 0x000, 0, 0);
781        let dist = a.distance_ly(&b);
782        let expected =
783            ((4095.0_f64).powi(2) + (255.0_f64).powi(2) + (4095.0_f64).powi(2)).sqrt() * 400.0;
784        assert!(
785            (dist - expected).abs() < 1.0,
786            "expected {}, got {}",
787            expected,
788            dist
789        );
790    }
791
792    #[test]
793    fn distance_to_core() {
794        let addr = GalacticAddress::new(3, 4, 0, 0x100, 0, 0);
795        let dist = addr.distance_to_core_ly();
796        assert!(
797            (dist - 2000.0).abs() < 0.001,
798            "expected 2000.0, got {}",
799            dist
800        );
801    }
802
803    #[test]
804    fn distance_to_core_at_origin() {
805        let addr = GalacticAddress::new(0, 0, 0, 0x100, 0, 0);
806        assert_eq!(addr.distance_to_core_ly(), 0.0);
807    }
808
809    #[test]
810    fn different_region() {
811        let a = GalacticAddress::new(100, 50, -200, 0x123, 0, 0);
812        let b = GalacticAddress::new(101, 50, -200, 0x123, 0, 0);
813        assert!(!a.same_region(&b));
814        assert!(!a.same_system(&b));
815    }
816
817    #[test]
818    fn within_zero_distance() {
819        let a = GalacticAddress::new(0, 0, 0, 0x100, 0, 0);
820        assert!(a.within(&a, 0.0));
821    }
822
823    #[test]
824    fn distance_is_symmetric() {
825        let a = GalacticAddress::new(-500, 42, 1000, 0x123, 0, 0);
826        let b = GalacticAddress::new(300, -100, -800, 0x456, 0, 0);
827        assert_eq!(a.distance_ly(&b), b.distance_ly(&a));
828    }
829}