1use std::collections::hash_map::Entry;
4
5use rustc_hash::{FxHashMap, FxHashSet};
6use steel_registry::vanilla_ticket_types::{PLAYER_LOADING, PLAYER_SIMULATION};
7use steel_utils::ChunkPos;
8use uuid::Uuid;
9
10use super::{
11 chunk_ticket::ChunkTicket,
12 chunk_ticket_manager::ChunkTicketLevel,
13 chunk_ticket_storage::{ChunkTicketStorage, SourceProjectionChanges},
14};
15
16#[must_use]
17const fn player_loading_ticket() -> ChunkTicket {
18 ChunkTicket::new(&PLAYER_LOADING, ChunkTicketLevel::ENTITY_TICKING_CHUNK)
19}
20
21#[must_use]
22const fn player_simulation_ticket(simulation_distance: u8) -> ChunkTicket {
23 ChunkTicket::new(
24 &PLAYER_SIMULATION,
25 ChunkTicketLevel::for_entity_ticking_radius(simulation_distance),
26 )
27}
28
29#[derive(Debug)]
37pub(crate) struct PlayerTicketTracker {
38 players_by_pos: FxHashMap<ChunkPos, FxHashSet<Uuid>>,
39 player_positions: FxHashMap<Uuid, ChunkPos>,
40 loading_coverage: FxHashMap<ChunkPos, usize>,
41 view_distance: u8,
42 simulation_distance: u8,
43}
44
45impl PlayerTicketTracker {
46 #[must_use]
48 pub(crate) fn new(view_distance: u8, simulation_distance: u8) -> Self {
49 Self {
50 players_by_pos: FxHashMap::default(),
51 player_positions: FxHashMap::default(),
52 loading_coverage: FxHashMap::default(),
53 view_distance,
54 simulation_distance,
55 }
56 }
57
58 pub(crate) fn add_player(
60 &mut self,
61 storage: &mut ChunkTicketStorage,
62 pos: ChunkPos,
63 player_id: Uuid,
64 ) -> SourceProjectionChanges {
65 let mut changes = SourceProjectionChanges::default();
66
67 if let Some(previous_pos) = self.player_positions.get(&player_id).copied() {
68 if previous_pos == pos {
69 return changes;
70 }
71
72 self.remove_player_from_center(storage, previous_pos, player_id, &mut changes);
73 self.player_positions.remove(&player_id);
74 }
75
76 let players = self.players_by_pos.entry(pos).or_default();
77 let was_empty = players.is_empty();
78 assert!(
79 players.insert(player_id),
80 "player position index disagrees with center membership"
81 );
82 assert!(
83 self.player_positions.insert(player_id, pos).is_none(),
84 "player position index retained a moved player"
85 );
86
87 if was_empty {
88 changes
89 .merge(storage.add_ticket(pos, player_simulation_ticket(self.simulation_distance)));
90 Self::for_each_square_position(pos, self.view_distance, |covered_pos| {
91 self.increment_loading_coverage(storage, covered_pos, &mut changes);
92 });
93 }
94
95 changes
96 }
97
98 pub(crate) fn remove_player(
100 &mut self,
101 storage: &mut ChunkTicketStorage,
102 pos: ChunkPos,
103 player_id: Uuid,
104 ) -> SourceProjectionChanges {
105 if self.player_positions.get(&player_id).copied() != Some(pos) {
106 return SourceProjectionChanges::default();
107 }
108
109 let mut changes = SourceProjectionChanges::default();
110 self.remove_player_from_center(storage, pos, player_id, &mut changes);
111 self.player_positions.remove(&player_id);
112 changes
113 }
114
115 pub(crate) fn set_simulation_distance(
117 &mut self,
118 storage: &mut ChunkTicketStorage,
119 simulation_distance: u8,
120 ) -> SourceProjectionChanges {
121 if self.simulation_distance == simulation_distance {
122 return SourceProjectionChanges::default();
123 }
124
125 let old_ticket = player_simulation_ticket(self.simulation_distance);
126 let new_ticket = player_simulation_ticket(simulation_distance);
127 let occupied_positions: Vec<_> = self.players_by_pos.keys().copied().collect();
128 let mut changes = SourceProjectionChanges::default();
129
130 for pos in occupied_positions {
131 changes.merge(storage.remove_ticket(pos, old_ticket));
132 changes.merge(storage.add_ticket(pos, new_ticket));
133 }
134
135 self.simulation_distance = simulation_distance;
136 changes
137 }
138
139 pub(crate) fn set_view_distance(
141 &mut self,
142 storage: &mut ChunkTicketStorage,
143 view_distance: u8,
144 ) -> SourceProjectionChanges {
145 if self.view_distance == view_distance {
146 return SourceProjectionChanges::default();
147 }
148
149 let old_distance = self.view_distance;
150 let occupied_positions: Vec<_> = self.players_by_pos.keys().copied().collect();
151 let mut changes = SourceProjectionChanges::default();
152
153 for center in occupied_positions {
154 if view_distance > old_distance {
155 Self::for_each_square_ring(center, old_distance, view_distance, |covered_pos| {
156 self.increment_loading_coverage(storage, covered_pos, &mut changes);
157 });
158 } else {
159 Self::for_each_square_ring(center, view_distance, old_distance, |covered_pos| {
160 self.decrement_loading_coverage(storage, covered_pos, &mut changes);
161 });
162 }
163 }
164
165 self.view_distance = view_distance;
166 changes
167 }
168
169 fn remove_player_from_center(
170 &mut self,
171 storage: &mut ChunkTicketStorage,
172 pos: ChunkPos,
173 player_id: Uuid,
174 changes: &mut SourceProjectionChanges,
175 ) {
176 let center_became_empty = {
177 let Some(players) = self.players_by_pos.get_mut(&pos) else {
178 panic!("player position index references a missing center");
179 };
180 assert!(
181 players.remove(&player_id),
182 "player position index disagrees with center membership"
183 );
184 players.is_empty()
185 };
186
187 if !center_became_empty {
188 return;
189 }
190
191 self.players_by_pos.remove(&pos);
192 changes
193 .merge(storage.remove_ticket(pos, player_simulation_ticket(self.simulation_distance)));
194 Self::for_each_square_position(pos, self.view_distance, |covered_pos| {
195 self.decrement_loading_coverage(storage, covered_pos, changes);
196 });
197 }
198
199 fn increment_loading_coverage(
200 &mut self,
201 storage: &mut ChunkTicketStorage,
202 pos: ChunkPos,
203 changes: &mut SourceProjectionChanges,
204 ) {
205 match self.loading_coverage.entry(pos) {
206 Entry::Vacant(entry) => {
207 entry.insert(1);
208 changes.merge(storage.add_ticket(pos, player_loading_ticket()));
209 }
210 Entry::Occupied(mut entry) => {
211 let coverage = entry.get_mut();
212 assert_ne!(
213 *coverage,
214 usize::MAX,
215 "player loading coverage refcount exhausted"
216 );
217 *coverage += 1;
218 }
219 }
220 }
221
222 fn decrement_loading_coverage(
223 &mut self,
224 storage: &mut ChunkTicketStorage,
225 pos: ChunkPos,
226 changes: &mut SourceProjectionChanges,
227 ) {
228 let Entry::Occupied(mut entry) = self.loading_coverage.entry(pos) else {
229 panic!("player loading coverage is missing a covered position");
230 };
231
232 match *entry.get() {
233 0 => panic!("player loading coverage contains a zero refcount"),
234 1 => {
235 entry.remove();
236 changes.merge(storage.remove_ticket(pos, player_loading_ticket()));
237 }
238 _ => *entry.get_mut() -= 1,
239 }
240 }
241
242 fn for_each_square_position(center: ChunkPos, distance: u8, mut visit: impl FnMut(ChunkPos)) {
243 let distance = i32::from(distance);
244 for offset_x in -distance..=distance {
245 for offset_z in -distance..=distance {
246 visit(ChunkPos::new(
247 center.0.x.wrapping_add(offset_x),
248 center.0.y.wrapping_add(offset_z),
249 ));
250 }
251 }
252 }
253
254 fn for_each_square_ring(
255 center: ChunkPos,
256 inner_distance: u8,
257 outer_distance: u8,
258 mut visit: impl FnMut(ChunkPos),
259 ) {
260 debug_assert!(inner_distance < outer_distance);
261 let inner_distance = i32::from(inner_distance);
262 let outer_distance = i32::from(outer_distance);
263
264 for offset_x in -outer_distance..=outer_distance {
265 for offset_z in -outer_distance..=outer_distance {
266 if offset_x.abs().max(offset_z.abs()) <= inner_distance {
267 continue;
268 }
269
270 visit(ChunkPos::new(
271 center.0.x.wrapping_add(offset_x),
272 center.0.y.wrapping_add(offset_z),
273 ));
274 }
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 const FIRST_PLAYER: Uuid = Uuid::from_u128(1);
284 const SECOND_PLAYER: Uuid = Uuid::from_u128(2);
285
286 #[test]
287 fn colocated_players_share_center_and_loading_tickets() {
288 let center = ChunkPos::new(4, -2);
289 let mut storage = ChunkTicketStorage::new();
290 let mut tracker = PlayerTicketTracker::new(1, 3);
291
292 let first_add = tracker.add_player(&mut storage, center, FIRST_PLAYER);
293 let second_add = tracker.add_player(&mut storage, center, SECOND_PLAYER);
294 let first_remove = tracker.remove_player(&mut storage, center, FIRST_PLAYER);
295
296 assert_eq!(first_add.load_positions.len(), 9);
297 assert_eq!(first_add.simulation_positions, vec![center]);
298 assert_eq!(second_add, SourceProjectionChanges::default());
299 assert_eq!(first_remove, SourceProjectionChanges::default());
300 assert_eq!(
301 tracker.players_by_pos.get(¢er).map(FxHashSet::len),
302 Some(1)
303 );
304
305 let last_remove = tracker.remove_player(&mut storage, center, SECOND_PLAYER);
306 assert_eq!(last_remove.load_positions.len(), 9);
307 assert_eq!(last_remove.simulation_positions, vec![center]);
308 assert!(tracker.players_by_pos.is_empty());
309 assert!(tracker.loading_coverage.is_empty());
310 assert_eq!(storage.load_source_level(center), None);
311 assert_eq!(storage.simulation_source_level(center), None);
312 }
313
314 #[test]
315 fn overlapping_views_keep_loading_tickets_until_last_center_leaves() {
316 let first_center = ChunkPos::new(0, 0);
317 let second_center = ChunkPos::new(1, 0);
318 let shared_pos = ChunkPos::new(0, 1);
319 let first_only_pos = ChunkPos::new(-1, 0);
320 let mut storage = ChunkTicketStorage::new();
321 let mut tracker = PlayerTicketTracker::new(1, 2);
322
323 let _ = tracker.add_player(&mut storage, first_center, FIRST_PLAYER);
324 let second_add = tracker.add_player(&mut storage, second_center, SECOND_PLAYER);
325
326 assert_eq!(second_add.load_positions.len(), 3);
327 assert_eq!(tracker.loading_coverage.get(&shared_pos), Some(&2));
328
329 let first_remove = tracker.remove_player(&mut storage, first_center, FIRST_PLAYER);
330 assert_eq!(first_remove.load_positions.len(), 3);
331 assert_eq!(tracker.loading_coverage.get(&shared_pos), Some(&1));
332 assert_eq!(
333 storage.load_source_level(shared_pos),
334 Some(ChunkTicketLevel::ENTITY_TICKING_CHUNK)
335 );
336 assert_eq!(storage.load_source_level(first_only_pos), None);
337 }
338
339 #[test]
340 fn movement_is_atomic_and_stale_removal_cannot_remove_new_position() {
341 let old_pos = ChunkPos::new(-7, 5);
342 let new_pos = ChunkPos::new(9, 3);
343 let mut storage = ChunkTicketStorage::new();
344 let mut tracker = PlayerTicketTracker::new(0, 4);
345
346 let _ = tracker.add_player(&mut storage, old_pos, FIRST_PLAYER);
347 let movement = tracker.add_player(&mut storage, new_pos, FIRST_PLAYER);
348 let duplicate_add = tracker.add_player(&mut storage, new_pos, FIRST_PLAYER);
349 let stale_remove = tracker.remove_player(&mut storage, old_pos, FIRST_PLAYER);
350
351 assert_eq!(movement.load_positions, vec![old_pos, new_pos]);
352 assert_eq!(movement.simulation_positions, vec![old_pos, new_pos]);
353 assert_eq!(duplicate_add, SourceProjectionChanges::default());
354 assert_eq!(stale_remove, SourceProjectionChanges::default());
355 assert_eq!(tracker.player_positions.get(&FIRST_PLAYER), Some(&new_pos));
356 assert_eq!(storage.load_source_level(old_pos), None);
357 assert_eq!(storage.simulation_source_level(old_pos), None);
358 assert_eq!(
359 storage.load_source_level(new_pos),
360 Some(ChunkTicketLevel::ENTITY_TICKING_CHUNK)
361 );
362 assert_eq!(
363 storage.simulation_source_level(new_pos),
364 Some(ChunkTicketLevel::for_entity_ticking_radius(4))
365 );
366 }
367
368 #[test]
369 fn distance_changes_replace_simulation_tickets_and_adjust_only_view_ring() {
370 let center = ChunkPos::new(2, 8);
371 let mut storage = ChunkTicketStorage::new();
372 let mut tracker = PlayerTicketTracker::new(0, 2);
373 let _ = tracker.add_player(&mut storage, center, FIRST_PLAYER);
374
375 let simulation_changes = tracker.set_simulation_distance(&mut storage, 5);
376 assert_eq!(simulation_changes.load_positions, Vec::new());
377 assert_eq!(
378 simulation_changes.simulation_positions,
379 vec![center, center]
380 );
381 assert_eq!(
382 storage.simulation_source_level(center),
383 Some(ChunkTicketLevel::for_entity_ticking_radius(5))
384 );
385
386 let expanded = tracker.set_view_distance(&mut storage, 2);
387 assert_eq!(expanded.load_positions.len(), 24);
388 assert_eq!(tracker.loading_coverage.len(), 25);
389
390 let shrunk = tracker.set_view_distance(&mut storage, 1);
391 assert_eq!(shrunk.load_positions.len(), 16);
392 assert_eq!(tracker.loading_coverage.len(), 9);
393 assert_eq!(storage.load_source_level(ChunkPos::new(4, 8)), None);
394 assert_eq!(
395 storage.load_source_level(ChunkPos::new(3, 9)),
396 Some(ChunkTicketLevel::ENTITY_TICKING_CHUNK)
397 );
398 }
399}