Skip to main content

sectorsync_core/
replication.rs

1//! Replication planning helpers.
2
3use 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/// Per-client replication budget.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct ReplicationBudget {
16    /// Maximum entities to include in a frame.
17    pub max_entities: usize,
18    /// Estimated byte budget for a frame.
19    pub max_bytes: usize,
20    /// Estimated bytes charged per selected entity by simple planners.
21    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/// Replication planner result.
35#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct ReplicationPlan {
37    /// Selected entity handles.
38    pub entities: Vec<EntityHandle>,
39    /// Planner statistics.
40    pub stats: ReplicationStats,
41}
42
43/// Aggregated work and retained-capacity signals from one viewer batch.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub struct ReplicationBatchStats {
46    /// Viewer queries planned in input order.
47    pub viewers: usize,
48    /// Spatial candidates returned across all viewers.
49    pub candidates: usize,
50    /// Candidate records considered after stale-handle filtering.
51    pub considered: usize,
52    /// Entities selected across all plans.
53    pub selected: usize,
54    /// Estimated payload bytes across all plans.
55    pub estimated_bytes: usize,
56    /// Queries that probed the regular cell grid.
57    pub grid_queries: usize,
58    /// Queries that scanned occupied cells.
59    pub occupied_queries: usize,
60    /// Grid cells probed across the batch.
61    pub grid_cells_probed: usize,
62    /// Occupied cells scanned across the batch.
63    pub occupied_cells_scanned: usize,
64    /// Cells intersecting viewer query bounds across the batch.
65    pub matched_cells: usize,
66    /// Largest retained candidate-handle capacity.
67    pub candidate_capacity_max: usize,
68    /// Largest retained candidate-deduplication capacity.
69    pub dedup_capacity_max: usize,
70    /// Largest retained matched-cell capacity.
71    pub matching_cell_capacity_max: usize,
72    /// Largest retained priority candidate capacity.
73    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    /// Merges another deterministic batch partition into this report.
114    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/// Ordered plans and aggregate statistics produced for a viewer batch.
141#[derive(Clone, Debug, Default, PartialEq, Eq)]
142pub struct ReplicationBatchResult {
143    /// One plan per input viewer, retaining input order.
144    pub plans: Vec<ReplicationPlan>,
145    /// Aggregate work signals for the batch.
146    pub stats: ReplicationBatchStats,
147}
148
149/// Bounded per-client replication tracking configuration.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct ReplicationTrackerConfig {
152    /// Maximum tracked client/entity entries.
153    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/// Per-client/entity replication tracking key.
165#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
166pub struct ReplicationTrackKey {
167    /// Client that received the entity update.
168    pub client_id: ClientId,
169    /// Station-local entity handle selected by the planner.
170    pub entity: EntityHandle,
171}
172
173/// Per-client/entity replication tracking record.
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub struct ReplicationTrackRecord {
176    /// Client that received the entity update.
177    pub client_id: ClientId,
178    /// Station-local entity handle selected by the planner.
179    pub entity: EntityHandle,
180    /// Last tick where this entity was sent to the client.
181    pub last_sent: Tick,
182    /// Last tick where the caller confirmed delivery.
183    pub last_acked: Option<Tick>,
184}
185
186/// Replication tracker statistics.
187#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
188pub struct ReplicationTrackerStats {
189    /// Currently tracked entries.
190    pub entries: usize,
191    /// Total record insertions or updates.
192    pub sent_records: usize,
193    /// Total ACK updates applied.
194    pub acked_records: usize,
195    /// Records pruned by explicit cleanup.
196    pub pruned_records: usize,
197}
198
199/// Replication tracking error.
200#[derive(Clone, Copy, Debug, PartialEq, Eq)]
201pub enum ReplicationTrackerError {
202    /// Recording would exceed the configured entry capacity.
203    CapacityExceeded {
204        /// Entries currently tracked.
205        current: usize,
206        /// New entries needed for this operation.
207        needed: usize,
208        /// Maximum tracked entries.
209        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/// Bounded per-client replication send/ACK tracker.
231#[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    /// Creates an empty tracker.
246    pub fn new(config: ReplicationTrackerConfig) -> Self {
247        Self {
248            config,
249            records: BTreeMap::new(),
250            stats: ReplicationTrackerStats::default(),
251        }
252    }
253
254    /// Returns tracker configuration.
255    pub const fn config(&self) -> ReplicationTrackerConfig {
256        self.config
257    }
258
259    /// Returns tracker statistics.
260    pub const fn stats(&self) -> ReplicationTrackerStats {
261        self.stats
262    }
263
264    /// Returns tracked entry count.
265    pub fn len(&self) -> usize {
266        self.records.len()
267    }
268
269    /// Returns whether no entries are tracked.
270    pub fn is_empty(&self) -> bool {
271        self.records.is_empty()
272    }
273
274    /// Returns the last sent tick for a client/entity pair.
275    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    /// Returns a tracked record for a client/entity pair.
282    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    /// Records that a planned set of entities was sent to a client.
289    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    /// Records delivery acknowledgement for one client/entity pair.
319    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    /// Records delivery acknowledgement for every entity in a plan.
337    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    /// Removes all entries for one client.
350    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    /// Removes entries last sent before `older_than`.
360    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/// Replication planner statistics.
400#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
401pub struct ReplicationStats {
402    /// Candidate handles returned from the spatial index.
403    pub candidates: usize,
404    /// Candidate records considered after stale handle filtering.
405    pub considered: usize,
406    /// Selected entities.
407    pub selected: usize,
408    /// Entities skipped because the budget was exhausted.
409    pub skipped_by_budget: usize,
410    /// Entities skipped because their cadence interval has not elapsed.
411    pub skipped_by_cadence: usize,
412    /// Estimated frame bytes.
413    pub estimated_bytes: usize,
414}
415
416/// Stateless distance-based replication cadence helper.
417#[derive(Clone, Copy, Debug, Default)]
418pub struct ReplicationCadence;
419
420impl ReplicationCadence {
421    /// Returns the target update frequency for a policy at a squared distance.
422    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    /// Returns the tick interval for a policy at a squared distance.
438    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    /// Returns whether a replication update should be sent at `now`.
449    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/// Stateless replication priority scoring helper.
465#[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    /// Returns a deterministic priority score for budgeted selection.
482    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/// Reusable scratch storage for allocation-aware replication planning.
506#[derive(Clone, Debug, Default)]
507pub struct ReplicationScratch {
508    cell_query: CellQueryScratch,
509    prioritized: Vec<PrioritizedReplicationCandidate>,
510}
511
512impl ReplicationScratch {
513    /// Clears retained planning results while keeping allocated capacity.
514    pub fn clear(&mut self) {
515        self.cell_query.clear();
516        self.prioritized.clear();
517    }
518
519    /// Number of spatial candidates retained from the last query.
520    pub fn candidate_count(&self) -> usize {
521        self.cell_query.len()
522    }
523
524    /// Capacity retained for priority candidate sorting.
525    pub fn prioritized_capacity(&self) -> usize {
526        self.prioritized.capacity()
527    }
528
529    /// Work counters from the last spatial candidate query.
530    pub const fn query_stats(&self) -> CellQueryStats {
531        self.cell_query.stats()
532    }
533
534    /// Capacity retained for spatial candidate handles.
535    pub fn candidate_capacity(&self) -> usize {
536        self.cell_query.handle_capacity()
537    }
538
539    /// Capacity retained by spatial candidate deduplication.
540    pub fn candidate_dedup_capacity(&self) -> usize {
541        self.cell_query.dedup_capacity()
542    }
543
544    /// Capacity retained for cells matched by sparse spatial queries.
545    pub fn matching_cell_capacity(&self) -> usize {
546        self.cell_query.matching_cell_capacity()
547    }
548}
549
550/// Simple range/visibility-based replication planner.
551#[derive(Clone, Copy, Debug, Default)]
552pub struct ReplicationPlanner;
553
554impl ReplicationPlanner {
555    /// Plans a frame for one viewer using the station-local spatial index.
556    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    /// Plans a frame using caller-provided scratch storage.
577    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    /// Plans viewers in input order while reusing caller-provided scratch.
600    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    /// Plans one range-only viewer using the optional SIMD candidate filter.
624    ///
625    /// With the `simd` feature this evaluates candidate distances in eight-lane
626    /// groups. Without it, the same API uses the scalar range-only planner.
627    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    /// Plans a range-only viewer batch in input order with optional SIMD filtering.
641    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    /// Plans a frame and skips entities whose distance-based cadence has not elapsed.
664    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    /// Plans a cadence-aware frame using caller-provided scratch storage.
700    #[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    /// Plans a frame and selects the highest-priority entities when budgeted.
739    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    /// Plans a budgeted priority frame using caller-provided scratch storage.
762    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    /// Plans a budgeted priority frame with distance-based cadence checks.
786    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    /// Plans a priority/cadence frame using caller-provided scratch storage.
824    #[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}