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