Skip to main content

aetheris_protocol/
types.rs

1//! Protocol-level primitive types.
2pub const PROTOCOL_VERSION: u32 = 3;
3
4use serde::{Deserialize, Serialize};
5
6/// A globally unique entity identifier used in all network communication.
7/// Assigned by the server. Immutable for the lifetime of the entity.
8///
9/// This is NOT the ECS's internal entity ID. The `WorldState` adapter
10/// translates between `NetworkId` and the ECS's local handle.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12pub struct NetworkId(pub u64);
13
14/// The ECS's internal entity handle. Opaque to the network layer.
15/// In Phase 1 (Bevy), this wraps `bevy_ecs::entity::Entity`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct LocalId(pub u64);
18
19/// A unique identifier for a connected client session.
20/// Assigned by the transport layer on connection, released on disconnect.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22pub struct ClientId(pub u64);
23
24/// A component type identifier. Used by the Encoder to determine
25/// how to serialize/deserialize a specific component's fields.
26///
27/// In Phase 1, this is a simple enum discriminant.
28/// In Phase 3, this may become a compile-time type hash.
29///
30/// ### Reservation Policy (M1020/M1015):
31/// - `0–1023` (except 128): Engine Core (Replicated).
32/// - `1024–2047`: Official Engine Extensions.
33/// - `128`: Explicitly reserved for Input Commands (Transient/Inbound-Only).
34/// - `32768+`: Reserved for Non-Replicated/Inbound variants.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36pub struct ComponentKind(pub u16);
37
38/// Discriminant for client-to-server input commands.
39/// Tagged as Transient/Inbound-Only.
40pub const INPUT_COMMAND_KIND: ComponentKind = ComponentKind(128);
41
42/// Replicated component for Room Definition.
43pub const ROOM_DEFINITION_KIND: ComponentKind = ComponentKind(129);
44
45/// Replicated component for Room Bounds.
46pub const ROOM_BOUNDS_KIND: ComponentKind = ComponentKind(130);
47
48/// Replicated component for Room Membership.
49pub const ROOM_MEMBERSHIP_KIND: ComponentKind = ComponentKind(131);
50
51/// Replicated component for the mining laser beam state.
52pub const MINING_BEAM_KIND: ComponentKind = ComponentKind(1024);
53
54/// Replicated component for ship cargo state (replicated to owner).
55pub const CARGO_HOLD_KIND: ComponentKind = ComponentKind(1025);
56
57/// Replicated component for asteroid ore depletion tracking.
58pub const ASTEROID_KIND: ComponentKind = ComponentKind(1026);
59
60/// Replicated component for primary weapon state.
61pub const WEAPON_KIND: ComponentKind = ComponentKind(1027);
62
63/// Replicated component for shield pool state.
64pub const SHIELD_POOL_KIND: ComponentKind = ComponentKind(1028);
65
66/// Replicated component for hull pool state.
67pub const HULL_POOL_KIND: ComponentKind = ComponentKind(1029);
68
69/// Replicated component for cargo drop state.
70pub const CARGO_DROP_KIND: ComponentKind = ComponentKind(1030);
71
72/// Replicated component for projectile marker state.
73pub const PROJECTILE_MARKER_KIND: ComponentKind = ComponentKind(13);
74
75/// Action bitflag: fire primary weapon.
76pub const ACTION_FIRE_WEAPON: u32 = 1 << 2;
77
78/// Standard transform component used for replication (`ComponentKind` 1).
79#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
80#[repr(C)]
81pub struct Transform {
82    /// Position X
83    pub x: f32,
84    /// Position Y
85    pub y: f32,
86    /// Position Z
87    pub z: f32,
88    /// Rotation in radians
89    pub rotation: f32,
90    /// The high-level entity type identifier for early client rendering.
91    pub entity_type: u16,
92}
93
94/// Ship classification for rendering and stat selection.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[repr(u8)]
97pub enum ShipClass {
98    Interceptor = 0,
99    Dreadnought = 1,
100    Hauler = 2,
101}
102
103/// Constant identifiers for entity types used in replication and rendering.
104pub const ENTITY_TYPE_INTERCEPTOR: u16 = 1;
105pub const ENTITY_TYPE_AI_INTERCEPTOR: u16 = 2;
106pub const ENTITY_TYPE_DREADNOUGHT: u16 = 3;
107pub const ENTITY_TYPE_HAULER: u16 = 4;
108pub const ENTITY_TYPE_ASTEROID: u16 = 5;
109pub const ENTITY_TYPE_CARGO_DROP: u16 = 6;
110pub const ENTITY_TYPE_TRAINING_DUMMY: u16 = 10;
111pub const ENTITY_TYPE_PROJECTILE: u16 = 20;
112
113/// Returns the default authoritative vitals (`max_hp`, `max_shield`) for a given entity type.
114///
115/// These values are the single source of truth for UI and early client-side prediction
116/// before authoritative `ShipStats` updates arrive.
117#[must_use]
118pub const fn get_default_stats(entity_type: u16) -> (u16, u16) {
119    match entity_type {
120        ENTITY_TYPE_INTERCEPTOR | ENTITY_TYPE_AI_INTERCEPTOR => (200, 100),
121        ENTITY_TYPE_DREADNOUGHT => (1500, 500),
122        ENTITY_TYPE_HAULER => (600, 200),
123        ENTITY_TYPE_ASTEROID => (500, 0),
124        ENTITY_TYPE_TRAINING_DUMMY => (100, 50),
125        ENTITY_TYPE_CARGO_DROP | ENTITY_TYPE_PROJECTILE => (1, 0),
126        _ => (100, 100),
127    }
128}
129
130/// Unique identifier for a weapon type.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
132pub struct WeaponId(pub u8);
133
134/// A globally unique sector/room identifier.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
136pub struct SectorId(pub u64);
137
138/// Material types extracted from asteroids.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[repr(u8)]
141pub enum OreType {
142    RawOre = 0,
143}
144
145/// Projectile delivery classification.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[repr(u8)]
148pub enum ProjectileType {
149    PulseLaser = 0,
150    SeekerMissile = 1,
151}
152
153/// NPC Drone behavior state Machine.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[repr(u8)]
156pub enum AIState {
157    Patrol = 0,
158    Aggro = 1,
159    Combat = 2,
160    Return = 3,
161}
162
163/// Definitive respawn target semantics.
164#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
165pub enum RespawnLocation {
166    /// The server calculates dynamically the Nearest Safe Zone.
167    NearestSafeZone,
168    /// Respawn docked at a specific station entity.
169    Station(u64),
170    /// Respawn at arbitrary x, y coordinates (admin/debug).
171    Coordinate(f32, f32),
172}
173
174/// Individual input actions performed by a player in a single tick.
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
176pub enum PlayerInputKind {
177    /// Directional thrust/movement.
178    Move { x: f32, y: f32 },
179    /// Toggle mining beam on a specific target.
180    ToggleMining { target: NetworkId },
181    /// Fire primary weapon (for VS-03).
182    FirePrimary,
183}
184
185/// Maximum allowed actions in a single `InputCommand` to prevent payload `DoS`.
186/// Chosen to stay well within `MAX_SAFE_PAYLOAD_SIZE` (1200 bytes).
187pub const MAX_ACTIONS: usize = 128;
188
189/// Bitmask of all currently supported action flags.
190pub const ALLOWED_ACTIONS_MASK: u32 = ACTION_FIRE_WEAPON;
191
192/// Aggregated user input for a single simulation tick.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct InputCommand {
195    /// The client-side tick this input was generated at.
196    pub tick: u64,
197    /// List of actions performed in this tick.
198    pub actions: Vec<PlayerInputKind>,
199    /// Bitmask of actions for high-frequency binary inputs.
200    #[serde(default)]
201    pub actions_mask: u32,
202    /// The tick of the last server state the client saw before sending this input.
203    pub last_seen_input_tick: Option<u64>,
204}
205
206impl InputCommand {
207    /// Returns a new `InputCommand` with all `Move` inputs clamped to [-1.0, 1.0].
208    #[must_use]
209    pub fn clamped(mut self) -> Self {
210        for action in &mut self.actions {
211            if let PlayerInputKind::Move { x, y } = action {
212                *x = x.clamp(-1.0, 1.0);
213                *y = y.clamp(-1.0, 1.0);
214            }
215        }
216        self
217    }
218
219    /// Validates the command against protocol constraints.
220    ///
221    /// # Errors
222    /// Returns an error message if the command exceeds `MAX_ACTIONS` or has unknown bits in `actions_mask`.
223    pub fn validate(&self) -> Result<(), &'static str> {
224        if self.actions.len() > MAX_ACTIONS {
225            return Err("Too many actions in InputCommand");
226        }
227        if (self.actions_mask & !ALLOWED_ACTIONS_MASK) != 0 {
228            return Err("Unknown bits in actions_mask");
229        }
230        Ok(())
231    }
232}
233
234/// Replicated state for a ship's mining beam.
235#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
236pub struct MiningBeam {
237    pub active: bool,
238    pub target: Option<NetworkId>,
239    #[serde(default)]
240    pub mining_range: f32,
241    #[serde(default)]
242    pub base_mining_rate: u16,
243}
244
245/// Replicated state for a ship's cargo hold.
246#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
247pub struct CargoHold {
248    pub ore_count: u16,
249    pub capacity: u16,
250}
251
252#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
253pub struct Asteroid {
254    pub ore_remaining: u16,
255    pub total_capacity: u16,
256}
257
258/// Replicated state for a ship's primary weapon.
259#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
260pub struct Weapon {
261    pub cooldown_ticks: u16,
262    pub last_fired_tick: u64,
263}
264
265/// Replicated state for a ship's shield pool.
266#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
267pub struct ShieldPool {
268    pub current: u16,
269    pub max: u16,
270}
271
272/// Replicated state for a ship's hull pool.
273#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
274pub struct HullPool {
275    pub current: u16,
276    pub max: u16,
277}
278
279/// Replicated state for a cargo drop entity.
280#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
281pub struct CargoDrop {
282    pub quantity: u16,
283}
284
285/// Basic vitals for any ship entity.
286///
287/// NOTE: Zero values in maxima (`max_hp`, `max_shield`, `max_energy`) represent an uninitialized
288/// or dead state. Logic that performs divisions or percentage calculations must verify
289/// non-zero maxima.
290#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
291pub struct ShipStats {
292    pub hp: u16,
293    pub max_hp: u16,
294    pub shield: u16,
295    pub max_shield: u16,
296    pub energy: u16,
297    pub max_energy: u16,
298    pub shield_regen_per_s: u16,
299    pub energy_regen_per_s: u16,
300}
301
302impl Default for ShipStats {
303    /// Returns a baseline valid state (100 HP/Shield/Energy).
304    fn default() -> Self {
305        Self {
306            hp: 100,
307            max_hp: 100,
308            shield: 100,
309            max_shield: 100,
310            energy: 100,
311            max_energy: 100,
312            shield_regen_per_s: 0,
313            energy_regen_per_s: 0,
314        }
315    }
316}
317
318/// Maximum byte length (UTF-8) for [`RoomName`] and [`PermissionString`].
319///
320/// Chosen well below [`MAX_SAFE_PAYLOAD_SIZE`](crate::MAX_SAFE_PAYLOAD_SIZE)
321/// to leave ample room for the surrounding struct framing in the wire format.
322pub const MAX_ROOM_STRING_BYTES: usize = 64;
323
324/// Error returned when a [`RoomName`] or [`PermissionString`] exceeds the
325/// allowed byte length.
326#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
327#[error("string too long: {len} bytes exceeds the maximum of {max} bytes")]
328pub struct RoomStringError {
329    /// Actual byte length of the rejected string.
330    pub len: usize,
331    /// Maximum allowed byte length ([`MAX_ROOM_STRING_BYTES`]).
332    pub max: usize,
333}
334
335/// A validated room name.
336///
337/// Guaranteed not to exceed [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8).
338/// The limit is enforced at construction time via [`RoomName::new`] and at
339/// Serde decode time, so a value held in this type can never produce a payload
340/// that exceeds [`MAX_SAFE_PAYLOAD_SIZE`](crate::MAX_SAFE_PAYLOAD_SIZE).
341#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
342#[serde(try_from = "String", into = "String")]
343pub struct RoomName(String);
344
345impl RoomName {
346    /// Creates a `RoomName`, returning [`RoomStringError`] if `s` exceeds
347    /// [`MAX_ROOM_STRING_BYTES`] bytes.
348    ///
349    /// # Errors
350    ///
351    /// Returns [`RoomStringError`] if the byte length of `s` exceeds
352    /// [`MAX_ROOM_STRING_BYTES`].
353    #[must_use = "the validated RoomName must be used"]
354    pub fn new(s: impl Into<String>) -> Result<Self, RoomStringError> {
355        let s = s.into();
356        if s.len() > MAX_ROOM_STRING_BYTES {
357            return Err(RoomStringError {
358                len: s.len(),
359                max: MAX_ROOM_STRING_BYTES,
360            });
361        }
362        Ok(Self(s))
363    }
364
365    /// Returns the name as a string slice.
366    #[must_use]
367    pub fn as_str(&self) -> &str {
368        &self.0
369    }
370}
371
372impl TryFrom<String> for RoomName {
373    type Error = RoomStringError;
374    fn try_from(s: String) -> Result<Self, Self::Error> {
375        Self::new(s)
376    }
377}
378
379impl From<RoomName> for String {
380    fn from(n: RoomName) -> String {
381        n.0
382    }
383}
384
385impl std::fmt::Display for RoomName {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        self.0.fmt(f)
388    }
389}
390
391/// A validated access-control permission token.
392///
393/// Guaranteed not to exceed [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8).
394/// Used by [`RoomAccessPolicy::Permission`].
395/// The limit is enforced at construction time via [`PermissionString::new`] and
396/// at Serde decode time.
397#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
398#[serde(try_from = "String", into = "String")]
399pub struct PermissionString(String);
400
401impl PermissionString {
402    /// Creates a `PermissionString`, returning [`RoomStringError`] if `s`
403    /// exceeds [`MAX_ROOM_STRING_BYTES`] bytes.
404    ///
405    /// # Errors
406    ///
407    /// Returns [`RoomStringError`] if the byte length of `s` exceeds
408    /// [`MAX_ROOM_STRING_BYTES`].
409    #[must_use = "the validated PermissionString must be used"]
410    pub fn new(s: impl Into<String>) -> Result<Self, RoomStringError> {
411        let s = s.into();
412        if s.len() > MAX_ROOM_STRING_BYTES {
413            return Err(RoomStringError {
414                len: s.len(),
415                max: MAX_ROOM_STRING_BYTES,
416            });
417        }
418        Ok(Self(s))
419    }
420
421    /// Returns the permission token as a string slice.
422    #[must_use]
423    pub fn as_str(&self) -> &str {
424        &self.0
425    }
426}
427
428impl TryFrom<String> for PermissionString {
429    type Error = RoomStringError;
430    fn try_from(s: String) -> Result<Self, Self::Error> {
431        Self::new(s)
432    }
433}
434
435impl From<PermissionString> for String {
436    fn from(p: PermissionString) -> String {
437        p.0
438    }
439}
440
441impl std::fmt::Display for PermissionString {
442    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443        self.0.fmt(f)
444    }
445}
446
447/// Access control policy for the room.
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
449pub enum RoomAccessPolicy {
450    /// Anyone can enter.
451    Open,
452    /// Only clients holding the specified [`PermissionString`] token can enter.
453    ///
454    /// The token is replicated verbatim in the wire format and is guaranteed
455    /// not to exceed [`MAX_ROOM_STRING_BYTES`] bytes.
456    Permission(PermissionString),
457    /// Only explicitly invited clients can enter.
458    InviteOnly,
459    /// Locked — no one can enter.
460    Locked,
461}
462
463/// Defines a spatial region as a Room.
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct RoomDefinition {
466    /// Human-readable room identifier.
467    ///
468    /// Replicated verbatim in the wire format. Guaranteed not to exceed
469    /// [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8) by the [`RoomName`] type.
470    pub name: RoomName,
471    pub capacity: u32,
472    pub access: RoomAccessPolicy,
473    pub is_template: bool,
474}
475
476/// Spatial bounds of the room in world coordinates.
477#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
478pub struct RoomBounds {
479    pub min_x: f32,
480    pub min_y: f32,
481    pub max_x: f32,
482    pub max_y: f32,
483}
484
485/// Defines which Room an entity currently belongs to.
486#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
487pub struct RoomMembership(pub NetworkId);
488
489use std::sync::atomic::{AtomicU64, Ordering};
490use thiserror::Error;
491
492#[derive(Debug, Error, PartialEq, Eq)]
493pub enum AllocatorError {
494    #[error("NetworkId overflow (reached u64::MAX)")]
495    Overflow,
496    #[error("NetworkId allocator exhausted (reached limit)")]
497    Exhausted,
498}
499
500/// Authoritative allocator for [`NetworkId`]s.
501///
502/// Used by the server to ensure IDs are unique and monotonically increasing.
503/// Thread-safe and lock-free.
504#[derive(Debug)]
505pub struct NetworkIdAllocator {
506    start_id: u64,
507    next: AtomicU64,
508}
509
510impl Default for NetworkIdAllocator {
511    fn default() -> Self {
512        Self::new(1)
513    }
514}
515
516impl NetworkIdAllocator {
517    /// Creates a new allocator starting from a specific ID. 0 is reserved.
518    #[must_use]
519    pub fn new(start_id: u64) -> Self {
520        Self {
521            start_id,
522            next: AtomicU64::new(start_id),
523        }
524    }
525
526    /// Allocates a new unique [`NetworkId`].
527    ///
528    /// # Errors
529    /// Returns [`AllocatorError::Overflow`] if the next ID would exceed `u64::MAX`.
530    pub fn allocate(&self) -> Result<NetworkId, AllocatorError> {
531        let val = self
532            .next
533            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |curr| {
534                if curr == u64::MAX {
535                    None
536                } else {
537                    Some(curr + 1)
538                }
539            })
540            .map_err(|_| AllocatorError::Overflow)?;
541
542        if val == 0 {
543            return Err(AllocatorError::Exhausted);
544        }
545
546        Ok(NetworkId(val))
547    }
548
549    /// Resets the allocator to its initial `start_id`.
550    /// Use only in tests or clear-world scenarios.
551    pub fn reset(&self) {
552        self.next.store(self.start_id, Ordering::Relaxed);
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_primitive_derives() {
562        let nid1 = NetworkId(42);
563        let nid2 = nid1;
564        assert_eq!(nid1, nid2);
565
566        let lid1 = LocalId(42);
567        let lid2 = LocalId(42);
568        assert_eq!(lid1, lid2);
569
570        let cid = ClientId(99);
571        assert_eq!(format!("{cid:?}"), "ClientId(99)");
572
573        let kind = ComponentKind(1);
574        assert_eq!(kind.0, 1);
575    }
576
577    #[test]
578    fn test_input_command_clamping() {
579        let cmd = InputCommand {
580            tick: 1,
581            actions: vec![PlayerInputKind::Move { x: 2.0, y: -5.0 }],
582            actions_mask: 0,
583            last_seen_input_tick: None,
584        };
585        let clamped = cmd.clamped();
586        if let PlayerInputKind::Move { x, y } = clamped.actions[0] {
587            assert!((x - 1.0).abs() < f32::EPSILON);
588            assert!((y - -1.0).abs() < f32::EPSILON);
589        } else {
590            panic!("Expected Move action");
591        }
592
593        let valid = InputCommand {
594            tick: 1,
595            actions: vec![PlayerInputKind::Move { x: 0.5, y: -0.2 }],
596            actions_mask: 0,
597            last_seen_input_tick: None,
598        };
599        let clamped = valid.clamped();
600        if let PlayerInputKind::Move { x, y } = clamped.actions[0] {
601            assert!((x - 0.5).abs() < f32::EPSILON);
602            assert!((y - -0.2).abs() < f32::EPSILON);
603        } else {
604            panic!("Expected Move action");
605        }
606    }
607
608    #[test]
609    fn test_ship_stats_non_zero_default() {
610        let stats = ShipStats::default();
611        assert!(stats.max_hp > 0);
612        assert!(stats.max_shield > 0);
613        assert!(stats.max_energy > 0);
614        assert_eq!(stats.hp, stats.max_hp);
615    }
616
617    #[test]
618    fn test_get_default_stats() {
619        assert_eq!(get_default_stats(ENTITY_TYPE_INTERCEPTOR), (200, 100));
620        assert_eq!(get_default_stats(ENTITY_TYPE_AI_INTERCEPTOR), (200, 100));
621        assert_eq!(get_default_stats(ENTITY_TYPE_DREADNOUGHT), (1500, 500));
622        assert_eq!(get_default_stats(ENTITY_TYPE_HAULER), (600, 200));
623        assert_eq!(get_default_stats(ENTITY_TYPE_ASTEROID), (500, 0));
624        assert_eq!(get_default_stats(ENTITY_TYPE_CARGO_DROP), (1, 0));
625        assert_eq!(get_default_stats(ENTITY_TYPE_TRAINING_DUMMY), (100, 50));
626        assert_eq!(get_default_stats(ENTITY_TYPE_PROJECTILE), (1, 0));
627        assert_eq!(get_default_stats(999), (100, 100)); // Default fallback
628    }
629}