Skip to main content

sectorsync_core/
station.rs

1//! Station-local entity storage and runtime primitives.
2
3use std::collections::HashMap;
4
5use crate::entity::{DirtyMask, EntityRecord, EntityRole, EntityTags};
6use crate::handoff::HandoffTransfer;
7use crate::ids::{
8    EntityHandle, EntityId, InstanceId, NodeId, OwnerEpoch, PolicyId, StationId, Tick,
9};
10use crate::snapshot::{SnapshotMeta, SnapshotVersion, StationSnapshot};
11use crate::spatial::{Bounds, Position3};
12
13/// Station runtime configuration.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct StationConfig {
16    /// Station id.
17    pub station_id: StationId,
18    /// Node id selected by the embedding application.
19    pub node_id: NodeId,
20    /// World instance id.
21    pub instance_id: InstanceId,
22    /// Fixed authoritative tick rate in hertz.
23    pub tick_rate_hz: u16,
24}
25
26/// Station-local storage and metadata.
27#[derive(Clone, Debug)]
28pub struct Station {
29    config: StationConfig,
30    tick: Tick,
31    owner_epoch: OwnerEpoch,
32    records: Vec<Option<EntityRecord>>,
33    generations: Vec<u32>,
34    free: Vec<u32>,
35    by_id: HashMap<EntityId, EntityHandle>,
36}
37
38/// Capacity observations from restoring one Station snapshot.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub struct StationRestoreStats {
41    /// Entity records requested by the snapshot.
42    pub snapshot_entities: usize,
43    /// Record-slot capacity available before inserting snapshot entities.
44    pub initial_entity_capacity: usize,
45    /// Entity-id index capacity available before inserting snapshot entities.
46    pub initial_id_index_capacity: usize,
47    /// Record-slot capacity after all snapshot entities were inserted.
48    pub final_entity_capacity: usize,
49    /// Entity-id index capacity after all snapshot entities were inserted.
50    pub final_id_index_capacity: usize,
51    /// Whether record/generation storage grew during insertion.
52    pub entity_capacity_grew: bool,
53    /// Whether the entity-id index grew during insertion.
54    pub id_index_capacity_grew: bool,
55}
56
57impl Station {
58    /// Creates an empty station.
59    pub fn new(config: StationConfig) -> Self {
60        Self::with_capacity(config, 0)
61    }
62
63    /// Creates an empty station with capacity for local entity records.
64    pub fn with_capacity(config: StationConfig, entity_capacity: usize) -> Self {
65        Self {
66            config,
67            tick: Tick::new(0),
68            owner_epoch: OwnerEpoch::new(0),
69            records: Vec::with_capacity(entity_capacity),
70            generations: Vec::with_capacity(entity_capacity),
71            free: Vec::new(),
72            by_id: HashMap::with_capacity(entity_capacity),
73        }
74    }
75
76    /// Reserves capacity for at least `additional` more local entities.
77    pub fn reserve_entities(&mut self, additional: usize) {
78        self.records.reserve(additional);
79        self.generations.reserve(additional);
80        self.by_id.reserve(additional);
81    }
82
83    /// Reserves recycled-handle slots for caller-expected despawn churn.
84    pub fn reserve_free_handles(&mut self, additional: usize) {
85        self.free.reserve(additional);
86    }
87
88    /// Releases unused retained storage without changing live handles.
89    pub fn reclaim_retained_capacity(&mut self) {
90        self.records.shrink_to_fit();
91        self.generations.shrink_to_fit();
92        self.free.shrink_to_fit();
93        self.by_id.shrink_to_fit();
94    }
95
96    /// Entity record slots currently retained without another allocation.
97    pub fn entity_capacity(&self) -> usize {
98        self.records.capacity().min(self.generations.capacity())
99    }
100
101    /// Entity-id lookup entries currently retained without another rehash.
102    pub fn id_index_capacity(&self) -> usize {
103        self.by_id.capacity()
104    }
105
106    /// Recycled handle indexes currently retained without another allocation.
107    pub fn free_list_capacity(&self) -> usize {
108        self.free.capacity()
109    }
110
111    /// Returns station configuration.
112    pub const fn config(&self) -> StationConfig {
113        self.config
114    }
115
116    /// Current station tick.
117    pub const fn tick(&self) -> Tick {
118        self.tick
119    }
120
121    /// Current owner epoch.
122    pub const fn owner_epoch(&self) -> OwnerEpoch {
123        self.owner_epoch
124    }
125
126    /// Reserves and returns the next owner epoch for this station.
127    pub fn next_owner_epoch(&mut self) -> OwnerEpoch {
128        self.owner_epoch = OwnerEpoch::new(self.owner_epoch.get().saturating_add(1));
129        self.owner_epoch
130    }
131
132    /// Number of live entity records.
133    pub fn len(&self) -> usize {
134        self.by_id.len()
135    }
136
137    /// Returns whether the station has no entity records.
138    pub fn is_empty(&self) -> bool {
139        self.by_id.is_empty()
140    }
141
142    /// Advances the authoritative station tick.
143    pub fn advance_tick(&mut self) {
144        self.tick = Tick::new(self.tick.get().saturating_add(1));
145    }
146
147    /// Spawns an authoritative entity in this station.
148    pub fn spawn_owned(
149        &mut self,
150        id: EntityId,
151        position: Position3,
152        bounds: Bounds,
153        policy_id: PolicyId,
154    ) -> Result<EntityHandle, StationError> {
155        if self.by_id.contains_key(&id) {
156            return Err(StationError::DuplicateEntity(id));
157        }
158
159        let handle = self.allocate_handle();
160        let record = EntityRecord::owned(id, handle, position, bounds, policy_id, self.owner_epoch);
161        self.insert_allocated(handle, record);
162        Ok(handle)
163    }
164
165    /// Inserts or refreshes a read-only ghost entity.
166    #[allow(clippy::too_many_arguments)]
167    pub fn upsert_ghost(
168        &mut self,
169        id: EntityId,
170        position: Position3,
171        bounds: Bounds,
172        policy_id: PolicyId,
173        owner_station: StationId,
174        owner_epoch: OwnerEpoch,
175        expires_at: Tick,
176    ) -> EntityHandle {
177        if let Some(handle) = self.by_id.get(&id).copied() {
178            if let Some(record) = self.get_mut(handle) {
179                record.position = position;
180                record.bounds = bounds;
181                record.policy_id = policy_id;
182                record.role = EntityRole::Ghost {
183                    owner_station,
184                    owner_epoch,
185                    expires_at,
186                };
187                record.dirty.insert(DirtyMask::TRANSFORM);
188            }
189            return handle;
190        }
191
192        let handle = self.allocate_handle();
193        let record = EntityRecord::ghost(
194            id,
195            handle,
196            position,
197            bounds,
198            policy_id,
199            owner_station,
200            owner_epoch,
201            expires_at,
202        );
203        self.insert_allocated(handle, record);
204        handle
205    }
206
207    /// Gets an entity by handle if the generation is still valid.
208    pub fn get(&self, handle: EntityHandle) -> Option<&EntityRecord> {
209        let index = usize::try_from(handle.index()).ok()?;
210        let generation = self.generations.get(index).copied()?;
211        if generation != handle.generation() {
212            return None;
213        }
214        self.records.get(index)?.as_ref()
215    }
216
217    /// Gets a mutable entity record by handle.
218    pub fn get_mut(&mut self, handle: EntityHandle) -> Option<&mut EntityRecord> {
219        let index = usize::try_from(handle.index()).ok()?;
220        let generation = self.generations.get(index).copied()?;
221        if generation != handle.generation() {
222            return None;
223        }
224        self.records.get_mut(index)?.as_mut()
225    }
226
227    /// Gets an entity by stable id.
228    pub fn get_by_id(&self, id: EntityId) -> Option<&EntityRecord> {
229        let handle = self.by_id.get(&id).copied()?;
230        self.get(handle)
231    }
232
233    /// Gets a mutable entity record by stable id.
234    pub fn get_by_id_mut(&mut self, id: EntityId) -> Option<&mut EntityRecord> {
235        let handle = self.by_id.get(&id).copied()?;
236        self.get_mut(handle)
237    }
238
239    /// Gets a station-local handle by stable id.
240    pub fn handle_by_id(&self, id: EntityId) -> Option<EntityHandle> {
241        self.by_id.get(&id).copied()
242    }
243
244    /// Moves an authoritative entity and marks transform dirty.
245    pub fn move_owned(
246        &mut self,
247        handle: EntityHandle,
248        position: Position3,
249    ) -> Result<(), StationError> {
250        let record = self
251            .get_mut(handle)
252            .ok_or(StationError::MissingEntityHandle(handle))?;
253        if !record.is_owned() {
254            return Err(StationError::NotOwner(record.id));
255        }
256        record.position = position;
257        record.dirty.insert(DirtyMask::TRANSFORM);
258        Ok(())
259    }
260
261    /// Replaces authoritative entity tags and marks tags dirty.
262    pub fn set_tags(&mut self, handle: EntityHandle, tags: EntityTags) -> Result<(), StationError> {
263        let record = self
264            .get_mut(handle)
265            .ok_or(StationError::MissingEntityHandle(handle))?;
266        if !record.is_owned() {
267            return Err(StationError::NotOwner(record.id));
268        }
269        record.tags = tags;
270        record.dirty.insert(DirtyMask::TAGS);
271        Ok(())
272    }
273
274    /// Clears selected dirty bits for a local entity record.
275    pub fn clear_dirty(
276        &mut self,
277        handle: EntityHandle,
278        mask: DirtyMask,
279    ) -> Result<(), StationError> {
280        let record = self
281            .get_mut(handle)
282            .ok_or(StationError::MissingEntityHandle(handle))?;
283        record.dirty.remove(mask);
284        Ok(())
285    }
286
287    /// Iterates over live records.
288    pub fn iter(&self) -> impl Iterator<Item = &EntityRecord> {
289        self.records.iter().filter_map(Option::as_ref)
290    }
291
292    /// Removes an entity record by handle.
293    pub fn remove(&mut self, handle: EntityHandle) -> Result<EntityRecord, StationError> {
294        let index = usize::try_from(handle.index())
295            .map_err(|_| StationError::MissingEntityHandle(handle))?;
296        let generation = self
297            .generations
298            .get(index)
299            .copied()
300            .ok_or(StationError::MissingEntityHandle(handle))?;
301        if generation != handle.generation() {
302            return Err(StationError::MissingEntityHandle(handle));
303        }
304
305        let record = self.records[index]
306            .take()
307            .ok_or(StationError::MissingEntityHandle(handle))?;
308        self.by_id.remove(&record.id);
309        self.generations[index] = self.generations[index].saturating_add(1);
310        self.free.push(handle.index());
311        Ok(record)
312    }
313
314    /// Removes an entity record by stable id.
315    pub fn remove_by_id(&mut self, id: EntityId) -> Result<EntityRecord, StationError> {
316        let handle = self
317            .by_id
318            .get(&id)
319            .copied()
320            .ok_or(StationError::MissingEntity(id))?;
321        self.remove(handle)
322    }
323
324    /// Prepares an outgoing handoff transfer without mutating ownership yet.
325    pub fn prepare_outgoing_handoff(
326        &self,
327        entity_id: EntityId,
328        target_station: StationId,
329        target_owner_epoch: OwnerEpoch,
330        source_ghost_expires_at: Tick,
331    ) -> Result<HandoffTransfer, StationError> {
332        if target_station == self.config.station_id {
333            return Err(StationError::HandoffTargetIsSource(target_station));
334        }
335
336        let entity = self
337            .get_by_id(entity_id)
338            .ok_or(StationError::MissingEntity(entity_id))?;
339        if !entity.is_owned() {
340            return Err(StationError::NotOwner(entity_id));
341        }
342
343        Ok(HandoffTransfer {
344            entity_id,
345            source_station: self.config.station_id,
346            target_station,
347            source_owner_epoch: entity.role.owner_epoch(),
348            target_owner_epoch,
349            prepared_at: self.tick,
350            source_ghost_expires_at,
351            entity: entity.clone(),
352        })
353    }
354
355    /// Prewarms or refreshes a target-side ghost before owner commit.
356    pub fn prewarm_handoff_ghost(
357        &mut self,
358        transfer: &HandoffTransfer,
359    ) -> Result<EntityHandle, StationError> {
360        if transfer.target_station != self.config.station_id {
361            return Err(StationError::WrongHandoffTarget {
362                expected: self.config.station_id,
363                actual: transfer.target_station,
364            });
365        }
366
367        Ok(self.upsert_ghost(
368            transfer.entity_id,
369            transfer.entity.position,
370            transfer.entity.bounds,
371            transfer.entity.policy_id,
372            transfer.source_station,
373            transfer.source_owner_epoch,
374            transfer.source_ghost_expires_at,
375        ))
376    }
377
378    /// Commits the target side of an incoming handoff and becomes authoritative.
379    pub fn commit_incoming_handoff(
380        &mut self,
381        transfer: HandoffTransfer,
382    ) -> Result<EntityHandle, StationError> {
383        if transfer.target_station != self.config.station_id {
384            return Err(StationError::WrongHandoffTarget {
385                expected: self.config.station_id,
386                actual: transfer.target_station,
387            });
388        }
389
390        if let Some(handle) = self.handle_by_id(transfer.entity_id) {
391            let record = self
392                .get_mut(handle)
393                .ok_or(StationError::MissingEntityHandle(handle))?;
394            if record.is_owned() {
395                return Err(StationError::AlreadyOwner(transfer.entity_id));
396            }
397
398            *record = transfer.entity;
399            record.handle = handle;
400            record.role = EntityRole::Owned {
401                owner_epoch: transfer.target_owner_epoch,
402            };
403            record.dirty.insert(DirtyMask::TRANSFORM);
404            self.owner_epoch = transfer.target_owner_epoch;
405            return Ok(handle);
406        }
407
408        let handle = self.allocate_handle();
409        let mut record = transfer.entity;
410        record.handle = handle;
411        record.role = EntityRole::Owned {
412            owner_epoch: transfer.target_owner_epoch,
413        };
414        record.dirty.insert(DirtyMask::TRANSFORM);
415        self.owner_epoch = transfer.target_owner_epoch;
416        self.insert_allocated(handle, record);
417        Ok(handle)
418    }
419
420    /// Commits the source side of an outgoing handoff and keeps a short-lived ghost.
421    pub fn commit_outgoing_handoff(
422        &mut self,
423        transfer: &HandoffTransfer,
424    ) -> Result<EntityHandle, StationError> {
425        if transfer.source_station != self.config.station_id {
426            return Err(StationError::WrongHandoffSource {
427                expected: self.config.station_id,
428                actual: transfer.source_station,
429            });
430        }
431
432        let handle = self
433            .handle_by_id(transfer.entity_id)
434            .ok_or(StationError::MissingEntity(transfer.entity_id))?;
435        let record = self
436            .get_mut(handle)
437            .ok_or(StationError::MissingEntityHandle(handle))?;
438        if !record.is_owned() {
439            return Err(StationError::NotOwner(transfer.entity_id));
440        }
441
442        record.role = EntityRole::Ghost {
443            owner_station: transfer.target_station,
444            owner_epoch: transfer.target_owner_epoch,
445            expires_at: transfer.source_ghost_expires_at,
446        };
447        record.dirty.insert(DirtyMask::TRANSFORM);
448        Ok(handle)
449    }
450
451    /// Exports an in-memory station snapshot.
452    pub fn snapshot(&self, version: SnapshotVersion) -> StationSnapshot {
453        let mut snapshot = StationSnapshot::default();
454        self.snapshot_into(version, &mut snapshot);
455        snapshot
456    }
457
458    /// Exports an in-memory snapshot into caller-owned reusable storage.
459    pub fn snapshot_into(&self, version: SnapshotVersion, snapshot: &mut StationSnapshot) {
460        snapshot.entities.clear();
461        snapshot.entities.extend(self.iter().cloned());
462        snapshot.meta = SnapshotMeta {
463            instance_id: self.config.instance_id,
464            station_id: self.config.station_id,
465            tick: self.tick,
466            entity_count: snapshot.entities.len(),
467            owner_epoch: self.owner_epoch,
468            version,
469        };
470    }
471
472    /// Restores station state from a snapshot.
473    pub fn restore(config: StationConfig, snapshot: StationSnapshot) -> Result<Self, StationError> {
474        Self::restore_tracked(config, snapshot).map(|(station, _)| station)
475    }
476
477    /// Restores station state and reports capacity behavior during insertion.
478    pub fn restore_tracked(
479        config: StationConfig,
480        snapshot: StationSnapshot,
481    ) -> Result<(Self, StationRestoreStats), StationError> {
482        if snapshot.meta.station_id != config.station_id {
483            return Err(StationError::SnapshotStationMismatch {
484                expected: config.station_id,
485                actual: snapshot.meta.station_id,
486            });
487        }
488
489        let entity_count = snapshot.entities.len();
490        let mut station = Self::with_capacity(config, entity_count);
491        let initial_entity_capacity = station.entity_capacity();
492        let initial_id_index_capacity = station.id_index_capacity();
493        station.tick = snapshot.meta.tick;
494        station.owner_epoch = snapshot.meta.owner_epoch;
495
496        for mut record in snapshot.entities {
497            if station.by_id.contains_key(&record.id) {
498                return Err(StationError::DuplicateEntity(record.id));
499            }
500            let handle = station.allocate_handle();
501            record.handle = handle;
502            station.insert_allocated(handle, record);
503        }
504
505        let stats = StationRestoreStats {
506            snapshot_entities: entity_count,
507            initial_entity_capacity,
508            initial_id_index_capacity,
509            final_entity_capacity: station.entity_capacity(),
510            final_id_index_capacity: station.id_index_capacity(),
511            entity_capacity_grew: station.entity_capacity() > initial_entity_capacity,
512            id_index_capacity_grew: station.id_index_capacity() > initial_id_index_capacity,
513        };
514        Ok((station, stats))
515    }
516
517    fn allocate_handle(&mut self) -> EntityHandle {
518        if let Some(index) = self.free.pop() {
519            let generation = self.generations[index as usize];
520            EntityHandle::new(index, generation)
521        } else {
522            let index =
523                u32::try_from(self.records.len()).expect("station entity capacity exceeded");
524            self.records.push(None);
525            self.generations.push(0);
526            EntityHandle::new(index, 0)
527        }
528    }
529
530    fn insert_allocated(&mut self, handle: EntityHandle, record: EntityRecord) {
531        let index = handle.index() as usize;
532        self.by_id.insert(record.id, handle);
533        self.records[index] = Some(record);
534    }
535}
536
537/// Station operation error.
538#[derive(Clone, Copy, Debug, PartialEq, Eq)]
539pub enum StationError {
540    /// Entity id already exists in this station.
541    DuplicateEntity(EntityId),
542    /// Entity id does not exist in this station.
543    MissingEntity(EntityId),
544    /// Entity handle is missing or stale.
545    MissingEntityHandle(EntityHandle),
546    /// Operation requires an authoritative entity.
547    NotOwner(EntityId),
548    /// This station is already authoritative for the entity.
549    AlreadyOwner(EntityId),
550    /// Handoff target cannot be the source station.
551    HandoffTargetIsSource(StationId),
552    /// Incoming transfer was addressed to a different target station.
553    WrongHandoffTarget {
554        /// Expected target station id.
555        expected: StationId,
556        /// Actual target station id in transfer.
557        actual: StationId,
558    },
559    /// Outgoing transfer was addressed from a different source station.
560    WrongHandoffSource {
561        /// Expected source station id.
562        expected: StationId,
563        /// Actual source station id in transfer.
564        actual: StationId,
565    },
566    /// Snapshot was captured from a different station.
567    SnapshotStationMismatch {
568        /// Expected station id.
569        expected: StationId,
570        /// Actual snapshot station id.
571        actual: StationId,
572    },
573}
574
575impl core::fmt::Display for StationError {
576    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
577        match self {
578            Self::DuplicateEntity(id) => write!(f, "duplicate entity id {}", id.get()),
579            Self::MissingEntity(id) => write!(f, "missing entity id {}", id.get()),
580            Self::MissingEntityHandle(handle) => {
581                write!(
582                    f,
583                    "missing entity handle index={} generation={}",
584                    handle.index(),
585                    handle.generation()
586                )
587            }
588            Self::NotOwner(id) => write!(f, "entity {} is not authoritative here", id.get()),
589            Self::AlreadyOwner(id) => {
590                write!(f, "entity {} is already authoritative here", id.get())
591            }
592            Self::HandoffTargetIsSource(station_id) => {
593                write!(
594                    f,
595                    "handoff target {} is the source station",
596                    station_id.get()
597                )
598            }
599            Self::WrongHandoffTarget { expected, actual } => write!(
600                f,
601                "wrong handoff target: expected {}, got {}",
602                expected.get(),
603                actual.get()
604            ),
605            Self::WrongHandoffSource { expected, actual } => write!(
606                f,
607                "wrong handoff source: expected {}, got {}",
608                expected.get(),
609                actual.get()
610            ),
611            Self::SnapshotStationMismatch { expected, actual } => write!(
612                f,
613                "snapshot station mismatch: expected {}, got {}",
614                expected.get(),
615                actual.get()
616            ),
617        }
618    }
619}
620
621impl std::error::Error for StationError {}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    fn config() -> StationConfig {
628        StationConfig {
629            station_id: StationId::new(1),
630            node_id: NodeId::new(1),
631            instance_id: InstanceId::new(7),
632            tick_rate_hz: 20,
633        }
634    }
635
636    fn config_with_station(station_id: u32) -> StationConfig {
637        StationConfig {
638            station_id: StationId::new(station_id),
639            node_id: NodeId::new(1),
640            instance_id: InstanceId::new(7),
641            tick_rate_hz: 20,
642        }
643    }
644
645    #[test]
646    fn explicit_entity_capacity_is_retained_and_grows_on_request() {
647        let mut station = Station::with_capacity(config(), 8);
648
649        assert!(station.entity_capacity() >= 8);
650        assert!(station.id_index_capacity() >= 8);
651        assert_eq!(station.free_list_capacity(), 0);
652
653        station.reserve_entities(32);
654        assert!(station.entity_capacity() >= 32);
655        assert!(station.id_index_capacity() >= 32);
656        assert_eq!(station.free_list_capacity(), 0);
657        station.reserve_free_handles(8);
658        assert!(station.free_list_capacity() >= 8);
659
660        let handle = station
661            .spawn_owned(
662                EntityId::new(1),
663                Position3::new(0.0, 0.0, 0.0),
664                Bounds::Point,
665                PolicyId::new(1),
666            )
667            .expect("reserved station should spawn");
668        assert_eq!(handle, EntityHandle::new(0, 0));
669    }
670
671    #[test]
672    fn reclaim_retained_capacity_preserves_live_handles() {
673        let mut station = Station::with_capacity(config(), 64);
674        let handle = station
675            .spawn_owned(
676                EntityId::new(1),
677                Position3::new(1.0, 2.0, 3.0),
678                Bounds::Point,
679                PolicyId::new(1),
680            )
681            .expect("spawn");
682
683        station.reclaim_retained_capacity();
684
685        assert_eq!(
686            station.get(handle).expect("live handle").id,
687            EntityId::new(1)
688        );
689        assert!(station.entity_capacity() >= 1);
690        assert!(station.id_index_capacity() >= 1);
691    }
692
693    #[test]
694    fn owned_entities_can_move_and_snapshot_restore() {
695        let mut station = Station::new(config());
696        let handle = station
697            .spawn_owned(
698                EntityId::new(42),
699                Position3::new(1.0, 2.0, 3.0),
700                Bounds::Point,
701                PolicyId::new(0),
702            )
703            .expect("spawn should work");
704
705        station
706            .move_owned(handle, Position3::new(2.0, 3.0, 4.0))
707            .expect("owned move should work");
708        station.advance_tick();
709
710        let version = SnapshotVersion::default();
711        let snapshot = station.snapshot(version);
712        let mut reusable = StationSnapshot::default();
713        reusable.entities.reserve(1);
714        station.snapshot_into(version, &mut reusable);
715        assert_eq!(reusable, snapshot);
716        let retained_pointer = reusable.entities.as_ptr();
717        let retained_capacity = reusable.entities.capacity();
718        station.snapshot_into(version, &mut reusable);
719        assert_eq!(reusable, snapshot);
720        assert_eq!(reusable.entities.as_ptr(), retained_pointer);
721        assert_eq!(reusable.entities.capacity(), retained_capacity);
722        let (restored, restore_stats) =
723            Station::restore_tracked(config(), snapshot).expect("restore should work");
724
725        assert_eq!(restored.len(), 1);
726        assert!(restored.entity_capacity() >= 1);
727        assert!(restored.id_index_capacity() >= 1);
728        assert_eq!(restore_stats.snapshot_entities, 1);
729        assert!(restore_stats.initial_entity_capacity >= 1);
730        assert!(restore_stats.initial_id_index_capacity >= 1);
731        assert!(!restore_stats.entity_capacity_grew);
732        assert!(!restore_stats.id_index_capacity_grew);
733        assert_eq!(restored.tick(), Tick::new(1));
734        assert_eq!(
735            restored
736                .get_by_id(EntityId::new(42))
737                .expect("entity should exist")
738                .position,
739            Position3::new(2.0, 3.0, 4.0)
740        );
741    }
742
743    #[test]
744    fn ghosts_cannot_be_moved_as_owned() {
745        let mut station = Station::new(config());
746        let handle = station.upsert_ghost(
747            EntityId::new(5),
748            Position3::new(0.0, 0.0, 0.0),
749            Bounds::Point,
750            PolicyId::new(0),
751            StationId::new(2),
752            OwnerEpoch::new(1),
753            Tick::new(10),
754        );
755
756        let error = station
757            .move_owned(handle, Position3::new(1.0, 0.0, 0.0))
758            .expect_err("ghost move should fail");
759        assert_eq!(error, StationError::NotOwner(EntityId::new(5)));
760    }
761
762    #[test]
763    fn owned_tags_can_be_replaced_and_mark_dirty() {
764        let mut station = Station::new(config());
765        let handle = station
766            .spawn_owned(
767                EntityId::new(6),
768                Position3::new(0.0, 0.0, 0.0),
769                Bounds::Point,
770                PolicyId::new(0),
771            )
772            .expect("spawn should work");
773        let tags = EntityTags::from_bits(0b101);
774
775        station
776            .set_tags(handle, tags)
777            .expect("set tags should work");
778        let record = station.get(handle).expect("entity should exist");
779
780        assert_eq!(record.tags, tags);
781        assert!(record.dirty.contains(DirtyMask::TAGS));
782    }
783
784    #[test]
785    fn selected_dirty_bits_can_be_cleared() {
786        let mut station = Station::new(config());
787        let handle = station
788            .spawn_owned(
789                EntityId::new(8),
790                Position3::new(0.0, 0.0, 0.0),
791                Bounds::Point,
792                PolicyId::new(0),
793            )
794            .expect("spawn should work");
795        station
796            .set_tags(handle, EntityTags::from_bits(1))
797            .expect("set tags should work");
798
799        station
800            .clear_dirty(handle, DirtyMask::TAGS)
801            .expect("clear dirty should work");
802        let record = station.get(handle).expect("entity should exist");
803
804        assert!(!record.dirty.contains(DirtyMask::TAGS));
805        assert!(record.dirty.contains(DirtyMask::TRANSFORM));
806    }
807
808    #[test]
809    fn ghost_tags_cannot_be_replaced_as_owned() {
810        let mut station = Station::new(config());
811        let handle = station.upsert_ghost(
812            EntityId::new(7),
813            Position3::new(0.0, 0.0, 0.0),
814            Bounds::Point,
815            PolicyId::new(0),
816            StationId::new(2),
817            OwnerEpoch::new(1),
818            Tick::new(10),
819        );
820
821        let error = station
822            .set_tags(handle, EntityTags::from_bits(1))
823            .expect_err("ghost tags should fail");
824        assert_eq!(error, StationError::NotOwner(EntityId::new(7)));
825    }
826
827    #[test]
828    fn two_phase_handoff_prewarms_target_and_commits_owner_switch() {
829        let mut source = Station::new(config_with_station(1));
830        let mut target = Station::new(config_with_station(2));
831
832        source
833            .spawn_owned(
834                EntityId::new(9),
835                Position3::new(10.0, 20.0, 30.0),
836                Bounds::Point,
837                PolicyId::new(0),
838            )
839            .expect("spawn should work");
840        source.advance_tick();
841
842        let target_epoch = target.next_owner_epoch();
843        let transfer = source
844            .prepare_outgoing_handoff(
845                EntityId::new(9),
846                StationId::new(2),
847                target_epoch,
848                Tick::new(8),
849            )
850            .expect("prepare should work");
851
852        let ghost_handle = target
853            .prewarm_handoff_ghost(&transfer)
854            .expect("prewarm should work");
855        assert!(!target.get(ghost_handle).expect("ghost exists").is_owned());
856
857        let owner_handle = target
858            .commit_incoming_handoff(transfer.clone())
859            .expect("incoming commit should work");
860        assert!(target.get(owner_handle).expect("owner exists").is_owned());
861
862        let source_handle = source
863            .commit_outgoing_handoff(&transfer)
864            .expect("outgoing commit should work");
865        let source_record = source.get(source_handle).expect("source ghost exists");
866        assert!(!source_record.is_owned());
867        assert_eq!(source_record.role.owner_epoch(), target_epoch);
868    }
869}