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/// Standard transform component used for replication (`ComponentKind` 1).
61#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
62#[repr(C)]
63pub struct Transform {
64    /// Position X
65    pub x: f32,
66    /// Position Y
67    pub y: f32,
68    /// Position Z
69    pub z: f32,
70    /// Rotation in radians
71    pub rotation: f32,
72    /// The high-level entity type identifier for early client rendering.
73    pub entity_type: u16,
74}
75
76/// Ship classification for rendering and stat selection.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[repr(u8)]
79pub enum ShipClass {
80    Interceptor = 0,
81    Dreadnought = 1,
82    Hauler = 2,
83}
84
85/// Unique identifier for a weapon type.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
87pub struct WeaponId(pub u8);
88
89/// A globally unique sector/room identifier.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
91pub struct SectorId(pub u64);
92
93/// Material types extracted from asteroids.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[repr(u8)]
96pub enum OreType {
97    RawOre = 0,
98}
99
100/// Projectile delivery classification.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[repr(u8)]
103pub enum ProjectileType {
104    PulseLaser = 0,
105    SeekerMissile = 1,
106}
107
108/// NPC Drone behavior state Machine.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[repr(u8)]
111pub enum AIState {
112    Patrol = 0,
113    Aggro = 1,
114    Combat = 2,
115    Return = 3,
116}
117
118/// Definitive respawn target semantics.
119#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
120pub enum RespawnLocation {
121    /// The server calculates dynamically the Nearest Safe Zone.
122    NearestSafeZone,
123    /// Respawn docked at a specific station entity.
124    Station(u64),
125    /// Respawn at arbitrary x, y coordinates (admin/debug).
126    Coordinate(f32, f32),
127}
128
129/// Individual input actions performed by a player in a single tick.
130#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
131pub enum PlayerInputKind {
132    /// Directional thrust/movement.
133    Move { x: f32, y: f32 },
134    /// Toggle mining beam on a specific target.
135    ToggleMining { target: NetworkId },
136    /// Fire primary weapon (for VS-03).
137    FirePrimary,
138}
139
140/// Maximum allowed actions in a single `InputCommand` to prevent payload `DoS`.
141/// Chosen to stay well within `MAX_SAFE_PAYLOAD_SIZE` (1200 bytes).
142pub const MAX_ACTIONS: usize = 128;
143
144/// Aggregated user input for a single simulation tick.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct InputCommand {
147    /// The client-side tick this input was generated at.
148    pub tick: u64,
149    /// List of actions performed in this tick.
150    pub actions: Vec<PlayerInputKind>,
151    /// The tick of the last server state the client saw before sending this input.
152    pub last_seen_input_tick: Option<u64>,
153}
154
155impl InputCommand {
156    /// Returns a new `InputCommand` with all `Move` inputs clamped to [-1.0, 1.0].
157    #[must_use]
158    pub fn clamped(mut self) -> Self {
159        for action in &mut self.actions {
160            if let PlayerInputKind::Move { x, y } = action {
161                *x = x.clamp(-1.0, 1.0);
162                *y = y.clamp(-1.0, 1.0);
163            }
164        }
165        self
166    }
167
168    /// Validates the command against protocol constraints.
169    ///
170    /// # Errors
171    /// Returns an error message if the command exceeds `MAX_ACTIONS`.
172    pub fn validate(&self) -> Result<(), &'static str> {
173        if self.actions.len() > MAX_ACTIONS {
174            return Err("Too many actions in InputCommand");
175        }
176        Ok(())
177    }
178}
179
180/// Replicated state for a ship's mining beam.
181#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
182pub struct MiningBeam {
183    pub active: bool,
184    pub target: Option<NetworkId>,
185}
186
187/// Replicated state for a ship's cargo hold.
188#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
189pub struct CargoHold {
190    pub ore_count: u16,
191    pub capacity: u16,
192}
193
194/// Replicated state for an asteroid's resource depletion.
195#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
196pub struct Asteroid {
197    pub ore_remaining: u16,
198    pub total_capacity: u16,
199}
200
201/// Basic vitals for any ship entity.
202///
203/// NOTE: Zero values in maxima (`max_hp`, `max_shield`, `max_energy`) represent an uninitialized
204/// or dead state. Logic that performs divisions or percentage calculations must verify
205/// non-zero maxima.
206#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
207pub struct ShipStats {
208    pub hp: u16,
209    pub max_hp: u16,
210    pub shield: u16,
211    pub max_shield: u16,
212    pub energy: u16,
213    pub max_energy: u16,
214    pub shield_regen_per_s: u16,
215    pub energy_regen_per_s: u16,
216}
217
218impl Default for ShipStats {
219    /// Returns a baseline valid state (100 HP/Shield/Energy).
220    fn default() -> Self {
221        Self {
222            hp: 100,
223            max_hp: 100,
224            shield: 100,
225            max_shield: 100,
226            energy: 100,
227            max_energy: 100,
228            shield_regen_per_s: 0,
229            energy_regen_per_s: 0,
230        }
231    }
232}
233
234/// Maximum byte length (UTF-8) for [`RoomName`] and [`PermissionString`].
235///
236/// Chosen well below [`MAX_SAFE_PAYLOAD_SIZE`](crate::MAX_SAFE_PAYLOAD_SIZE)
237/// to leave ample room for the surrounding struct framing in the wire format.
238pub const MAX_ROOM_STRING_BYTES: usize = 64;
239
240/// Error returned when a [`RoomName`] or [`PermissionString`] exceeds the
241/// allowed byte length.
242#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
243#[error("string too long: {len} bytes exceeds the maximum of {max} bytes")]
244pub struct RoomStringError {
245    /// Actual byte length of the rejected string.
246    pub len: usize,
247    /// Maximum allowed byte length ([`MAX_ROOM_STRING_BYTES`]).
248    pub max: usize,
249}
250
251/// A validated room name.
252///
253/// Guaranteed not to exceed [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8).
254/// The limit is enforced at construction time via [`RoomName::new`] and at
255/// Serde decode time, so a value held in this type can never produce a payload
256/// that exceeds [`MAX_SAFE_PAYLOAD_SIZE`](crate::MAX_SAFE_PAYLOAD_SIZE).
257#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
258#[serde(try_from = "String", into = "String")]
259pub struct RoomName(String);
260
261impl RoomName {
262    /// Creates a `RoomName`, returning [`RoomStringError`] if `s` exceeds
263    /// [`MAX_ROOM_STRING_BYTES`] bytes.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`RoomStringError`] if the byte length of `s` exceeds
268    /// [`MAX_ROOM_STRING_BYTES`].
269    #[must_use = "the validated RoomName must be used"]
270    pub fn new(s: impl Into<String>) -> Result<Self, RoomStringError> {
271        let s = s.into();
272        if s.len() > MAX_ROOM_STRING_BYTES {
273            return Err(RoomStringError {
274                len: s.len(),
275                max: MAX_ROOM_STRING_BYTES,
276            });
277        }
278        Ok(Self(s))
279    }
280
281    /// Returns the name as a string slice.
282    #[must_use]
283    pub fn as_str(&self) -> &str {
284        &self.0
285    }
286}
287
288impl TryFrom<String> for RoomName {
289    type Error = RoomStringError;
290    fn try_from(s: String) -> Result<Self, Self::Error> {
291        Self::new(s)
292    }
293}
294
295impl From<RoomName> for String {
296    fn from(n: RoomName) -> String {
297        n.0
298    }
299}
300
301impl std::fmt::Display for RoomName {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        self.0.fmt(f)
304    }
305}
306
307/// A validated access-control permission token.
308///
309/// Guaranteed not to exceed [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8).
310/// Used by [`RoomAccessPolicy::Permission`].
311/// The limit is enforced at construction time via [`PermissionString::new`] and
312/// at Serde decode time.
313#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
314#[serde(try_from = "String", into = "String")]
315pub struct PermissionString(String);
316
317impl PermissionString {
318    /// Creates a `PermissionString`, returning [`RoomStringError`] if `s`
319    /// exceeds [`MAX_ROOM_STRING_BYTES`] bytes.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`RoomStringError`] if the byte length of `s` exceeds
324    /// [`MAX_ROOM_STRING_BYTES`].
325    #[must_use = "the validated PermissionString must be used"]
326    pub fn new(s: impl Into<String>) -> Result<Self, RoomStringError> {
327        let s = s.into();
328        if s.len() > MAX_ROOM_STRING_BYTES {
329            return Err(RoomStringError {
330                len: s.len(),
331                max: MAX_ROOM_STRING_BYTES,
332            });
333        }
334        Ok(Self(s))
335    }
336
337    /// Returns the permission token as a string slice.
338    #[must_use]
339    pub fn as_str(&self) -> &str {
340        &self.0
341    }
342}
343
344impl TryFrom<String> for PermissionString {
345    type Error = RoomStringError;
346    fn try_from(s: String) -> Result<Self, Self::Error> {
347        Self::new(s)
348    }
349}
350
351impl From<PermissionString> for String {
352    fn from(p: PermissionString) -> String {
353        p.0
354    }
355}
356
357impl std::fmt::Display for PermissionString {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        self.0.fmt(f)
360    }
361}
362
363/// Access control policy for the room.
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
365pub enum RoomAccessPolicy {
366    /// Anyone can enter.
367    Open,
368    /// Only clients holding the specified [`PermissionString`] token can enter.
369    ///
370    /// The token is replicated verbatim in the wire format and is guaranteed
371    /// not to exceed [`MAX_ROOM_STRING_BYTES`] bytes.
372    Permission(PermissionString),
373    /// Only explicitly invited clients can enter.
374    InviteOnly,
375    /// Locked — no one can enter.
376    Locked,
377}
378
379/// Defines a spatial region as a Room.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct RoomDefinition {
382    /// Human-readable room identifier.
383    ///
384    /// Replicated verbatim in the wire format. Guaranteed not to exceed
385    /// [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8) by the [`RoomName`] type.
386    pub name: RoomName,
387    pub capacity: u32,
388    pub access: RoomAccessPolicy,
389    pub is_template: bool,
390}
391
392/// Spatial bounds of the room in world coordinates.
393#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
394pub struct RoomBounds {
395    pub min_x: f32,
396    pub min_y: f32,
397    pub max_x: f32,
398    pub max_y: f32,
399}
400
401/// Defines which Room an entity currently belongs to.
402#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
403pub struct RoomMembership(pub NetworkId);
404
405use std::sync::atomic::{AtomicU64, Ordering};
406use thiserror::Error;
407
408#[derive(Debug, Error, PartialEq, Eq)]
409pub enum AllocatorError {
410    #[error("NetworkId overflow (reached u64::MAX)")]
411    Overflow,
412    #[error("NetworkId allocator exhausted (reached limit)")]
413    Exhausted,
414}
415
416/// Authoritative allocator for [`NetworkId`]s.
417///
418/// Used by the server to ensure IDs are unique and monotonically increasing.
419/// Thread-safe and lock-free.
420#[derive(Debug)]
421pub struct NetworkIdAllocator {
422    start_id: u64,
423    next: AtomicU64,
424}
425
426impl Default for NetworkIdAllocator {
427    fn default() -> Self {
428        Self::new(1)
429    }
430}
431
432impl NetworkIdAllocator {
433    /// Creates a new allocator starting from a specific ID. 0 is reserved.
434    #[must_use]
435    pub fn new(start_id: u64) -> Self {
436        Self {
437            start_id,
438            next: AtomicU64::new(start_id),
439        }
440    }
441
442    /// Allocates a new unique [`NetworkId`].
443    ///
444    /// # Errors
445    /// Returns [`AllocatorError::Overflow`] if the next ID would exceed `u64::MAX`.
446    pub fn allocate(&self) -> Result<NetworkId, AllocatorError> {
447        let val = self
448            .next
449            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |curr| {
450                if curr == u64::MAX {
451                    None
452                } else {
453                    Some(curr + 1)
454                }
455            })
456            .map_err(|_| AllocatorError::Overflow)?;
457
458        if val == 0 {
459            return Err(AllocatorError::Exhausted);
460        }
461
462        Ok(NetworkId(val))
463    }
464
465    /// Resets the allocator to its initial `start_id`.
466    /// Use only in tests or clear-world scenarios.
467    pub fn reset(&self) {
468        self.next.store(self.start_id, Ordering::Relaxed);
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn test_primitive_derives() {
478        let nid1 = NetworkId(42);
479        let nid2 = nid1;
480        assert_eq!(nid1, nid2);
481
482        let lid1 = LocalId(42);
483        let lid2 = LocalId(42);
484        assert_eq!(lid1, lid2);
485
486        let cid = ClientId(99);
487        assert_eq!(format!("{cid:?}"), "ClientId(99)");
488
489        let kind = ComponentKind(1);
490        assert_eq!(kind.0, 1);
491    }
492
493    #[test]
494    fn test_input_command_clamping() {
495        let cmd = InputCommand {
496            tick: 1,
497            actions: vec![PlayerInputKind::Move { x: 2.0, y: -5.0 }],
498            last_seen_input_tick: None,
499        };
500        let clamped = cmd.clamped();
501        if let PlayerInputKind::Move { x, y } = clamped.actions[0] {
502            assert!((x - 1.0).abs() < f32::EPSILON);
503            assert!((y - -1.0).abs() < f32::EPSILON);
504        } else {
505            panic!("Expected Move action");
506        }
507
508        let valid = InputCommand {
509            tick: 1,
510            actions: vec![PlayerInputKind::Move { x: 0.5, y: -0.2 }],
511            last_seen_input_tick: None,
512        };
513        let clamped = valid.clamped();
514        if let PlayerInputKind::Move { x, y } = clamped.actions[0] {
515            assert!((x - 0.5).abs() < f32::EPSILON);
516            assert!((y - -0.2).abs() < f32::EPSILON);
517        } else {
518            panic!("Expected Move action");
519        }
520    }
521
522    #[test]
523    fn test_ship_stats_non_zero_default() {
524        let stats = ShipStats::default();
525        assert!(stats.max_hp > 0);
526        assert!(stats.max_shield > 0);
527        assert!(stats.max_energy > 0);
528        assert_eq!(stats.hp, stats.max_hp);
529    }
530}