1use std::collections::BTreeMap;
4
5use crate::ids::{ClientId, EntityHandle, Tick};
6#[cfg(not(feature = "simd"))]
7use crate::interest::RangeOnlyVisibility;
8use crate::interest::{ViewerQuery, VisibilityFilter};
9use crate::policy::{CompiledSyncPolicy, PolicyTable};
10use crate::spatial_index::{CellIndex, CellQueryScratch, CellQueryStats, CellQueryStrategy};
11use crate::station::Station;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct ReplicationBudget {
16 pub max_entities: usize,
18 pub max_bytes: usize,
20 pub estimated_entity_bytes: usize,
22}
23
24impl Default for ReplicationBudget {
25 fn default() -> Self {
26 Self {
27 max_entities: 300,
28 max_bytes: 16 * 1024,
29 estimated_entity_bytes: 32,
30 }
31 }
32}
33
34#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct ReplicationPlan {
37 pub entities: Vec<EntityHandle>,
39 pub stats: ReplicationStats,
41}
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub struct ReplicationBatchStats {
46 pub viewers: usize,
48 pub candidates: usize,
50 pub considered: usize,
52 pub selected: usize,
54 pub estimated_bytes: usize,
56 pub grid_queries: usize,
58 pub occupied_queries: usize,
60 pub grid_cells_probed: usize,
62 pub occupied_cells_scanned: usize,
64 pub matched_cells: usize,
66 pub candidate_capacity_max: usize,
68 pub dedup_capacity_max: usize,
70 pub matching_cell_capacity_max: usize,
72 pub priority_capacity_max: usize,
74}
75
76impl ReplicationBatchStats {
77 fn record(&mut self, plan: &ReplicationPlan, scratch: &ReplicationScratch) {
78 self.viewers = self.viewers.saturating_add(1);
79 self.candidates = self.candidates.saturating_add(plan.stats.candidates);
80 self.considered = self.considered.saturating_add(plan.stats.considered);
81 self.selected = self.selected.saturating_add(plan.stats.selected);
82 self.estimated_bytes = self
83 .estimated_bytes
84 .saturating_add(plan.stats.estimated_bytes);
85 let query = scratch.query_stats();
86 match query.strategy {
87 CellQueryStrategy::Grid => self.grid_queries = self.grid_queries.saturating_add(1),
88 CellQueryStrategy::OccupiedCells => {
89 self.occupied_queries = self.occupied_queries.saturating_add(1);
90 }
91 }
92 self.grid_cells_probed = self
93 .grid_cells_probed
94 .saturating_add(query.grid_cells_probed);
95 self.occupied_cells_scanned = self
96 .occupied_cells_scanned
97 .saturating_add(query.occupied_cells_scanned);
98 self.matched_cells = self.matched_cells.saturating_add(query.matched_cells);
99 self.candidate_capacity_max = self
100 .candidate_capacity_max
101 .max(scratch.candidate_capacity());
102 self.dedup_capacity_max = self
103 .dedup_capacity_max
104 .max(scratch.candidate_dedup_capacity());
105 self.matching_cell_capacity_max = self
106 .matching_cell_capacity_max
107 .max(scratch.matching_cell_capacity());
108 self.priority_capacity_max = self
109 .priority_capacity_max
110 .max(scratch.prioritized_capacity());
111 }
112
113 pub fn merge(&mut self, other: Self) {
115 self.viewers = self.viewers.saturating_add(other.viewers);
116 self.candidates = self.candidates.saturating_add(other.candidates);
117 self.considered = self.considered.saturating_add(other.considered);
118 self.selected = self.selected.saturating_add(other.selected);
119 self.estimated_bytes = self.estimated_bytes.saturating_add(other.estimated_bytes);
120 self.grid_queries = self.grid_queries.saturating_add(other.grid_queries);
121 self.occupied_queries = self.occupied_queries.saturating_add(other.occupied_queries);
122 self.grid_cells_probed = self
123 .grid_cells_probed
124 .saturating_add(other.grid_cells_probed);
125 self.occupied_cells_scanned = self
126 .occupied_cells_scanned
127 .saturating_add(other.occupied_cells_scanned);
128 self.matched_cells = self.matched_cells.saturating_add(other.matched_cells);
129 self.candidate_capacity_max = self
130 .candidate_capacity_max
131 .max(other.candidate_capacity_max);
132 self.dedup_capacity_max = self.dedup_capacity_max.max(other.dedup_capacity_max);
133 self.matching_cell_capacity_max = self
134 .matching_cell_capacity_max
135 .max(other.matching_cell_capacity_max);
136 self.priority_capacity_max = self.priority_capacity_max.max(other.priority_capacity_max);
137 }
138}
139
140#[derive(Clone, Debug, Default, PartialEq, Eq)]
142pub struct ReplicationBatchResult {
143 pub plans: Vec<ReplicationPlan>,
145 pub stats: ReplicationBatchStats,
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct ReplicationTrackerConfig {
152 pub max_entries: usize,
154}
155
156impl Default for ReplicationTrackerConfig {
157 fn default() -> Self {
158 Self {
159 max_entries: 65_536,
160 }
161 }
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
166pub struct ReplicationTrackKey {
167 pub client_id: ClientId,
169 pub entity: EntityHandle,
171}
172
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub struct ReplicationTrackRecord {
176 pub client_id: ClientId,
178 pub entity: EntityHandle,
180 pub last_sent: Tick,
182 pub last_acked: Option<Tick>,
184}
185
186#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
188pub struct ReplicationTrackerStats {
189 pub entries: usize,
191 pub sent_records: usize,
193 pub acked_records: usize,
195 pub pruned_records: usize,
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
201pub enum ReplicationTrackerError {
202 CapacityExceeded {
204 current: usize,
206 needed: usize,
208 max: usize,
210 },
211}
212
213impl core::fmt::Display for ReplicationTrackerError {
214 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
215 match self {
216 Self::CapacityExceeded {
217 current,
218 needed,
219 max,
220 } => write!(
221 f,
222 "replication tracker capacity exceeded: current {current}, needed {needed}, max {max}"
223 ),
224 }
225 }
226}
227
228impl std::error::Error for ReplicationTrackerError {}
229
230#[derive(Clone, Debug)]
232pub struct ReplicationTracker {
233 config: ReplicationTrackerConfig,
234 records: BTreeMap<ReplicationTrackKey, ReplicationTrackRecord>,
235 stats: ReplicationTrackerStats,
236}
237
238impl Default for ReplicationTracker {
239 fn default() -> Self {
240 Self::new(ReplicationTrackerConfig::default())
241 }
242}
243
244impl ReplicationTracker {
245 pub fn new(config: ReplicationTrackerConfig) -> Self {
247 Self {
248 config,
249 records: BTreeMap::new(),
250 stats: ReplicationTrackerStats::default(),
251 }
252 }
253
254 pub const fn config(&self) -> ReplicationTrackerConfig {
256 self.config
257 }
258
259 pub const fn stats(&self) -> ReplicationTrackerStats {
261 self.stats
262 }
263
264 pub fn len(&self) -> usize {
266 self.records.len()
267 }
268
269 pub fn is_empty(&self) -> bool {
271 self.records.is_empty()
272 }
273
274 pub fn last_sent(&self, client_id: ClientId, entity: EntityHandle) -> Option<Tick> {
276 self.records
277 .get(&ReplicationTrackKey { client_id, entity })
278 .map(|record| record.last_sent)
279 }
280
281 pub fn get(&self, client_id: ClientId, entity: EntityHandle) -> Option<ReplicationTrackRecord> {
283 self.records
284 .get(&ReplicationTrackKey { client_id, entity })
285 .copied()
286 }
287
288 pub fn record_plan_sent(
290 &mut self,
291 client_id: ClientId,
292 plan: &ReplicationPlan,
293 sent_at: Tick,
294 ) -> Result<usize, ReplicationTrackerError> {
295 self.ensure_capacity_for(client_id, &plan.entities)?;
296 let mut recorded = 0;
297 for entity in &plan.entities {
298 let key = ReplicationTrackKey {
299 client_id,
300 entity: *entity,
301 };
302 self.records.insert(
303 key,
304 ReplicationTrackRecord {
305 client_id,
306 entity: *entity,
307 last_sent: sent_at,
308 last_acked: None,
309 },
310 );
311 recorded += 1;
312 }
313 self.refresh_entry_count();
314 self.stats.sent_records = self.stats.sent_records.saturating_add(recorded);
315 Ok(recorded)
316 }
317
318 pub fn acknowledge(
320 &mut self,
321 client_id: ClientId,
322 entity: EntityHandle,
323 acked_at: Tick,
324 ) -> bool {
325 let Some(record) = self
326 .records
327 .get_mut(&ReplicationTrackKey { client_id, entity })
328 else {
329 return false;
330 };
331 record.last_acked = Some(acked_at);
332 self.stats.acked_records = self.stats.acked_records.saturating_add(1);
333 true
334 }
335
336 pub fn acknowledge_plan(
338 &mut self,
339 client_id: ClientId,
340 plan: &ReplicationPlan,
341 acked_at: Tick,
342 ) -> usize {
343 plan.entities
344 .iter()
345 .filter(|entity| self.acknowledge(client_id, **entity, acked_at))
346 .count()
347 }
348
349 pub fn clear_client(&mut self, client_id: ClientId) -> usize {
351 let before = self.records.len();
352 self.records.retain(|key, _| key.client_id != client_id);
353 let pruned = before.saturating_sub(self.records.len());
354 self.stats.pruned_records = self.stats.pruned_records.saturating_add(pruned);
355 self.refresh_entry_count();
356 pruned
357 }
358
359 pub fn prune_sent_before(&mut self, older_than: Tick) -> usize {
361 let before = self.records.len();
362 self.records
363 .retain(|_, record| record.last_sent.get() >= older_than.get());
364 let pruned = before.saturating_sub(self.records.len());
365 self.stats.pruned_records = self.stats.pruned_records.saturating_add(pruned);
366 self.refresh_entry_count();
367 pruned
368 }
369
370 fn ensure_capacity_for(
371 &self,
372 client_id: ClientId,
373 entities: &[EntityHandle],
374 ) -> Result<(), ReplicationTrackerError> {
375 let mut needed = 0_usize;
376 for entity in entities {
377 if !self.records.contains_key(&ReplicationTrackKey {
378 client_id,
379 entity: *entity,
380 }) {
381 needed = needed.saturating_add(1);
382 }
383 }
384 if self.records.len().saturating_add(needed) > self.config.max_entries {
385 return Err(ReplicationTrackerError::CapacityExceeded {
386 current: self.records.len(),
387 needed,
388 max: self.config.max_entries,
389 });
390 }
391 Ok(())
392 }
393
394 fn refresh_entry_count(&mut self) {
395 self.stats.entries = self.records.len();
396 }
397}
398
399#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
401pub struct ReplicationStats {
402 pub candidates: usize,
404 pub considered: usize,
406 pub selected: usize,
408 pub skipped_by_budget: usize,
410 pub skipped_by_cadence: usize,
412 pub estimated_bytes: usize,
414}
415
416#[derive(Clone, Copy, Debug, Default)]
418pub struct ReplicationCadence;
419
420impl ReplicationCadence {
421 pub fn target_hz(policy: &CompiledSyncPolicy, distance_squared: f32) -> u16 {
423 let min_hz = policy.min_hz.max(1);
424 let max_hz = policy.max_hz.max(min_hz);
425 let radius_squared = policy.interest_radius * policy.interest_radius;
426 let closeness =
427 if radius_squared.is_finite() && radius_squared > 0.0 && distance_squared.is_finite() {
428 1.0 - (distance_squared / radius_squared).clamp(0.0, 1.0)
429 } else {
430 1.0
431 };
432 let span = f32::from(max_hz - min_hz);
433 let target = f32::from(min_hz) + span * closeness;
434 rounded_frequency_to_u16(target, min_hz, max_hz)
435 }
436
437 pub fn interval_ticks(
439 policy: &CompiledSyncPolicy,
440 station_tick_rate_hz: u16,
441 distance_squared: f32,
442 ) -> u64 {
443 let tick_rate = u64::from(station_tick_rate_hz.max(1));
444 let target_hz = u64::from(Self::target_hz(policy, distance_squared).max(1));
445 tick_rate.div_ceil(target_hz).max(1)
446 }
447
448 pub fn should_send(
450 policy: &CompiledSyncPolicy,
451 station_tick_rate_hz: u16,
452 distance_squared: f32,
453 now: Tick,
454 last_sent: Option<Tick>,
455 ) -> bool {
456 let Some(last_sent) = last_sent else {
457 return true;
458 };
459 let interval = Self::interval_ticks(policy, station_tick_rate_hz, distance_squared);
460 now.get().saturating_sub(last_sent.get()) >= interval
461 }
462}
463
464#[derive(Clone, Copy, Debug, Default)]
466pub struct ReplicationPriority;
467
468#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
469fn rounded_frequency_to_u16(target: f32, min_hz: u16, max_hz: u16) -> u16 {
470 let bounded = target.round().clamp(f32::from(min_hz), f32::from(max_hz));
471 bounded as u16
472}
473
474#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
475fn normalized_score_to_u64(closeness: f32) -> u64 {
476 debug_assert!(closeness.is_finite() && (0.0..=1.0).contains(&closeness));
477 (closeness * 1_000_000.0).round() as u64
478}
479
480impl ReplicationPriority {
481 pub fn score(policy: &CompiledSyncPolicy, distance_squared: f32) -> u64 {
483 let weight = u64::from(policy.priority_weight.max(1));
484 let radius_squared = policy.interest_radius * policy.interest_radius;
485 let distance_score =
486 if radius_squared.is_finite() && radius_squared > 0.0 && distance_squared.is_finite() {
487 let closeness = 1.0 - (distance_squared / radius_squared).clamp(0.0, 1.0);
488 normalized_score_to_u64(closeness)
489 } else {
490 1_000_000
491 };
492 weight
493 .saturating_mul(1_000_000)
494 .saturating_add(distance_score)
495 }
496}
497
498#[derive(Clone, Copy, Debug, PartialEq)]
499struct PrioritizedReplicationCandidate {
500 handle: EntityHandle,
501 score: u64,
502 distance_squared: f32,
503}
504
505#[derive(Clone, Debug, Default)]
507pub struct ReplicationScratch {
508 cell_query: CellQueryScratch,
509 prioritized: Vec<PrioritizedReplicationCandidate>,
510}
511
512impl ReplicationScratch {
513 pub fn clear(&mut self) {
515 self.cell_query.clear();
516 self.prioritized.clear();
517 }
518
519 pub fn candidate_count(&self) -> usize {
521 self.cell_query.len()
522 }
523
524 pub fn prioritized_capacity(&self) -> usize {
526 self.prioritized.capacity()
527 }
528
529 pub const fn query_stats(&self) -> CellQueryStats {
531 self.cell_query.stats()
532 }
533
534 pub fn candidate_capacity(&self) -> usize {
536 self.cell_query.handle_capacity()
537 }
538
539 pub fn candidate_dedup_capacity(&self) -> usize {
541 self.cell_query.dedup_capacity()
542 }
543
544 pub fn matching_cell_capacity(&self) -> usize {
546 self.cell_query.matching_cell_capacity()
547 }
548}
549
550#[derive(Clone, Copy, Debug, Default)]
552pub struct ReplicationPlanner;
553
554impl ReplicationPlanner {
555 pub fn plan_for_viewer<F: VisibilityFilter>(
557 station: &Station,
558 index: &CellIndex,
559 policies: &PolicyTable,
560 viewer: &ViewerQuery,
561 filter: &F,
562 budget: ReplicationBudget,
563 ) -> ReplicationPlan {
564 let candidates = index.query_sphere(viewer.position, viewer.radius);
565 Self::plan_for_candidates_inner(
566 station,
567 &candidates,
568 policies,
569 viewer,
570 filter,
571 budget,
572 |_, _, _| true,
573 )
574 }
575
576 pub fn plan_for_viewer_with_scratch<F: VisibilityFilter>(
578 station: &Station,
579 index: &CellIndex,
580 policies: &PolicyTable,
581 viewer: &ViewerQuery,
582 filter: &F,
583 budget: ReplicationBudget,
584 scratch: &mut ReplicationScratch,
585 ) -> ReplicationPlan {
586 let candidates =
587 index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
588 Self::plan_for_candidates_inner(
589 station,
590 candidates,
591 policies,
592 viewer,
593 filter,
594 budget,
595 |_, _, _| true,
596 )
597 }
598
599 pub fn plan_for_viewers_with_scratch<F: VisibilityFilter>(
601 station: &Station,
602 index: &CellIndex,
603 policies: &PolicyTable,
604 viewers: &[ViewerQuery],
605 filter: &F,
606 budget: ReplicationBudget,
607 scratch: &mut ReplicationScratch,
608 ) -> ReplicationBatchResult {
609 let mut batch = ReplicationBatchResult {
610 plans: Vec::with_capacity(viewers.len()),
611 stats: ReplicationBatchStats::default(),
612 };
613 for viewer in viewers {
614 let plan = Self::plan_for_viewer_with_scratch(
615 station, index, policies, viewer, filter, budget, scratch,
616 );
617 batch.stats.record(&plan, scratch);
618 batch.plans.push(plan);
619 }
620 batch
621 }
622
623 pub fn plan_for_viewer_range_with_scratch(
628 station: &Station,
629 index: &CellIndex,
630 policies: &PolicyTable,
631 viewer: &ViewerQuery,
632 budget: ReplicationBudget,
633 scratch: &mut ReplicationScratch,
634 ) -> ReplicationPlan {
635 let candidates =
636 index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
637 Self::plan_for_range_candidates(station, candidates, policies, viewer, budget)
638 }
639
640 pub fn plan_for_viewers_range_with_scratch(
642 station: &Station,
643 index: &CellIndex,
644 policies: &PolicyTable,
645 viewers: &[ViewerQuery],
646 budget: ReplicationBudget,
647 scratch: &mut ReplicationScratch,
648 ) -> ReplicationBatchResult {
649 let mut batch = ReplicationBatchResult {
650 plans: Vec::with_capacity(viewers.len()),
651 stats: ReplicationBatchStats::default(),
652 };
653 for viewer in viewers {
654 let plan = Self::plan_for_viewer_range_with_scratch(
655 station, index, policies, viewer, budget, scratch,
656 );
657 batch.stats.record(&plan, scratch);
658 batch.plans.push(plan);
659 }
660 batch
661 }
662
663 pub fn plan_for_viewer_with_cadence<F, L>(
665 station: &Station,
666 index: &CellIndex,
667 policies: &PolicyTable,
668 viewer: &ViewerQuery,
669 filter: &F,
670 budget: ReplicationBudget,
671 last_sent: L,
672 ) -> ReplicationPlan
673 where
674 F: VisibilityFilter,
675 L: Fn(EntityHandle) -> Option<Tick>,
676 {
677 let tick_rate_hz = station.config().tick_rate_hz;
678 let now = station.tick();
679 let candidates = index.query_sphere(viewer.position, viewer.radius);
680 Self::plan_for_candidates_inner(
681 station,
682 &candidates,
683 policies,
684 viewer,
685 filter,
686 budget,
687 |handle, policy, distance_squared| {
688 ReplicationCadence::should_send(
689 policy,
690 tick_rate_hz,
691 distance_squared,
692 now,
693 last_sent(handle),
694 )
695 },
696 )
697 }
698
699 #[allow(clippy::too_many_arguments)]
701 pub fn plan_for_viewer_with_cadence_and_scratch<F, L>(
702 station: &Station,
703 index: &CellIndex,
704 policies: &PolicyTable,
705 viewer: &ViewerQuery,
706 filter: &F,
707 budget: ReplicationBudget,
708 last_sent: L,
709 scratch: &mut ReplicationScratch,
710 ) -> ReplicationPlan
711 where
712 F: VisibilityFilter,
713 L: Fn(EntityHandle) -> Option<Tick>,
714 {
715 let tick_rate_hz = station.config().tick_rate_hz;
716 let now = station.tick();
717 let candidates =
718 index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
719 Self::plan_for_candidates_inner(
720 station,
721 candidates,
722 policies,
723 viewer,
724 filter,
725 budget,
726 |handle, policy, distance_squared| {
727 ReplicationCadence::should_send(
728 policy,
729 tick_rate_hz,
730 distance_squared,
731 now,
732 last_sent(handle),
733 )
734 },
735 )
736 }
737
738 pub fn plan_for_viewer_prioritized<F: VisibilityFilter>(
740 station: &Station,
741 index: &CellIndex,
742 policies: &PolicyTable,
743 viewer: &ViewerQuery,
744 filter: &F,
745 budget: ReplicationBudget,
746 ) -> ReplicationPlan {
747 let candidates = index.query_sphere(viewer.position, viewer.radius);
748 let mut prioritized = Vec::new();
749 Self::plan_for_candidates_prioritized_inner(
750 station,
751 &candidates,
752 policies,
753 viewer,
754 filter,
755 budget,
756 &mut prioritized,
757 |_, _, _| true,
758 )
759 }
760
761 pub fn plan_for_viewer_prioritized_with_scratch<F: VisibilityFilter>(
763 station: &Station,
764 index: &CellIndex,
765 policies: &PolicyTable,
766 viewer: &ViewerQuery,
767 filter: &F,
768 budget: ReplicationBudget,
769 scratch: &mut ReplicationScratch,
770 ) -> ReplicationPlan {
771 let candidates =
772 index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
773 Self::plan_for_candidates_prioritized_inner(
774 station,
775 candidates,
776 policies,
777 viewer,
778 filter,
779 budget,
780 &mut scratch.prioritized,
781 |_, _, _| true,
782 )
783 }
784
785 pub fn plan_for_viewer_prioritized_with_cadence<F, L>(
787 station: &Station,
788 index: &CellIndex,
789 policies: &PolicyTable,
790 viewer: &ViewerQuery,
791 filter: &F,
792 budget: ReplicationBudget,
793 last_sent: L,
794 ) -> ReplicationPlan
795 where
796 F: VisibilityFilter,
797 L: Fn(EntityHandle) -> Option<Tick>,
798 {
799 let tick_rate_hz = station.config().tick_rate_hz;
800 let now = station.tick();
801 let candidates = index.query_sphere(viewer.position, viewer.radius);
802 let mut prioritized = Vec::new();
803 Self::plan_for_candidates_prioritized_inner(
804 station,
805 &candidates,
806 policies,
807 viewer,
808 filter,
809 budget,
810 &mut prioritized,
811 |handle, policy, distance_squared| {
812 ReplicationCadence::should_send(
813 policy,
814 tick_rate_hz,
815 distance_squared,
816 now,
817 last_sent(handle),
818 )
819 },
820 )
821 }
822
823 #[allow(clippy::too_many_arguments)]
825 pub fn plan_for_viewer_prioritized_with_cadence_and_scratch<F, L>(
826 station: &Station,
827 index: &CellIndex,
828 policies: &PolicyTable,
829 viewer: &ViewerQuery,
830 filter: &F,
831 budget: ReplicationBudget,
832 last_sent: L,
833 scratch: &mut ReplicationScratch,
834 ) -> ReplicationPlan
835 where
836 F: VisibilityFilter,
837 L: Fn(EntityHandle) -> Option<Tick>,
838 {
839 let tick_rate_hz = station.config().tick_rate_hz;
840 let now = station.tick();
841 let candidates =
842 index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
843 Self::plan_for_candidates_prioritized_inner(
844 station,
845 candidates,
846 policies,
847 viewer,
848 filter,
849 budget,
850 &mut scratch.prioritized,
851 |handle, policy, distance_squared| {
852 ReplicationCadence::should_send(
853 policy,
854 tick_rate_hz,
855 distance_squared,
856 now,
857 last_sent(handle),
858 )
859 },
860 )
861 }
862
863 fn plan_for_candidates_inner<F, C>(
864 station: &Station,
865 candidates: &[EntityHandle],
866 policies: &PolicyTable,
867 viewer: &ViewerQuery,
868 filter: &F,
869 budget: ReplicationBudget,
870 cadence_allows: C,
871 ) -> ReplicationPlan
872 where
873 F: VisibilityFilter,
874 C: Fn(EntityHandle, &CompiledSyncPolicy, f32) -> bool,
875 {
876 let max_entities = viewer.max_entities.min(budget.max_entities);
877 let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
878 let hard_limit = max_entities.min(max_by_bytes);
879
880 let mut plan = ReplicationPlan {
881 entities: Vec::with_capacity(hard_limit),
882 stats: ReplicationStats {
883 candidates: candidates.len(),
884 ..ReplicationStats::default()
885 },
886 };
887
888 for handle in candidates {
889 let Some(entity) = station.get(*handle) else {
890 continue;
891 };
892 plan.stats.considered += 1;
893
894 let Some(policy) = policies.get(entity.policy_id) else {
895 continue;
896 };
897 let distance_squared = entity.position.distance_squared(viewer.position);
898 let policy_radius_sq = policy.interest_radius * policy.interest_radius;
899 if distance_squared > policy_radius_sq {
900 continue;
901 }
902 if !filter.is_visible_with_distance(viewer, entity, distance_squared) {
903 continue;
904 }
905 if !cadence_allows(*handle, policy, distance_squared) {
906 plan.stats.skipped_by_cadence += 1;
907 continue;
908 }
909
910 if plan.entities.len() >= hard_limit {
911 plan.stats.skipped_by_budget += 1;
912 continue;
913 }
914
915 plan.entities.push(*handle);
916 }
917
918 plan.stats.selected = plan.entities.len();
919 plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
920 plan
921 }
922
923 #[cfg(not(feature = "simd"))]
924 fn plan_for_range_candidates(
925 station: &Station,
926 candidates: &[EntityHandle],
927 policies: &PolicyTable,
928 viewer: &ViewerQuery,
929 budget: ReplicationBudget,
930 ) -> ReplicationPlan {
931 Self::plan_for_candidates_inner(
932 station,
933 candidates,
934 policies,
935 viewer,
936 &RangeOnlyVisibility,
937 budget,
938 |_, _, _| true,
939 )
940 }
941
942 #[cfg(feature = "simd")]
943 fn plan_for_range_candidates(
944 station: &Station,
945 candidates: &[EntityHandle],
946 policies: &PolicyTable,
947 viewer: &ViewerQuery,
948 budget: ReplicationBudget,
949 ) -> ReplicationPlan {
950 use wide::{CmpLe, f32x8};
951
952 const LANES: usize = 8;
953 let max_entities = viewer.max_entities.min(budget.max_entities);
954 let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
955 let hard_limit = max_entities.min(max_by_bytes);
956 let mut plan = ReplicationPlan {
957 entities: Vec::with_capacity(hard_limit),
958 stats: ReplicationStats {
959 candidates: candidates.len(),
960 ..ReplicationStats::default()
961 },
962 };
963 let viewer_radius_squared = viewer.radius_squared();
964
965 for handles in candidates.chunks(LANES) {
966 let mut distance_squared = [f32::NAN; LANES];
967 let mut policy_radius_squared = [f32::NAN; LANES];
968 let mut valid_lanes = 0_u8;
969
970 for (lane, handle) in handles.iter().copied().enumerate() {
971 let Some(entity) = station.get(handle) else {
972 continue;
973 };
974 plan.stats.considered = plan.stats.considered.saturating_add(1);
975 let Some(policy) = policies.get(entity.policy_id) else {
976 continue;
977 };
978 distance_squared[lane] = entity.position.distance_squared(viewer.position);
979 policy_radius_squared[lane] = policy.interest_radius * policy.interest_radius;
980 valid_lanes |= 1 << lane;
981 }
982
983 let visible_lanes = u8::try_from(
984 (f32x8::new(distance_squared).cmp_le(f32x8::new(policy_radius_squared))
985 & f32x8::new(distance_squared).cmp_le(f32x8::splat(viewer_radius_squared)))
986 .move_mask(),
987 )
988 .expect("eight-lane SIMD mask fits u8")
989 & valid_lanes;
990
991 for (lane, handle) in handles.iter().copied().enumerate() {
992 if visible_lanes & (1 << lane) == 0 {
993 continue;
994 }
995 if plan.entities.len() >= hard_limit {
996 plan.stats.skipped_by_budget = plan.stats.skipped_by_budget.saturating_add(1);
997 } else {
998 plan.entities.push(handle);
999 }
1000 }
1001 }
1002
1003 plan.stats.selected = plan.entities.len();
1004 plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
1005 plan
1006 }
1007
1008 #[allow(clippy::too_many_arguments)]
1009 fn plan_for_candidates_prioritized_inner<F, C>(
1010 station: &Station,
1011 candidates: &[EntityHandle],
1012 policies: &PolicyTable,
1013 viewer: &ViewerQuery,
1014 filter: &F,
1015 budget: ReplicationBudget,
1016 eligible: &mut Vec<PrioritizedReplicationCandidate>,
1017 cadence_allows: C,
1018 ) -> ReplicationPlan
1019 where
1020 F: VisibilityFilter,
1021 C: Fn(EntityHandle, &CompiledSyncPolicy, f32) -> bool,
1022 {
1023 let max_entities = viewer.max_entities.min(budget.max_entities);
1024 let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
1025 let hard_limit = max_entities.min(max_by_bytes);
1026 let mut plan = ReplicationPlan {
1027 entities: Vec::with_capacity(hard_limit),
1028 stats: ReplicationStats {
1029 candidates: candidates.len(),
1030 ..ReplicationStats::default()
1031 },
1032 };
1033 eligible.clear();
1034
1035 for handle in candidates {
1036 let Some(entity) = station.get(*handle) else {
1037 continue;
1038 };
1039 plan.stats.considered += 1;
1040
1041 let Some(policy) = policies.get(entity.policy_id) else {
1042 continue;
1043 };
1044 let distance_squared = entity.position.distance_squared(viewer.position);
1045 let policy_radius_sq = policy.interest_radius * policy.interest_radius;
1046 if distance_squared > policy_radius_sq {
1047 continue;
1048 }
1049 if !filter.is_visible_with_distance(viewer, entity, distance_squared) {
1050 continue;
1051 }
1052 if !cadence_allows(*handle, policy, distance_squared) {
1053 plan.stats.skipped_by_cadence += 1;
1054 continue;
1055 }
1056
1057 eligible.push(PrioritizedReplicationCandidate {
1058 handle: *handle,
1059 score: ReplicationPriority::score(policy, distance_squared),
1060 distance_squared,
1061 });
1062 }
1063
1064 eligible.sort_by(|left, right| {
1065 right
1066 .score
1067 .cmp(&left.score)
1068 .then_with(|| left.distance_squared.total_cmp(&right.distance_squared))
1069 .then_with(|| left.handle.cmp(&right.handle))
1070 });
1071
1072 plan.stats.skipped_by_budget = eligible.len().saturating_sub(hard_limit);
1073 plan.entities.extend(
1074 eligible
1075 .iter()
1076 .take(hard_limit)
1077 .map(|candidate| candidate.handle),
1078 );
1079 plan.stats.selected = plan.entities.len();
1080 plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
1081 plan
1082 }
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use super::*;
1088 use crate::entity::EntityTags;
1089 use crate::ids::{ClientId, EntityId, InstanceId, NodeId, PolicyId, StationId};
1090 use crate::interest::{AndVisibility, FrustumVisibility, RangeOnlyVisibility, TagVisibility};
1091 use crate::policy::CompiledSyncPolicy;
1092 use crate::spatial::{Aabb3, Bounds, Frustum3, GridSpec, Position3};
1093 use crate::station::{Station, StationConfig};
1094
1095 #[test]
1096 fn planner_applies_composed_frustum_visibility_filter() {
1097 let mut station = Station::new(StationConfig {
1098 station_id: StationId::new(1),
1099 node_id: NodeId::new(1),
1100 instance_id: InstanceId::new(1),
1101 tick_rate_hz: 20,
1102 });
1103 let grid = GridSpec::new(16.0).expect("grid is valid");
1104 let mut index = CellIndex::new(grid);
1105 let mut policies = PolicyTable::default();
1106 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1107
1108 let visible = station
1109 .spawn_owned(
1110 EntityId::new(1),
1111 Position3::new(10.0, 0.0, 0.0),
1112 Bounds::Point,
1113 PolicyId::new(1),
1114 )
1115 .expect("spawn visible");
1116 let outside_frustum = station
1117 .spawn_owned(
1118 EntityId::new(2),
1119 Position3::new(-10.0, 0.0, 0.0),
1120 Bounds::Point,
1121 PolicyId::new(1),
1122 )
1123 .expect("spawn outside frustum");
1124 index.upsert(visible, Position3::new(10.0, 0.0, 0.0), Bounds::Point);
1125 index.upsert(
1126 outside_frustum,
1127 Position3::new(-10.0, 0.0, 0.0),
1128 Bounds::Point,
1129 );
1130
1131 let viewer = ViewerQuery {
1132 client_id: ClientId::new(7),
1133 position: Position3::new(0.0, 0.0, 0.0),
1134 radius: 128.0,
1135 max_entities: 8,
1136 };
1137 let frustum = Frustum3::from_aabb(Aabb3::new(
1138 Position3::new(0.0, -20.0, -20.0),
1139 Position3::new(80.0, 20.0, 20.0),
1140 ));
1141 let filter = AndVisibility::new(RangeOnlyVisibility, FrustumVisibility::new(frustum));
1142
1143 let plan = ReplicationPlanner::plan_for_viewer(
1144 &station,
1145 &index,
1146 &policies,
1147 &viewer,
1148 &filter,
1149 ReplicationBudget::default(),
1150 );
1151
1152 assert_eq!(plan.entities, vec![visible]);
1153 assert_eq!(plan.stats.selected, 1);
1154 assert_eq!(plan.stats.considered, 2);
1155 }
1156
1157 #[test]
1158 fn planner_applies_tag_visibility_filter() {
1159 let mut station = Station::new(StationConfig {
1160 station_id: StationId::new(1),
1161 node_id: NodeId::new(1),
1162 instance_id: InstanceId::new(1),
1163 tick_rate_hz: 20,
1164 });
1165 let grid = GridSpec::new(16.0).expect("grid is valid");
1166 let mut index = CellIndex::new(grid);
1167 let mut policies = PolicyTable::default();
1168 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1169
1170 let static_visible = station
1171 .spawn_owned(
1172 EntityId::new(1),
1173 Position3::new(10.0, 0.0, 0.0),
1174 Bounds::Point,
1175 PolicyId::new(1),
1176 )
1177 .expect("spawn static");
1178 let fast_mover = station
1179 .spawn_owned(
1180 EntityId::new(2),
1181 Position3::new(12.0, 0.0, 0.0),
1182 Bounds::Point,
1183 PolicyId::new(1),
1184 )
1185 .expect("spawn mover");
1186 station
1187 .set_tags(static_visible, EntityTags::from_bits(0b001))
1188 .expect("tag static");
1189 station
1190 .set_tags(fast_mover, EntityTags::from_bits(0b010))
1191 .expect("tag mover");
1192 index.upsert(
1193 static_visible,
1194 Position3::new(10.0, 0.0, 0.0),
1195 Bounds::Point,
1196 );
1197 index.upsert(fast_mover, Position3::new(12.0, 0.0, 0.0), Bounds::Point);
1198
1199 let viewer = ViewerQuery {
1200 client_id: ClientId::new(7),
1201 position: Position3::new(0.0, 0.0, 0.0),
1202 radius: 128.0,
1203 max_entities: 8,
1204 };
1205 let filter = AndVisibility::new(
1206 RangeOnlyVisibility,
1207 TagVisibility::new(EntityTags::from_bits(0b001), EntityTags::from_bits(0b010)),
1208 );
1209
1210 let plan = ReplicationPlanner::plan_for_viewer(
1211 &station,
1212 &index,
1213 &policies,
1214 &viewer,
1215 &filter,
1216 ReplicationBudget::default(),
1217 );
1218
1219 assert_eq!(plan.entities, vec![static_visible]);
1220 assert_eq!(plan.stats.selected, 1);
1221 assert_eq!(plan.stats.considered, 2);
1222 }
1223
1224 #[test]
1225 fn range_batch_matches_ordered_scalar_plans() {
1226 let mut station = Station::new(StationConfig {
1227 station_id: StationId::new(1),
1228 node_id: NodeId::new(1),
1229 instance_id: InstanceId::new(1),
1230 tick_rate_hz: 128,
1231 });
1232 let grid = GridSpec::new(16.0).expect("grid is valid");
1233 let mut index = CellIndex::new(grid);
1234 let mut policies = PolicyTable::default();
1235 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 128, 96.0));
1236 for entity_index in 0_u16..24 {
1237 let position = Position3::new(f32::from(entity_index) * 8.0 - 64.0, 0.0, 0.0);
1238 let handle = station
1239 .spawn_owned(
1240 EntityId::new(u64::from(entity_index)),
1241 position,
1242 Bounds::Point,
1243 PolicyId::new(1),
1244 )
1245 .expect("entity id is unique");
1246 index.upsert(handle, position, Bounds::Point);
1247 }
1248 let viewers = [
1249 ViewerQuery {
1250 client_id: ClientId::new(1),
1251 position: Position3::new(0.0, 0.0, 0.0),
1252 radius: 80.0,
1253 max_entities: 32,
1254 },
1255 ViewerQuery {
1256 client_id: ClientId::new(2),
1257 position: Position3::new(48.0, 0.0, 0.0),
1258 radius: 48.0,
1259 max_entities: 8,
1260 },
1261 ];
1262 let mut scalar_scratch = ReplicationScratch::default();
1263 let expected = viewers
1264 .iter()
1265 .map(|viewer| {
1266 ReplicationPlanner::plan_for_viewer_with_scratch(
1267 &station,
1268 &index,
1269 &policies,
1270 viewer,
1271 &RangeOnlyVisibility,
1272 ReplicationBudget::default(),
1273 &mut scalar_scratch,
1274 )
1275 })
1276 .collect::<Vec<_>>();
1277
1278 let mut batch_scratch = ReplicationScratch::default();
1279 let batch = ReplicationPlanner::plan_for_viewers_range_with_scratch(
1280 &station,
1281 &index,
1282 &policies,
1283 &viewers,
1284 ReplicationBudget::default(),
1285 &mut batch_scratch,
1286 );
1287
1288 assert_eq!(batch.plans, expected);
1289 assert_eq!(batch.stats.viewers, viewers.len());
1290 assert_eq!(
1291 batch.stats.selected,
1292 expected.iter().map(|plan| plan.stats.selected).sum()
1293 );
1294 assert_eq!(
1295 batch.stats.grid_queries + batch.stats.occupied_queries,
1296 viewers.len()
1297 );
1298 }
1299
1300 #[test]
1301 fn range_batch_preserves_scalar_nan_radius_semantics() {
1302 let mut station = Station::new(StationConfig {
1303 station_id: StationId::new(1),
1304 node_id: NodeId::new(1),
1305 instance_id: InstanceId::new(1),
1306 tick_rate_hz: 128,
1307 });
1308 let mut policies = PolicyTable::default();
1309 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 128, 96.0));
1310 let handle = station
1311 .spawn_owned(
1312 EntityId::new(1),
1313 Position3::new(1.0, 2.0, 3.0),
1314 Bounds::Point,
1315 PolicyId::new(1),
1316 )
1317 .expect("spawn entity");
1318 let viewer = ViewerQuery {
1319 client_id: ClientId::new(1),
1320 position: Position3::new(0.0, 0.0, 0.0),
1321 radius: f32::NAN,
1322 max_entities: 8,
1323 };
1324 let candidates = [handle];
1325 let scalar = ReplicationPlanner::plan_for_candidates_inner(
1326 &station,
1327 &candidates,
1328 &policies,
1329 &viewer,
1330 &RangeOnlyVisibility,
1331 ReplicationBudget::default(),
1332 |_, _, _| true,
1333 );
1334 let range = ReplicationPlanner::plan_for_range_candidates(
1335 &station,
1336 &candidates,
1337 &policies,
1338 &viewer,
1339 ReplicationBudget::default(),
1340 );
1341
1342 assert!(scalar.entities.is_empty());
1343 assert_eq!(range, scalar);
1344 }
1345
1346 #[test]
1347 fn cadence_scales_interval_by_squared_distance() {
1348 let policy = CompiledSyncPolicy::new(PolicyId::new(1), 2, 20, 100.0);
1349
1350 assert_eq!(ReplicationCadence::target_hz(&policy, 0.0), 20);
1351 assert_eq!(ReplicationCadence::interval_ticks(&policy, 20, 0.0), 1);
1352 assert_eq!(ReplicationCadence::target_hz(&policy, 100.0_f32 * 100.0), 2);
1353 assert_eq!(
1354 ReplicationCadence::interval_ticks(&policy, 20, 100.0_f32 * 100.0),
1355 10
1356 );
1357 }
1358
1359 #[test]
1360 fn priority_score_prefers_weight_then_distance() {
1361 let mut low = CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 100.0);
1362 low.priority_weight = 1;
1363 let mut high = CompiledSyncPolicy::new(PolicyId::new(2), 1, 20, 100.0);
1364 high.priority_weight = 10;
1365
1366 assert!(
1367 ReplicationPriority::score(&high, 90.0 * 90.0) > ReplicationPriority::score(&low, 0.0)
1368 );
1369 assert!(
1370 ReplicationPriority::score(&low, 0.0) > ReplicationPriority::score(&low, 90.0 * 90.0)
1371 );
1372 }
1373
1374 #[test]
1375 fn planner_with_cadence_skips_recent_far_entities() {
1376 let mut station = Station::new(StationConfig {
1377 station_id: StationId::new(1),
1378 node_id: NodeId::new(1),
1379 instance_id: InstanceId::new(1),
1380 tick_rate_hz: 20,
1381 });
1382 for _ in 0..10 {
1383 station.advance_tick();
1384 }
1385 let grid = GridSpec::new(16.0).expect("grid is valid");
1386 let mut index = CellIndex::new(grid);
1387 let mut policies = PolicyTable::default();
1388 policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 2, 20, 128.0));
1389
1390 let near = station
1391 .spawn_owned(
1392 EntityId::new(1),
1393 Position3::new(0.0, 0.0, 0.0),
1394 Bounds::Point,
1395 PolicyId::new(1),
1396 )
1397 .expect("spawn near");
1398 let far = station
1399 .spawn_owned(
1400 EntityId::new(2),
1401 Position3::new(120.0, 0.0, 0.0),
1402 Bounds::Point,
1403 PolicyId::new(1),
1404 )
1405 .expect("spawn far");
1406 index.upsert(near, Position3::new(0.0, 0.0, 0.0), Bounds::Point);
1407 index.upsert(far, Position3::new(120.0, 0.0, 0.0), Bounds::Point);
1408
1409 let viewer = ViewerQuery {
1410 client_id: ClientId::new(7),
1411 position: Position3::new(0.0, 0.0, 0.0),
1412 radius: 128.0,
1413 max_entities: 8,
1414 };
1415 let plan = ReplicationPlanner::plan_for_viewer_with_cadence(
1416 &station,
1417 &index,
1418 &policies,
1419 &viewer,
1420 &RangeOnlyVisibility,
1421 ReplicationBudget::default(),
1422 |_| Some(Tick::new(9)),
1423 );
1424
1425 assert_eq!(plan.entities, vec![near]);
1426 assert_eq!(plan.stats.selected, 1);
1427 assert_eq!(plan.stats.skipped_by_cadence, 1);
1428 }
1429
1430 #[test]
1431 fn prioritized_planner_uses_policy_weight_under_budget() {
1432 let mut station = Station::new(StationConfig {
1433 station_id: StationId::new(1),
1434 node_id: NodeId::new(1),
1435 instance_id: InstanceId::new(1),
1436 tick_rate_hz: 20,
1437 });
1438 let grid = GridSpec::new(16.0).expect("grid is valid");
1439 let mut index = CellIndex::new(grid);
1440 let mut policies = PolicyTable::default();
1441 let mut low = CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0);
1442 low.priority_weight = 1;
1443 let mut high = CompiledSyncPolicy::new(PolicyId::new(2), 1, 20, 128.0);
1444 high.priority_weight = 10;
1445 policies.set(low);
1446 policies.set(high);
1447
1448 let near_low = station
1449 .spawn_owned(
1450 EntityId::new(1),
1451 Position3::new(0.0, 0.0, 0.0),
1452 Bounds::Point,
1453 PolicyId::new(1),
1454 )
1455 .expect("spawn near low priority");
1456 let far_high = station
1457 .spawn_owned(
1458 EntityId::new(2),
1459 Position3::new(96.0, 0.0, 0.0),
1460 Bounds::Point,
1461 PolicyId::new(2),
1462 )
1463 .expect("spawn far high priority");
1464 index.upsert(near_low, Position3::new(0.0, 0.0, 0.0), Bounds::Point);
1465 index.upsert(far_high, Position3::new(96.0, 0.0, 0.0), Bounds::Point);
1466
1467 let viewer = ViewerQuery {
1468 client_id: ClientId::new(7),
1469 position: Position3::new(0.0, 0.0, 0.0),
1470 radius: 128.0,
1471 max_entities: 1,
1472 };
1473 let plan = ReplicationPlanner::plan_for_viewer_prioritized(
1474 &station,
1475 &index,
1476 &policies,
1477 &viewer,
1478 &RangeOnlyVisibility,
1479 ReplicationBudget {
1480 max_entities: 1,
1481 max_bytes: 32,
1482 estimated_entity_bytes: 32,
1483 },
1484 );
1485
1486 assert_eq!(plan.entities, vec![far_high]);
1487 assert_eq!(plan.stats.selected, 1);
1488 assert_eq!(plan.stats.skipped_by_budget, 1);
1489
1490 let mut scratch = ReplicationScratch::default();
1491 let scratch_plan = ReplicationPlanner::plan_for_viewer_prioritized_with_scratch(
1492 &station,
1493 &index,
1494 &policies,
1495 &viewer,
1496 &RangeOnlyVisibility,
1497 ReplicationBudget {
1498 max_entities: 1,
1499 max_bytes: 32,
1500 estimated_entity_bytes: 32,
1501 },
1502 &mut scratch,
1503 );
1504 assert_eq!(scratch_plan.entities, plan.entities);
1505 assert_eq!(scratch_plan.stats, plan.stats);
1506 assert_eq!(scratch.candidate_count(), 2);
1507 assert!(scratch.prioritized_capacity() >= 2);
1508 assert_eq!(scratch.query_stats().candidate_handles, 2);
1509 assert!(scratch.candidate_capacity() >= 2);
1510 assert!(scratch.candidate_dedup_capacity() >= 2);
1511 }
1512
1513 #[test]
1514 fn replication_tracker_records_sent_ack_and_prune() {
1515 let client_id = ClientId::new(7);
1516 let first = EntityHandle::new(1, 0);
1517 let second = EntityHandle::new(2, 0);
1518 let plan = ReplicationPlan {
1519 entities: vec![first, second],
1520 stats: ReplicationStats::default(),
1521 };
1522 let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig { max_entries: 4 });
1523
1524 let recorded = tracker
1525 .record_plan_sent(client_id, &plan, Tick::new(10))
1526 .expect("recording should fit");
1527 assert_eq!(recorded, 2);
1528 assert_eq!(tracker.last_sent(client_id, first), Some(Tick::new(10)));
1529 assert_eq!(tracker.stats().entries, 2);
1530 assert_eq!(tracker.stats().sent_records, 2);
1531
1532 assert!(tracker.acknowledge(client_id, first, Tick::new(11)));
1533 assert_eq!(
1534 tracker
1535 .get(client_id, first)
1536 .expect("tracked record")
1537 .last_acked,
1538 Some(Tick::new(11))
1539 );
1540 assert_eq!(tracker.stats().acked_records, 1);
1541
1542 assert_eq!(tracker.prune_sent_before(Tick::new(11)), 2);
1543 assert!(tracker.is_empty());
1544 assert_eq!(tracker.stats().pruned_records, 2);
1545 }
1546
1547 #[test]
1548 fn replication_tracker_rejects_capacity_without_partial_insert() {
1549 let client_id = ClientId::new(7);
1550 let plan = ReplicationPlan {
1551 entities: vec![EntityHandle::new(1, 0), EntityHandle::new(2, 0)],
1552 stats: ReplicationStats::default(),
1553 };
1554 let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig { max_entries: 1 });
1555
1556 let error = tracker
1557 .record_plan_sent(client_id, &plan, Tick::new(10))
1558 .expect_err("recording should exceed capacity");
1559
1560 assert_eq!(
1561 error,
1562 ReplicationTrackerError::CapacityExceeded {
1563 current: 0,
1564 needed: 2,
1565 max: 1,
1566 }
1567 );
1568 assert!(tracker.is_empty());
1569 assert_eq!(tracker.stats().sent_records, 0);
1570 }
1571}