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