1#![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
8pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct ChunkTicketLevel(u8);
24
25impl ChunkTicketLevel {
26 pub const STRONGEST: Self = Self(0);
28 pub const ENTITY_TICKING_CHUNK: Self = Self(MAX_SUPPORTED_VIEW_DISTANCE);
30 pub const BLOCK_TICKING_CHUNK: Self = Self(MAX_SUPPORTED_VIEW_DISTANCE + 1);
32 pub const FULL_CHUNK: Self = Self(FULL_CHUNK_LEVEL_RAW);
34 pub const MAX: Self = Self(MAX_LEVEL_RAW);
36
37 #[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 #[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 #[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 #[must_use]
61 pub const fn raw(self) -> u8 {
62 self.0
63 }
64
65 #[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#[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 GENERATION_PYRAMID
151 .get_step_to(ChunkStatus::Full)
152 .accumulated_dependencies
153 .get(distance)
154 }
155 }
156 }
157}
158
159#[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
180pub 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}