1use std::cmp::Ordering;
4use std::collections::{BTreeMap, HashMap};
5use std::hash::Hash;
6
7use crate::entity::EntityRecord;
8use crate::ids::{ClientId, EntityHandle, Tick};
9#[cfg(not(feature = "simd"))]
10use crate::interest::RangeOnlyVisibility;
11use crate::interest::{ViewerQuery, VisibilityFilter};
12use crate::policy::{CompiledSyncPolicy, PolicyTable};
13use crate::spatial::{Bounds, CellCoord3};
14use crate::spatial_index::{CellIndex, CellQueryScratch, CellQueryStats, CellQueryStrategy};
15use crate::station::Station;
16
17const HASHED_REPLICATION_TRACKER_MIN_ENTRIES: usize = 2_048;
18
19#[derive(Clone, Debug)]
20enum AdaptiveTrackMap<K, V> {
21 Ordered(BTreeMap<K, V>),
22 Hashed(HashMap<K, V>),
23}
24
25impl<K: Copy + Eq + Hash + Ord, V> AdaptiveTrackMap<K, V> {
26 fn new() -> Self {
27 Self::Ordered(BTreeMap::new())
28 }
29
30 fn len(&self) -> usize {
31 match self {
32 Self::Ordered(entries) => entries.len(),
33 Self::Hashed(entries) => entries.len(),
34 }
35 }
36
37 fn is_empty(&self) -> bool {
38 match self {
39 Self::Ordered(entries) => entries.is_empty(),
40 Self::Hashed(entries) => entries.is_empty(),
41 }
42 }
43
44 fn contains_key(&self, key: &K) -> bool {
45 match self {
46 Self::Ordered(entries) => entries.contains_key(key),
47 Self::Hashed(entries) => entries.contains_key(key),
48 }
49 }
50
51 fn get(&self, key: &K) -> Option<&V> {
52 match self {
53 Self::Ordered(entries) => entries.get(key),
54 Self::Hashed(entries) => entries.get(key),
55 }
56 }
57
58 fn get_mut(&mut self, key: &K) -> Option<&mut V> {
59 match self {
60 Self::Ordered(entries) => entries.get_mut(key),
61 Self::Hashed(entries) => entries.get_mut(key),
62 }
63 }
64
65 fn insert(&mut self, key: K, value: V) -> Option<V> {
66 let promote = match self {
67 Self::Ordered(entries) => {
68 entries.len() >= HASHED_REPLICATION_TRACKER_MIN_ENTRIES.saturating_sub(1)
69 && !entries.contains_key(&key)
70 }
71 Self::Hashed(_) => false,
72 };
73 if promote {
74 let Self::Ordered(ordered) = std::mem::replace(self, Self::Hashed(HashMap::new()))
75 else {
76 unreachable!("promotion starts from ordered tracker storage");
77 };
78 let mut hashed = HashMap::with_capacity(ordered.len().saturating_add(1));
79 hashed.extend(ordered);
80 *self = Self::Hashed(hashed);
81 }
82 match self {
83 Self::Ordered(entries) => entries.insert(key, value),
84 Self::Hashed(entries) => entries.insert(key, value),
85 }
86 }
87
88 fn retain<F>(&mut self, mut keep: F)
89 where
90 F: FnMut(&K, &mut V) -> bool,
91 {
92 match self {
93 Self::Ordered(entries) => entries.retain(|key, value| keep(key, value)),
94 Self::Hashed(entries) => entries.retain(|key, value| keep(key, value)),
95 }
96 }
97
98 #[cfg(test)]
99 fn is_hashed(&self) -> bool {
100 matches!(self, Self::Hashed(_))
101 }
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub struct ReplicationBudget {
107 pub max_entities: usize,
109 pub max_bytes: usize,
111 pub estimated_entity_bytes: usize,
113}
114
115impl Default for ReplicationBudget {
116 fn default() -> Self {
117 Self {
118 max_entities: 300,
119 max_bytes: 16 * 1024,
120 estimated_entity_bytes: 32,
121 }
122 }
123}
124
125#[derive(Clone, Debug, Default, PartialEq, Eq)]
127pub struct ReplicationPlan {
128 pub entities: Vec<EntityHandle>,
130 pub stats: ReplicationStats,
132}
133
134#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
136pub struct ReplicationBatchStats {
137 pub viewers: usize,
139 pub candidates: usize,
141 pub considered: usize,
143 pub selected: usize,
145 pub unexamined_after_budget: usize,
147 pub estimated_bytes: usize,
149 pub grid_queries: usize,
151 pub occupied_queries: usize,
153 pub grid_cells_probed: usize,
155 pub occupied_cells_scanned: usize,
157 pub matched_cells: usize,
159 pub candidate_capacity_max: usize,
161 pub dedup_capacity_max: usize,
163 pub matching_cell_capacity_max: usize,
165 pub priority_capacity_max: usize,
167 pub unique_query_ranges: usize,
169 pub reused_query_ranges: usize,
171 pub cached_candidate_handles: usize,
173 pub query_cache_capacity_max: usize,
175 pub query_cache_candidate_capacity_max: usize,
177}
178
179impl ReplicationBatchStats {
180 fn record(&mut self, plan: &ReplicationPlan, scratch: &ReplicationScratch) {
181 self.record_plan(plan, scratch);
182 self.viewers = self.viewers.saturating_add(1);
183 let query = scratch.query_stats();
184 match query.strategy {
185 CellQueryStrategy::Grid => self.grid_queries = self.grid_queries.saturating_add(1),
186 CellQueryStrategy::OccupiedCells => {
187 self.occupied_queries = self.occupied_queries.saturating_add(1);
188 }
189 }
190 self.grid_cells_probed = self
191 .grid_cells_probed
192 .saturating_add(query.grid_cells_probed);
193 self.occupied_cells_scanned = self
194 .occupied_cells_scanned
195 .saturating_add(query.occupied_cells_scanned);
196 self.matched_cells = self.matched_cells.saturating_add(query.matched_cells);
197 }
198
199 fn record_reused_query(&mut self, plan: &ReplicationPlan, scratch: &ReplicationScratch) {
200 self.record_plan(plan, scratch);
201 self.viewers = self.viewers.saturating_add(1);
202 self.reused_query_ranges = self.reused_query_ranges.saturating_add(1);
203 }
204
205 fn record_plan(&mut self, plan: &ReplicationPlan, scratch: &ReplicationScratch) {
206 self.candidates = self.candidates.saturating_add(plan.stats.candidates);
207 self.considered = self.considered.saturating_add(plan.stats.considered);
208 self.selected = self.selected.saturating_add(plan.stats.selected);
209 self.unexamined_after_budget = self
210 .unexamined_after_budget
211 .saturating_add(plan.stats.unexamined_after_budget);
212 self.estimated_bytes = self
213 .estimated_bytes
214 .saturating_add(plan.stats.estimated_bytes);
215 self.candidate_capacity_max = self
216 .candidate_capacity_max
217 .max(scratch.candidate_capacity());
218 self.dedup_capacity_max = self
219 .dedup_capacity_max
220 .max(scratch.candidate_dedup_capacity());
221 self.matching_cell_capacity_max = self
222 .matching_cell_capacity_max
223 .max(scratch.matching_cell_capacity());
224 self.priority_capacity_max = self
225 .priority_capacity_max
226 .max(scratch.prioritized_capacity());
227 }
228
229 pub fn merge(&mut self, other: Self) {
231 self.viewers = self.viewers.saturating_add(other.viewers);
232 self.candidates = self.candidates.saturating_add(other.candidates);
233 self.considered = self.considered.saturating_add(other.considered);
234 self.selected = self.selected.saturating_add(other.selected);
235 self.estimated_bytes = self.estimated_bytes.saturating_add(other.estimated_bytes);
236 self.grid_queries = self.grid_queries.saturating_add(other.grid_queries);
237 self.occupied_queries = self.occupied_queries.saturating_add(other.occupied_queries);
238 self.grid_cells_probed = self
239 .grid_cells_probed
240 .saturating_add(other.grid_cells_probed);
241 self.occupied_cells_scanned = self
242 .occupied_cells_scanned
243 .saturating_add(other.occupied_cells_scanned);
244 self.matched_cells = self.matched_cells.saturating_add(other.matched_cells);
245 self.candidate_capacity_max = self
246 .candidate_capacity_max
247 .max(other.candidate_capacity_max);
248 self.dedup_capacity_max = self.dedup_capacity_max.max(other.dedup_capacity_max);
249 self.matching_cell_capacity_max = self
250 .matching_cell_capacity_max
251 .max(other.matching_cell_capacity_max);
252 self.priority_capacity_max = self.priority_capacity_max.max(other.priority_capacity_max);
253 self.unique_query_ranges = self
254 .unique_query_ranges
255 .saturating_add(other.unique_query_ranges);
256 self.reused_query_ranges = self
257 .reused_query_ranges
258 .saturating_add(other.reused_query_ranges);
259 self.cached_candidate_handles = self
260 .cached_candidate_handles
261 .saturating_add(other.cached_candidate_handles);
262 self.query_cache_capacity_max = self
263 .query_cache_capacity_max
264 .max(other.query_cache_capacity_max);
265 self.query_cache_candidate_capacity_max = self
266 .query_cache_candidate_capacity_max
267 .max(other.query_cache_candidate_capacity_max);
268 }
269}
270
271#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
272struct BatchQueryKey {
273 min: CellCoord3,
274 max: CellCoord3,
275}
276
277#[derive(Clone, Debug)]
278struct CachedBatchQuery {
279 handles: Vec<EntityHandle>,
280}
281
282#[derive(Clone, Debug, Default, PartialEq, Eq)]
284pub struct ReplicationBatchResult {
285 pub plans: Vec<ReplicationPlan>,
287 pub stats: ReplicationBatchStats,
289}
290
291#[derive(Clone, Copy, Debug, PartialEq, Eq)]
293pub struct ReplicationBatchView<'a> {
294 pub plans: &'a [ReplicationPlan],
296 pub stats: ReplicationBatchStats,
298}
299
300#[derive(Clone, Debug, Default)]
306pub struct ReplicationBatchScratch {
307 plans: Vec<ReplicationPlan>,
308 active_plans: usize,
309 stats: ReplicationBatchStats,
310 query_counts: HashMap<BatchQueryKey, usize>,
311 query_cache_index: HashMap<BatchQueryKey, usize>,
312 query_cache: Vec<CachedBatchQuery>,
313 active_query_cache: usize,
314}
315
316impl ReplicationBatchScratch {
317 pub fn new() -> Self {
319 Self {
320 plans: Vec::new(),
321 active_plans: 0,
322 stats: ReplicationBatchStats::default(),
323 query_counts: HashMap::new(),
324 query_cache_index: HashMap::new(),
325 query_cache: Vec::new(),
326 active_query_cache: 0,
327 }
328 }
329
330 pub fn retained_plan_slots(&self) -> usize {
332 self.plans.len()
333 }
334
335 pub fn retained_entity_capacity(&self) -> usize {
337 self.plans.iter().map(|plan| plan.entities.capacity()).sum()
338 }
339
340 pub fn retained_query_cache_slots(&self) -> usize {
342 self.query_cache.len()
343 }
344
345 pub fn retained_query_cache_candidate_capacity(&self) -> usize {
347 self.query_cache
348 .iter()
349 .map(|entry| entry.handles.capacity())
350 .sum()
351 }
352
353 pub fn view(&self) -> ReplicationBatchView<'_> {
355 ReplicationBatchView {
356 plans: &self.plans[..self.active_plans],
357 stats: self.stats,
358 }
359 }
360
361 fn prepare(&mut self, plans: usize) {
362 if self.plans.len() < plans {
363 self.plans.resize_with(plans, ReplicationPlan::default);
364 }
365 self.active_plans = plans;
366 self.stats = ReplicationBatchStats::default();
367 self.query_counts.clear();
368 self.query_cache_index.clear();
369 self.active_query_cache = 0;
370 }
371
372 fn prepare_query_ranges(&mut self, index: &CellIndex, viewers: &[ViewerQuery]) {
373 for viewer in viewers {
374 let key = batch_query_key(index, viewer);
375 let count = self.query_counts.entry(key).or_insert(0);
376 *count = count.saturating_add(1);
377 }
378 self.stats.unique_query_ranges = self.query_counts.len();
379 }
380
381 fn query_repeats(&self, key: BatchQueryKey) -> bool {
382 self.query_counts.get(&key).is_some_and(|count| *count > 1)
383 }
384
385 fn cached_query(&self, key: BatchQueryKey) -> Option<usize> {
386 self.query_cache_index.get(&key).copied()
387 }
388
389 fn cache_query(
390 &mut self,
391 key: BatchQueryKey,
392 handles: &[EntityHandle],
393 max_cached_query_ranges: usize,
394 ) {
395 if self.active_query_cache >= max_cached_query_ranges {
396 return;
397 }
398 let cache_index = self.active_query_cache;
399 if cache_index == self.query_cache.len() {
400 self.query_cache.push(CachedBatchQuery {
401 handles: Vec::new(),
402 });
403 }
404 let entry = &mut self.query_cache[cache_index];
405 entry.handles.clear();
406 entry.handles.extend_from_slice(handles);
407 self.query_cache_index.insert(key, cache_index);
408 self.active_query_cache = self.active_query_cache.saturating_add(1);
409 self.stats.cached_candidate_handles = self
410 .stats
411 .cached_candidate_handles
412 .saturating_add(handles.len());
413 }
414
415 fn record_query_cache_capacity(&mut self) {
416 self.stats.query_cache_capacity_max = self.query_cache.len();
417 self.stats.query_cache_candidate_capacity_max = self
418 .query_cache
419 .iter()
420 .map(|entry| entry.handles.capacity())
421 .sum();
422 }
423}
424
425fn batch_query_key(index: &CellIndex, viewer: &ViewerQuery) -> BatchQueryKey {
426 let aabb = Bounds::Sphere {
427 radius: viewer.radius,
428 }
429 .to_aabb(viewer.position);
430 BatchQueryKey {
431 min: index.grid().cell_at(aabb.min),
432 max: index.grid().cell_at(aabb.max),
433 }
434}
435
436#[derive(Clone, Copy, Debug, PartialEq, Eq)]
438pub struct ReplicationTrackerConfig {
439 pub max_entries: usize,
441}
442
443impl Default for ReplicationTrackerConfig {
444 fn default() -> Self {
445 Self {
446 max_entries: 65_536,
447 }
448 }
449}
450
451#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
453pub struct ReplicationTrackKey {
454 pub client_id: ClientId,
456 pub entity: EntityHandle,
458}
459
460#[derive(Clone, Copy, Debug, PartialEq, Eq)]
462pub struct ReplicationTrackRecord {
463 pub client_id: ClientId,
465 pub entity: EntityHandle,
467 pub last_sent: Tick,
469 pub last_acked: Option<Tick>,
471}
472
473#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
475pub struct ReplicationTrackerStats {
476 pub entries: usize,
478 pub sent_records: usize,
480 pub acked_records: usize,
482 pub pruned_records: usize,
484}
485
486#[derive(Clone, Copy, Debug, PartialEq, Eq)]
488pub enum ReplicationTrackerError {
489 CapacityExceeded {
491 current: usize,
493 needed: usize,
495 max: usize,
497 },
498}
499
500impl core::fmt::Display for ReplicationTrackerError {
501 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
502 match self {
503 Self::CapacityExceeded {
504 current,
505 needed,
506 max,
507 } => write!(
508 f,
509 "replication tracker capacity exceeded: current {current}, needed {needed}, max {max}"
510 ),
511 }
512 }
513}
514
515impl std::error::Error for ReplicationTrackerError {}
516
517#[derive(Clone, Debug)]
519pub struct ReplicationTracker {
520 config: ReplicationTrackerConfig,
521 records: AdaptiveTrackMap<ReplicationTrackKey, ReplicationTrackRecord>,
522 stats: ReplicationTrackerStats,
523}
524
525impl Default for ReplicationTracker {
526 fn default() -> Self {
527 Self::new(ReplicationTrackerConfig::default())
528 }
529}
530
531impl ReplicationTracker {
532 pub fn new(config: ReplicationTrackerConfig) -> Self {
534 Self {
535 config,
536 records: AdaptiveTrackMap::new(),
537 stats: ReplicationTrackerStats::default(),
538 }
539 }
540
541 pub const fn config(&self) -> ReplicationTrackerConfig {
543 self.config
544 }
545
546 pub const fn stats(&self) -> ReplicationTrackerStats {
548 self.stats
549 }
550
551 pub fn len(&self) -> usize {
553 self.records.len()
554 }
555
556 pub fn is_empty(&self) -> bool {
558 self.records.is_empty()
559 }
560
561 pub fn last_sent(&self, client_id: ClientId, entity: EntityHandle) -> Option<Tick> {
563 self.records
564 .get(&ReplicationTrackKey { client_id, entity })
565 .map(|record| record.last_sent)
566 }
567
568 pub fn get(&self, client_id: ClientId, entity: EntityHandle) -> Option<ReplicationTrackRecord> {
570 self.records
571 .get(&ReplicationTrackKey { client_id, entity })
572 .copied()
573 }
574
575 pub fn record_plan_sent(
577 &mut self,
578 client_id: ClientId,
579 plan: &ReplicationPlan,
580 sent_at: Tick,
581 ) -> Result<usize, ReplicationTrackerError> {
582 self.ensure_capacity_for(client_id, &plan.entities)?;
583 let mut recorded = 0;
584 for entity in &plan.entities {
585 let key = ReplicationTrackKey {
586 client_id,
587 entity: *entity,
588 };
589 self.records.insert(
590 key,
591 ReplicationTrackRecord {
592 client_id,
593 entity: *entity,
594 last_sent: sent_at,
595 last_acked: None,
596 },
597 );
598 recorded += 1;
599 }
600 self.refresh_entry_count();
601 self.stats.sent_records = self.stats.sent_records.saturating_add(recorded);
602 Ok(recorded)
603 }
604
605 pub fn acknowledge(
607 &mut self,
608 client_id: ClientId,
609 entity: EntityHandle,
610 acked_at: Tick,
611 ) -> bool {
612 let Some(record) = self
613 .records
614 .get_mut(&ReplicationTrackKey { client_id, entity })
615 else {
616 return false;
617 };
618 record.last_acked = Some(acked_at);
619 self.stats.acked_records = self.stats.acked_records.saturating_add(1);
620 true
621 }
622
623 pub fn acknowledge_plan(
625 &mut self,
626 client_id: ClientId,
627 plan: &ReplicationPlan,
628 acked_at: Tick,
629 ) -> usize {
630 plan.entities
631 .iter()
632 .filter(|entity| self.acknowledge(client_id, **entity, acked_at))
633 .count()
634 }
635
636 pub fn clear_client(&mut self, client_id: ClientId) -> usize {
638 let before = self.records.len();
639 self.records.retain(|key, _| key.client_id != client_id);
640 let pruned = before.saturating_sub(self.records.len());
641 self.stats.pruned_records = self.stats.pruned_records.saturating_add(pruned);
642 self.refresh_entry_count();
643 pruned
644 }
645
646 pub fn prune_sent_before(&mut self, older_than: Tick) -> usize {
648 let before = self.records.len();
649 self.records
650 .retain(|_, record| record.last_sent.get() >= older_than.get());
651 let pruned = before.saturating_sub(self.records.len());
652 self.stats.pruned_records = self.stats.pruned_records.saturating_add(pruned);
653 self.refresh_entry_count();
654 pruned
655 }
656
657 fn ensure_capacity_for(
658 &self,
659 client_id: ClientId,
660 entities: &[EntityHandle],
661 ) -> Result<(), ReplicationTrackerError> {
662 if self.records.len().saturating_add(entities.len()) <= self.config.max_entries {
663 return Ok(());
664 }
665 let mut needed = 0_usize;
666 for entity in entities {
667 if !self.records.contains_key(&ReplicationTrackKey {
668 client_id,
669 entity: *entity,
670 }) {
671 needed = needed.saturating_add(1);
672 }
673 }
674 if self.records.len().saturating_add(needed) > self.config.max_entries {
675 return Err(ReplicationTrackerError::CapacityExceeded {
676 current: self.records.len(),
677 needed,
678 max: self.config.max_entries,
679 });
680 }
681 Ok(())
682 }
683
684 fn refresh_entry_count(&mut self) {
685 self.stats.entries = self.records.len();
686 }
687}
688
689#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
691pub struct ReplicationStats {
692 pub candidates: usize,
694 pub considered: usize,
696 pub selected: usize,
698 pub skipped_by_budget: usize,
700 pub unexamined_after_budget: usize,
702 pub skipped_by_cadence: usize,
704 pub estimated_bytes: usize,
706}
707
708#[derive(Clone, Copy, Debug, Default)]
710pub struct ReplicationCadence;
711
712impl ReplicationCadence {
713 pub fn target_hz(policy: &CompiledSyncPolicy, distance_squared: f32) -> u16 {
715 let min_hz = policy.min_hz.max(1);
716 let max_hz = policy.max_hz.max(min_hz);
717 let radius_squared = policy.interest_radius * policy.interest_radius;
718 let closeness =
719 if radius_squared.is_finite() && radius_squared > 0.0 && distance_squared.is_finite() {
720 1.0 - (distance_squared / radius_squared).clamp(0.0, 1.0)
721 } else {
722 1.0
723 };
724 let span = f32::from(max_hz - min_hz);
725 let target = f32::from(min_hz) + span * closeness;
726 rounded_frequency_to_u16(target, min_hz, max_hz)
727 }
728
729 pub fn interval_ticks(
731 policy: &CompiledSyncPolicy,
732 station_tick_rate_hz: u16,
733 distance_squared: f32,
734 ) -> u64 {
735 let tick_rate = u64::from(station_tick_rate_hz.max(1));
736 let target_hz = u64::from(Self::target_hz(policy, distance_squared).max(1));
737 tick_rate.div_ceil(target_hz).max(1)
738 }
739
740 pub fn should_send(
742 policy: &CompiledSyncPolicy,
743 station_tick_rate_hz: u16,
744 distance_squared: f32,
745 now: Tick,
746 last_sent: Option<Tick>,
747 ) -> bool {
748 let Some(last_sent) = last_sent else {
749 return true;
750 };
751 let interval = Self::interval_ticks(policy, station_tick_rate_hz, distance_squared);
752 now.get().saturating_sub(last_sent.get()) >= interval
753 }
754}
755
756#[derive(Clone, Copy, Debug, Default)]
758pub struct ReplicationPriority;
759
760#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
761fn rounded_frequency_to_u16(target: f32, min_hz: u16, max_hz: u16) -> u16 {
762 let bounded = target.round().clamp(f32::from(min_hz), f32::from(max_hz));
763 bounded as u16
764}
765
766#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
767fn normalized_score_to_u64(closeness: f32) -> u64 {
768 debug_assert!(closeness.is_finite() && (0.0..=1.0).contains(&closeness));
769 (closeness * 1_000_000.0).round() as u64
770}
771
772impl ReplicationPriority {
773 pub fn score(policy: &CompiledSyncPolicy, distance_squared: f32) -> u64 {
775 let weight = u64::from(policy.priority_weight.max(1));
776 let radius_squared = policy.interest_radius * policy.interest_radius;
777 let distance_score =
778 if radius_squared.is_finite() && radius_squared > 0.0 && distance_squared.is_finite() {
779 let closeness = 1.0 - (distance_squared / radius_squared).clamp(0.0, 1.0);
780 normalized_score_to_u64(closeness)
781 } else {
782 1_000_000
783 };
784 weight
785 .saturating_mul(1_000_000)
786 .saturating_add(distance_score)
787 }
788}
789
790#[derive(Clone, Copy, Debug, PartialEq)]
791struct PrioritizedReplicationCandidate {
792 handle: EntityHandle,
793 score: u64,
794 distance_squared: f32,
795}
796
797#[derive(Clone, Debug, Default)]
799pub struct ReplicationScratch {
800 cell_query: CellQueryScratch,
801 prioritized: Vec<PrioritizedReplicationCandidate>,
802}
803
804impl ReplicationScratch {
805 pub fn clear(&mut self) {
807 self.cell_query.clear();
808 self.prioritized.clear();
809 }
810
811 pub fn candidate_count(&self) -> usize {
813 self.cell_query.len()
814 }
815
816 pub fn prioritized_capacity(&self) -> usize {
818 self.prioritized.capacity()
819 }
820
821 pub const fn query_stats(&self) -> CellQueryStats {
823 self.cell_query.stats()
824 }
825
826 pub fn candidate_capacity(&self) -> usize {
828 self.cell_query.handle_capacity()
829 }
830
831 pub fn candidate_dedup_capacity(&self) -> usize {
833 self.cell_query.dedup_capacity()
834 }
835
836 pub fn matching_cell_capacity(&self) -> usize {
838 self.cell_query.matching_cell_capacity()
839 }
840}
841
842#[derive(Clone, Copy, Debug, Default)]
844pub struct ReplicationPlanner;
845
846#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
848pub enum ReplicationSelectionMode {
849 #[default]
851 Throughput,
852 Prioritized,
854}
855
856impl ReplicationPlanner {
857 #[allow(clippy::too_many_arguments)]
863 pub fn plan_for_viewer_configured_into<F, E, L>(
864 station: &Station,
865 index: &CellIndex,
866 policies: &PolicyTable,
867 viewer: &ViewerQuery,
868 filter: &F,
869 budget: ReplicationBudget,
870 mode: ReplicationSelectionMode,
871 eligible: E,
872 last_sent: L,
873 scratch: &mut ReplicationScratch,
874 plan: &mut ReplicationPlan,
875 ) where
876 F: VisibilityFilter,
877 E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
878 L: Fn(&ViewerQuery, EntityHandle) -> Option<Tick>,
879 {
880 index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
881 let candidates = scratch.cell_query.handles();
882 Self::plan_for_candidates_configured_into(
883 station,
884 candidates,
885 policies,
886 viewer,
887 filter,
888 budget,
889 mode,
890 eligible,
891 last_sent,
892 &mut scratch.prioritized,
893 plan,
894 );
895 }
896
897 #[allow(clippy::too_many_arguments)]
899 pub fn plan_for_viewers_configured_into<'a, F, E, L>(
900 station: &Station,
901 index: &CellIndex,
902 policies: &PolicyTable,
903 viewers: &[ViewerQuery],
904 filter: &F,
905 budget: ReplicationBudget,
906 mode: ReplicationSelectionMode,
907 max_cached_query_ranges: usize,
908 eligible: E,
909 last_sent: L,
910 scratch: &mut ReplicationScratch,
911 batch: &'a mut ReplicationBatchScratch,
912 ) -> ReplicationBatchView<'a>
913 where
914 F: VisibilityFilter,
915 E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
916 L: Fn(&ViewerQuery, EntityHandle) -> Option<Tick>,
917 {
918 batch.prepare(viewers.len());
919 batch.prepare_query_ranges(index, viewers);
920 for (viewer_index, viewer) in viewers.iter().enumerate() {
921 let key = batch_query_key(index, viewer);
922 if let Some(cache_index) = batch.cached_query(key) {
923 {
924 let candidates = &batch.query_cache[cache_index].handles;
925 let plan = &mut batch.plans[viewer_index];
926 Self::plan_for_candidates_configured_into(
927 station,
928 candidates,
929 policies,
930 viewer,
931 filter,
932 budget,
933 mode,
934 &eligible,
935 &last_sent,
936 &mut scratch.prioritized,
937 plan,
938 );
939 }
940 batch
941 .stats
942 .record_reused_query(&batch.plans[viewer_index], scratch);
943 continue;
944 }
945
946 index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
947 if batch.query_repeats(key) {
948 batch.cache_query(key, scratch.cell_query.handles(), max_cached_query_ranges);
949 }
950 {
951 let candidates = scratch.cell_query.handles();
952 let plan = &mut batch.plans[viewer_index];
953 Self::plan_for_candidates_configured_into(
954 station,
955 candidates,
956 policies,
957 viewer,
958 filter,
959 budget,
960 mode,
961 &eligible,
962 &last_sent,
963 &mut scratch.prioritized,
964 plan,
965 );
966 }
967 batch.stats.record(&batch.plans[viewer_index], scratch);
968 }
969 batch.record_query_cache_capacity();
970 batch.view()
971 }
972
973 #[allow(clippy::too_many_arguments)]
974 fn plan_for_candidates_configured_into<F, E, L>(
975 station: &Station,
976 candidates: &[EntityHandle],
977 policies: &PolicyTable,
978 viewer: &ViewerQuery,
979 filter: &F,
980 budget: ReplicationBudget,
981 mode: ReplicationSelectionMode,
982 eligible: E,
983 last_sent: L,
984 prioritized: &mut Vec<PrioritizedReplicationCandidate>,
985 plan: &mut ReplicationPlan,
986 ) where
987 F: VisibilityFilter,
988 E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
989 L: Fn(&ViewerQuery, EntityHandle) -> Option<Tick>,
990 {
991 let tick_rate_hz = station.config().tick_rate_hz;
992 let now = station.tick();
993 match mode {
994 ReplicationSelectionMode::Throughput => Self::plan_for_candidates_inner_into(
995 station,
996 candidates,
997 policies,
998 viewer,
999 filter,
1000 budget,
1001 |handle, policy, distance_squared| {
1002 ReplicationCadence::should_send(
1003 policy,
1004 tick_rate_hz,
1005 distance_squared,
1006 now,
1007 last_sent(viewer, handle),
1008 )
1009 },
1010 eligible,
1011 true,
1012 plan,
1013 ),
1014 ReplicationSelectionMode::Prioritized => {
1015 Self::plan_for_candidates_prioritized_inner_into(
1016 station,
1017 candidates,
1018 policies,
1019 viewer,
1020 filter,
1021 budget,
1022 prioritized,
1023 |handle, policy, distance_squared| {
1024 ReplicationCadence::should_send(
1025 policy,
1026 tick_rate_hz,
1027 distance_squared,
1028 now,
1029 last_sent(viewer, handle),
1030 )
1031 },
1032 eligible,
1033 plan,
1034 );
1035 }
1036 }
1037 }
1038
1039 #[allow(clippy::too_many_arguments)]
1041 pub fn plan_for_viewer_into<F: VisibilityFilter>(
1042 station: &Station,
1043 index: &CellIndex,
1044 policies: &PolicyTable,
1045 viewer: &ViewerQuery,
1046 filter: &F,
1047 budget: ReplicationBudget,
1048 scratch: &mut ReplicationScratch,
1049 plan: &mut ReplicationPlan,
1050 ) {
1051 Self::plan_for_viewer_configured_into(
1052 station,
1053 index,
1054 policies,
1055 viewer,
1056 filter,
1057 budget,
1058 ReplicationSelectionMode::Throughput,
1059 |_, _, _| true,
1060 |_, _| None,
1061 scratch,
1062 plan,
1063 );
1064 }
1065
1066 #[allow(clippy::too_many_arguments)]
1068 pub fn plan_for_viewers_into<'a, F: VisibilityFilter>(
1069 station: &Station,
1070 index: &CellIndex,
1071 policies: &PolicyTable,
1072 viewers: &[ViewerQuery],
1073 filter: &F,
1074 budget: ReplicationBudget,
1075 scratch: &mut ReplicationScratch,
1076 batch: &'a mut ReplicationBatchScratch,
1077 ) -> ReplicationBatchView<'a> {
1078 Self::plan_for_viewers_configured_into(
1079 station,
1080 index,
1081 policies,
1082 viewers,
1083 filter,
1084 budget,
1085 ReplicationSelectionMode::Throughput,
1086 0,
1087 |_, _, _| true,
1088 |_, _| None,
1089 scratch,
1090 batch,
1091 )
1092 }
1093
1094 #[allow(clippy::too_many_arguments)]
1096 pub fn plan_for_viewer_range_into(
1097 station: &Station,
1098 index: &CellIndex,
1099 policies: &PolicyTable,
1100 viewer: &ViewerQuery,
1101 budget: ReplicationBudget,
1102 scratch: &mut ReplicationScratch,
1103 plan: &mut ReplicationPlan,
1104 ) {
1105 let candidates =
1106 index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
1107 Self::plan_for_range_candidates_into(station, candidates, policies, viewer, budget, plan);
1108 }
1109
1110 #[allow(clippy::too_many_arguments)]
1112 pub fn plan_for_viewers_range_into<'a>(
1113 station: &Station,
1114 index: &CellIndex,
1115 policies: &PolicyTable,
1116 viewers: &[ViewerQuery],
1117 budget: ReplicationBudget,
1118 scratch: &mut ReplicationScratch,
1119 batch: &'a mut ReplicationBatchScratch,
1120 ) -> ReplicationBatchView<'a> {
1121 batch.prepare(viewers.len());
1122 for (plan, viewer) in batch.plans[..viewers.len()].iter_mut().zip(viewers) {
1123 Self::plan_for_viewer_range_into(
1124 station, index, policies, viewer, budget, scratch, plan,
1125 );
1126 batch.stats.record(plan, scratch);
1127 }
1128 batch.view()
1129 }
1130
1131 #[cfg(test)]
1132 fn plan_for_candidates_inner<F, C>(
1133 station: &Station,
1134 candidates: &[EntityHandle],
1135 policies: &PolicyTable,
1136 viewer: &ViewerQuery,
1137 filter: &F,
1138 budget: ReplicationBudget,
1139 cadence_allows: C,
1140 ) -> ReplicationPlan
1141 where
1142 F: VisibilityFilter,
1143 C: Fn(EntityHandle, &CompiledSyncPolicy, f32) -> bool,
1144 {
1145 let mut plan = ReplicationPlan::default();
1146 Self::plan_for_candidates_inner_into(
1147 station,
1148 candidates,
1149 policies,
1150 viewer,
1151 filter,
1152 budget,
1153 cadence_allows,
1154 |_, _, _| true,
1155 false,
1156 &mut plan,
1157 );
1158 plan
1159 }
1160
1161 #[allow(clippy::too_many_arguments)]
1162 fn plan_for_candidates_inner_into<F, C, E>(
1163 station: &Station,
1164 candidates: &[EntityHandle],
1165 policies: &PolicyTable,
1166 viewer: &ViewerQuery,
1167 filter: &F,
1168 budget: ReplicationBudget,
1169 cadence_allows: C,
1170 eligible: E,
1171 stop_when_budget_full: bool,
1172 plan: &mut ReplicationPlan,
1173 ) where
1174 F: VisibilityFilter,
1175 C: Fn(EntityHandle, &CompiledSyncPolicy, f32) -> bool,
1176 E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
1177 {
1178 let max_entities = viewer.max_entities.min(budget.max_entities);
1179 let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
1180 let hard_limit = max_entities.min(max_by_bytes);
1181 plan.entities.clear();
1182 plan.entities.reserve(hard_limit.min(candidates.len()));
1183 plan.stats = ReplicationStats {
1184 candidates: candidates.len(),
1185 ..ReplicationStats::default()
1186 };
1187
1188 if stop_when_budget_full && hard_limit == 0 {
1189 plan.stats.unexamined_after_budget = candidates.len();
1190 return;
1191 }
1192
1193 for (candidate_index, handle) in candidates.iter().enumerate() {
1194 let Some(entity) = station.get(*handle) else {
1195 continue;
1196 };
1197 plan.stats.considered += 1;
1198
1199 let Some(policy) = policies.get(entity.policy_id) else {
1200 continue;
1201 };
1202 let distance_squared = entity.position.distance_squared(viewer.position);
1203 let policy_radius_sq = policy.interest_radius * policy.interest_radius;
1204 if distance_squared > policy_radius_sq {
1205 continue;
1206 }
1207 if !filter.is_visible_with_distance(viewer, entity, distance_squared) {
1208 continue;
1209 }
1210 if !cadence_allows(*handle, policy, distance_squared) {
1211 plan.stats.skipped_by_cadence += 1;
1212 continue;
1213 }
1214 if !eligible(viewer, *handle, entity) {
1215 continue;
1216 }
1217
1218 if plan.entities.len() >= hard_limit {
1219 plan.stats.skipped_by_budget += 1;
1220 continue;
1221 }
1222
1223 plan.entities.push(*handle);
1224 if stop_when_budget_full && plan.entities.len() == hard_limit {
1225 plan.stats.unexamined_after_budget =
1226 candidates.len().saturating_sub(candidate_index + 1);
1227 break;
1228 }
1229 }
1230
1231 plan.stats.selected = plan.entities.len();
1232 plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
1233 }
1234
1235 #[cfg(all(not(feature = "simd"), test))]
1236 fn plan_for_range_candidates(
1237 station: &Station,
1238 candidates: &[EntityHandle],
1239 policies: &PolicyTable,
1240 viewer: &ViewerQuery,
1241 budget: ReplicationBudget,
1242 ) -> ReplicationPlan {
1243 let mut plan = ReplicationPlan::default();
1244 Self::plan_for_range_candidates_into(
1245 station, candidates, policies, viewer, budget, &mut plan,
1246 );
1247 plan
1248 }
1249
1250 #[cfg(not(feature = "simd"))]
1251 fn plan_for_range_candidates_into(
1252 station: &Station,
1253 candidates: &[EntityHandle],
1254 policies: &PolicyTable,
1255 viewer: &ViewerQuery,
1256 budget: ReplicationBudget,
1257 plan: &mut ReplicationPlan,
1258 ) {
1259 Self::plan_for_candidates_inner_into(
1260 station,
1261 candidates,
1262 policies,
1263 viewer,
1264 &RangeOnlyVisibility,
1265 budget,
1266 |_, _, _| true,
1267 |_, _, _| true,
1268 false,
1269 plan,
1270 );
1271 }
1272
1273 #[cfg(all(feature = "simd", test))]
1274 fn plan_for_range_candidates(
1275 station: &Station,
1276 candidates: &[EntityHandle],
1277 policies: &PolicyTable,
1278 viewer: &ViewerQuery,
1279 budget: ReplicationBudget,
1280 ) -> ReplicationPlan {
1281 let mut plan = ReplicationPlan::default();
1282 Self::plan_for_range_candidates_into(
1283 station, candidates, policies, viewer, budget, &mut plan,
1284 );
1285 plan
1286 }
1287
1288 #[cfg(feature = "simd")]
1289 fn plan_for_range_candidates_into(
1290 station: &Station,
1291 candidates: &[EntityHandle],
1292 policies: &PolicyTable,
1293 viewer: &ViewerQuery,
1294 budget: ReplicationBudget,
1295 plan: &mut ReplicationPlan,
1296 ) {
1297 use wide::{CmpLe, f32x8};
1298
1299 const LANES: usize = 8;
1300 let max_entities = viewer.max_entities.min(budget.max_entities);
1301 let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
1302 let hard_limit = max_entities.min(max_by_bytes);
1303 plan.entities.clear();
1304 plan.entities.reserve(hard_limit.min(candidates.len()));
1305 plan.stats = ReplicationStats {
1306 candidates: candidates.len(),
1307 ..ReplicationStats::default()
1308 };
1309 let viewer_radius_squared = viewer.radius_squared();
1310
1311 for handles in candidates.chunks(LANES) {
1312 let mut distance_squared = [f32::NAN; LANES];
1313 let mut policy_radius_squared = [f32::NAN; LANES];
1314 let mut valid_lanes = 0_u8;
1315
1316 for (lane, handle) in handles.iter().copied().enumerate() {
1317 let Some(entity) = station.get(handle) else {
1318 continue;
1319 };
1320 plan.stats.considered = plan.stats.considered.saturating_add(1);
1321 let Some(policy) = policies.get(entity.policy_id) else {
1322 continue;
1323 };
1324 distance_squared[lane] = entity.position.distance_squared(viewer.position);
1325 policy_radius_squared[lane] = policy.interest_radius * policy.interest_radius;
1326 valid_lanes |= 1 << lane;
1327 }
1328
1329 let visible_lanes = u8::try_from(
1330 (f32x8::new(distance_squared).cmp_le(f32x8::new(policy_radius_squared))
1331 & f32x8::new(distance_squared).cmp_le(f32x8::splat(viewer_radius_squared)))
1332 .move_mask(),
1333 )
1334 .expect("eight-lane SIMD mask fits u8")
1335 & valid_lanes;
1336
1337 for (lane, handle) in handles.iter().copied().enumerate() {
1338 if visible_lanes & (1 << lane) == 0 {
1339 continue;
1340 }
1341 if plan.entities.len() >= hard_limit {
1342 plan.stats.skipped_by_budget = plan.stats.skipped_by_budget.saturating_add(1);
1343 } else {
1344 plan.entities.push(handle);
1345 }
1346 }
1347 }
1348
1349 plan.stats.selected = plan.entities.len();
1350 plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
1351 }
1352
1353 #[allow(clippy::too_many_arguments)]
1354 #[allow(clippy::too_many_arguments)]
1355 fn plan_for_candidates_prioritized_inner_into<F, C, E>(
1356 station: &Station,
1357 candidates: &[EntityHandle],
1358 policies: &PolicyTable,
1359 viewer: &ViewerQuery,
1360 filter: &F,
1361 budget: ReplicationBudget,
1362 eligible: &mut Vec<PrioritizedReplicationCandidate>,
1363 cadence_allows: C,
1364 candidate_eligible: E,
1365 plan: &mut ReplicationPlan,
1366 ) where
1367 F: VisibilityFilter,
1368 C: Fn(EntityHandle, &CompiledSyncPolicy, f32) -> bool,
1369 E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
1370 {
1371 let max_entities = viewer.max_entities.min(budget.max_entities);
1372 let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
1373 let hard_limit = max_entities.min(max_by_bytes);
1374 plan.entities.clear();
1375 plan.entities.reserve(hard_limit.min(candidates.len()));
1376 plan.stats = ReplicationStats {
1377 candidates: candidates.len(),
1378 ..ReplicationStats::default()
1379 };
1380 eligible.clear();
1381
1382 for handle in candidates {
1383 let Some(entity) = station.get(*handle) else {
1384 continue;
1385 };
1386 plan.stats.considered += 1;
1387
1388 let Some(policy) = policies.get(entity.policy_id) else {
1389 continue;
1390 };
1391 let distance_squared = entity.position.distance_squared(viewer.position);
1392 let policy_radius_sq = policy.interest_radius * policy.interest_radius;
1393 if distance_squared > policy_radius_sq {
1394 continue;
1395 }
1396 if !filter.is_visible_with_distance(viewer, entity, distance_squared) {
1397 continue;
1398 }
1399 if !cadence_allows(*handle, policy, distance_squared) {
1400 plan.stats.skipped_by_cadence += 1;
1401 continue;
1402 }
1403 if !candidate_eligible(viewer, *handle, entity) {
1404 continue;
1405 }
1406
1407 eligible.push(PrioritizedReplicationCandidate {
1408 handle: *handle,
1409 score: ReplicationPriority::score(policy, distance_squared),
1410 distance_squared,
1411 });
1412 }
1413
1414 let selected = prioritize_candidates(eligible, hard_limit);
1415
1416 plan.stats.skipped_by_budget = eligible.len().saturating_sub(selected);
1417 plan.entities.extend(
1418 eligible
1419 .iter()
1420 .take(selected)
1421 .map(|candidate| candidate.handle),
1422 );
1423 plan.stats.selected = plan.entities.len();
1424 plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
1425 }
1426}
1427
1428fn compare_prioritized_candidates(
1429 left: &PrioritizedReplicationCandidate,
1430 right: &PrioritizedReplicationCandidate,
1431) -> Ordering {
1432 right
1433 .score
1434 .cmp(&left.score)
1435 .then_with(|| left.distance_squared.total_cmp(&right.distance_squared))
1436 .then_with(|| left.handle.cmp(&right.handle))
1437}
1438
1439fn prioritize_candidates(eligible: &mut [PrioritizedReplicationCandidate], limit: usize) -> usize {
1440 let selected = eligible.len().min(limit);
1441 if selected == 0 {
1442 return 0;
1443 }
1444 if selected.saturating_mul(2) < eligible.len() {
1445 eligible.select_nth_unstable_by(selected, compare_prioritized_candidates);
1446 eligible[..selected].sort_by(compare_prioritized_candidates);
1447 } else {
1448 eligible.sort_by(compare_prioritized_candidates);
1449 }
1450 selected
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456 use crate::entity::EntityTags;
1457 use crate::ids::{ClientId, EntityId, InstanceId, NodeId, PolicyId, StationId};
1458 use crate::interest::{AndVisibility, FrustumVisibility, RangeOnlyVisibility, TagVisibility};
1459 use crate::policy::CompiledSyncPolicy;
1460 use crate::spatial::{Aabb3, Bounds, Frustum3, GridSpec, Position3};
1461 use crate::station::{Station, StationConfig};
1462
1463 fn plan_owned<F: VisibilityFilter>(
1464 station: &Station,
1465 index: &CellIndex,
1466 policies: &PolicyTable,
1467 viewer: &ViewerQuery,
1468 filter: &F,
1469 budget: ReplicationBudget,
1470 ) -> ReplicationPlan {
1471 let mut scratch = ReplicationScratch::default();
1472 let mut plan = ReplicationPlan::default();
1473 ReplicationPlanner::plan_for_viewer_into(
1474 station,
1475 index,
1476 policies,
1477 viewer,
1478 filter,
1479 budget,
1480 &mut scratch,
1481 &mut plan,
1482 );
1483 plan
1484 }
1485
1486 #[test]
1487 fn planner_applies_composed_frustum_visibility_filter() {
1488 let mut station = Station::new(StationConfig {
1489 station_id: StationId::new(1),
1490 node_id: NodeId::new(1),
1491 instance_id: InstanceId::new(1),
1492 tick_rate_hz: 20,
1493 });
1494 let grid = GridSpec::new(16.0).expect("grid is valid");
1495 let mut index = CellIndex::new(grid);
1496 let mut policies = PolicyTable::default();
1497 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1498
1499 let visible = station
1500 .spawn_owned(
1501 EntityId::new(1),
1502 Position3::new(10.0, 0.0, 0.0),
1503 Bounds::Point,
1504 PolicyId::new(1),
1505 )
1506 .expect("spawn visible");
1507 let outside_frustum = station
1508 .spawn_owned(
1509 EntityId::new(2),
1510 Position3::new(-10.0, 0.0, 0.0),
1511 Bounds::Point,
1512 PolicyId::new(1),
1513 )
1514 .expect("spawn outside frustum");
1515 index.upsert(visible, Position3::new(10.0, 0.0, 0.0), Bounds::Point);
1516 index.upsert(
1517 outside_frustum,
1518 Position3::new(-10.0, 0.0, 0.0),
1519 Bounds::Point,
1520 );
1521
1522 let viewer = ViewerQuery {
1523 client_id: ClientId::new(7),
1524 position: Position3::new(0.0, 0.0, 0.0),
1525 radius: 128.0,
1526 max_entities: 8,
1527 };
1528 let frustum = Frustum3::from_aabb(Aabb3::new(
1529 Position3::new(0.0, -20.0, -20.0),
1530 Position3::new(80.0, 20.0, 20.0),
1531 ));
1532 let filter = AndVisibility::new(RangeOnlyVisibility, FrustumVisibility::new(frustum));
1533
1534 let plan = plan_owned(
1535 &station,
1536 &index,
1537 &policies,
1538 &viewer,
1539 &filter,
1540 ReplicationBudget::default(),
1541 );
1542
1543 assert_eq!(plan.entities, vec![visible]);
1544 assert_eq!(plan.stats.selected, 1);
1545 assert_eq!(plan.stats.considered, 2);
1546 }
1547
1548 #[test]
1549 fn planner_applies_tag_visibility_filter() {
1550 let mut station = Station::new(StationConfig {
1551 station_id: StationId::new(1),
1552 node_id: NodeId::new(1),
1553 instance_id: InstanceId::new(1),
1554 tick_rate_hz: 20,
1555 });
1556 let grid = GridSpec::new(16.0).expect("grid is valid");
1557 let mut index = CellIndex::new(grid);
1558 let mut policies = PolicyTable::default();
1559 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1560
1561 let static_visible = station
1562 .spawn_owned(
1563 EntityId::new(1),
1564 Position3::new(10.0, 0.0, 0.0),
1565 Bounds::Point,
1566 PolicyId::new(1),
1567 )
1568 .expect("spawn static");
1569 let fast_mover = station
1570 .spawn_owned(
1571 EntityId::new(2),
1572 Position3::new(12.0, 0.0, 0.0),
1573 Bounds::Point,
1574 PolicyId::new(1),
1575 )
1576 .expect("spawn mover");
1577 station
1578 .set_tags(static_visible, EntityTags::from_bits(0b001))
1579 .expect("tag static");
1580 station
1581 .set_tags(fast_mover, EntityTags::from_bits(0b010))
1582 .expect("tag mover");
1583 index.upsert(
1584 static_visible,
1585 Position3::new(10.0, 0.0, 0.0),
1586 Bounds::Point,
1587 );
1588 index.upsert(fast_mover, Position3::new(12.0, 0.0, 0.0), Bounds::Point);
1589
1590 let viewer = ViewerQuery {
1591 client_id: ClientId::new(7),
1592 position: Position3::new(0.0, 0.0, 0.0),
1593 radius: 128.0,
1594 max_entities: 8,
1595 };
1596 let filter = AndVisibility::new(
1597 RangeOnlyVisibility,
1598 TagVisibility::new(EntityTags::from_bits(0b001), EntityTags::from_bits(0b010)),
1599 );
1600
1601 let plan = plan_owned(
1602 &station,
1603 &index,
1604 &policies,
1605 &viewer,
1606 &filter,
1607 ReplicationBudget::default(),
1608 );
1609
1610 assert_eq!(plan.entities, vec![static_visible]);
1611 assert_eq!(plan.stats.selected, 1);
1612 assert_eq!(plan.stats.considered, 2);
1613 }
1614
1615 #[test]
1616 fn caller_eligibility_filters_before_replication_budget() {
1617 let mut station = Station::new(StationConfig {
1618 station_id: StationId::new(1),
1619 node_id: NodeId::new(1),
1620 instance_id: InstanceId::new(1),
1621 tick_rate_hz: 20,
1622 });
1623 let mut index = CellIndex::new(GridSpec::new(16.0).expect("grid is valid"));
1624 let mut policies = PolicyTable::default();
1625 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1626 let handles = (1_u16..=3)
1627 .map(|id| {
1628 let position = Position3::new(f32::from(id), 0.0, 0.0);
1629 let handle = station
1630 .spawn_owned(
1631 EntityId::new(u64::from(id)),
1632 position,
1633 Bounds::Point,
1634 PolicyId::new(1),
1635 )
1636 .expect("entity id is unique");
1637 index.upsert(handle, position, Bounds::Point);
1638 handle
1639 })
1640 .collect::<Vec<_>>();
1641 let viewer = ViewerQuery {
1642 client_id: ClientId::new(1),
1643 position: Position3::new(0.0, 0.0, 0.0),
1644 radius: 128.0,
1645 max_entities: 1,
1646 };
1647 let mut scratch = ReplicationScratch::default();
1648 let mut plan = ReplicationPlan::default();
1649
1650 ReplicationPlanner::plan_for_viewer_configured_into(
1651 &station,
1652 &index,
1653 &policies,
1654 &viewer,
1655 &RangeOnlyVisibility,
1656 ReplicationBudget {
1657 max_entities: 1,
1658 ..ReplicationBudget::default()
1659 },
1660 ReplicationSelectionMode::Throughput,
1661 |_, handle, _| handle == handles[2],
1662 |_, _| None,
1663 &mut scratch,
1664 &mut plan,
1665 );
1666
1667 assert_eq!(plan.entities, vec![handles[2]]);
1668 assert_eq!(plan.stats.selected, 1);
1669 assert_eq!(plan.stats.skipped_by_budget, 0);
1670 }
1671
1672 #[test]
1673 fn work_bounded_planner_stops_after_first_fit_budget() {
1674 let mut station = Station::new(StationConfig {
1675 station_id: StationId::new(1),
1676 node_id: NodeId::new(1),
1677 instance_id: InstanceId::new(1),
1678 tick_rate_hz: 20,
1679 });
1680 let mut index = CellIndex::new(GridSpec::new(16.0).expect("grid is valid"));
1681 let mut policies = PolicyTable::default();
1682 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1683 for id in 1_u16..=8 {
1684 let position = Position3::new(f32::from(id), 0.0, 0.0);
1685 let handle = station
1686 .spawn_owned(
1687 EntityId::new(u64::from(id)),
1688 position,
1689 Bounds::Point,
1690 PolicyId::new(1),
1691 )
1692 .expect("entity id is unique");
1693 index.upsert(handle, position, Bounds::Point);
1694 }
1695 let viewer = ViewerQuery {
1696 client_id: ClientId::new(1),
1697 position: Position3::new(0.0, 0.0, 0.0),
1698 radius: 128.0,
1699 max_entities: 2,
1700 };
1701 let mut scratch = ReplicationScratch::default();
1702 let mut plan = ReplicationPlan::default();
1703
1704 ReplicationPlanner::plan_for_viewer_configured_into(
1705 &station,
1706 &index,
1707 &policies,
1708 &viewer,
1709 &RangeOnlyVisibility,
1710 ReplicationBudget {
1711 max_entities: 2,
1712 ..ReplicationBudget::default()
1713 },
1714 ReplicationSelectionMode::Throughput,
1715 |_, _, _| true,
1716 |_, _| None,
1717 &mut scratch,
1718 &mut plan,
1719 );
1720
1721 assert_eq!(plan.stats.selected, 2);
1722 assert_eq!(plan.stats.considered, 2);
1723 assert_eq!(plan.stats.skipped_by_budget, 0);
1724 assert_eq!(plan.stats.unexamined_after_budget, 6);
1725 }
1726
1727 #[test]
1728 #[allow(clippy::too_many_lines)]
1729 fn range_batch_matches_ordered_scalar_plans() {
1730 let mut station = Station::new(StationConfig {
1731 station_id: StationId::new(1),
1732 node_id: NodeId::new(1),
1733 instance_id: InstanceId::new(1),
1734 tick_rate_hz: 128,
1735 });
1736 let grid = GridSpec::new(16.0).expect("grid is valid");
1737 let mut index = CellIndex::new(grid);
1738 let mut policies = PolicyTable::default();
1739 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 128, 96.0));
1740 for entity_index in 0_u16..24 {
1741 let position = Position3::new(f32::from(entity_index) * 8.0 - 64.0, 0.0, 0.0);
1742 let handle = station
1743 .spawn_owned(
1744 EntityId::new(u64::from(entity_index)),
1745 position,
1746 Bounds::Point,
1747 PolicyId::new(1),
1748 )
1749 .expect("entity id is unique");
1750 index.upsert(handle, position, Bounds::Point);
1751 }
1752 let viewers = [
1753 ViewerQuery {
1754 client_id: ClientId::new(1),
1755 position: Position3::new(0.0, 0.0, 0.0),
1756 radius: 80.0,
1757 max_entities: 32,
1758 },
1759 ViewerQuery {
1760 client_id: ClientId::new(2),
1761 position: Position3::new(48.0, 0.0, 0.0),
1762 radius: 48.0,
1763 max_entities: 8,
1764 },
1765 ];
1766 let mut scalar_scratch = ReplicationScratch::default();
1767 let expected = viewers
1768 .iter()
1769 .map(|viewer| {
1770 let mut plan = ReplicationPlan::default();
1771 ReplicationPlanner::plan_for_viewer_range_into(
1772 &station,
1773 &index,
1774 &policies,
1775 viewer,
1776 ReplicationBudget::default(),
1777 &mut scalar_scratch,
1778 &mut plan,
1779 );
1780 plan
1781 })
1782 .collect::<Vec<_>>();
1783
1784 let mut batch_scratch = ReplicationScratch::default();
1785 let mut batch_output = ReplicationBatchScratch::default();
1786 let batch_view = ReplicationPlanner::plan_for_viewers_range_into(
1787 &station,
1788 &index,
1789 &policies,
1790 &viewers,
1791 ReplicationBudget::default(),
1792 &mut batch_scratch,
1793 &mut batch_output,
1794 );
1795 let batch = ReplicationBatchResult {
1796 plans: batch_view.plans.to_vec(),
1797 stats: batch_view.stats,
1798 };
1799
1800 assert_eq!(batch.plans, expected);
1801 assert_eq!(batch.stats.viewers, viewers.len());
1802 assert_eq!(
1803 batch.stats.selected,
1804 expected.iter().map(|plan| plan.stats.selected).sum()
1805 );
1806 assert_eq!(
1807 batch.stats.grid_queries + batch.stats.occupied_queries,
1808 viewers.len()
1809 );
1810
1811 let mut reusable_planning = ReplicationScratch::default();
1812 let mut reusable_output = ReplicationBatchScratch::new();
1813 {
1814 let reused = ReplicationPlanner::plan_for_viewers_range_into(
1815 &station,
1816 &index,
1817 &policies,
1818 &viewers,
1819 ReplicationBudget::default(),
1820 &mut reusable_planning,
1821 &mut reusable_output,
1822 );
1823 assert_eq!(reused.plans, expected);
1824 assert_eq!(reused.stats, batch.stats);
1825 }
1826 let retained_capacity = reusable_output.retained_entity_capacity();
1827 assert_eq!(reusable_output.retained_plan_slots(), viewers.len());
1828
1829 let reused = ReplicationPlanner::plan_for_viewers_range_into(
1830 &station,
1831 &index,
1832 &policies,
1833 &viewers[..1],
1834 ReplicationBudget::default(),
1835 &mut reusable_planning,
1836 &mut reusable_output,
1837 );
1838 assert_eq!(reused.plans, &expected[..1]);
1839 assert_eq!(reusable_output.retained_plan_slots(), viewers.len());
1840 assert_eq!(
1841 reusable_output.retained_entity_capacity(),
1842 retained_capacity
1843 );
1844 }
1845
1846 #[test]
1847 fn range_batch_preserves_scalar_nan_radius_semantics() {
1848 let mut station = Station::new(StationConfig {
1849 station_id: StationId::new(1),
1850 node_id: NodeId::new(1),
1851 instance_id: InstanceId::new(1),
1852 tick_rate_hz: 128,
1853 });
1854 let mut policies = PolicyTable::default();
1855 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 128, 96.0));
1856 let handle = station
1857 .spawn_owned(
1858 EntityId::new(1),
1859 Position3::new(1.0, 2.0, 3.0),
1860 Bounds::Point,
1861 PolicyId::new(1),
1862 )
1863 .expect("spawn entity");
1864 let viewer = ViewerQuery {
1865 client_id: ClientId::new(1),
1866 position: Position3::new(0.0, 0.0, 0.0),
1867 radius: f32::NAN,
1868 max_entities: 8,
1869 };
1870 let candidates = [handle];
1871 let scalar = ReplicationPlanner::plan_for_candidates_inner(
1872 &station,
1873 &candidates,
1874 &policies,
1875 &viewer,
1876 &RangeOnlyVisibility,
1877 ReplicationBudget::default(),
1878 |_, _, _| true,
1879 );
1880 let range = ReplicationPlanner::plan_for_range_candidates(
1881 &station,
1882 &candidates,
1883 &policies,
1884 &viewer,
1885 ReplicationBudget::default(),
1886 );
1887
1888 assert!(scalar.entities.is_empty());
1889 assert_eq!(range, scalar);
1890 }
1891
1892 #[test]
1893 fn cadence_scales_interval_by_squared_distance() {
1894 let policy = CompiledSyncPolicy::new(PolicyId::new(1), 2, 20, 100.0);
1895
1896 assert_eq!(ReplicationCadence::target_hz(&policy, 0.0), 20);
1897 assert_eq!(ReplicationCadence::interval_ticks(&policy, 20, 0.0), 1);
1898 assert_eq!(ReplicationCadence::target_hz(&policy, 100.0_f32 * 100.0), 2);
1899 assert_eq!(
1900 ReplicationCadence::interval_ticks(&policy, 20, 100.0_f32 * 100.0),
1901 10
1902 );
1903 }
1904
1905 #[test]
1906 fn priority_score_prefers_weight_then_distance() {
1907 let mut low = CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 100.0);
1908 low.priority_weight = 1;
1909 let mut high = CompiledSyncPolicy::new(PolicyId::new(2), 1, 20, 100.0);
1910 high.priority_weight = 10;
1911
1912 assert!(
1913 ReplicationPriority::score(&high, 90.0 * 90.0) > ReplicationPriority::score(&low, 0.0)
1914 );
1915 assert!(
1916 ReplicationPriority::score(&low, 0.0) > ReplicationPriority::score(&low, 90.0 * 90.0)
1917 );
1918 }
1919
1920 #[test]
1921 fn top_k_priority_selection_matches_full_sort_for_all_budget_edges() {
1922 let candidates = (0_u32..257)
1923 .map(|index| PrioritizedReplicationCandidate {
1924 handle: EntityHandle::new(index, index % 3),
1925 score: u64::from(index.wrapping_mul(37) % 23),
1926 distance_squared: f32::from(
1927 u16::try_from(index.wrapping_mul(19) % 41).expect("distance fits u16"),
1928 ),
1929 })
1930 .collect::<Vec<_>>();
1931
1932 for limit in [0, 1, 7, 64, 256, 257, 300] {
1933 let mut expected = candidates.clone();
1934 expected.sort_by(compare_prioritized_candidates);
1935 expected.truncate(limit.min(expected.len()));
1936 let mut actual = candidates.clone();
1937 let selected = prioritize_candidates(&mut actual, limit);
1938
1939 assert_eq!(selected, expected.len());
1940 assert_eq!(&actual[..selected], expected.as_slice());
1941 }
1942 }
1943
1944 #[test]
1945 fn configured_kernel_combines_mode_eligibility_and_per_viewer_cadence() {
1946 let mut station = Station::new(StationConfig {
1947 station_id: StationId::new(1),
1948 node_id: NodeId::new(1),
1949 instance_id: InstanceId::new(1),
1950 tick_rate_hz: 20,
1951 });
1952 station.advance_tick();
1953 let mut index = CellIndex::new(GridSpec::new(16.0).expect("grid"));
1954 let mut policies = PolicyTable::default();
1955 let mut low = CompiledSyncPolicy::new(PolicyId::new(1), 20, 20, 128.0);
1956 low.priority_weight = 1;
1957 let mut high = CompiledSyncPolicy::new(PolicyId::new(2), 20, 20, 128.0);
1958 high.priority_weight = 100;
1959 policies.set(low);
1960 policies.set(high);
1961 let low_handle = station
1962 .spawn_owned(
1963 EntityId::new(1),
1964 Position3::new(1.0, 0.0, 0.0),
1965 Bounds::Point,
1966 PolicyId::new(1),
1967 )
1968 .expect("low");
1969 let high_handle = station
1970 .spawn_owned(
1971 EntityId::new(2),
1972 Position3::new(2.0, 0.0, 0.0),
1973 Bounds::Point,
1974 PolicyId::new(2),
1975 )
1976 .expect("high");
1977 index.upsert(low_handle, Position3::new(1.0, 0.0, 0.0), Bounds::Point);
1978 index.upsert(high_handle, Position3::new(2.0, 0.0, 0.0), Bounds::Point);
1979 let viewer = ViewerQuery {
1980 client_id: ClientId::new(7),
1981 position: Position3::new(0.0, 0.0, 0.0),
1982 radius: 128.0,
1983 max_entities: 1,
1984 };
1985 let budget = ReplicationBudget {
1986 max_entities: 1,
1987 max_bytes: 64,
1988 estimated_entity_bytes: 32,
1989 };
1990 let mut scratch = ReplicationScratch::default();
1991 let mut plan = ReplicationPlan::default();
1992
1993 ReplicationPlanner::plan_for_viewer_configured_into(
1994 &station,
1995 &index,
1996 &policies,
1997 &viewer,
1998 &RangeOnlyVisibility,
1999 budget,
2000 ReplicationSelectionMode::Throughput,
2001 |_, handle, _| handle == low_handle,
2002 |_, _| None,
2003 &mut scratch,
2004 &mut plan,
2005 );
2006 assert_eq!(plan.entities, vec![low_handle]);
2007
2008 ReplicationPlanner::plan_for_viewer_configured_into(
2009 &station,
2010 &index,
2011 &policies,
2012 &viewer,
2013 &RangeOnlyVisibility,
2014 budget,
2015 ReplicationSelectionMode::Prioritized,
2016 |_, _, _| true,
2017 |_, handle| (handle == high_handle).then_some(station.tick()),
2018 &mut scratch,
2019 &mut plan,
2020 );
2021 assert_eq!(plan.entities, vec![low_handle]);
2022 assert_eq!(plan.stats.skipped_by_cadence, 1);
2023 }
2024
2025 #[test]
2026 #[allow(clippy::too_many_lines)]
2027 fn configured_batch_reuses_only_repeated_quantized_aoi_ranges() {
2028 let mut station = Station::with_capacity(
2029 StationConfig {
2030 station_id: StationId::new(1),
2031 node_id: NodeId::new(1),
2032 instance_id: InstanceId::new(1),
2033 tick_rate_hz: 20,
2034 },
2035 64,
2036 );
2037 let mut index = CellIndex::with_capacity(GridSpec::new(16.0).expect("grid"), 64, 64);
2038 let mut policies = PolicyTable::default();
2039 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 20, 20, 128.0));
2040 for entity in 0..64_u64 {
2041 let lane = u16::try_from(entity % 8).expect("entity lane fits u16");
2042 let position = Position3::new(f32::from(lane) * 8.0, 0.0, 0.0);
2043 let handle = station
2044 .spawn_owned(
2045 EntityId::new(entity + 1),
2046 position,
2047 Bounds::Point,
2048 PolicyId::new(1),
2049 )
2050 .expect("spawn");
2051 index.upsert(handle, position, Bounds::Point);
2052 }
2053 let viewers = [
2054 ViewerQuery {
2055 client_id: ClientId::new(1),
2056 position: Position3::new(0.0, 0.0, 0.0),
2057 radius: 32.0,
2058 max_entities: 64,
2059 },
2060 ViewerQuery {
2061 client_id: ClientId::new(2),
2062 position: Position3::new(1.0, 0.0, 0.0),
2063 radius: 32.0,
2064 max_entities: 64,
2065 },
2066 ViewerQuery {
2067 client_id: ClientId::new(3),
2068 position: Position3::new(24.0, 0.0, 0.0),
2069 radius: 32.0,
2070 max_entities: 64,
2071 },
2072 ViewerQuery {
2073 client_id: ClientId::new(4),
2074 position: Position3::new(80.0, 0.0, 0.0),
2075 radius: 32.0,
2076 max_entities: 64,
2077 },
2078 ];
2079 let budget = ReplicationBudget {
2080 max_entities: 64,
2081 max_bytes: 64 * 32,
2082 estimated_entity_bytes: 32,
2083 };
2084 let mut cached_scratch = ReplicationScratch::default();
2085 let mut cached_output = ReplicationBatchScratch::default();
2086 let cached = ReplicationPlanner::plan_for_viewers_configured_into(
2087 &station,
2088 &index,
2089 &policies,
2090 &viewers,
2091 &RangeOnlyVisibility,
2092 budget,
2093 ReplicationSelectionMode::Throughput,
2094 4,
2095 |_, _, _| true,
2096 |_, _| None,
2097 &mut cached_scratch,
2098 &mut cached_output,
2099 );
2100 let cached_plans = cached.plans.to_vec();
2101 let cached_stats = cached.stats;
2102
2103 let mut uncached_scratch = ReplicationScratch::default();
2104 let mut uncached_output = ReplicationBatchScratch::default();
2105 let uncached = ReplicationPlanner::plan_for_viewers_configured_into(
2106 &station,
2107 &index,
2108 &policies,
2109 &viewers,
2110 &RangeOnlyVisibility,
2111 budget,
2112 ReplicationSelectionMode::Throughput,
2113 0,
2114 |_, _, _| true,
2115 |_, _| None,
2116 &mut uncached_scratch,
2117 &mut uncached_output,
2118 );
2119
2120 assert_eq!(cached_plans, uncached.plans);
2121 assert_eq!(cached_stats.unique_query_ranges, 3);
2122 assert_eq!(cached_stats.reused_query_ranges, 1);
2123 assert_eq!(uncached.stats.reused_query_ranges, 0);
2124 assert_eq!(
2125 cached_stats.grid_queries + cached_stats.occupied_queries,
2126 viewers.len() - 1
2127 );
2128 assert_eq!(
2129 uncached.stats.grid_queries + uncached.stats.occupied_queries,
2130 viewers.len()
2131 );
2132 assert_eq!(cached_output.retained_query_cache_slots(), 1);
2133 assert!(cached_output.retained_query_cache_candidate_capacity() >= 1);
2134 }
2135
2136 #[test]
2137 fn planner_with_cadence_skips_recent_far_entities() {
2138 let mut station = Station::new(StationConfig {
2139 station_id: StationId::new(1),
2140 node_id: NodeId::new(1),
2141 instance_id: InstanceId::new(1),
2142 tick_rate_hz: 20,
2143 });
2144 for _ in 0..10 {
2145 station.advance_tick();
2146 }
2147 let grid = GridSpec::new(16.0).expect("grid is valid");
2148 let mut index = CellIndex::new(grid);
2149 let mut policies = PolicyTable::default();
2150 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 2, 20, 128.0));
2151
2152 let near = station
2153 .spawn_owned(
2154 EntityId::new(1),
2155 Position3::new(0.0, 0.0, 0.0),
2156 Bounds::Point,
2157 PolicyId::new(1),
2158 )
2159 .expect("spawn near");
2160 let far = station
2161 .spawn_owned(
2162 EntityId::new(2),
2163 Position3::new(120.0, 0.0, 0.0),
2164 Bounds::Point,
2165 PolicyId::new(1),
2166 )
2167 .expect("spawn far");
2168 index.upsert(near, Position3::new(0.0, 0.0, 0.0), Bounds::Point);
2169 index.upsert(far, Position3::new(120.0, 0.0, 0.0), Bounds::Point);
2170
2171 let viewer = ViewerQuery {
2172 client_id: ClientId::new(7),
2173 position: Position3::new(0.0, 0.0, 0.0),
2174 radius: 128.0,
2175 max_entities: 8,
2176 };
2177 let mut scratch = ReplicationScratch::default();
2178 let mut plan = ReplicationPlan::default();
2179 ReplicationPlanner::plan_for_viewer_configured_into(
2180 &station,
2181 &index,
2182 &policies,
2183 &viewer,
2184 &RangeOnlyVisibility,
2185 ReplicationBudget::default(),
2186 ReplicationSelectionMode::Throughput,
2187 |_, _, _| true,
2188 |_, _| Some(Tick::new(9)),
2189 &mut scratch,
2190 &mut plan,
2191 );
2192
2193 assert_eq!(plan.entities, vec![near]);
2194 assert_eq!(plan.stats.selected, 1);
2195 assert_eq!(plan.stats.skipped_by_cadence, 1);
2196
2197 let mut reusable = ReplicationPlan::default();
2198 ReplicationPlanner::plan_for_viewer_configured_into(
2199 &station,
2200 &index,
2201 &policies,
2202 &viewer,
2203 &RangeOnlyVisibility,
2204 ReplicationBudget::default(),
2205 ReplicationSelectionMode::Throughput,
2206 |_, _, _| true,
2207 |_, _| Some(Tick::new(9)),
2208 &mut scratch,
2209 &mut reusable,
2210 );
2211 assert_eq!(reusable, plan);
2212 }
2213
2214 #[test]
2215 fn prioritized_planner_uses_policy_weight_under_budget() {
2216 let mut station = Station::new(StationConfig {
2217 station_id: StationId::new(1),
2218 node_id: NodeId::new(1),
2219 instance_id: InstanceId::new(1),
2220 tick_rate_hz: 20,
2221 });
2222 let grid = GridSpec::new(16.0).expect("grid is valid");
2223 let mut index = CellIndex::new(grid);
2224 let mut policies = PolicyTable::default();
2225 let mut low = CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0);
2226 low.priority_weight = 1;
2227 let mut high = CompiledSyncPolicy::new(PolicyId::new(2), 1, 20, 128.0);
2228 high.priority_weight = 10;
2229 policies.set(low);
2230 policies.set(high);
2231
2232 let near_low = station
2233 .spawn_owned(
2234 EntityId::new(1),
2235 Position3::new(0.0, 0.0, 0.0),
2236 Bounds::Point,
2237 PolicyId::new(1),
2238 )
2239 .expect("spawn near low priority");
2240 let far_high = station
2241 .spawn_owned(
2242 EntityId::new(2),
2243 Position3::new(96.0, 0.0, 0.0),
2244 Bounds::Point,
2245 PolicyId::new(2),
2246 )
2247 .expect("spawn far high priority");
2248 index.upsert(near_low, Position3::new(0.0, 0.0, 0.0), Bounds::Point);
2249 index.upsert(far_high, Position3::new(96.0, 0.0, 0.0), Bounds::Point);
2250
2251 let viewer = ViewerQuery {
2252 client_id: ClientId::new(7),
2253 position: Position3::new(0.0, 0.0, 0.0),
2254 radius: 128.0,
2255 max_entities: 1,
2256 };
2257 let budget = ReplicationBudget {
2258 max_entities: 1,
2259 max_bytes: 32,
2260 estimated_entity_bytes: 32,
2261 };
2262 let mut scratch = ReplicationScratch::default();
2263 let mut plan = ReplicationPlan::default();
2264 ReplicationPlanner::plan_for_viewer_configured_into(
2265 &station,
2266 &index,
2267 &policies,
2268 &viewer,
2269 &RangeOnlyVisibility,
2270 budget,
2271 ReplicationSelectionMode::Prioritized,
2272 |_, _, _| true,
2273 |_, _| None,
2274 &mut scratch,
2275 &mut plan,
2276 );
2277
2278 assert_eq!(plan.entities, vec![far_high]);
2279 assert_eq!(plan.stats.selected, 1);
2280 assert_eq!(plan.stats.skipped_by_budget, 1);
2281
2282 let mut scratch_plan = ReplicationPlan::default();
2283 ReplicationPlanner::plan_for_viewer_configured_into(
2284 &station,
2285 &index,
2286 &policies,
2287 &viewer,
2288 &RangeOnlyVisibility,
2289 budget,
2290 ReplicationSelectionMode::Prioritized,
2291 |_, _, _| true,
2292 |_, _| None,
2293 &mut scratch,
2294 &mut scratch_plan,
2295 );
2296 assert_eq!(scratch_plan.entities, plan.entities);
2297 assert_eq!(scratch_plan.stats, plan.stats);
2298 assert_eq!(scratch.candidate_count(), 2);
2299 assert!(scratch.prioritized_capacity() >= 2);
2300 assert_eq!(scratch.query_stats().candidate_handles, 2);
2301 assert!(scratch.candidate_capacity() >= 2);
2302 assert!(scratch.candidate_dedup_capacity() >= 2);
2303
2304 assert_prioritized_output_reuse(
2305 &station,
2306 &index,
2307 &policies,
2308 &viewer,
2309 budget,
2310 &plan,
2311 &mut scratch,
2312 );
2313 }
2314
2315 fn assert_prioritized_output_reuse(
2316 station: &Station,
2317 index: &CellIndex,
2318 policies: &PolicyTable,
2319 viewer: &ViewerQuery,
2320 budget: ReplicationBudget,
2321 expected: &ReplicationPlan,
2322 scratch: &mut ReplicationScratch,
2323 ) {
2324 let mut reusable = ReplicationPlan::default();
2325 ReplicationPlanner::plan_for_viewer_configured_into(
2326 station,
2327 index,
2328 policies,
2329 viewer,
2330 &RangeOnlyVisibility,
2331 budget,
2332 ReplicationSelectionMode::Prioritized,
2333 |_, _, _| true,
2334 |_, _| None,
2335 scratch,
2336 &mut reusable,
2337 );
2338 let retained_entities = reusable.entities.as_ptr();
2339 assert_eq!(&reusable, expected);
2340 ReplicationPlanner::plan_for_viewer_configured_into(
2341 station,
2342 index,
2343 policies,
2344 viewer,
2345 &RangeOnlyVisibility,
2346 budget,
2347 ReplicationSelectionMode::Prioritized,
2348 |_, _, _| true,
2349 |_, _| None,
2350 scratch,
2351 &mut reusable,
2352 );
2353 assert_eq!(reusable.entities.as_ptr(), retained_entities);
2354 }
2355
2356 #[test]
2357 fn replication_tracker_records_sent_ack_and_prune() {
2358 let client_id = ClientId::new(7);
2359 let first = EntityHandle::new(1, 0);
2360 let second = EntityHandle::new(2, 0);
2361 let plan = ReplicationPlan {
2362 entities: vec![first, second],
2363 stats: ReplicationStats::default(),
2364 };
2365 let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig { max_entries: 4 });
2366
2367 let recorded = tracker
2368 .record_plan_sent(client_id, &plan, Tick::new(10))
2369 .expect("recording should fit");
2370 assert_eq!(recorded, 2);
2371 assert_eq!(tracker.last_sent(client_id, first), Some(Tick::new(10)));
2372 assert_eq!(tracker.stats().entries, 2);
2373 assert_eq!(tracker.stats().sent_records, 2);
2374
2375 assert!(tracker.acknowledge(client_id, first, Tick::new(11)));
2376 assert_eq!(
2377 tracker
2378 .get(client_id, first)
2379 .expect("tracked record")
2380 .last_acked,
2381 Some(Tick::new(11))
2382 );
2383 assert_eq!(tracker.stats().acked_records, 1);
2384
2385 assert_eq!(tracker.prune_sent_before(Tick::new(11)), 2);
2386 assert!(tracker.is_empty());
2387 assert_eq!(tracker.stats().pruned_records, 2);
2388 }
2389
2390 #[test]
2391 fn replication_tracker_rejects_capacity_without_partial_insert() {
2392 let client_id = ClientId::new(7);
2393 let plan = ReplicationPlan {
2394 entities: vec![EntityHandle::new(1, 0), EntityHandle::new(2, 0)],
2395 stats: ReplicationStats::default(),
2396 };
2397 let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig { max_entries: 1 });
2398
2399 let error = tracker
2400 .record_plan_sent(client_id, &plan, Tick::new(10))
2401 .expect_err("recording should exceed capacity");
2402
2403 assert_eq!(
2404 error,
2405 ReplicationTrackerError::CapacityExceeded {
2406 current: 0,
2407 needed: 2,
2408 max: 1,
2409 }
2410 );
2411 assert!(tracker.is_empty());
2412 assert_eq!(tracker.stats().sent_records, 0);
2413 }
2414
2415 #[test]
2416 fn replication_tracker_uses_exact_capacity_check_near_limit() {
2417 let client_id = ClientId::new(7);
2418 let first = EntityHandle::new(1, 0);
2419 let second = EntityHandle::new(2, 0);
2420 let third = EntityHandle::new(3, 0);
2421 let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig { max_entries: 2 });
2422 tracker
2423 .record_plan_sent(
2424 client_id,
2425 &ReplicationPlan {
2426 entities: vec![first],
2427 stats: ReplicationStats::default(),
2428 },
2429 Tick::new(1),
2430 )
2431 .expect("initial record should fit");
2432 tracker
2433 .record_plan_sent(
2434 client_id,
2435 &ReplicationPlan {
2436 entities: vec![first, second],
2437 stats: ReplicationStats::default(),
2438 },
2439 Tick::new(2),
2440 )
2441 .expect("one existing and one new record should fit exactly");
2442
2443 let error = tracker
2444 .record_plan_sent(
2445 client_id,
2446 &ReplicationPlan {
2447 entities: vec![first, second, third],
2448 stats: ReplicationStats::default(),
2449 },
2450 Tick::new(3),
2451 )
2452 .expect_err("new record should exceed exact capacity");
2453 assert_eq!(
2454 error,
2455 ReplicationTrackerError::CapacityExceeded {
2456 current: 2,
2457 needed: 1,
2458 max: 2,
2459 }
2460 );
2461 assert_eq!(tracker.last_sent(client_id, first), Some(Tick::new(2)));
2462 assert_eq!(tracker.last_sent(client_id, second), Some(Tick::new(2)));
2463 assert_eq!(tracker.get(client_id, third), None);
2464 }
2465
2466 #[test]
2467 fn replication_tracker_promotes_without_losing_ack_or_prune_state() {
2468 let first_client = ClientId::new(1);
2469 let second_client = ClientId::new(2);
2470 let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig {
2471 max_entries: HASHED_REPLICATION_TRACKER_MIN_ENTRIES + 1,
2472 });
2473 let initial_entities: Vec<_> = (0..HASHED_REPLICATION_TRACKER_MIN_ENTRIES - 2)
2474 .map(|index| {
2475 EntityHandle::new(u32::try_from(index).expect("test entity index fits u32"), 1)
2476 })
2477 .collect();
2478 tracker
2479 .record_plan_sent(
2480 first_client,
2481 &ReplicationPlan {
2482 entities: initial_entities.clone(),
2483 stats: ReplicationStats::default(),
2484 },
2485 Tick::new(1),
2486 )
2487 .expect("initial records should fit");
2488 let second_entity = EntityHandle::new(u32::MAX, 1);
2489 tracker
2490 .record_plan_sent(
2491 second_client,
2492 &ReplicationPlan {
2493 entities: vec![second_entity],
2494 stats: ReplicationStats::default(),
2495 },
2496 Tick::new(1),
2497 )
2498 .expect("second client record should fit");
2499 assert!(!tracker.records.is_hashed());
2500
2501 let first_entity = initial_entities[0];
2502 tracker
2503 .record_plan_sent(
2504 first_client,
2505 &ReplicationPlan {
2506 entities: vec![first_entity],
2507 stats: ReplicationStats::default(),
2508 },
2509 Tick::new(2),
2510 )
2511 .expect("existing record should update without promotion");
2512 assert!(!tracker.records.is_hashed());
2513
2514 let final_entity = EntityHandle::new(
2515 u32::try_from(HASHED_REPLICATION_TRACKER_MIN_ENTRIES)
2516 .expect("test entity index fits u32"),
2517 1,
2518 );
2519 tracker
2520 .record_plan_sent(
2521 first_client,
2522 &ReplicationPlan {
2523 entities: vec![final_entity],
2524 stats: ReplicationStats::default(),
2525 },
2526 Tick::new(2),
2527 )
2528 .expect("threshold record should promote");
2529 assert!(tracker.records.is_hashed());
2530 assert!(tracker.acknowledge(first_client, final_entity, Tick::new(3)));
2531 assert_eq!(tracker.clear_client(second_client), 1);
2532 assert!(tracker.records.is_hashed());
2533 assert_eq!(
2534 tracker.prune_sent_before(Tick::new(2)),
2535 initial_entities.len() - 1
2536 );
2537 assert!(tracker.records.is_hashed());
2538 assert_eq!(tracker.len(), 2);
2539 assert_eq!(tracker.stats().entries, 2);
2540 assert_eq!(tracker.stats().acked_records, 1);
2541 assert_eq!(
2542 tracker
2543 .get(first_client, final_entity)
2544 .expect("acked record should survive")
2545 .last_acked,
2546 Some(Tick::new(3))
2547 );
2548 }
2549}