1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct StationConfig {
16 pub station_id: StationId,
18 pub node_id: NodeId,
20 pub instance_id: InstanceId,
22 pub tick_rate_hz: u16,
24}
25
26#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub struct StationRestoreStats {
41 pub snapshot_entities: usize,
43 pub initial_entity_capacity: usize,
45 pub initial_id_index_capacity: usize,
47 pub final_entity_capacity: usize,
49 pub final_id_index_capacity: usize,
51 pub entity_capacity_grew: bool,
53 pub id_index_capacity_grew: bool,
55}
56
57impl Station {
58 pub fn new(config: StationConfig) -> Self {
60 Self::with_capacity(config, 0)
61 }
62
63 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 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 pub fn reserve_free_handles(&mut self, additional: usize) {
85 self.free.reserve(additional);
86 }
87
88 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 pub fn entity_capacity(&self) -> usize {
98 self.records.capacity().min(self.generations.capacity())
99 }
100
101 pub fn id_index_capacity(&self) -> usize {
103 self.by_id.capacity()
104 }
105
106 pub fn free_list_capacity(&self) -> usize {
108 self.free.capacity()
109 }
110
111 pub const fn config(&self) -> StationConfig {
113 self.config
114 }
115
116 pub const fn tick(&self) -> Tick {
118 self.tick
119 }
120
121 pub const fn owner_epoch(&self) -> OwnerEpoch {
123 self.owner_epoch
124 }
125
126 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 pub fn len(&self) -> usize {
134 self.by_id.len()
135 }
136
137 pub fn is_empty(&self) -> bool {
139 self.by_id.is_empty()
140 }
141
142 pub fn advance_tick(&mut self) {
144 self.tick = Tick::new(self.tick.get().saturating_add(1));
145 }
146
147 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 #[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 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 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 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 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 pub fn handle_by_id(&self, id: EntityId) -> Option<EntityHandle> {
241 self.by_id.get(&id).copied()
242 }
243
244 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 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 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 pub fn iter(&self) -> impl Iterator<Item = &EntityRecord> {
289 self.records.iter().filter_map(Option::as_ref)
290 }
291
292 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 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 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 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 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 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 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 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 pub fn restore(config: StationConfig, snapshot: StationSnapshot) -> Result<Self, StationError> {
474 Self::restore_tracked(config, snapshot).map(|(station, _)| station)
475 }
476
477 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
539pub enum StationError {
540 DuplicateEntity(EntityId),
542 MissingEntity(EntityId),
544 MissingEntityHandle(EntityHandle),
546 NotOwner(EntityId),
548 AlreadyOwner(EntityId),
550 HandoffTargetIsSource(StationId),
552 WrongHandoffTarget {
554 expected: StationId,
556 actual: StationId,
558 },
559 WrongHandoffSource {
561 expected: StationId,
563 actual: StationId,
565 },
566 SnapshotStationMismatch {
568 expected: StationId,
570 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}