Skip to main content

steel_core/chunk/
player_ticket_tracker.rs

1//! Vanilla-style player ownership for loading and simulation tickets.
2
3use 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/// Owns player positions and the logical coverage of Vanilla's player tickets.
30///
31/// Steel materializes the coverage immediately; generation admission remains
32/// bounded by its generation scheduler instead of Vanilla's ticket dispatcher.
33///
34/// The caller holds this tracker and [`ChunkTicketStorage`] under the same
35/// ingress lock, so one method call is the atomic player-source mutation.
36#[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    /// Creates an empty tracker with the current global player distances.
47    #[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    /// Adds a player, moving an existing UUID from its previous position.
59    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    /// Removes a player only when the UUID still maps to the supplied position.
99    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    /// Replaces the simulation ticket at every occupied player center.
116    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    /// Updates loading coverage for a changed global view distance.
140    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(&center).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}