1use std::{
4 mem,
5 sync::atomic::{AtomicU64, Ordering},
6 time::Duration,
7};
8
9use rustc_hash::{FxHashMap, FxHashSet};
10use steel_registry::steel_ticket_types::CHUNK_REQUEST;
11use steel_utils::{ChunkPos, locks::SyncMutex};
12use uuid::Uuid;
13
14use crate::chunk::gameplay_chunk_lookup_cache::GameplayChunkLookupCacheStats;
15use crate::chunk::{
16 chunk_ticket::ChunkTicket,
17 chunk_ticket_manager::{ChunkTicketLevel, LoadLevelChange, LoadTicketManager},
18 chunk_ticket_storage::{
19 ChunkTicketStorage, PersistentChunkTickets, SourceLevelUpdate, SourceProjectionChanges,
20 TimedTicketExpiration,
21 },
22 player_ticket_tracker::PlayerTicketTracker,
23 simulation_ticket_manager::{SimulationLevelChange, SimulationTicketManager},
24};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub(crate) enum PlayerTicketOperation {
28 Add { pos: ChunkPos, player_id: Uuid },
29 Remove { pos: ChunkPos, player_id: Uuid },
30}
31
32#[derive(Debug, Default)]
34pub struct ChunkMapSchedulingTimings {
35 pub ticket_updates: Duration,
37 pub block_entity_unloads: Duration,
39 pub readiness_demotions: Duration,
41 pub lifecycle_commit: Duration,
43 pub readiness_reconcile: Duration,
45 pub post_process_generation: Duration,
47 pub post_process_chunk_count: usize,
49 pub post_process_position_count: usize,
51 pub readiness_candidate_count: usize,
53 pub ticking_snapshot_rebuild: Duration,
55 pub rebuilt_ticking_chunk_count: usize,
57 pub lookup_cache: GameplayChunkLookupCacheStats,
59 pub schedule_generation: Duration,
61 pub scheduled_count: usize,
63 pub run_generation: Duration,
65 pub process_unloads: Duration,
67}
68
69#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
71pub(crate) struct ChunkTicketReceipt(u64);
72
73impl ChunkTicketReceipt {
74 const INITIAL: Self = Self(0);
75
76 fn next(self) -> Self {
77 assert_ne!(self.0, u64::MAX, "chunk ticket receipt exhausted");
78 Self(self.0 + 1)
79 }
80}
81
82#[must_use = "chunk scheduling changes must be applied before publishing the receipt"]
84#[derive(Debug, Default)]
85pub(crate) struct ChunkSchedulingUpdateBatch {
86 pub(crate) through_receipt: ChunkTicketReceipt,
87 pub(crate) load_changes: Vec<LoadLevelChange>,
88 pub(crate) simulation_changes: Vec<SimulationLevelChange>,
89}
90
91#[derive(Debug)]
92struct SourceProjectionBatch {
93 through_receipt: ChunkTicketReceipt,
94 load_updates: Vec<SourceLevelUpdate>,
95 simulation_updates: Vec<SourceLevelUpdate>,
96}
97
98#[derive(Debug)]
99struct TicketOperationIngress {
100 storage: ChunkTicketStorage,
101 player_tickets: PlayerTicketTracker,
102 chunk_request_leases: FxHashMap<(ChunkPos, ChunkTicketLevel), usize>,
103 latest_receipt: ChunkTicketReceipt,
104 dirty_load_positions: FxHashSet<ChunkPos>,
105 dirty_simulation_positions: FxHashSet<ChunkPos>,
106}
107
108impl TicketOperationIngress {
109 fn new(storage: ChunkTicketStorage, view_distance: u8, simulation_distance: u8) -> Self {
110 Self {
111 storage,
112 player_tickets: PlayerTicketTracker::new(view_distance, simulation_distance),
113 chunk_request_leases: FxHashMap::default(),
114 latest_receipt: ChunkTicketReceipt::INITIAL,
115 dirty_load_positions: FxHashSet::default(),
116 dirty_simulation_positions: FxHashSet::default(),
117 }
118 }
119
120 fn push_player(&mut self, operation: PlayerTicketOperation) -> ChunkTicketReceipt {
121 let changes = self.apply_player_operation(operation);
122 self.record_changes(changes);
123 self.allocate_receipt()
124 }
125
126 fn push_player_batch(
127 &mut self,
128 operations: impl IntoIterator<Item = PlayerTicketOperation>,
129 ) -> Option<ChunkTicketReceipt> {
130 let mut operations = operations.into_iter().peekable();
131 operations.peek()?;
132 for operation in operations {
133 let changes = self.apply_player_operation(operation);
134 self.record_changes(changes);
135 }
136 Some(self.allocate_receipt())
137 }
138
139 fn apply_player_operation(
140 &mut self,
141 operation: PlayerTicketOperation,
142 ) -> SourceProjectionChanges {
143 match operation {
144 PlayerTicketOperation::Add { pos, player_id } => {
145 self.player_tickets
146 .add_player(&mut self.storage, pos, player_id)
147 }
148 PlayerTicketOperation::Remove { pos, player_id } => {
149 self.player_tickets
150 .remove_player(&mut self.storage, pos, player_id)
151 }
152 }
153 }
154
155 fn acquire_chunk_request_leases(
156 &mut self,
157 positions: impl IntoIterator<Item = ChunkPos>,
158 ticket_level: ChunkTicketLevel,
159 ) -> Option<ChunkTicketReceipt> {
160 let mut positions = positions.into_iter().peekable();
161 positions.peek()?;
162
163 for pos in positions {
164 let lease_count = self
165 .chunk_request_leases
166 .entry((pos, ticket_level))
167 .or_default();
168 assert_ne!(
169 *lease_count,
170 usize::MAX,
171 "chunk request lease count exhausted"
172 );
173 let needs_ticket = *lease_count == 0;
174 *lease_count += 1;
175
176 if needs_ticket {
177 let ticket = ChunkTicket::new(&CHUNK_REQUEST, ticket_level);
178 let changes = self.storage.add_ticket(pos, ticket);
179 self.record_changes(changes);
180 }
181 }
182
183 Some(self.allocate_receipt())
184 }
185
186 fn release_chunk_request_leases(
187 &mut self,
188 positions: impl IntoIterator<Item = ChunkPos>,
189 ticket_level: ChunkTicketLevel,
190 ) -> Option<ChunkTicketReceipt> {
191 let mut positions = positions.into_iter().peekable();
192 positions.peek()?;
193
194 for pos in positions {
195 let key = (pos, ticket_level);
196 let Some(lease_count) = self.chunk_request_leases.get_mut(&key) else {
197 panic!("released an unowned chunk request lease at {pos:?}");
198 };
199 let remove_ticket = *lease_count == 1;
200 if remove_ticket {
201 self.chunk_request_leases.remove(&key);
202 let ticket = ChunkTicket::new(&CHUNK_REQUEST, ticket_level);
203 let changes = self.storage.remove_ticket(pos, ticket);
204 self.record_changes(changes);
205 } else {
206 *lease_count -= 1;
207 }
208 }
209
210 Some(self.allocate_receipt())
211 }
212
213 fn record_changes(&mut self, changes: SourceProjectionChanges) {
214 let SourceProjectionChanges {
215 load_positions,
216 simulation_positions,
217 } = changes;
218
219 self.dirty_load_positions.extend(load_positions);
220 self.dirty_simulation_positions.extend(simulation_positions);
221 }
222
223 fn allocate_receipt(&mut self) -> ChunkTicketReceipt {
224 self.latest_receipt = self.latest_receipt.next();
225 self.latest_receipt
226 }
227
228 fn take_source_projections(
229 &mut self,
230 view_distance: u8,
231 simulation_distance: u8,
232 ) -> SourceProjectionBatch {
233 let view_distance_changes = self
234 .player_tickets
235 .set_view_distance(&mut self.storage, view_distance);
236 self.record_changes(view_distance_changes);
237 let simulation_distance_changes = self
238 .player_tickets
239 .set_simulation_distance(&mut self.storage, simulation_distance);
240 self.record_changes(simulation_distance_changes);
241
242 let load_positions = Self::take_sorted_positions(&mut self.dirty_load_positions);
243 let simulation_positions =
244 Self::take_sorted_positions(&mut self.dirty_simulation_positions);
245 let load_updates = load_positions
246 .into_iter()
247 .map(|pos| self.storage.load_source_update(pos))
248 .collect();
249 let simulation_updates = simulation_positions
250 .into_iter()
251 .map(|pos| self.storage.simulation_source_update(pos))
252 .collect();
253
254 SourceProjectionBatch {
255 through_receipt: self.latest_receipt,
256 load_updates,
257 simulation_updates,
258 }
259 }
260
261 fn take_sorted_positions(positions: &mut FxHashSet<ChunkPos>) -> Vec<ChunkPos> {
262 let mut positions: Vec<_> = mem::take(positions).into_iter().collect();
263 positions.sort_unstable_by_key(|pos| (pos.0.x, pos.0.y));
264 positions
265 }
266}
267
268#[derive(Debug)]
269struct ChunkSchedulingTrackers {
270 load: LoadTicketManager,
271 simulation: SimulationTicketManager,
272}
273
274pub(crate) struct ChunkSchedulingCoordinator {
280 ticket_ingress: SyncMutex<TicketOperationIngress>,
281 trackers: SyncMutex<ChunkSchedulingTrackers>,
282 committed_receipt: AtomicU64,
283}
284
285impl ChunkSchedulingCoordinator {
286 pub fn new(
287 ticket_storage: ChunkTicketStorage,
288 view_distance: u8,
289 simulation_distance: u8,
290 ) -> Self {
291 let initial_load_sources = ticket_storage.initial_load_sources();
292 let initial_simulation_sources = ticket_storage.initial_simulation_sources();
293 let mut load = LoadTicketManager::new();
294 load.apply_source_updates(initial_load_sources);
295 let mut simulation = SimulationTicketManager::new();
296 simulation.apply_source_updates(initial_simulation_sources);
297
298 Self {
299 ticket_ingress: SyncMutex::new(TicketOperationIngress::new(
300 ticket_storage,
301 view_distance,
302 simulation_distance,
303 )),
304 trackers: SyncMutex::new(ChunkSchedulingTrackers { load, simulation }),
305 committed_receipt: AtomicU64::new(ChunkTicketReceipt::INITIAL.0),
306 }
307 }
308
309 pub(crate) fn queue_player_ticket_operation(
310 &self,
311 operation: PlayerTicketOperation,
312 ) -> ChunkTicketReceipt {
313 self.ticket_ingress.lock().push_player(operation)
314 }
315
316 pub(crate) fn queue_player_ticket_operations(
317 &self,
318 operations: impl IntoIterator<Item = PlayerTicketOperation>,
319 ) -> Option<ChunkTicketReceipt> {
320 self.ticket_ingress.lock().push_player_batch(operations)
321 }
322
323 pub(crate) fn acquire_chunk_request_leases(
324 &self,
325 positions: impl IntoIterator<Item = ChunkPos>,
326 ticket_level: ChunkTicketLevel,
327 ) -> Option<ChunkTicketReceipt> {
328 self.ticket_ingress
329 .lock()
330 .acquire_chunk_request_leases(positions, ticket_level)
331 }
332
333 pub(crate) fn release_chunk_request_leases(
334 &self,
335 positions: impl IntoIterator<Item = ChunkPos>,
336 ticket_level: ChunkTicketLevel,
337 ) -> Option<ChunkTicketReceipt> {
338 self.ticket_ingress
339 .lock()
340 .release_chunk_request_leases(positions, ticket_level)
341 }
342
343 pub fn run_all_updates(
344 &self,
345 view_distance: u8,
346 simulation_distance: u8,
347 ) -> ChunkSchedulingUpdateBatch {
348 let mut trackers = self.trackers.lock();
349 let projections = {
350 let mut ingress = self.ticket_ingress.lock();
351 ingress.take_source_projections(view_distance, simulation_distance)
352 };
353
354 trackers
355 .simulation
356 .apply_source_updates(projections.simulation_updates);
357 trackers.simulation.run_all_updates();
358 let simulation_changes = trackers.simulation.take_changes();
359
360 trackers.load.apply_source_updates(projections.load_updates);
361 trackers.load.run_all_updates();
362 let load_changes = trackers.load.take_changes();
363
364 ChunkSchedulingUpdateBatch {
365 through_receipt: projections.through_receipt,
366 load_changes,
367 simulation_changes,
368 }
369 }
370
371 pub(crate) fn add_or_refresh_portal_ticket(&self, pos: ChunkPos) {
372 let mut ingress = self.ticket_ingress.lock();
373 let changes = ingress.storage.add_or_refresh_portal_ticket(pos);
374 ingress.record_changes(changes);
375 }
376
377 pub(crate) fn add_or_refresh_ender_pearl_ticket(&self, pos: ChunkPos) {
378 let mut ingress = self.ticket_ingress.lock();
379 let changes = ingress.storage.add_or_refresh_ender_pearl_ticket(pos);
380 ingress.record_changes(changes);
381 }
382
383 #[must_use]
384 pub(crate) fn timed_ticket_expirations(&self) -> Vec<TimedTicketExpiration> {
385 self.ticket_ingress
386 .lock()
387 .storage
388 .timed_ticket_expirations()
389 }
390
391 pub(crate) fn tick_timed_tickets(&self, expirations: &[TimedTicketExpiration]) {
392 let mut ingress = self.ticket_ingress.lock();
393 let changes = ingress.storage.tick_timed_tickets(expirations);
394 ingress.record_changes(changes);
395 }
396
397 #[must_use]
398 pub(crate) fn persistent_chunk_tickets(&self) -> PersistentChunkTickets {
399 self.ticket_ingress.lock().storage.to_persistent()
400 }
401
402 #[must_use]
403 pub fn simulation_level(&self, pos: ChunkPos) -> Option<ChunkTicketLevel> {
404 self.trackers.lock().simulation.get_level(pos)
405 }
406
407 pub fn recycle_update_batch(&self, batch: ChunkSchedulingUpdateBatch) {
408 let mut trackers = self.trackers.lock();
409 trackers.load.recycle_changes(batch.load_changes);
410 trackers
411 .simulation
412 .recycle_changes(batch.simulation_changes);
413 }
414
415 pub fn publish_committed(&self, receipt: ChunkTicketReceipt) {
416 let _ = self
417 .committed_receipt
418 .fetch_max(receipt.0, Ordering::Release);
419 }
420
421 #[must_use]
422 pub fn is_receipt_committed(&self, receipt: ChunkTicketReceipt) -> bool {
423 self.committed_receipt.load(Ordering::Acquire) >= receipt.0
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use std::iter;
430
431 use super::*;
432 use crate::chunk::chunk_ticket_manager::ChunkTicketLevel;
433 use uuid::Uuid;
434
435 const TEST_VIEW_DISTANCE_CHUNKS: u8 = 4;
436 const TEST_SIMULATION_DISTANCE_CHUNKS: u8 = 4;
437
438 fn coordinator(simulation_distance: u8) -> ChunkSchedulingCoordinator {
439 ChunkSchedulingCoordinator::new(
440 ChunkTicketStorage::new(),
441 TEST_VIEW_DISTANCE_CHUNKS,
442 simulation_distance,
443 )
444 }
445
446 fn has_load_change(
447 changes: &[LoadLevelChange],
448 pos: ChunkPos,
449 new_level: Option<ChunkTicketLevel>,
450 ) -> bool {
451 changes
452 .iter()
453 .any(|change| change.pos == pos && change.new_level == new_level)
454 }
455
456 fn has_simulation_change(
457 changes: &[SimulationLevelChange],
458 pos: ChunkPos,
459 new_level: Option<ChunkTicketLevel>,
460 ) -> bool {
461 changes
462 .iter()
463 .any(|change| change.pos == pos && change.new_level == new_level)
464 }
465
466 #[test]
467 fn chunk_request_leases_share_one_ticket_and_keep_ordered_receipts() {
468 let pos = ChunkPos::new(6, -9);
469 let level = ChunkTicketLevel::FULL_CHUNK;
470 let mut ingress = TicketOperationIngress::new(
471 ChunkTicketStorage::new(),
472 TEST_VIEW_DISTANCE_CHUNKS,
473 TEST_SIMULATION_DISTANCE_CHUNKS,
474 );
475
476 let first_acquire = ingress
477 .acquire_chunk_request_leases([pos], level)
478 .expect("non-empty acquisition should produce a receipt");
479 let second_acquire = ingress
480 .acquire_chunk_request_leases([pos], level)
481 .expect("non-empty acquisition should produce a receipt");
482 let additions = ingress
483 .take_source_projections(TEST_VIEW_DISTANCE_CHUNKS, TEST_SIMULATION_DISTANCE_CHUNKS);
484
485 assert!(second_acquire > first_acquire);
486 assert_eq!(additions.through_receipt, second_acquire);
487 assert_eq!(
488 additions.load_updates,
489 [SourceLevelUpdate {
490 pos,
491 level: Some(level),
492 }]
493 );
494
495 let first_release = ingress
496 .release_chunk_request_leases([pos], level)
497 .expect("non-empty release should produce a receipt");
498 let retained = ingress
499 .take_source_projections(TEST_VIEW_DISTANCE_CHUNKS, TEST_SIMULATION_DISTANCE_CHUNKS);
500 assert!(first_release > second_acquire);
501 assert_eq!(retained.through_receipt, first_release);
502 assert_eq!(retained.load_updates, []);
503
504 let final_release = ingress
505 .release_chunk_request_leases([pos], level)
506 .expect("non-empty release should produce a receipt");
507 let removal = ingress
508 .take_source_projections(TEST_VIEW_DISTANCE_CHUNKS, TEST_SIMULATION_DISTANCE_CHUNKS);
509 assert!(final_release > first_release);
510 assert_eq!(removal.through_receipt, final_release);
511 assert_eq!(
512 removal.load_updates,
513 [SourceLevelUpdate { pos, level: None }]
514 );
515 }
516
517 #[test]
518 fn empty_batches_have_no_receipt_and_true_no_ops_are_barriers() {
519 let coordinator = coordinator(TEST_SIMULATION_DISTANCE_CHUNKS);
520 assert_eq!(
521 coordinator.queue_player_ticket_operations(iter::empty()),
522 None
523 );
524
525 let pos = ChunkPos::new(0, 0);
526 let receipt = coordinator.queue_player_ticket_operation(PlayerTicketOperation::Remove {
527 pos,
528 player_id: Uuid::from_u128(1),
529 });
530 let batch =
531 coordinator.run_all_updates(TEST_VIEW_DISTANCE_CHUNKS, TEST_SIMULATION_DISTANCE_CHUNKS);
532
533 assert_eq!(receipt, ChunkTicketReceipt(1));
534 assert_eq!(batch.through_receipt, receipt);
535 assert_eq!(batch.load_changes, []);
536 assert_eq!(batch.simulation_changes, []);
537 assert!(!coordinator.is_receipt_committed(receipt));
538
539 coordinator.publish_committed(batch.through_receipt);
540 assert!(coordinator.is_receipt_committed(receipt));
541 }
542
543 #[test]
544 fn unified_batch_and_receipt_commit_atomically() {
545 let coordinator = coordinator(TEST_SIMULATION_DISTANCE_CHUNKS);
546 let pos = ChunkPos::new(0, 0);
547 let player_id = Uuid::from_u128(2);
548 let receipt = coordinator
549 .queue_player_ticket_operation(PlayerTicketOperation::Add { pos, player_id });
550
551 let batch =
552 coordinator.run_all_updates(TEST_VIEW_DISTANCE_CHUNKS, TEST_SIMULATION_DISTANCE_CHUNKS);
553
554 assert_eq!(batch.through_receipt, receipt);
555 assert!(has_load_change(
556 &batch.load_changes,
557 pos,
558 Some(ChunkTicketLevel::ENTITY_TICKING_CHUNK)
559 ));
560 assert!(has_simulation_change(
561 &batch.simulation_changes,
562 pos,
563 Some(ChunkTicketLevel::for_entity_ticking_radius(
564 TEST_SIMULATION_DISTANCE_CHUNKS
565 ))
566 ));
567 assert_eq!(
568 coordinator.simulation_level(pos),
569 Some(ChunkTicketLevel::for_entity_ticking_radius(
570 TEST_SIMULATION_DISTANCE_CHUNKS
571 ))
572 );
573 assert!(!coordinator.is_receipt_committed(receipt));
574
575 coordinator.publish_committed(batch.through_receipt);
576 assert!(coordinator.is_receipt_committed(receipt));
577 coordinator.recycle_update_batch(batch);
578
579 let removal = coordinator
580 .queue_player_ticket_operation(PlayerTicketOperation::Remove { pos, player_id });
581 let batch =
582 coordinator.run_all_updates(TEST_VIEW_DISTANCE_CHUNKS, TEST_SIMULATION_DISTANCE_CHUNKS);
583 assert_eq!(removal, ChunkTicketReceipt(2));
584 assert_eq!(batch.through_receipt, removal);
585 assert!(has_load_change(&batch.load_changes, pos, None));
586 assert!(has_simulation_change(&batch.simulation_changes, pos, None));
587 assert!(!coordinator.is_receipt_committed(removal));
588
589 coordinator.publish_committed(batch.through_receipt);
590 assert!(coordinator.is_receipt_committed(removal));
591 }
592
593 #[test]
594 fn simulation_distance_reprojects_players_without_allocating_a_receipt() {
595 let initial_distance = 2;
596 let updated_distance = 3;
597 let coordinator = coordinator(initial_distance);
598 let pos = ChunkPos::new(5, -3);
599 let receipt = coordinator.queue_player_ticket_operation(PlayerTicketOperation::Add {
600 pos,
601 player_id: Uuid::from_u128(5),
602 });
603
604 let initial = coordinator.run_all_updates(TEST_VIEW_DISTANCE_CHUNKS, initial_distance);
605 assert_eq!(initial.through_receipt, receipt);
606 assert_eq!(
607 coordinator.simulation_level(pos),
608 Some(ChunkTicketLevel::for_entity_ticking_radius(
609 initial_distance
610 ))
611 );
612
613 let updated = coordinator.run_all_updates(TEST_VIEW_DISTANCE_CHUNKS, updated_distance);
614
615 assert_eq!(updated.through_receipt, receipt);
616 assert_eq!(updated.load_changes, []);
617 assert!(has_simulation_change(
618 &updated.simulation_changes,
619 pos,
620 Some(ChunkTicketLevel::for_entity_ticking_radius(
621 updated_distance
622 ))
623 ));
624 assert_eq!(
625 coordinator.simulation_level(pos),
626 Some(ChunkTicketLevel::for_entity_ticking_radius(
627 updated_distance
628 ))
629 );
630 }
631}