1use crate::ids::StationId;
4use crate::spatial::CellCoord3;
5
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
8pub struct CellLoadSample {
9 pub cell: CellCoord3,
11 pub owned_entities: usize,
13 pub ghost_entities: usize,
15 pub subscribers: usize,
17 pub estimated_updates: usize,
19 pub estimated_bytes: usize,
21 pub tick_cost_units: u64,
23 pub event_pressure: usize,
25}
26
27impl CellLoadSample {
28 pub fn pressure_score(self) -> u64 {
30 let entities = (self.owned_entities + self.ghost_entities) as u64;
31 let subscribers = self.subscribers as u64;
32 let updates = self.estimated_updates as u64;
33 let bytes = (self.estimated_bytes / 256) as u64;
34 let events = self.event_pressure as u64;
35 entities
36 .saturating_mul(8)
37 .saturating_add(subscribers.saturating_mul(4))
38 .saturating_add(updates.saturating_mul(2))
39 .saturating_add(bytes)
40 .saturating_add(events.saturating_mul(16))
41 .saturating_add(self.tick_cost_units)
42 }
43}
44
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub struct StationLoadSample {
48 pub station_id: StationId,
50 pub owned_entities: usize,
52 pub ghost_entities: usize,
54 pub subscribers: usize,
56 pub queued_events: usize,
58 pub estimated_bytes: usize,
60 pub tick_cost_units: u64,
62 pub cells: Vec<CellLoadSample>,
64}
65
66impl StationLoadSample {
67 pub const fn total_entities(&self) -> usize {
69 self.owned_entities + self.ghost_entities
70 }
71
72 pub fn max_cell_pressure(&self) -> u64 {
74 self.cells
75 .iter()
76 .map(|cell| cell.pressure_score())
77 .max()
78 .unwrap_or(0)
79 }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub struct HotspotThresholds {
85 pub max_station_entities: usize,
87 pub max_station_subscribers: usize,
89 pub max_queued_events: usize,
91 pub max_estimated_bytes: usize,
93 pub max_tick_cost_units: u64,
95 pub max_cell_pressure: u64,
97}
98
99impl Default for HotspotThresholds {
100 fn default() -> Self {
101 Self {
102 max_station_entities: 50_000,
103 max_station_subscribers: 2_000,
104 max_queued_events: 10_000,
105 max_estimated_bytes: 64 * 1024 * 1024,
106 max_tick_cost_units: 16_000,
107 max_cell_pressure: 10_000,
108 }
109 }
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum HotspotSeverity {
115 Normal,
117 Warm,
119 Hot,
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct HotspotDecision {
126 pub station_id: StationId,
128 pub severity: HotspotSeverity,
130 pub score: u64,
132 pub reasons: Vec<&'static str>,
134}
135
136impl Default for HotspotDecision {
137 fn default() -> Self {
138 Self {
139 station_id: StationId::default(),
140 severity: HotspotSeverity::Normal,
141 score: 0,
142 reasons: Vec::new(),
143 }
144 }
145}
146
147#[derive(Clone, Debug, Default, PartialEq, Eq)]
149pub struct SplitProposal {
150 pub source_station: StationId,
152 pub cells_to_move: Vec<CellCoord3>,
154 pub moved_pressure_score: u64,
156}
157
158#[derive(Clone, Debug, Default)]
160pub struct HotspotSplitScratch {
161 cells: Vec<CellLoadSample>,
162}
163
164impl HotspotSplitScratch {
165 pub const fn new() -> Self {
167 Self { cells: Vec::new() }
168 }
169
170 pub fn candidate_capacity(&self) -> usize {
172 self.cells.capacity()
173 }
174}
175
176#[derive(Clone, Copy, Debug, Default)]
178pub struct HotspotPlanner;
179
180impl HotspotPlanner {
181 pub fn evaluate(sample: &StationLoadSample, thresholds: HotspotThresholds) -> HotspotDecision {
183 let mut decision = HotspotDecision::default();
184 Self::evaluate_into(sample, thresholds, &mut decision);
185 decision
186 }
187
188 pub fn evaluate_into(
190 sample: &StationLoadSample,
191 thresholds: HotspotThresholds,
192 decision: &mut HotspotDecision,
193 ) {
194 let mut score = 0_u64;
195 decision.station_id = sample.station_id;
196 decision.reasons.clear();
197
198 if sample.total_entities() > thresholds.max_station_entities {
199 score = score.saturating_add(1);
200 decision.reasons.push("station_entities");
201 }
202 if sample.subscribers > thresholds.max_station_subscribers {
203 score = score.saturating_add(1);
204 decision.reasons.push("station_subscribers");
205 }
206 if sample.queued_events > thresholds.max_queued_events {
207 score = score.saturating_add(1);
208 decision.reasons.push("queued_events");
209 }
210 if sample.estimated_bytes > thresholds.max_estimated_bytes {
211 score = score.saturating_add(1);
212 decision.reasons.push("estimated_bytes");
213 }
214 if sample.tick_cost_units > thresholds.max_tick_cost_units {
215 score = score.saturating_add(1);
216 decision.reasons.push("tick_cost");
217 }
218 if sample.max_cell_pressure() > thresholds.max_cell_pressure {
219 score = score.saturating_add(1);
220 decision.reasons.push("cell_pressure");
221 }
222
223 decision.severity = match score {
224 0 => HotspotSeverity::Normal,
225 1 => HotspotSeverity::Warm,
226 _ => HotspotSeverity::Hot,
227 };
228 decision.score = score;
229 }
230
231 pub fn propose_cell_split(
233 sample: &StationLoadSample,
234 max_cells_to_move: usize,
235 ) -> SplitProposal {
236 let mut scratch = HotspotSplitScratch::new();
237 let mut proposal = SplitProposal::default();
238 Self::propose_cell_split_into(sample, max_cells_to_move, &mut scratch, &mut proposal);
239 proposal
240 }
241
242 pub fn propose_cell_split_with_scratch(
244 sample: &StationLoadSample,
245 max_cells_to_move: usize,
246 scratch: &mut HotspotSplitScratch,
247 ) -> SplitProposal {
248 let mut proposal = SplitProposal::default();
249 Self::propose_cell_split_into(sample, max_cells_to_move, scratch, &mut proposal);
250 proposal
251 }
252
253 pub fn propose_cell_split_into(
255 sample: &StationLoadSample,
256 max_cells_to_move: usize,
257 scratch: &mut HotspotSplitScratch,
258 proposal: &mut SplitProposal,
259 ) {
260 let selected = max_cells_to_move.min(sample.cells.len());
261 scratch.cells.clear();
262 scratch.cells.extend_from_slice(&sample.cells);
263 prioritize_cell_samples(&mut scratch.cells, selected);
264
265 proposal.source_station = sample.station_id;
266 proposal.cells_to_move.clear();
267 proposal.cells_to_move.reserve(selected);
268 proposal.moved_pressure_score = 0;
269 for cell in &scratch.cells[..selected] {
270 proposal.moved_pressure_score = proposal
271 .moved_pressure_score
272 .saturating_add(cell.pressure_score());
273 proposal.cells_to_move.push(cell.cell);
274 }
275 }
276}
277
278fn compare_cell_samples(left: &CellLoadSample, right: &CellLoadSample) -> core::cmp::Ordering {
279 right
280 .pressure_score()
281 .cmp(&left.pressure_score())
282 .then_with(|| left.cell.cmp(&right.cell))
283}
284
285fn prioritize_cell_samples(cells: &mut [CellLoadSample], selected: usize) {
286 if selected == 0 {
287 return;
288 }
289 if selected.saturating_mul(2) < cells.len() {
290 cells.select_nth_unstable_by(selected, compare_cell_samples);
291 cells[..selected].sort_by(compare_cell_samples);
292 } else {
293 cells.sort_by(compare_cell_samples);
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn planner_detects_hot_station_and_suggests_hottest_cells() {
303 let sample = StationLoadSample {
304 station_id: StationId::new(3),
305 owned_entities: 100,
306 subscribers: 20,
307 cells: vec![
308 CellLoadSample {
309 cell: CellCoord3::new(0, 0, 0),
310 owned_entities: 1,
311 ..CellLoadSample::default()
312 },
313 CellLoadSample {
314 cell: CellCoord3::new(1, 0, 0),
315 owned_entities: 100,
316 event_pressure: 50,
317 ..CellLoadSample::default()
318 },
319 ],
320 ..StationLoadSample::default()
321 };
322 let thresholds = HotspotThresholds {
323 max_station_entities: 50,
324 max_cell_pressure: 10,
325 ..HotspotThresholds::default()
326 };
327
328 let decision = HotspotPlanner::evaluate(&sample, thresholds);
329 assert_eq!(decision.severity, HotspotSeverity::Hot);
330
331 let mut reused_decision = HotspotDecision::default();
332 HotspotPlanner::evaluate_into(&sample, thresholds, &mut reused_decision);
333 assert_eq!(reused_decision, decision);
334 let reason_capacity = reused_decision.reasons.capacity();
335 HotspotPlanner::evaluate_into(
336 &StationLoadSample {
337 station_id: StationId::new(4),
338 ..StationLoadSample::default()
339 },
340 thresholds,
341 &mut reused_decision,
342 );
343 assert_eq!(reused_decision.station_id, StationId::new(4));
344 assert_eq!(reused_decision.severity, HotspotSeverity::Normal);
345 assert!(reused_decision.reasons.is_empty());
346 assert_eq!(reused_decision.reasons.capacity(), reason_capacity);
347
348 let proposal = HotspotPlanner::propose_cell_split(&sample, 1);
349 assert_eq!(proposal.cells_to_move, vec![CellCoord3::new(1, 0, 0)]);
350 }
351
352 #[test]
353 fn split_top_k_matches_full_sort_and_reuses_capacity_at_budget_edges() {
354 let cells = (0_i32..257)
355 .map(|index| CellLoadSample {
356 cell: CellCoord3::new(index, index % 5, index % 7),
357 owned_entities: usize::try_from(index * 37 % 23).expect("non-negative"),
358 event_pressure: usize::try_from(index * 19 % 11).expect("non-negative"),
359 ..CellLoadSample::default()
360 })
361 .collect::<Vec<_>>();
362 let sample = StationLoadSample {
363 station_id: StationId::new(9),
364 cells: cells.clone(),
365 ..StationLoadSample::default()
366 };
367 let mut scratch = HotspotSplitScratch::new();
368 let mut proposal = SplitProposal::default();
369
370 for requested in [0, 1, 7, 64, 128, 129, 256, 257, 300] {
371 let selected = requested.min(cells.len());
372 let mut expected = cells.clone();
373 expected.sort_by(compare_cell_samples);
374 expected.truncate(selected);
375 HotspotPlanner::propose_cell_split_into(
376 &sample,
377 requested,
378 &mut scratch,
379 &mut proposal,
380 );
381
382 assert_eq!(
383 proposal.cells_to_move,
384 expected.iter().map(|cell| cell.cell).collect::<Vec<_>>()
385 );
386 assert_eq!(
387 proposal.moved_pressure_score,
388 expected.iter().fold(0_u64, |score, cell| {
389 score.saturating_add(cell.pressure_score())
390 })
391 );
392 }
393 let candidate_capacity = scratch.candidate_capacity();
394 let output_capacity = proposal.cells_to_move.capacity();
395 HotspotPlanner::propose_cell_split_into(
396 &StationLoadSample {
397 station_id: StationId::new(10),
398 cells: cells[..8].to_vec(),
399 ..StationLoadSample::default()
400 },
401 2,
402 &mut scratch,
403 &mut proposal,
404 );
405 assert_eq!(proposal.source_station, StationId::new(10));
406 assert_eq!(scratch.candidate_capacity(), candidate_capacity);
407 assert_eq!(proposal.cells_to_move.capacity(), output_capacity);
408 }
409}