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    #[serde(default)]
186    pub mining_range: f32,
187    #[serde(default)]
188    pub base_mining_rate: u16,
189}
190
191/// Replicated state for a ship's cargo hold.
192#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
193pub struct CargoHold {
194    pub ore_count: u16,
195    pub capacity: u16,
196}
197
198/// Replicated state for an asteroid's resource depletion.
199#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
200pub struct Asteroid {
201    pub ore_remaining: u16,
202    pub total_capacity: u16,
203}
204
205/// Basic vitals for any ship entity.
206///
207/// NOTE: Zero values in maxima (`max_hp`, `max_shield`, `max_energy`) represent an uninitialized
208/// or dead state. Logic that performs divisions or percentage calculations must verify
209/// non-zero maxima.
210#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
211pub struct ShipStats {
212    pub hp: u16,
213    pub max_hp: u16,
214    pub shield: u16,
215    pub max_shield: u16,
216    pub energy: u16,
217    pub max_energy: u16,
218    pub shield_regen_per_s: u16,
219    pub energy_regen_per_s: u16,
220}
221
222impl Default for ShipStats {
223    /// Returns a baseline valid state (100 HP/Shield/Energy).
224    fn default() -> Self {
225        Self {
226            hp: 100,
227            max_hp: 100,
228            shield: 100,
229            max_shield: 100,
230            energy: 100,
231            max_energy: 100,
232            shield_regen_per_s: 0,
233            energy_regen_per_s: 0,
234        }
235    }
236}
237
238/// Maximum byte length (UTF-8) for [`RoomName`] and [`PermissionString`].
239///
240/// Chosen well below [`MAX_SAFE_PAYLOAD_SIZE`](crate::MAX_SAFE_PAYLOAD_SIZE)
241/// to leave ample room for the surrounding struct framing in the wire format.
242pub const MAX_ROOM_STRING_BYTES: usize = 64;
243
244/// Error returned when a [`RoomName`] or [`PermissionString`] exceeds the
245/// allowed byte length.
246#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
247#[error("string too long: {len} bytes exceeds the maximum of {max} bytes")]
248pub struct RoomStringError {
249    /// Actual byte length of the rejected string.
250    pub len: usize,
251    /// Maximum allowed byte length ([`MAX_ROOM_STRING_BYTES`]).
252    pub max: usize,
253}
254
255/// A validated room name.
256///
257/// Guaranteed not to exceed [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8).
258/// The limit is enforced at construction time via [`RoomName::new`] and at
259/// Serde decode time, so a value held in this type can never produce a payload
260/// that exceeds [`MAX_SAFE_PAYLOAD_SIZE`](crate::MAX_SAFE_PAYLOAD_SIZE).
261#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
262#[serde(try_from = "String", into = "String")]
263pub struct RoomName(String);
264
265impl RoomName {
266    /// Creates a `RoomName`, returning [`RoomStringError`] if `s` exceeds
267    /// [`MAX_ROOM_STRING_BYTES`] bytes.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`RoomStringError`] if the byte length of `s` exceeds
272    /// [`MAX_ROOM_STRING_BYTES`].
273    #[must_use = "the validated RoomName must be used"]
274    pub fn new(s: impl Into<String>) -> Result<Self, RoomStringError> {
275        let s = s.into();
276        if s.len() > MAX_ROOM_STRING_BYTES {
277            return Err(RoomStringError {
278                len: s.len(),
279                max: MAX_ROOM_STRING_BYTES,
280            });
281        }
282        Ok(Self(s))
283    }
284
285    /// Returns the name as a string slice.
286    #[must_use]
287    pub fn as_str(&self) -> &str {
288        &self.0
289    }
290}
291
292impl TryFrom<String> for RoomName {
293    type Error = RoomStringError;
294    fn try_from(s: String) -> Result<Self, Self::Error> {
295        Self::new(s)
296    }
297}
298
299impl From<RoomName> for String {
300    fn from(n: RoomName) -> String {
301        n.0
302    }
303}
304
305impl std::fmt::Display for RoomName {
306    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        self.0.fmt(f)
308    }
309}
310
311/// A validated access-control permission token.
312///
313/// Guaranteed not to exceed [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8).
314/// Used by [`RoomAccessPolicy::Permission`].
315/// The limit is enforced at construction time via [`PermissionString::new`] and
316/// at Serde decode time.
317#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
318#[serde(try_from = "String", into = "String")]
319pub struct PermissionString(String);
320
321impl PermissionString {
322    /// Creates a `PermissionString`, returning [`RoomStringError`] if `s`
323    /// exceeds [`MAX_ROOM_STRING_BYTES`] bytes.
324    ///
325    /// # Errors
326    ///
327    /// Returns [`RoomStringError`] if the byte length of `s` exceeds
328    /// [`MAX_ROOM_STRING_BYTES`].
329    #[must_use = "the validated PermissionString must be used"]
330    pub fn new(s: impl Into<String>) -> Result<Self, RoomStringError> {
331        let s = s.into();
332        if s.len() > MAX_ROOM_STRING_BYTES {
333            return Err(RoomStringError {
334                len: s.len(),
335                max: MAX_ROOM_STRING_BYTES,
336            });
337        }
338        Ok(Self(s))
339    }
340
341    /// Returns the permission token as a string slice.
342    #[must_use]
343    pub fn as_str(&self) -> &str {
344        &self.0
345    }
346}
347
348impl TryFrom<String> for PermissionString {
349    type Error = RoomStringError;
350    fn try_from(s: String) -> Result<Self, Self::Error> {
351        Self::new(s)
352    }
353}
354
355impl From<PermissionString> for String {
356    fn from(p: PermissionString) -> String {
357        p.0
358    }
359}
360
361impl std::fmt::Display for PermissionString {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        self.0.fmt(f)
364    }
365}
366
367/// Access control policy for the room.
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369pub enum RoomAccessPolicy {
370    /// Anyone can enter.
371    Open,
372    /// Only clients holding the specified [`PermissionString`] token can enter.
373    ///
374    /// The token is replicated verbatim in the wire format and is guaranteed
375    /// not to exceed [`MAX_ROOM_STRING_BYTES`] bytes.
376    Permission(PermissionString),
377    /// Only explicitly invited clients can enter.
378    InviteOnly,
379    /// Locked — no one can enter.
380    Locked,
381}
382
383/// Defines a spatial region as a Room.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct RoomDefinition {
386    /// Human-readable room identifier.
387    ///
388    /// Replicated verbatim in the wire format. Guaranteed not to exceed
389    /// [`MAX_ROOM_STRING_BYTES`] bytes (UTF-8) by the [`RoomName`] type.
390    pub name: RoomName,
391    pub capacity: u32,
392    pub access: RoomAccessPolicy,
393    pub is_template: bool,
394}
395
396/// Spatial bounds of the room in world coordinates.
397#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
398pub struct RoomBounds {
399    pub min_x: f32,
400    pub min_y: f32,
401    pub max_x: f32,
402    pub max_y: f32,
403}
404
405/// Defines which Room an entity currently belongs to.
406#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
407pub struct RoomMembership(pub NetworkId);
408
409use std::sync::atomic::{AtomicU64, Ordering};
410use thiserror::Error;
411
412#[derive(Debug, Error, PartialEq, Eq)]
413pub enum AllocatorError {
414    #[error("NetworkId overflow (reached u64::MAX)")]
415    Overflow,
416    #[error("NetworkId allocator exhausted (reached limit)")]
417    Exhausted,
418}
419
420/// Authoritative allocator for [`NetworkId`]s.
421///
422/// Used by the server to ensure IDs are unique and monotonically increasing.
423/// Thread-safe and lock-free.
424#[derive(Debug)]
425pub struct NetworkIdAllocator {
426    start_id: u64,
427    next: AtomicU64,
428}
429
430impl Default for NetworkIdAllocator {
431    fn default() -> Self {
432        Self::new(1)
433    }
434}
435
436impl NetworkIdAllocator {
437    /// Creates a new allocator starting from a specific ID. 0 is reserved.
438    #[must_use]
439    pub fn new(start_id: u64) -> Self {
440        Self {
441            start_id,
442            next: AtomicU64::new(start_id),
443        }
444    }
445
446    /// Allocates a new unique [`NetworkId`].
447    ///
448    /// # Errors
449    /// Returns [`AllocatorError::Overflow`] if the next ID would exceed `u64::MAX`.
450    pub fn allocate(&self) -> Result<NetworkId, AllocatorError> {
451        let val = self
452            .next
453            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |curr| {
454                if curr == u64::MAX {
455                    None
456                } else {
457                    Some(curr + 1)
458                }
459            })
460            .map_err(|_| AllocatorError::Overflow)?;
461
462        if val == 0 {
463            return Err(AllocatorError::Exhausted);
464        }
465
466        Ok(NetworkId(val))
467    }
468
469    /// Resets the allocator to its initial `start_id`.
470    /// Use only in tests or clear-world scenarios.
471    pub fn reset(&self) {
472        self.next.store(self.start_id, Ordering::Relaxed);
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_primitive_derives() {
482        let nid1 = NetworkId(42);
483        let nid2 = nid1;
484        assert_eq!(nid1, nid2);
485
486        let lid1 = LocalId(42);
487        let lid2 = LocalId(42);
488        assert_eq!(lid1, lid2);
489
490        let cid = ClientId(99);
491        assert_eq!(format!("{cid:?}"), "ClientId(99)");
492
493        let kind = ComponentKind(1);
494        assert_eq!(kind.0, 1);
495    }
496
497    #[test]
498    fn test_input_command_clamping() {
499        let cmd = InputCommand {
500            tick: 1,
501            actions: vec![PlayerInputKind::Move { x: 2.0, y: -5.0 }],
502            last_seen_input_tick: None,
503        };
504        let clamped = cmd.clamped();
505        if let PlayerInputKind::Move { x, y } = clamped.actions[0] {
506            assert!((x - 1.0).abs() < f32::EPSILON);
507            assert!((y - -1.0).abs() < f32::EPSILON);
508        } else {
509            panic!("Expected Move action");
510        }
511
512        let valid = InputCommand {
513            tick: 1,
514            actions: vec![PlayerInputKind::Move { x: 0.5, y: -0.2 }],
515            last_seen_input_tick: None,
516        };
517        let clamped = valid.clamped();
518        if let PlayerInputKind::Move { x, y } = clamped.actions[0] {
519            assert!((x - 0.5).abs() < f32::EPSILON);
520            assert!((y - -0.2).abs() < f32::EPSILON);
521        } else {
522            panic!("Expected Move action");
523        }
524    }
525
526    #[test]
527    fn test_ship_stats_non_zero_default() {
528        let stats = ShipStats::default();
529        assert!(stats.max_hp > 0);
530        assert!(stats.max_shield > 0);
531        assert!(stats.max_energy > 0);
532        assert_eq!(stats.hp, stats.max_hp);
533    }
534}