Skip to main content

steel_core/chunk/
chunk_ticket_manager.rs

1//! Chunk ticket management for tracking load levels and propagation.
2#![expect(missing_docs, reason = "internal module; items are self-explanatory")]
3
4use crate::chunk::{
5    chunk_pyramid::GENERATION_PYRAMID, chunk_tracker::ChunkTracker, status::ChunkStatus,
6};
7
8/// The maximum supported view distance for players.
9pub const MAX_SUPPORTED_VIEW_DISTANCE: u8 = 128;
10const FULL_CHUNK_LEVEL_RAW: u8 = MAX_SUPPORTED_VIEW_DISTANCE + 2;
11const RADIUS_AROUND_FULL_CHUNK: u8 = GENERATION_PYRAMID
12    .get_step_to(ChunkStatus::Full)
13    .accumulated_dependencies
14    .get_radius_of(ChunkStatus::Empty) as u8;
15const MAX_LEVEL_RAW: u8 = FULL_CHUNK_LEVEL_RAW + RADIUS_AROUND_FULL_CHUNK;
16
17/// A chunk ticket level.
18///
19/// Lower levels are stronger tickets. `FULL_CHUNK_LEVEL_RAW` is the boundary
20/// where a propagated ticket can still make a chunk full; larger levels only
21/// keep dependency chunks loaded far enough for generation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct ChunkTicketLevel(u8);
24
25impl ChunkTicketLevel {
26    /// The strongest possible ticket level.
27    pub const STRONGEST: Self = Self(0);
28    /// The weakest level whose full chunk may tick entities.
29    pub const ENTITY_TICKING_CHUNK: Self = Self(MAX_SUPPORTED_VIEW_DISTANCE);
30    /// The weakest level whose full chunk may tick blocks.
31    pub const BLOCK_TICKING_CHUNK: Self = Self(MAX_SUPPORTED_VIEW_DISTANCE + 1);
32    /// The weakest level that still permits a full chunk.
33    pub const FULL_CHUNK: Self = Self(FULL_CHUNK_LEVEL_RAW);
34    /// The weakest level kept by ticket propagation.
35    pub const MAX: Self = Self(MAX_LEVEL_RAW);
36
37    /// Builds a ticket level from its raw propagated value.
38    #[must_use]
39    pub const fn new(raw: u8) -> Option<Self> {
40        if raw <= MAX_LEVEL_RAW {
41            Some(Self(raw))
42        } else {
43            None
44        }
45    }
46
47    /// Builds a full-chunk ticket level from a square radius.
48    #[must_use]
49    pub const fn for_full_chunk_radius(radius: u8) -> Self {
50        Self(FULL_CHUNK_LEVEL_RAW.saturating_sub(radius))
51    }
52
53    /// Builds an entity-ticking ticket level from a square radius.
54    #[must_use]
55    pub const fn for_entity_ticking_radius(radius: u8) -> Self {
56        Self(Self::ENTITY_TICKING_CHUNK.0.saturating_sub(radius))
57    }
58
59    /// Returns the raw level value used for compact storage.
60    #[must_use]
61    pub const fn raw(self) -> u8 {
62        self.0
63    }
64
65    /// Returns vanilla's full-chunk status for this propagated level.
66    #[must_use]
67    pub const fn full_status(self) -> FullChunkStatus {
68        if self.0 <= Self::ENTITY_TICKING_CHUNK.0 {
69            FullChunkStatus::EntityTicking
70        } else if self.0 <= Self::BLOCK_TICKING_CHUNK.0 {
71            FullChunkStatus::BlockTicking
72        } else if self.0 <= Self::FULL_CHUNK.0 {
73            FullChunkStatus::Full
74        } else {
75            FullChunkStatus::Inaccessible
76        }
77    }
78
79    #[must_use]
80    pub const fn is_full(self) -> bool {
81        self.0 <= Self::FULL_CHUNK.0
82    }
83
84    #[must_use]
85    pub const fn is_block_ticking(self) -> bool {
86        self.0 <= Self::BLOCK_TICKING_CHUNK.0
87    }
88
89    #[must_use]
90    pub const fn is_entity_ticking(self) -> bool {
91        self.0 <= Self::ENTITY_TICKING_CHUNK.0
92    }
93}
94
95/// Vanilla full-chunk accessibility and ticking status.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
97pub enum FullChunkStatus {
98    Inaccessible,
99    Full,
100    BlockTicking,
101    EntityTicking,
102}
103
104impl FullChunkStatus {
105    #[must_use]
106    pub const fn is_or_after(self, status: Self) -> bool {
107        self as u8 >= status as u8
108    }
109}
110
111#[must_use]
112pub const fn is_full(level: ChunkTicketLevel) -> bool {
113    level.is_full()
114}
115
116#[must_use]
117pub const fn full_status(level: Option<ChunkTicketLevel>) -> FullChunkStatus {
118    match level {
119        Some(level) => level.full_status(),
120        None => FullChunkStatus::Inaccessible,
121    }
122}
123
124#[must_use]
125pub const fn is_block_ticking(level: Option<ChunkTicketLevel>) -> bool {
126    match level {
127        Some(level) => level.is_block_ticking(),
128        None => false,
129    }
130}
131
132#[must_use]
133pub const fn is_entity_ticking(level: Option<ChunkTicketLevel>) -> bool {
134    match level {
135        Some(level) => level.is_entity_ticking(),
136        None => false,
137    }
138}
139
140#[must_use]
141pub const fn generation_status(level: Option<ChunkTicketLevel>) -> Option<ChunkStatus> {
142    match level {
143        None => None,
144        Some(level) => {
145            if is_full(level) {
146                Some(ChunkStatus::Full)
147            } else {
148                let distance = (level.raw() - FULL_CHUNK_LEVEL_RAW) as usize;
149                // Fallback to None if distance is out of bounds (simulating Vanilla logic)
150                GENERATION_PYRAMID
151                    .get_step_to(ChunkStatus::Full)
152                    .accumulated_dependencies
153                    .get(distance)
154            }
155        }
156    }
157}
158
159/// Returns the ticket level that permits a chunk to reach at least `status`.
160///
161/// This is derived from the full-chunk dependency pyramid so request tickets use
162/// the same propagation rules as player tickets.
163#[must_use]
164pub const fn ticket_level_for_status(status: ChunkStatus) -> ChunkTicketLevel {
165    if matches!(status, ChunkStatus::Full) {
166        ChunkTicketLevel::FULL_CHUNK
167    } else {
168        ChunkTicketLevel(
169            FULL_CHUNK_LEVEL_RAW
170                + GENERATION_PYRAMID
171                    .get_step_to(ChunkStatus::Full)
172                    .accumulated_dependencies
173                    .get_radius_of(status) as u8,
174        )
175    }
176}
177
178pub use super::chunk_tracker::ChunkLevelChange as LoadLevelChange;
179
180/// Vanilla's loading tracker, retaining generation dependencies beyond Full.
181pub type LoadTicketManager = ChunkTracker<{ ChunkTicketLevel::MAX.raw() }>;
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::chunk::chunk_ticket_storage::SourceLevelUpdate;
187    use steel_utils::ChunkPos;
188
189    fn source(pos: ChunkPos, level: Option<ChunkTicketLevel>) -> SourceLevelUpdate {
190        SourceLevelUpdate { pos, level }
191    }
192
193    #[test]
194    fn source_updates_are_deferred_and_idempotent() {
195        let mut manager = LoadTicketManager::new();
196        let center = ChunkPos::new(0, 0);
197        let level = ChunkTicketLevel::FULL_CHUNK;
198
199        manager.apply_source_update(source(center, Some(level)));
200
201        assert!(manager.is_dirty());
202        assert_eq!(manager.get_level(center), None);
203
204        manager.run_all_updates();
205
206        assert!(!manager.is_dirty());
207        assert_eq!(manager.get_level(center), Some(level));
208
209        manager.apply_source_update(source(center, Some(level)));
210
211        assert!(!manager.is_dirty());
212    }
213
214    #[test]
215    fn source_level_propagates_by_chebyshev_distance() {
216        let mut manager = LoadTicketManager::new();
217        let center = ChunkPos::new(0, 0);
218        manager.apply_source_update(source(center, Some(ChunkTicketLevel::FULL_CHUNK)));
219        manager.run_all_updates();
220
221        assert_eq!(
222            manager.get_level(center),
223            Some(ChunkTicketLevel::FULL_CHUNK)
224        );
225        assert_eq!(
226            manager.get_level(ChunkPos::new(-1, -1)),
227            ChunkTicketLevel::new(ChunkTicketLevel::FULL_CHUNK.raw() + 1)
228        );
229        assert_eq!(
230            manager.get_level(ChunkPos::new(2, 0)),
231            ChunkTicketLevel::new(ChunkTicketLevel::FULL_CHUNK.raw() + 2)
232        );
233    }
234
235    #[test]
236    fn overlapping_sources_keep_the_strongest_propagated_level() {
237        let mut manager = LoadTicketManager::new();
238        let source_level = ChunkTicketLevel::FULL_CHUNK;
239        manager.apply_source_updates([
240            source(ChunkPos::new(0, 0), Some(source_level)),
241            source(ChunkPos::new(3, 0), Some(source_level)),
242        ]);
243        manager.run_all_updates();
244
245        let adjacent_level =
246            ChunkTicketLevel::new(source_level.raw() + 1).expect("adjacent level is valid");
247        assert_eq!(manager.get_level(ChunkPos::new(1, 0)), Some(adjacent_level));
248        assert_eq!(manager.get_level(ChunkPos::new(2, 0)), Some(adjacent_level));
249    }
250
251    #[test]
252    fn changing_a_source_level_updates_its_propagation() {
253        let mut manager = LoadTicketManager::new();
254        let center = ChunkPos::new(0, 0);
255        manager.apply_source_update(source(center, Some(ChunkTicketLevel::FULL_CHUNK)));
256        manager.run_all_updates();
257
258        manager.apply_source_update(source(center, Some(ChunkTicketLevel::ENTITY_TICKING_CHUNK)));
259        manager.run_all_updates();
260
261        assert_eq!(
262            manager.get_level(center),
263            Some(ChunkTicketLevel::ENTITY_TICKING_CHUNK)
264        );
265        assert_eq!(
266            manager.get_level(ChunkPos::new(2, 0)),
267            Some(ChunkTicketLevel::FULL_CHUNK)
268        );
269    }
270
271    #[test]
272    fn removing_a_source_reveals_overlapping_propagation() {
273        let mut manager = LoadTicketManager::new();
274        let first = ChunkPos::new(0, 0);
275        let second = ChunkPos::new(5, 0);
276        let source_level = ChunkTicketLevel::FULL_CHUNK;
277        manager.apply_source_updates([
278            source(first, Some(source_level)),
279            source(second, Some(source_level)),
280        ]);
281        manager.run_all_updates();
282
283        manager.apply_source_update(source(first, None));
284        manager.run_all_updates();
285
286        assert_eq!(
287            manager.get_level(first),
288            ChunkTicketLevel::new(source_level.raw() + 5)
289        );
290        assert_eq!(manager.get_level(second), Some(source_level));
291    }
292
293    #[test]
294    fn removing_the_last_source_reports_removed_levels() {
295        let mut manager = LoadTicketManager::new();
296        let center = ChunkPos::new(0, 0);
297        manager.apply_source_update(source(center, Some(ChunkTicketLevel::MAX)));
298        manager.run_all_updates();
299        manager.take_changes();
300
301        manager.apply_source_update(source(center, None));
302        manager.run_all_updates();
303
304        assert_eq!(
305            manager.take_changes(),
306            vec![LoadLevelChange {
307                pos: center,
308                new_level: None,
309            }]
310        );
311        assert_eq!(manager.get_level(center), None);
312    }
313
314    #[test]
315    fn no_recalculation_occurs_without_source_changes() {
316        let mut manager = LoadTicketManager::new();
317        manager.apply_source_update(source(
318            ChunkPos::new(0, 0),
319            Some(ChunkTicketLevel::FULL_CHUNK),
320        ));
321        manager.run_all_updates();
322
323        assert!(!manager.is_dirty());
324        assert_eq!(manager.run_all_updates(), []);
325        assert!(!manager.is_dirty());
326    }
327
328    #[test]
329    fn full_chunk_source_keeps_the_loading_moat() {
330        let mut manager = LoadTicketManager::new();
331        let center = ChunkPos::new(0, 0);
332        let source_level = ChunkTicketLevel::for_entity_ticking_radius(1);
333        manager.apply_source_update(source(center, Some(source_level)));
334        manager.run_all_updates();
335
336        assert!(is_entity_ticking(manager.get_level(center)));
337        assert_eq!(
338            manager.get_level(ChunkPos::new(1, 0)),
339            Some(ChunkTicketLevel::ENTITY_TICKING_CHUNK)
340        );
341        assert_eq!(
342            manager.get_level(ChunkPos::new(2, 0)),
343            Some(ChunkTicketLevel::BLOCK_TICKING_CHUNK)
344        );
345        assert_eq!(
346            manager.get_level(ChunkPos::new(3, 0)),
347            Some(ChunkTicketLevel::FULL_CHUNK)
348        );
349    }
350
351    #[test]
352    fn maximum_player_view_distance_fits_ticket_level() {
353        assert_eq!(ChunkTicketLevel::ENTITY_TICKING_CHUNK.raw(), 128);
354        assert_eq!(ChunkTicketLevel::BLOCK_TICKING_CHUNK.raw(), 129);
355        assert_eq!(ChunkTicketLevel::FULL_CHUNK.raw(), 130);
356        assert_eq!(
357            ChunkTicketLevel::for_entity_ticking_radius(MAX_SUPPORTED_VIEW_DISTANCE).raw(),
358            0
359        );
360    }
361
362    #[test]
363    fn full_chunk_status_matches_vanilla_ticket_thresholds() {
364        assert_eq!(
365            ChunkTicketLevel::ENTITY_TICKING_CHUNK.full_status(),
366            FullChunkStatus::EntityTicking
367        );
368        assert_eq!(
369            ChunkTicketLevel::BLOCK_TICKING_CHUNK.full_status(),
370            FullChunkStatus::BlockTicking
371        );
372        assert_eq!(
373            ChunkTicketLevel::FULL_CHUNK.full_status(),
374            FullChunkStatus::Full
375        );
376        assert_eq!(full_status(None), FullChunkStatus::Inaccessible);
377    }
378
379    #[test]
380    fn ticket_level_for_status_allows_requested_status() {
381        for index in 0..=ChunkStatus::Full.get_index() {
382            let status = ChunkStatus::from_index(index).expect("index is in status range");
383            let ticket_level = ticket_level_for_status(status);
384            let allowed = generation_status(Some(ticket_level));
385            assert!(
386                allowed.is_some_and(|allowed| allowed >= status),
387                "{status:?} request mapped to level {ticket_level:?}, which allows {allowed:?}"
388            );
389        }
390    }
391
392    #[test]
393    fn non_full_ticket_level_maps_to_generation_status() {
394        let ticket_level = ticket_level_for_status(ChunkStatus::StructureStarts);
395
396        assert!(!ticket_level.is_full());
397        assert!(generation_status(Some(ticket_level)).is_some_and(|status| {
398            status >= ChunkStatus::StructureStarts && status != ChunkStatus::Full
399        }));
400    }
401
402    #[test]
403    fn ticket_level_for_status_creates_required_dependency_holders() {
404        for index in 0..=ChunkStatus::Full.get_index() {
405            let status = ChunkStatus::from_index(index).expect("index is in status range");
406            let ticket_level = ticket_level_for_status(status);
407            let propagation_radius = usize::from(ChunkTicketLevel::MAX.raw() - ticket_level.raw());
408            let required_radius = GENERATION_PYRAMID
409                .get_step_to(status)
410                .accumulated_dependencies
411                .get_radius();
412
413            assert!(
414                propagation_radius >= required_radius,
415                "{status:?} request maps to level {ticket_level:?}, propagation radius {propagation_radius}, required radius {required_radius}"
416            );
417        }
418    }
419}