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