use sectorsync_core::{
command::{CommandQueueLimits, CommandQueues},
component::{ComponentDescriptor, ComponentStore, ComponentStoreError},
entity::{EntityRecord, EntityTags},
handoff::HandoffTransfer,
ids::{EntityHandle, EntityId, OwnerEpoch, PolicyId, StationId, Tick},
spatial::{Bounds, GridSpec, Position3},
spatial_index::{CellIndex, CellIndexUpdate, CellIndexUpdateScratch},
station::{Station, StationConfig, StationError},
};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StationRuntimeConfig {
pub station: StationConfig,
pub grid: GridSpec,
pub entity_capacity: usize,
pub occupied_cell_capacity: usize,
pub free_handle_capacity: usize,
pub command_queue_limits: Option<CommandQueueLimits>,
}
impl StationRuntimeConfig {
pub const fn new(station: StationConfig, grid: GridSpec) -> Self {
Self {
station,
grid,
entity_capacity: 0,
occupied_cell_capacity: 0,
free_handle_capacity: 0,
command_queue_limits: None,
}
}
#[must_use]
pub const fn with_capacity(
mut self,
entity_capacity: usize,
occupied_cell_capacity: usize,
) -> Self {
self.entity_capacity = entity_capacity;
self.occupied_cell_capacity = occupied_cell_capacity;
self
}
#[must_use]
pub const fn with_free_handle_capacity(mut self, capacity: usize) -> Self {
self.free_handle_capacity = capacity;
self
}
#[must_use]
pub const fn with_command_queues(mut self, limits: CommandQueueLimits) -> Self {
self.command_queue_limits = Some(limits);
self
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpawnEntity {
pub id: EntityId,
pub position: Position3,
pub bounds: Bounds,
pub policy_id: PolicyId,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GhostEntity {
pub id: EntityId,
pub position: Position3,
pub bounds: Bounds,
pub policy_id: PolicyId,
pub owner_station: StationId,
pub owner_epoch: OwnerEpoch,
pub expires_at: Tick,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct StationEntityUpdateReport {
pub handle: EntityHandle,
pub index_update: CellIndexUpdate,
}
impl SpawnEntity {
pub const fn new(
id: EntityId,
position: Position3,
bounds: Bounds,
policy_id: PolicyId,
) -> Self {
Self {
id,
position,
bounds,
policy_id,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct StationMoveReport {
pub index_update: CellIndexUpdate,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DespawnReport {
pub entity: EntityRecord,
pub index_removed: bool,
pub components_removed: usize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StationRuntimeCapacities {
pub station_entities: usize,
pub station_id_index: usize,
pub station_free_handles: usize,
pub indexed_entities: usize,
pub occupied_cells: usize,
pub index_update_cells: usize,
pub component_columns: usize,
pub command_ready: usize,
pub command_barrier: usize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StationRuntimeReclaimReport {
pub before: StationRuntimeCapacities,
pub after: StationRuntimeCapacities,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StationRuntimeError {
Station(StationError),
Component(ComponentStoreError),
}
impl core::fmt::Display for StationRuntimeError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Station(error) => write!(f, "{error}"),
Self::Component(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for StationRuntimeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Station(error) => Some(error),
Self::Component(error) => Some(error),
}
}
}
impl From<StationError> for StationRuntimeError {
fn from(error: StationError) -> Self {
Self::Station(error)
}
}
impl From<ComponentStoreError> for StationRuntimeError {
fn from(error: ComponentStoreError) -> Self {
Self::Component(error)
}
}
#[derive(Clone, Debug)]
pub struct StationRuntime {
station: Station,
index: CellIndex,
components: ComponentStore,
commands: Option<CommandQueues>,
index_update_scratch: CellIndexUpdateScratch,
}
impl StationRuntime {
pub fn new(config: StationRuntimeConfig) -> Self {
let mut station = Station::with_capacity(config.station, config.entity_capacity);
station.reserve_free_handles(config.free_handle_capacity);
Self {
station,
index: CellIndex::with_capacity(
config.grid,
config.entity_capacity,
config.occupied_cell_capacity,
),
components: ComponentStore::default(),
commands: config.command_queue_limits.map(CommandQueues::new),
index_update_scratch: CellIndexUpdateScratch::default(),
}
}
pub const fn station(&self) -> &Station {
&self.station
}
pub const fn index(&self) -> &CellIndex {
&self.index
}
pub const fn components(&self) -> &ComponentStore {
&self.components
}
pub const fn command_queues(&self) -> Option<&CommandQueues> {
self.commands.as_ref()
}
pub const fn command_queues_mut(&mut self) -> Option<&mut CommandQueues> {
self.commands.as_mut()
}
pub fn retained_capacities(&self) -> StationRuntimeCapacities {
StationRuntimeCapacities {
station_entities: self.station.entity_capacity(),
station_id_index: self.station.id_index_capacity(),
station_free_handles: self.station.free_list_capacity(),
indexed_entities: self.index.entity_capacity(),
occupied_cells: self.index.occupied_cell_capacity(),
index_update_cells: self.index_update_scratch.retained_cell_capacity(),
component_columns: self.components.column_slots_capacity(),
command_ready: self
.commands
.as_ref()
.map_or(0, CommandQueues::total_ready_retained_capacity),
command_barrier: self
.commands
.as_ref()
.map_or(0, CommandQueues::barrier_buffer_retained_capacity),
}
}
pub fn reclaim_retained_capacity(&mut self) -> StationRuntimeReclaimReport {
let before = self.retained_capacities();
self.station.reclaim_retained_capacity();
self.index.reclaim_retained_capacity();
self.index_update_scratch.reclaim_retained_capacity();
self.components.reclaim_retained_capacity();
if let Some(commands) = &mut self.commands {
commands.reclaim_retained_capacity();
}
StationRuntimeReclaimReport {
before,
after: self.retained_capacities(),
}
}
pub fn advance_tick(&mut self) {
self.station.advance_tick();
}
pub fn spawn_owned(&mut self, spawn: SpawnEntity) -> Result<EntityHandle, StationError> {
let handle =
self.station
.spawn_owned(spawn.id, spawn.position, spawn.bounds, spawn.policy_id)?;
let update = self.upsert_index(handle, spawn.position, spawn.bounds);
debug_assert_eq!(update, CellIndexUpdate::Inserted);
Ok(handle)
}
pub fn upsert_ghost(&mut self, ghost: GhostEntity) -> StationEntityUpdateReport {
let handle = self.station.upsert_ghost(
ghost.id,
ghost.position,
ghost.bounds,
ghost.policy_id,
ghost.owner_station,
ghost.owner_epoch,
ghost.expires_at,
);
let index_update = self.upsert_index(handle, ghost.position, ghost.bounds);
StationEntityUpdateReport {
handle,
index_update,
}
}
pub fn move_owned(
&mut self,
handle: EntityHandle,
position: Position3,
) -> Result<StationMoveReport, StationError> {
self.station.move_owned(handle, position)?;
let record = self
.station
.get(handle)
.expect("successful move retains the authoritative entity");
let (position, bounds) = (record.position, record.bounds);
let index_update = self.upsert_index(handle, position, bounds);
Ok(StationMoveReport { index_update })
}
pub fn despawn(&mut self, handle: EntityHandle) -> Result<DespawnReport, StationError> {
let entity = self.station.remove(handle)?;
let index_removed = self.index.remove(handle);
let components_removed = self.components.clear_entity(handle);
Ok(DespawnReport {
entity,
index_removed,
components_removed,
})
}
pub fn set_tags(&mut self, handle: EntityHandle, tags: EntityTags) -> Result<(), StationError> {
self.station.set_tags(handle, tags)
}
pub fn set_component_blob(
&mut self,
descriptor: &ComponentDescriptor,
handle: EntityHandle,
version: u64,
bytes: &[u8],
) -> Result<(), StationRuntimeError> {
self.ensure_owned(handle)?;
self.components
.set_blob_from_slice(descriptor, handle, version, bytes)?;
Ok(())
}
pub fn prepare_outgoing_handoff(
&self,
entity_id: EntityId,
target_station: StationId,
target_owner_epoch: OwnerEpoch,
source_ghost_expires_at: Tick,
) -> Result<HandoffTransfer, StationError> {
self.station.prepare_outgoing_handoff(
entity_id,
target_station,
target_owner_epoch,
source_ghost_expires_at,
)
}
pub fn prewarm_handoff_ghost(
&mut self,
transfer: &HandoffTransfer,
) -> Result<StationEntityUpdateReport, StationError> {
let handle = self.station.prewarm_handoff_ghost(transfer)?;
let record = self
.station
.get(handle)
.expect("successful handoff prewarm retains the ghost");
let (position, bounds) = (record.position, record.bounds);
let index_update = self.upsert_index(handle, position, bounds);
Ok(StationEntityUpdateReport {
handle,
index_update,
})
}
pub fn commit_incoming_handoff(
&mut self,
transfer: HandoffTransfer,
) -> Result<StationEntityUpdateReport, StationError> {
let position = transfer.entity.position;
let bounds = transfer.entity.bounds;
let handle = self.station.commit_incoming_handoff(transfer)?;
let index_update = self.upsert_index(handle, position, bounds);
Ok(StationEntityUpdateReport {
handle,
index_update,
})
}
pub fn commit_outgoing_handoff(
&mut self,
transfer: &HandoffTransfer,
) -> Result<EntityHandle, StationError> {
self.station.commit_outgoing_handoff(transfer)
}
pub fn low_level_parts_mut(
&mut self,
) -> (
&mut Station,
&mut CellIndex,
&mut ComponentStore,
Option<&mut CommandQueues>,
) {
(
&mut self.station,
&mut self.index,
&mut self.components,
self.commands.as_mut(),
)
}
fn upsert_index(
&mut self,
handle: EntityHandle,
position: Position3,
bounds: Bounds,
) -> CellIndexUpdate {
self.index
.upsert_with_scratch(handle, position, bounds, &mut self.index_update_scratch)
.update
}
fn ensure_owned(&self, handle: EntityHandle) -> Result<(), StationError> {
let record = self
.station
.get(handle)
.ok_or(StationError::MissingEntityHandle(handle))?;
if record.is_owned() {
Ok(())
} else {
Err(StationError::NotOwner(record.id))
}
}
pub fn into_parts(self) -> (Station, CellIndex, ComponentStore, Option<CommandQueues>) {
(self.station, self.index, self.components, self.commands)
}
}
#[cfg(test)]
mod tests {
use sectorsync_core::{
component::{ComponentMigrationMode, ComponentSyncMode},
ids::{ComponentId, InstanceId, NodeId, StationId, Tick},
};
use super::*;
fn config() -> StationRuntimeConfig {
StationRuntimeConfig::new(
StationConfig {
station_id: StationId::new(1),
node_id: NodeId::new(2),
instance_id: InstanceId::new(3),
tick_rate_hz: 20,
},
GridSpec::new(16.0).expect("valid grid"),
)
.with_capacity(8, 8)
}
fn config_for_station(station_id: u32) -> StationRuntimeConfig {
StationRuntimeConfig::new(
StationConfig {
station_id: StationId::new(station_id),
node_id: NodeId::new(2),
instance_id: InstanceId::new(3),
tick_rate_hz: 20,
},
GridSpec::new(16.0).expect("valid grid"),
)
.with_capacity(8, 8)
}
fn descriptor() -> ComponentDescriptor {
ComponentDescriptor::sparse_blob(
ComponentId::new(1),
"health",
ComponentSyncMode::Delta,
ComponentMigrationMode::Copy,
4,
)
}
#[test]
fn product_path_keeps_station_and_index_coherent() {
let mut runtime = StationRuntime::new(config());
let start = Position3::new(1.0, 2.0, 3.0);
let handle = runtime
.spawn_owned(SpawnEntity::new(
EntityId::new(10),
start,
Bounds::Point,
PolicyId::new(1),
))
.expect("spawn should succeed");
assert_eq!(
runtime.station().get(handle).expect("entity").position,
start
);
assert_eq!(runtime.index().query_sphere(start, 1.0), vec![handle]);
let moved = Position3::new(33.0, 2.0, 3.0);
let report = runtime
.move_owned(handle, moved)
.expect("move should succeed");
assert_eq!(report.index_update, CellIndexUpdate::Relocated);
assert!(runtime.index().query_sphere(start, 1.0).is_empty());
assert_eq!(runtime.index().query_sphere(moved, 1.0), vec![handle]);
let report = runtime.despawn(handle).expect("despawn should succeed");
assert!(report.index_removed);
assert_eq!(report.components_removed, 0);
assert!(runtime.station().is_empty());
assert!(runtime.index().query_sphere(moved, 1.0).is_empty());
}
#[test]
fn failed_station_operations_do_not_mutate_the_index() {
let mut runtime = StationRuntime::new(config());
let missing = EntityHandle::new(99, 0);
let error = runtime
.move_owned(missing, Position3::new(1.0, 0.0, 0.0))
.expect_err("missing move should fail");
assert_eq!(error, StationError::MissingEntityHandle(missing));
assert_eq!(runtime.index().entity_count(), 0);
let error = runtime
.despawn(missing)
.expect_err("missing despawn should fail");
assert_eq!(error, StationError::MissingEntityHandle(missing));
assert_eq!(runtime.index().entity_count(), 0);
}
#[test]
fn component_writes_require_authority_and_despawn_clears_storage() {
let mut runtime = StationRuntime::new(config());
let position = Position3::new(1.0, 0.0, 1.0);
let owned = runtime
.spawn_owned(SpawnEntity::new(
EntityId::new(1),
position,
Bounds::Point,
PolicyId::new(1),
))
.expect("owned");
runtime
.set_component_blob(&descriptor(), owned, 1, &[1, 2, 3, 4])
.expect("owned component write");
let ghost = runtime.upsert_ghost(GhostEntity {
id: EntityId::new(2),
position,
bounds: Bounds::Point,
policy_id: PolicyId::new(1),
owner_station: StationId::new(9),
owner_epoch: OwnerEpoch::new(1),
expires_at: Tick::new(10),
});
let error = runtime
.set_component_blob(&descriptor(), ghost.handle, 1, &[1, 2, 3, 4])
.expect_err("ghost component write must fail");
assert_eq!(
error,
StationRuntimeError::Station(StationError::NotOwner(EntityId::new(2)))
);
let report = runtime.despawn(owned).expect("despawn");
assert_eq!(report.components_removed, 1);
assert!(
runtime
.components()
.get_blob(ComponentId::new(1), owned)
.is_none()
);
}
#[test]
fn handoff_keeps_source_and_target_spatial_membership() {
let mut source = StationRuntime::new(config_for_station(1));
let mut target = StationRuntime::new(config_for_station(2));
let position = Position3::new(20.0, 0.0, 20.0);
source
.spawn_owned(SpawnEntity::new(
EntityId::new(7),
position,
Bounds::Point,
PolicyId::new(1),
))
.expect("source owner");
let transfer = source
.prepare_outgoing_handoff(
EntityId::new(7),
StationId::new(2),
OwnerEpoch::new(1),
Tick::new(10),
)
.expect("prepare");
target
.prewarm_handoff_ghost(&transfer)
.expect("target prewarm");
let target_owner = target
.commit_incoming_handoff(transfer.clone())
.expect("target commit");
let source_ghost = source
.commit_outgoing_handoff(&transfer)
.expect("source commit");
assert!(
target
.station()
.get(target_owner.handle)
.expect("target")
.is_owned()
);
assert!(
!source
.station()
.get(source_ghost)
.expect("source")
.is_owned()
);
assert_eq!(
target.index().query_sphere(position, 1.0),
vec![target_owner.handle]
);
assert_eq!(
source.index().query_sphere(position, 1.0),
vec![source_ghost]
);
}
#[test]
fn optional_commands_and_reclaim_are_explicit() {
let config =
config()
.with_free_handle_capacity(32)
.with_command_queues(CommandQueueLimits {
high: 4,
normal: 8,
low: 4,
});
let mut runtime = StationRuntime::new(config);
assert!(runtime.command_queues().is_some());
let before = runtime.retained_capacities();
assert!(before.station_entities >= 8);
assert!(before.station_free_handles >= 32);
let report = runtime.reclaim_retained_capacity();
assert_eq!(report.before, before);
assert!(report.after.station_entities <= report.before.station_entities);
assert!(report.after.station_free_handles <= report.before.station_free_handles);
assert!(runtime.command_queues_mut().is_some());
}
}