Skip to main content

repgraph/
lib.rs

1//! Replication graph: decide what to encode, not how.
2//!
3//! This crate provides interest management and per-client change list
4//! generation that feeds directly into `codec::encode_delta_from_changes`.
5
6use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
7
8use codec::{DeltaUpdateEntity, EntitySnapshot};
9use schema::ComponentId;
10
11/// Client identifier.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
13pub struct ClientId(pub u32);
14
15/// Basic 3D vector for spatial queries.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct Vec3 {
18    pub x: f32,
19    pub y: f32,
20    pub z: f32,
21}
22
23impl Vec3 {
24    #[must_use]
25    pub fn distance_sq(self, other: Self) -> f32 {
26        let dx = self.x - other.x;
27        let dy = self.y - other.y;
28        let dz = self.z - other.z;
29        dx * dx + dy * dy + dz * dz
30    }
31}
32
33/// Budget caps for per-client deltas.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct ClientBudget {
36    pub max_creates: usize,
37    pub max_updates: usize,
38    pub max_destroys: usize,
39}
40
41impl ClientBudget {
42    #[must_use]
43    pub fn unlimited() -> Self {
44        Self {
45            max_creates: usize::MAX,
46            max_updates: usize::MAX,
47            max_destroys: usize::MAX,
48        }
49    }
50}
51
52/// Client view configuration.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct ClientView {
55    pub position: Vec3,
56    pub radius: f32,
57    pub budget: ClientBudget,
58}
59
60impl ClientView {
61    #[must_use]
62    pub fn new(position: Vec3, radius: f32) -> Self {
63        Self {
64            position,
65            radius,
66            budget: ClientBudget::unlimited(),
67        }
68    }
69}
70
71/// Replication graph configuration.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct ReplicationConfig {
74    /// Maximum entities tracked globally (hard safety cap).
75    pub max_entities: usize,
76}
77
78impl ReplicationConfig {
79    #[must_use]
80    pub fn default_limits() -> Self {
81        Self {
82            max_entities: 1_000_000,
83        }
84    }
85}
86
87/// Per-client delta output.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ClientDelta {
90    pub creates: Vec<EntitySnapshot>,
91    pub destroys: Vec<codec::EntityId>,
92    pub updates: Vec<DeltaUpdateEntity>,
93}
94
95impl ClientDelta {
96    #[must_use]
97    pub fn is_empty(&self) -> bool {
98        self.creates.is_empty() && self.destroys.is_empty() && self.updates.is_empty()
99    }
100}
101
102/// World view adapter used to build snapshot/update payloads.
103pub trait WorldView {
104    /// Build a full entity snapshot for creates.
105    fn snapshot(&self, entity: codec::EntityId) -> EntitySnapshot;
106
107    /// Build a delta update from dirty components. Return `None` to skip.
108    fn update(
109        &self,
110        entity: codec::EntityId,
111        dirty_components: &[ComponentId],
112    ) -> Option<DeltaUpdateEntity>;
113}
114
115#[derive(Debug, Clone)]
116struct EntityEntry {
117    position: Vec3,
118    priority: u8,
119    dirty_components: Vec<ComponentId>,
120}
121
122#[derive(Debug, Clone)]
123struct ClientState {
124    view: ClientView,
125    known_entities: BTreeSet<codec::EntityId>,
126}
127
128/// Replication graph with basic spatial relevance and dirty tracking.
129#[derive(Debug, Clone)]
130pub struct ReplicationGraph {
131    config: ReplicationConfig,
132    entities: BTreeMap<codec::EntityId, EntityEntry>,
133    removed_entities: BTreeSet<codec::EntityId>,
134    clients: HashMap<ClientId, ClientState>,
135}
136
137impl ReplicationGraph {
138    #[must_use]
139    pub fn new(config: ReplicationConfig) -> Self {
140        Self {
141            config,
142            entities: BTreeMap::new(),
143            removed_entities: BTreeSet::new(),
144            clients: HashMap::new(),
145        }
146    }
147
148    /// Add or update a tracked entity.
149    pub fn update_entity(
150        &mut self,
151        entity: codec::EntityId,
152        position: Vec3,
153        dirty_components: &[ComponentId],
154    ) {
155        if self.entities.len() >= self.config.max_entities && !self.entities.contains_key(&entity) {
156            return;
157        }
158        let entry = self.entities.entry(entity).or_insert(EntityEntry {
159            position,
160            priority: 0,
161            dirty_components: Vec::new(),
162        });
163        entry.position = position;
164        push_unique_components(&mut entry.dirty_components, dirty_components);
165    }
166
167    /// Set entity priority (higher is more important).
168    pub fn set_entity_priority(&mut self, entity: codec::EntityId, priority: u8) {
169        if let Some(entry) = self.entities.get_mut(&entity) {
170            entry.priority = priority;
171        }
172    }
173
174    /// Remove an entity and schedule destroy for all clients.
175    pub fn remove_entity(&mut self, entity: codec::EntityId) {
176        self.entities.remove(&entity);
177        self.removed_entities.insert(entity);
178    }
179
180    /// Update or insert client view configuration.
181    pub fn upsert_client(&mut self, client: ClientId, view: ClientView) {
182        self.clients
183            .entry(client)
184            .and_modify(|state| state.view = view)
185            .or_insert(ClientState {
186                view,
187                known_entities: BTreeSet::new(),
188            });
189    }
190
191    /// Remove a client and its known-entity state.
192    pub fn remove_client(&mut self, client: ClientId) {
193        self.clients.remove(&client);
194    }
195
196    /// Build the per-client delta (creates/destroys/updates) from current graph state.
197    pub fn build_client_delta(&mut self, client: ClientId, world: &impl WorldView) -> ClientDelta {
198        let Some(state) = self.clients.get_mut(&client) else {
199            return ClientDelta {
200                creates: Vec::new(),
201                destroys: Vec::new(),
202                updates: Vec::new(),
203            };
204        };
205
206        let radius_sq = state.view.radius * state.view.radius;
207        let mut relevant = Vec::with_capacity(self.entities.len());
208        let mut relevant_set = HashSet::with_capacity(self.entities.len());
209        for (id, entry) in &self.entities {
210            if entry.position.distance_sq(state.view.position) <= radius_sq {
211                relevant.push(*id);
212                relevant_set.insert(*id);
213            }
214        }
215
216        let mut creates = Vec::new();
217        let mut updates = Vec::new();
218        for id in relevant.iter().copied() {
219            if !state.known_entities.contains(&id) {
220                creates.push(world.snapshot(id));
221                continue;
222            }
223            if let Some(entry) = self.entities.get(&id) {
224                if !entry.dirty_components.is_empty() {
225                    if let Some(update) = world.update(id, &entry.dirty_components) {
226                        updates.push(update);
227                    }
228                }
229            }
230        }
231
232        let mut destroys = Vec::new();
233        let mut destroys_set = HashSet::with_capacity(state.known_entities.len());
234        for id in state.known_entities.iter().copied() {
235            if !relevant_set.contains(&id) {
236                destroys.push(id);
237                destroys_set.insert(id);
238            }
239        }
240        for removed in &self.removed_entities {
241            if state.known_entities.contains(removed) && !destroys_set.contains(removed) {
242                destroys.push(*removed);
243                destroys_set.insert(*removed);
244            }
245        }
246        destroys.sort_by_key(|id| id.raw());
247
248        apply_budget(&mut creates, &mut updates, &mut destroys, state.view.budget);
249
250        for destroy in &destroys {
251            state.known_entities.remove(destroy);
252        }
253        for create in &creates {
254            state.known_entities.insert(create.id);
255        }
256
257        ClientDelta {
258            creates,
259            destroys,
260            updates,
261        }
262    }
263
264    /// Clear dirty flags after all clients have been processed for a tick.
265    pub fn clear_dirty(&mut self) {
266        for entry in self.entities.values_mut() {
267            entry.dirty_components.clear();
268        }
269    }
270
271    /// Clear pending removals after all clients have processed destroys.
272    pub fn clear_removed(&mut self) {
273        self.removed_entities.clear();
274    }
275}
276
277fn push_unique_components(target: &mut Vec<ComponentId>, new_components: &[ComponentId]) {
278    for component in new_components {
279        if !target.contains(component) {
280            target.push(*component);
281        }
282    }
283}
284
285fn apply_budget(
286    creates: &mut Vec<EntitySnapshot>,
287    updates: &mut Vec<DeltaUpdateEntity>,
288    destroys: &mut Vec<codec::EntityId>,
289    budget: ClientBudget,
290) {
291    if creates.len() > budget.max_creates {
292        creates.truncate(budget.max_creates);
293    }
294    if updates.len() > budget.max_updates {
295        updates.truncate(budget.max_updates);
296    }
297    if destroys.len() > budget.max_destroys {
298        destroys.truncate(budget.max_destroys);
299    }
300}