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