Skip to main content

steel_core/chunk/
chunk_ticket_manager.rs

1//! Chunk ticket management for tracking chunk levels and propagation.
2#![expect(missing_docs, reason = "internal module; items are self-explanatory")]
3
4use std::mem;
5
6use rustc_hash::{FxBuildHasher, FxHashMap};
7use serde::{Deserialize, Serialize};
8use smallvec::SmallVec;
9use steel_utils::ChunkPos;
10
11use crate::chunk::{chunk_pyramid::GENERATION_PYRAMID, status::ChunkStatus};
12
13/// The maximum supported view distance for players.
14pub const MAX_SUPPORTED_VIEW_DISTANCE: u8 = 128;
15const FULL_CHUNK_LEVEL_RAW: u8 = MAX_SUPPORTED_VIEW_DISTANCE + 2;
16const RADIUS_AROUND_FULL_CHUNK: u8 = GENERATION_PYRAMID
17    .get_step_to(ChunkStatus::Full)
18    .accumulated_dependencies
19    .get_radius_of(ChunkStatus::Empty) as u8;
20const MAX_LEVEL_RAW: u8 = FULL_CHUNK_LEVEL_RAW + RADIUS_AROUND_FULL_CHUNK;
21pub(crate) const PORTAL_TICKET_RADIUS: u8 = 3;
22const PORTAL_TICKET_TIMEOUT_TICKS: i64 = 300;
23pub(crate) const ENDER_PEARL_TICKET_TIMEOUT_TICKS: u32 = 40;
24const ENDER_PEARL_TICKET_RADIUS: u8 = 2;
25
26/// A chunk ticket level.
27///
28/// Lower levels are stronger tickets. `FULL_CHUNK_LEVEL_RAW` is the boundary
29/// where a propagated ticket can still make a chunk full; larger levels only
30/// keep dependency chunks loaded far enough for generation.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub struct ChunkTicketLevel(u8);
33
34impl ChunkTicketLevel {
35    /// The strongest possible ticket level.
36    pub const STRONGEST: Self = Self(0);
37    /// The weakest level whose full chunk may tick entities.
38    pub const ENTITY_TICKING_CHUNK: Self = Self(MAX_SUPPORTED_VIEW_DISTANCE);
39    /// The weakest level whose full chunk may tick blocks.
40    pub const BLOCK_TICKING_CHUNK: Self = Self(MAX_SUPPORTED_VIEW_DISTANCE + 1);
41    /// The weakest level that still permits a full chunk.
42    pub const FULL_CHUNK: Self = Self(FULL_CHUNK_LEVEL_RAW);
43    /// The weakest level kept by ticket propagation.
44    pub const MAX: Self = Self(MAX_LEVEL_RAW);
45
46    /// Builds a ticket level from its raw propagated value.
47    #[must_use]
48    pub const fn new(raw: u8) -> Option<Self> {
49        if raw <= MAX_LEVEL_RAW {
50            Some(Self(raw))
51        } else {
52            None
53        }
54    }
55
56    /// Builds a full-chunk ticket level from a square radius.
57    #[must_use]
58    pub const fn for_full_chunk_radius(radius: u8) -> Self {
59        Self(FULL_CHUNK_LEVEL_RAW.saturating_sub(radius))
60    }
61
62    /// Builds an entity-ticking ticket level from a square radius.
63    #[must_use]
64    pub const fn for_entity_ticking_radius(radius: u8) -> Self {
65        Self(Self::ENTITY_TICKING_CHUNK.0.saturating_sub(radius))
66    }
67
68    /// Returns the raw level value used for compact storage.
69    #[must_use]
70    pub const fn raw(self) -> u8 {
71        self.0
72    }
73
74    /// Returns vanilla's full-chunk status for this propagated level.
75    #[must_use]
76    pub const fn full_status(self) -> FullChunkStatus {
77        if self.0 <= Self::ENTITY_TICKING_CHUNK.0 {
78            FullChunkStatus::EntityTicking
79        } else if self.0 <= Self::BLOCK_TICKING_CHUNK.0 {
80            FullChunkStatus::BlockTicking
81        } else if self.0 <= Self::FULL_CHUNK.0 {
82            FullChunkStatus::Full
83        } else {
84            FullChunkStatus::Inaccessible
85        }
86    }
87
88    #[must_use]
89    pub const fn is_full(self) -> bool {
90        self.0 <= Self::FULL_CHUNK.0
91    }
92
93    #[must_use]
94    pub const fn is_block_ticking(self) -> bool {
95        self.0 <= Self::BLOCK_TICKING_CHUNK.0
96    }
97
98    #[must_use]
99    pub const fn is_entity_ticking(self) -> bool {
100        self.0 <= Self::ENTITY_TICKING_CHUNK.0
101    }
102
103    #[must_use]
104    const fn with_distance(self, distance: u8) -> Option<Self> {
105        let level = self.0.saturating_add(distance);
106        Self::new(level)
107    }
108
109    #[must_use]
110    const fn distance_to_max(self) -> u8 {
111        MAX_LEVEL_RAW - self.0
112    }
113
114    #[must_use]
115    const fn distance_to_block_ticking(self) -> u8 {
116        ChunkTicketLevel::BLOCK_TICKING_CHUNK
117            .0
118            .saturating_sub(self.0)
119    }
120}
121
122/// Vanilla full-chunk accessibility and ticking status.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
124pub enum FullChunkStatus {
125    Inaccessible,
126    Full,
127    BlockTicking,
128    EntityTicking,
129}
130
131impl FullChunkStatus {
132    #[must_use]
133    pub const fn is_or_after(self, status: Self) -> bool {
134        self as u8 >= status as u8
135    }
136}
137
138/// A chunk ticket's load and optional simulation strength.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct ChunkTicket {
141    load_level: ChunkTicketLevel,
142    simulation_level: Option<ChunkTicketLevel>,
143}
144
145impl ChunkTicket {
146    /// Creates a loading-only ticket.
147    #[must_use]
148    pub const fn loading(load_level: ChunkTicketLevel) -> Self {
149        Self {
150            load_level,
151            simulation_level: None,
152        }
153    }
154
155    /// Creates a loading-only ticket that makes chunks full within `radius`.
156    #[must_use]
157    pub const fn full_chunks(radius: u8) -> Self {
158        Self::loading(ChunkTicketLevel::for_full_chunk_radius(radius))
159    }
160
161    /// Creates a vanilla simulation ticket whose source level is `FULL - radius`.
162    #[must_use]
163    pub const fn simulated_full_chunks(radius: u8) -> Self {
164        let level = ChunkTicketLevel::for_full_chunk_radius(radius);
165        Self {
166            load_level: level,
167            simulation_level: Some(level),
168        }
169    }
170
171    /// Creates a ticket with separate full-load and entity-ticking radii.
172    #[must_use]
173    pub const fn full_chunks_with_entity_ticking(
174        load_radius: u8,
175        entity_ticking_radius: u8,
176    ) -> Self {
177        let entity_ticking_radius = if entity_ticking_radius > load_radius {
178            load_radius
179        } else {
180            entity_ticking_radius
181        };
182
183        Self {
184            load_level: ChunkTicketLevel::for_full_chunk_radius(load_radius),
185            simulation_level: Some(ChunkTicketLevel::for_entity_ticking_radius(
186                entity_ticking_radius,
187            )),
188        }
189    }
190
191    /// Creates a player ticket with Vanilla's two-chunk loading moat.
192    ///
193    /// Loading is entity-ticking through `view_distance`, block-ticking one
194    /// chunk farther, and full one chunk beyond that. Simulation is capped to
195    /// the view distance.
196    #[must_use]
197    pub const fn player(view_distance: u8, simulation_radius: u8) -> Self {
198        let simulation_radius = if simulation_radius > view_distance {
199            view_distance
200        } else {
201            simulation_radius
202        };
203
204        Self {
205            load_level: ChunkTicketLevel::for_entity_ticking_radius(view_distance),
206            simulation_level: Some(ChunkTicketLevel::for_entity_ticking_radius(
207                simulation_radius,
208            )),
209        }
210    }
211
212    #[must_use]
213    pub const fn load_level(self) -> ChunkTicketLevel {
214        self.load_level
215    }
216
217    #[must_use]
218    pub const fn simulation_level(self) -> Option<ChunkTicketLevel> {
219        self.simulation_level
220    }
221}
222
223#[must_use]
224pub const fn is_full(level: ChunkTicketLevel) -> bool {
225    level.is_full()
226}
227
228#[must_use]
229pub const fn full_status(level: Option<ChunkTicketLevel>) -> FullChunkStatus {
230    match level {
231        Some(level) => level.full_status(),
232        None => FullChunkStatus::Inaccessible,
233    }
234}
235
236#[must_use]
237pub const fn is_block_ticking(level: Option<ChunkTicketLevel>) -> bool {
238    match level {
239        Some(level) => level.is_block_ticking(),
240        None => false,
241    }
242}
243
244#[must_use]
245pub const fn is_entity_ticking(level: Option<ChunkTicketLevel>) -> bool {
246    match level {
247        Some(level) => level.is_entity_ticking(),
248        None => false,
249    }
250}
251
252#[must_use]
253pub const fn generation_status(level: Option<ChunkTicketLevel>) -> Option<ChunkStatus> {
254    match level {
255        None => None,
256        Some(level) => {
257            if is_full(level) {
258                Some(ChunkStatus::Full)
259            } else {
260                let distance = (level.raw() - FULL_CHUNK_LEVEL_RAW) as usize;
261                // Fallback to None if distance is out of bounds (simulating Vanilla logic)
262                GENERATION_PYRAMID
263                    .get_step_to(ChunkStatus::Full)
264                    .accumulated_dependencies
265                    .get(distance)
266            }
267        }
268    }
269}
270
271/// Returns the ticket level that permits a chunk to reach at least `status`.
272///
273/// This is derived from the full-chunk dependency pyramid so request tickets use
274/// the same propagation rules as player tickets.
275#[must_use]
276pub const fn ticket_level_for_status(status: ChunkStatus) -> ChunkTicketLevel {
277    if matches!(status, ChunkStatus::Full) {
278        ChunkTicketLevel::FULL_CHUNK
279    } else {
280        ChunkTicketLevel(
281            FULL_CHUNK_LEVEL_RAW
282                + GENERATION_PYRAMID
283                    .get_step_to(ChunkStatus::Full)
284                    .accumulated_dependencies
285                    .get_radius_of(status) as u8,
286        )
287    }
288}
289
290/// Up to 4 tickets stored inline per position.
291type TicketLevels = SmallVec<[ChunkTicket; 4]>;
292
293/// Persistent chunk ticket saved data.
294#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub(crate) struct PersistentChunkTickets {
296    #[serde(default)]
297    tickets: Vec<PersistentChunkTicket>,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301struct PersistentChunkTicket {
302    #[serde(rename = "type")]
303    kind: PersistentChunkTicketKind,
304    chunk_x: i32,
305    chunk_z: i32,
306    ticks_left: i64,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311enum PersistentChunkTicketKind {
312    Portal,
313}
314
315/// Timed chunk tickets owned by vanilla gameplay systems.
316#[derive(Debug, Default)]
317pub(crate) struct TimedChunkTickets {
318    tickets: Vec<TimedChunkTicket>,
319}
320
321impl TimedChunkTickets {
322    /// Restores timed tickets from persistent saved data.
323    pub(crate) fn from_persistent(persistent: PersistentChunkTickets) -> Self {
324        let mut timed_tickets = Self::default();
325        for ticket in persistent.tickets {
326            timed_tickets.add_loaded_persistent_ticket(ticket);
327        }
328        timed_tickets
329    }
330
331    /// Converts active timed tickets to persistent saved data.
332    pub(crate) fn to_persistent(&self) -> PersistentChunkTickets {
333        PersistentChunkTickets {
334            tickets: self
335                .tickets
336                .iter()
337                .copied()
338                .filter_map(TimedChunkTicket::to_persistent)
339                .collect(),
340        }
341    }
342
343    /// Inserts restored timed ticket sources into the active ticket manager.
344    pub(crate) fn activate_all(&self, ticket_manager: &mut ChunkTicketManager) {
345        for ticket in &self.tickets {
346            ticket_manager.add_ticket(ticket.pos, ticket.ticket);
347        }
348    }
349
350    /// Adds or refreshes vanilla's portal ticket.
351    pub(crate) fn add_portal_ticket(&mut self, pos: ChunkPos) -> Option<ChunkTicket> {
352        self.add_or_reset(
353            TimedChunkTicketKind::Portal,
354            pos,
355            portal_ticket(),
356            PORTAL_TICKET_TIMEOUT_TICKS,
357        )
358    }
359
360    /// Adds or refreshes vanilla's in-flight ender pearl ticket.
361    pub(crate) fn add_ender_pearl_ticket(&mut self, pos: ChunkPos) -> Option<ChunkTicket> {
362        self.add_or_reset(
363            TimedChunkTicketKind::EnderPearl,
364            pos,
365            ender_pearl_ticket(),
366            i64::from(ENDER_PEARL_TICKET_TIMEOUT_TICKS),
367        )
368    }
369
370    /// Decrements timed tickets and returns sources that expired this tick.
371    pub(crate) fn tick(
372        &mut self,
373        mut can_expire: impl FnMut(ChunkPos) -> bool,
374    ) -> Vec<(ChunkPos, ChunkTicket)> {
375        let mut expired_tickets = Vec::new();
376        let mut index = 0;
377        while index < self.tickets.len() {
378            let ticket = &mut self.tickets[index];
379            if !can_expire(ticket.pos) {
380                index += 1;
381                continue;
382            }
383
384            ticket.ticks_left -= 1;
385            if ticket.ticks_left >= 0 {
386                index += 1;
387                continue;
388            }
389
390            let expired = self.tickets.swap_remove(index);
391            expired_tickets.push((expired.pos, expired.ticket));
392        }
393        expired_tickets
394    }
395
396    fn add_or_reset(
397        &mut self,
398        kind: TimedChunkTicketKind,
399        pos: ChunkPos,
400        ticket: ChunkTicket,
401        ticks_left: i64,
402    ) -> Option<ChunkTicket> {
403        if let Some(existing) = self
404            .tickets
405            .iter_mut()
406            .find(|entry| entry.kind == kind && entry.pos == pos && entry.ticket == ticket)
407        {
408            existing.ticks_left = ticks_left;
409            return None;
410        }
411
412        self.tickets.push(TimedChunkTicket {
413            kind,
414            pos,
415            ticket,
416            ticks_left,
417        });
418        Some(ticket)
419    }
420
421    fn add_loaded_persistent_ticket(&mut self, persistent: PersistentChunkTicket) {
422        match persistent.kind {
423            PersistentChunkTicketKind::Portal => {
424                self.add_loaded_portal_ticket(
425                    ChunkPos::new(persistent.chunk_x, persistent.chunk_z),
426                    persistent.ticks_left,
427                );
428            }
429        }
430    }
431
432    fn add_loaded_portal_ticket(&mut self, pos: ChunkPos, ticks_left: i64) {
433        if let Some(existing) = self.tickets.iter_mut().find(|entry| {
434            entry.kind == TimedChunkTicketKind::Portal
435                && entry.pos == pos
436                && entry.ticket == portal_ticket()
437        }) {
438            existing.ticks_left = PORTAL_TICKET_TIMEOUT_TICKS;
439            return;
440        }
441
442        self.tickets.push(TimedChunkTicket::portal(pos, ticks_left));
443    }
444
445    #[cfg(test)]
446    #[must_use]
447    const fn len(&self) -> usize {
448        self.tickets.len()
449    }
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453struct TimedChunkTicket {
454    kind: TimedChunkTicketKind,
455    pos: ChunkPos,
456    ticket: ChunkTicket,
457    ticks_left: i64,
458}
459
460impl TimedChunkTicket {
461    const fn portal(pos: ChunkPos, ticks_left: i64) -> Self {
462        Self {
463            kind: TimedChunkTicketKind::Portal,
464            pos,
465            ticket: portal_ticket(),
466            ticks_left,
467        }
468    }
469
470    const fn to_persistent(self) -> Option<PersistentChunkTicket> {
471        match self.kind {
472            TimedChunkTicketKind::Portal => Some(PersistentChunkTicket {
473                kind: PersistentChunkTicketKind::Portal,
474                chunk_x: self.pos.0.x,
475                chunk_z: self.pos.0.y,
476                ticks_left: self.ticks_left,
477            }),
478            TimedChunkTicketKind::EnderPearl => None,
479        }
480    }
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484enum TimedChunkTicketKind {
485    Portal,
486    EnderPearl,
487}
488
489#[must_use]
490const fn portal_ticket() -> ChunkTicket {
491    ChunkTicket::simulated_full_chunks(PORTAL_TICKET_RADIUS)
492}
493
494#[must_use]
495const fn ender_pearl_ticket() -> ChunkTicket {
496    ChunkTicket::simulated_full_chunks(ENDER_PEARL_TICKET_RADIUS)
497}
498
499/// A level change for a chunk position.
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501pub struct LevelChange {
502    pub pos: ChunkPos,
503    /// `Some(level)` if level changed or added, `None` if removed.
504    pub new_level: Option<ChunkTicketLevel>,
505    /// `Some(level)` if simulation changed or added, `None` if removed.
506    pub new_simulation_level: Option<ChunkTicketLevel>,
507}
508
509/// Chunk ticket propagation.
510/// Lower levels = higher priority. Multiple tickets per position supported.
511#[derive(Debug)]
512pub struct ChunkTicketManager {
513    tickets: FxHashMap<ChunkPos, TicketLevels>,
514    levels: FxHashMap<ChunkPos, ChunkTicketLevel>,
515    simulation_levels: FxHashMap<ChunkPos, ChunkTicketLevel>,
516    dirty: bool,
517    /// Tracks changes from the last `run_all_updates()` call.
518    changes: Vec<LevelChange>,
519}
520
521impl Default for ChunkTicketManager {
522    fn default() -> Self {
523        Self::new()
524    }
525}
526
527impl ChunkTicketManager {
528    #[must_use]
529    pub fn new() -> Self {
530        Self {
531            tickets: FxHashMap::default(),
532            levels: FxHashMap::default(),
533            simulation_levels: FxHashMap::default(),
534            dirty: false,
535            changes: Vec::new(),
536        }
537    }
538
539    /// Adds a ticket source. Multiple tickets can exist at the same position.
540    pub fn add_ticket(&mut self, pos: ChunkPos, ticket: ChunkTicket) {
541        self.tickets.entry(pos).or_default().push(ticket);
542        self.dirty = true;
543    }
544
545    /// Removes one ticket matching `(pos, ticket)`. Returns true if found.
546    pub fn remove_ticket(&mut self, pos: ChunkPos, ticket: ChunkTicket) -> bool {
547        if let Some(tickets) = self.tickets.get_mut(&pos)
548            && let Some(idx) = tickets.iter().position(|stored| *stored == ticket)
549        {
550            tickets.swap_remove(idx);
551            self.dirty = true;
552            if tickets.is_empty() {
553                self.tickets.remove(&pos);
554            }
555            return true;
556        }
557        false
558    }
559
560    /// Removes all tickets at position. Returns true if any were present.
561    pub fn remove_all_tickets_at(&mut self, pos: ChunkPos) -> bool {
562        let removed = self.tickets.remove(&pos).is_some();
563        if removed {
564            self.dirty = true;
565        }
566        removed
567    }
568
569    /// Returns the minimum ticket level at position.
570    #[must_use]
571    pub fn get_ticket(&self, pos: ChunkPos) -> Option<ChunkTicketLevel> {
572        self.tickets
573            .get(&pos)
574            .and_then(|tickets| tickets.iter().map(|ticket| ticket.load_level()).min())
575    }
576
577    /// Iterator over the tickets currently held at `pos`.
578    pub fn tickets_at(&self, pos: ChunkPos) -> impl Iterator<Item = ChunkTicket> + '_ {
579        self.tickets
580            .get(&pos)
581            .into_iter()
582            .flat_map(|tickets| tickets.iter().copied())
583    }
584
585    /// Iterator over (position, `min_level`) for all ticket sources.
586    pub fn tickets(&self) -> impl Iterator<Item = (ChunkPos, ChunkTicketLevel)> + '_ {
587        self.tickets.iter().filter_map(|(&pos, tickets)| {
588            tickets
589                .iter()
590                .map(|ticket| ticket.load_level())
591                .min()
592                .map(|level| (pos, level))
593        })
594    }
595
596    #[must_use]
597    pub fn ticket_count(&self) -> usize {
598        self.tickets.values().map(smallvec::SmallVec::len).sum()
599    }
600
601    #[must_use]
602    pub fn ticket_position_count(&self) -> usize {
603        self.tickets.len()
604    }
605
606    /// Propagates all tickets. Only runs if dirty.
607    /// Returns a slice of changes (added/updated/removed levels).
608    pub fn run_all_updates(&mut self) -> &[LevelChange] {
609        self.changes.clear();
610
611        if !self.dirty {
612            return &self.changes;
613        }
614
615        // Swap out old levels to compare against later, reusing capacity
616        let old_capacity = self.levels.capacity();
617        let old_levels = mem::replace(
618            &mut self.levels,
619            FxHashMap::with_capacity_and_hasher(old_capacity, FxBuildHasher),
620        );
621        let old_simulation_capacity = self.simulation_levels.capacity();
622        let old_simulation_levels = mem::replace(
623            &mut self.simulation_levels,
624            FxHashMap::with_capacity_and_hasher(old_simulation_capacity, FxBuildHasher),
625        );
626
627        self.dirty = false;
628
629        // Propagate each ticket source
630        for (&source_pos, tickets) in &self.tickets {
631            let Some(source_level) = tickets.iter().map(|ticket| ticket.load_level()).min() else {
632                continue;
633            };
634
635            let radius = i32::from(source_level.distance_to_max());
636            let sx = source_pos.0.x;
637            let sy = source_pos.0.y;
638
639            for dy in -radius..=radius {
640                for dx in -radius..=radius {
641                    let distance = dx.abs().max(dy.abs()) as u8;
642                    let Some(level) = source_level.with_distance(distance) else {
643                        continue;
644                    };
645
646                    let pos = ChunkPos::new(sx + dx, sy + dy);
647                    self.levels
648                        .entry(pos)
649                        .and_modify(|e| *e = (*e).min(level))
650                        .or_insert(level);
651                }
652            }
653
654            let Some(simulation_level) = tickets
655                .iter()
656                .filter_map(|ticket| ticket.simulation_level())
657                .min()
658            else {
659                continue;
660            };
661
662            let radius = i32::from(simulation_level.distance_to_block_ticking());
663            for dy in -radius..=radius {
664                for dx in -radius..=radius {
665                    let distance = dx.abs().max(dy.abs()) as u8;
666                    let Some(level) = simulation_level.with_distance(distance) else {
667                        continue;
668                    };
669
670                    let pos = ChunkPos::new(sx + dx, sy + dy);
671                    self.simulation_levels
672                        .entry(pos)
673                        .and_modify(|e| *e = (*e).min(level))
674                        .or_insert(level);
675                }
676            }
677        }
678
679        // Find changed/added levels
680        for (&pos, &new_level) in &self.levels {
681            match old_levels.get(&pos) {
682                Some(&old_level) if old_level == new_level => {} // No change
683                _ => self.changes.push(LevelChange {
684                    pos,
685                    new_level: Some(new_level),
686                    new_simulation_level: self.simulation_levels.get(&pos).copied(),
687                }),
688            }
689        }
690
691        // Find removed levels
692        for &pos in old_levels.keys() {
693            if !self.levels.contains_key(&pos) {
694                self.changes.push(LevelChange {
695                    pos,
696                    new_level: None,
697                    new_simulation_level: None,
698                });
699            }
700        }
701
702        self.record_simulation_only_changes(&old_levels, &old_simulation_levels);
703
704        &self.changes
705    }
706
707    /// Takes the change list produced by the last propagation pass.
708    pub(crate) fn take_changes(&mut self) -> Vec<LevelChange> {
709        mem::take(&mut self.changes)
710    }
711
712    /// Returns a drained change buffer for reuse by the next propagation pass.
713    pub(crate) fn recycle_changes(&mut self, mut changes: Vec<LevelChange>) {
714        debug_assert!(self.changes.is_empty());
715        changes.clear();
716        self.changes = changes;
717    }
718
719    fn record_simulation_only_changes(
720        &mut self,
721        old_levels: &FxHashMap<ChunkPos, ChunkTicketLevel>,
722        old_simulation_levels: &FxHashMap<ChunkPos, ChunkTicketLevel>,
723    ) {
724        for (&pos, &new_level) in &self.simulation_levels {
725            let load_changed = old_levels.get(&pos) != self.levels.get(&pos);
726            if load_changed {
727                continue;
728            }
729
730            match old_simulation_levels.get(&pos) {
731                Some(&old_level) if old_level == new_level => {}
732                _ => self.changes.push(LevelChange {
733                    pos,
734                    new_level: self.levels.get(&pos).copied(),
735                    new_simulation_level: Some(new_level),
736                }),
737            }
738        }
739
740        for &pos in old_simulation_levels.keys() {
741            let load_changed = old_levels.get(&pos) != self.levels.get(&pos);
742            if load_changed || self.simulation_levels.contains_key(&pos) {
743                continue;
744            }
745
746            self.changes.push(LevelChange {
747                pos,
748                new_level: self.levels.get(&pos).copied(),
749                new_simulation_level: None,
750            });
751        }
752    }
753
754    /// Returns the propagated level at position. Call `run_all_updates()` first.
755    #[must_use]
756    pub fn get_level(&self, pos: ChunkPos) -> Option<ChunkTicketLevel> {
757        self.levels.get(&pos).copied()
758    }
759
760    /// Returns the propagated simulation level at position. Call `run_all_updates()` first.
761    #[must_use]
762    pub fn get_simulation_level(&self, pos: ChunkPos) -> Option<ChunkTicketLevel> {
763        self.simulation_levels.get(&pos).copied()
764    }
765
766    #[cfg(test)]
767    #[must_use]
768    const fn is_dirty(&self) -> bool {
769        self.dirty
770    }
771
772    #[expect(dead_code, reason = "utility method for tests and future use")]
773    fn clear(&mut self) {
774        self.tickets.clear();
775        self.levels.clear();
776        self.simulation_levels.clear();
777        self.dirty = false;
778        self.changes.clear();
779    }
780
781    pub fn iter_levels(&self) -> impl Iterator<Item = (ChunkPos, ChunkTicketLevel)> + '_ {
782        self.levels.iter().map(|(&pos, &level)| (pos, level))
783    }
784
785    pub fn iter_simulation_levels(
786        &self,
787    ) -> impl Iterator<Item = (ChunkPos, ChunkTicketLevel)> + '_ {
788        self.simulation_levels
789            .iter()
790            .map(|(&pos, &level)| (pos, level))
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    fn add_portal_ticket(
799        manager: &mut ChunkTicketManager,
800        timed_tickets: &mut TimedChunkTickets,
801        pos: ChunkPos,
802    ) {
803        if let Some(ticket) = timed_tickets.add_portal_ticket(pos) {
804            manager.add_ticket(pos, ticket);
805        }
806    }
807
808    fn add_ender_pearl_ticket(
809        manager: &mut ChunkTicketManager,
810        timed_tickets: &mut TimedChunkTickets,
811        pos: ChunkPos,
812    ) {
813        if let Some(ticket) = timed_tickets.add_ender_pearl_ticket(pos) {
814            manager.add_ticket(pos, ticket);
815        }
816    }
817
818    fn tick_timed_tickets(
819        manager: &mut ChunkTicketManager,
820        timed_tickets: &mut TimedChunkTickets,
821        can_expire: impl FnMut(ChunkPos) -> bool,
822    ) {
823        for (pos, ticket) in timed_tickets.tick(can_expire) {
824            manager.remove_ticket(pos, ticket);
825        }
826    }
827
828    #[test]
829    fn test_single_ticket_propagation() {
830        let mut manager = ChunkTicketManager::new();
831        manager.add_ticket(
832            ChunkPos::new(0, 0),
833            ChunkTicket::loading(ChunkTicketLevel::STRONGEST),
834        );
835        manager.run_all_updates();
836
837        assert_eq!(
838            manager.get_level(ChunkPos::new(0, 0)),
839            ChunkTicketLevel::new(0)
840        );
841        assert_eq!(
842            manager.get_level(ChunkPos::new(-1, -1)),
843            ChunkTicketLevel::new(1)
844        );
845        assert_eq!(
846            manager.get_level(ChunkPos::new(0, -1)),
847            ChunkTicketLevel::new(1)
848        );
849        assert_eq!(
850            manager.get_level(ChunkPos::new(1, 0)),
851            ChunkTicketLevel::new(1)
852        );
853        assert_eq!(
854            manager.get_level(ChunkPos::new(-2, -2)),
855            ChunkTicketLevel::new(2)
856        );
857    }
858
859    #[test]
860    fn test_deferred_updates() {
861        let mut manager = ChunkTicketManager::new();
862        manager.add_ticket(
863            ChunkPos::new(0, 0),
864            ChunkTicket::loading(ChunkTicketLevel::STRONGEST),
865        );
866
867        assert!(manager.is_dirty());
868        assert_eq!(manager.get_level(ChunkPos::new(0, 0)), None);
869
870        manager.run_all_updates();
871        assert!(!manager.is_dirty());
872        assert_eq!(
873            manager.get_level(ChunkPos::new(0, 0)),
874            ChunkTicketLevel::new(0)
875        );
876    }
877
878    #[test]
879    fn test_multiple_tickets_same_position() {
880        let mut manager = ChunkTicketManager::new();
881        manager.add_ticket(
882            ChunkPos::new(0, 0),
883            ChunkTicket::loading(ChunkTicketLevel::new(2).expect("test level is valid")),
884        );
885        manager.add_ticket(
886            ChunkPos::new(0, 0),
887            ChunkTicket::loading(ChunkTicketLevel::STRONGEST),
888        );
889        manager.add_ticket(
890            ChunkPos::new(0, 0),
891            ChunkTicket::loading(ChunkTicketLevel::new(1).expect("test level is valid")),
892        );
893        manager.run_all_updates();
894
895        assert_eq!(
896            manager.get_ticket(ChunkPos::new(0, 0)),
897            ChunkTicketLevel::new(0)
898        );
899        assert_eq!(
900            manager.get_level(ChunkPos::new(0, 0)),
901            ChunkTicketLevel::new(0)
902        );
903    }
904
905    #[test]
906    fn test_overlapping_propagation() {
907        let mut manager = ChunkTicketManager::new();
908        manager.add_ticket(
909            ChunkPos::new(0, 0),
910            ChunkTicket::loading(ChunkTicketLevel::STRONGEST),
911        );
912        manager.add_ticket(
913            ChunkPos::new(3, 0),
914            ChunkTicket::loading(ChunkTicketLevel::STRONGEST),
915        );
916        manager.run_all_updates();
917
918        assert_eq!(
919            manager.get_level(ChunkPos::new(1, 0)),
920            ChunkTicketLevel::new(1)
921        );
922        assert_eq!(
923            manager.get_level(ChunkPos::new(2, 0)),
924            ChunkTicketLevel::new(1)
925        );
926    }
927
928    #[test]
929    fn test_remove_ticket() {
930        let mut manager = ChunkTicketManager::new();
931        let ticket = ChunkTicket::loading(ChunkTicketLevel::STRONGEST);
932        manager.add_ticket(ChunkPos::new(0, 0), ticket);
933        manager.add_ticket(ChunkPos::new(5, 0), ticket);
934        manager.run_all_updates();
935
936        assert_eq!(
937            manager.get_level(ChunkPos::new(0, 0)),
938            ChunkTicketLevel::new(0)
939        );
940        assert_eq!(
941            manager.get_level(ChunkPos::new(5, 0)),
942            ChunkTicketLevel::new(0)
943        );
944
945        assert!(manager.remove_ticket(ChunkPos::new(0, 0), ticket));
946        manager.run_all_updates();
947
948        assert_eq!(
949            manager.get_level(ChunkPos::new(0, 0)),
950            ChunkTicketLevel::new(5)
951        );
952        assert_eq!(
953            manager.get_level(ChunkPos::new(5, 0)),
954            ChunkTicketLevel::new(0)
955        );
956    }
957
958    #[test]
959    fn test_remove_all_tickets_at_position() {
960        let mut manager = ChunkTicketManager::new();
961        let ticket = ChunkTicket::loading(ChunkTicketLevel::STRONGEST);
962        manager.add_ticket(ChunkPos::new(0, 0), ticket);
963        manager.run_all_updates();
964
965        manager.remove_ticket(ChunkPos::new(0, 0), ticket);
966        manager.run_all_updates();
967
968        assert_eq!(manager.get_level(ChunkPos::new(0, 0)), None);
969    }
970
971    #[test]
972    fn test_multiple_tickets_same_position_with_removal() {
973        let mut manager = ChunkTicketManager::new();
974        let level_0 = ChunkTicket::loading(ChunkTicketLevel::STRONGEST);
975        let level_1 = ChunkTicket::loading(ChunkTicketLevel::new(1).expect("test level is valid"));
976        let level_2 = ChunkTicket::loading(ChunkTicketLevel::new(2).expect("test level is valid"));
977        manager.add_ticket(ChunkPos::new(0, 0), level_0);
978        manager.add_ticket(ChunkPos::new(0, 0), level_2);
979        manager.add_ticket(ChunkPos::new(0, 0), level_1);
980        manager.run_all_updates();
981
982        assert_eq!(
983            manager.get_ticket(ChunkPos::new(0, 0)),
984            ChunkTicketLevel::new(0)
985        );
986        assert_eq!(manager.ticket_count(), 3);
987
988        manager.remove_ticket(ChunkPos::new(0, 0), level_0);
989        manager.run_all_updates();
990        assert_eq!(
991            manager.get_ticket(ChunkPos::new(0, 0)),
992            ChunkTicketLevel::new(1)
993        );
994
995        manager.remove_ticket(ChunkPos::new(0, 0), level_1);
996        manager.run_all_updates();
997        assert_eq!(
998            manager.get_ticket(ChunkPos::new(0, 0)),
999            ChunkTicketLevel::new(2)
1000        );
1001    }
1002
1003    #[test]
1004    fn test_duplicate_tickets_same_level() {
1005        let mut manager = ChunkTicketManager::new();
1006        let ticket = ChunkTicket::loading(ChunkTicketLevel::STRONGEST);
1007        manager.add_ticket(ChunkPos::new(0, 0), ticket);
1008        manager.add_ticket(ChunkPos::new(0, 0), ticket);
1009        manager.run_all_updates();
1010
1011        assert_eq!(manager.ticket_count(), 2);
1012
1013        manager.remove_ticket(ChunkPos::new(0, 0), ticket);
1014        manager.run_all_updates();
1015        assert_eq!(manager.ticket_count(), 1);
1016        assert_eq!(
1017            manager.get_level(ChunkPos::new(0, 0)),
1018            ChunkTicketLevel::new(0)
1019        );
1020
1021        manager.remove_ticket(ChunkPos::new(0, 0), ticket);
1022        manager.run_all_updates();
1023        assert_eq!(manager.ticket_count(), 0);
1024        assert_eq!(manager.get_level(ChunkPos::new(0, 0)), None);
1025    }
1026
1027    #[test]
1028    fn portal_timed_ticket_loads_simulates_resets_and_expires_like_vanilla() {
1029        let mut manager = ChunkTicketManager::new();
1030        let mut timed_tickets = TimedChunkTickets::default();
1031        let center = ChunkPos::new(0, 0);
1032
1033        add_portal_ticket(&mut manager, &mut timed_tickets, center);
1034        add_portal_ticket(&mut manager, &mut timed_tickets, center);
1035        manager.run_all_updates();
1036
1037        assert_eq!(timed_tickets.len(), 1);
1038        assert_eq!(manager.ticket_count(), 1);
1039        assert!(is_full(
1040            manager.get_level(center).expect("ticket should load")
1041        ));
1042        assert!(is_entity_ticking(manager.get_simulation_level(center)));
1043        assert!(is_entity_ticking(
1044            manager.get_simulation_level(ChunkPos::new(1, 0))
1045        ));
1046        assert!(is_block_ticking(
1047            manager.get_simulation_level(ChunkPos::new(2, 0))
1048        ));
1049        assert!(!is_entity_ticking(
1050            manager.get_simulation_level(ChunkPos::new(2, 0))
1051        ));
1052        assert_eq!(manager.get_simulation_level(ChunkPos::new(3, 0)), None);
1053        assert!(is_full(
1054            manager
1055                .get_level(ChunkPos::new(3, 0))
1056                .expect("portal ticket should load the full outer ring")
1057        ));
1058        assert!(!manager.get_level(ChunkPos::new(4, 0)).is_some_and(is_full));
1059
1060        for _ in 0..PORTAL_TICKET_TIMEOUT_TICKS {
1061            tick_timed_tickets(&mut manager, &mut timed_tickets, |_| true);
1062        }
1063        manager.run_all_updates();
1064        assert_eq!(manager.ticket_count(), 1);
1065
1066        tick_timed_tickets(&mut manager, &mut timed_tickets, |_| true);
1067        manager.run_all_updates();
1068        assert_eq!(manager.ticket_count(), 0);
1069        assert_eq!(manager.get_level(center), None);
1070        assert_eq!(manager.get_simulation_level(center), None);
1071    }
1072
1073    #[test]
1074    fn timed_ticket_does_not_age_until_chunk_can_expire() {
1075        let mut manager = ChunkTicketManager::new();
1076        let mut timed_tickets = TimedChunkTickets::default();
1077        let center = ChunkPos::new(0, 0);
1078
1079        add_portal_ticket(&mut manager, &mut timed_tickets, center);
1080        for _ in 0..=PORTAL_TICKET_TIMEOUT_TICKS {
1081            tick_timed_tickets(&mut manager, &mut timed_tickets, |_| false);
1082        }
1083        manager.run_all_updates();
1084        assert_eq!(manager.ticket_count(), 1);
1085
1086        for _ in 0..=PORTAL_TICKET_TIMEOUT_TICKS {
1087            tick_timed_tickets(&mut manager, &mut timed_tickets, |_| true);
1088        }
1089        manager.run_all_updates();
1090        assert_eq!(manager.ticket_count(), 0);
1091    }
1092
1093    #[test]
1094    fn persistent_portal_ticket_round_trips_remaining_ticks() {
1095        let persistent = PersistentChunkTickets {
1096            tickets: vec![PersistentChunkTicket {
1097                kind: PersistentChunkTicketKind::Portal,
1098                chunk_x: -4,
1099                chunk_z: 7,
1100                ticks_left: 123,
1101            }],
1102        };
1103
1104        let timed_tickets = TimedChunkTickets::from_persistent(persistent);
1105        let restored = timed_tickets.to_persistent();
1106
1107        assert_eq!(
1108            restored,
1109            PersistentChunkTickets {
1110                tickets: vec![PersistentChunkTicket {
1111                    kind: PersistentChunkTicketKind::Portal,
1112                    chunk_x: -4,
1113                    chunk_z: 7,
1114                    ticks_left: 123,
1115                }],
1116            }
1117        );
1118    }
1119
1120    #[test]
1121    fn duplicate_persistent_portal_ticket_resets_timeout_like_vanilla_activation() {
1122        let persistent = PersistentChunkTickets {
1123            tickets: vec![
1124                PersistentChunkTicket {
1125                    kind: PersistentChunkTicketKind::Portal,
1126                    chunk_x: 2,
1127                    chunk_z: 3,
1128                    ticks_left: 10,
1129                },
1130                PersistentChunkTicket {
1131                    kind: PersistentChunkTicketKind::Portal,
1132                    chunk_x: 2,
1133                    chunk_z: 3,
1134                    ticks_left: 20,
1135                },
1136            ],
1137        };
1138
1139        let restored = TimedChunkTickets::from_persistent(persistent).to_persistent();
1140
1141        assert_eq!(
1142            restored,
1143            PersistentChunkTickets {
1144                tickets: vec![PersistentChunkTicket {
1145                    kind: PersistentChunkTicketKind::Portal,
1146                    chunk_x: 2,
1147                    chunk_z: 3,
1148                    ticks_left: PORTAL_TICKET_TIMEOUT_TICKS,
1149                }],
1150            }
1151        );
1152    }
1153
1154    #[test]
1155    fn test_no_recalculation_when_clean() {
1156        let mut manager = ChunkTicketManager::new();
1157        manager.add_ticket(
1158            ChunkPos::new(0, 0),
1159            ChunkTicket::loading(ChunkTicketLevel::STRONGEST),
1160        );
1161        manager.run_all_updates();
1162
1163        assert!(!manager.is_dirty());
1164        manager.run_all_updates();
1165        assert!(!manager.is_dirty());
1166    }
1167
1168    #[test]
1169    fn simulated_full_ticket_propagates_simulation_only_to_block_ticking_area() {
1170        let mut manager = ChunkTicketManager::new();
1171        manager.add_ticket(ChunkPos::new(0, 0), ChunkTicket::simulated_full_chunks(1));
1172        manager.run_all_updates();
1173
1174        assert!(is_block_ticking(
1175            manager.get_simulation_level(ChunkPos::new(0, 0))
1176        ));
1177        assert!(!is_entity_ticking(
1178            manager.get_simulation_level(ChunkPos::new(0, 0))
1179        ));
1180        assert_eq!(manager.get_simulation_level(ChunkPos::new(1, 1)), None);
1181        assert!(is_full(
1182            manager
1183                .get_level(ChunkPos::new(1, 1))
1184                .expect("ticket should load the full outer ring")
1185        ));
1186        assert_eq!(
1187            manager.get_level(ChunkPos::new(1, 1)),
1188            Some(ChunkTicketLevel::FULL_CHUNK)
1189        );
1190    }
1191
1192    #[test]
1193    fn player_ticket_keeps_full_loading_moat_and_caps_simulation_radius() {
1194        let mut manager = ChunkTicketManager::new();
1195        let center = ChunkPos::new(0, 0);
1196        manager.add_ticket(center, ChunkTicket::player(1, 3));
1197        manager.run_all_updates();
1198
1199        assert!(is_entity_ticking(manager.get_level(center)));
1200        assert_eq!(
1201            manager.get_level(ChunkPos::new(1, 0)),
1202            Some(ChunkTicketLevel::ENTITY_TICKING_CHUNK)
1203        );
1204        assert_eq!(
1205            manager.get_level(ChunkPos::new(2, 0)),
1206            Some(ChunkTicketLevel::BLOCK_TICKING_CHUNK)
1207        );
1208        assert_eq!(
1209            manager.get_level(ChunkPos::new(3, 0)),
1210            Some(ChunkTicketLevel::FULL_CHUNK)
1211        );
1212
1213        assert!(is_entity_ticking(manager.get_simulation_level(center)));
1214        assert!(is_entity_ticking(
1215            manager.get_simulation_level(ChunkPos::new(1, 0))
1216        ));
1217        assert!(is_block_ticking(
1218            manager.get_simulation_level(ChunkPos::new(2, 0))
1219        ));
1220        assert!(!is_entity_ticking(
1221            manager.get_simulation_level(ChunkPos::new(2, 0))
1222        ));
1223        assert_eq!(manager.get_simulation_level(ChunkPos::new(3, 0)), None);
1224    }
1225
1226    #[test]
1227    fn maximum_player_view_distance_fits_ticket_level() {
1228        let ticket = ChunkTicket::player(MAX_SUPPORTED_VIEW_DISTANCE, MAX_SUPPORTED_VIEW_DISTANCE);
1229
1230        assert_eq!(ChunkTicketLevel::ENTITY_TICKING_CHUNK.raw(), 128);
1231        assert_eq!(ChunkTicketLevel::BLOCK_TICKING_CHUNK.raw(), 129);
1232        assert_eq!(ChunkTicketLevel::FULL_CHUNK.raw(), 130);
1233        assert_eq!(ticket.load_level().raw(), 0);
1234        assert_eq!(
1235            ticket.simulation_level().map(ChunkTicketLevel::raw),
1236            Some(0)
1237        );
1238    }
1239
1240    #[test]
1241    fn full_chunk_status_matches_vanilla_ticket_thresholds() {
1242        assert_eq!(
1243            ChunkTicketLevel::ENTITY_TICKING_CHUNK.full_status(),
1244            FullChunkStatus::EntityTicking
1245        );
1246        assert_eq!(
1247            ChunkTicketLevel::BLOCK_TICKING_CHUNK.full_status(),
1248            FullChunkStatus::BlockTicking
1249        );
1250        assert_eq!(
1251            ChunkTicketLevel::FULL_CHUNK.full_status(),
1252            FullChunkStatus::Full
1253        );
1254        assert_eq!(full_status(None), FullChunkStatus::Inaccessible);
1255    }
1256
1257    #[test]
1258    fn ticket_level_for_status_allows_requested_status() {
1259        for index in 0..=ChunkStatus::Full.get_index() {
1260            let status = ChunkStatus::from_index(index).expect("index is in status range");
1261            let ticket_level = ticket_level_for_status(status);
1262            let allowed = generation_status(Some(ticket_level));
1263            assert!(
1264                allowed.is_some_and(|allowed| allowed >= status),
1265                "{status:?} request mapped to level {ticket_level:?}, which allows {allowed:?}"
1266            );
1267        }
1268    }
1269
1270    #[test]
1271    fn non_full_ticket_level_maps_to_generation_status() {
1272        let ticket_level = ticket_level_for_status(ChunkStatus::StructureStarts);
1273
1274        assert!(!ticket_level.is_full());
1275        assert!(generation_status(Some(ticket_level)).is_some_and(|status| {
1276            status >= ChunkStatus::StructureStarts && status != ChunkStatus::Full
1277        }));
1278    }
1279
1280    #[test]
1281    fn ticket_level_for_status_creates_required_dependency_holders() {
1282        for index in 0..=ChunkStatus::Full.get_index() {
1283            let status = ChunkStatus::from_index(index).expect("index is in status range");
1284            let ticket_level = ticket_level_for_status(status);
1285            let propagation_radius = usize::from(ticket_level.distance_to_max());
1286            let required_radius = GENERATION_PYRAMID
1287                .get_step_to(status)
1288                .accumulated_dependencies
1289                .get_radius();
1290
1291            assert!(
1292                propagation_radius >= required_radius,
1293                "{status:?} request maps to level {ticket_level:?}, propagation radius {propagation_radius}, required radius {required_radius}"
1294            );
1295        }
1296    }
1297
1298    #[test]
1299    fn ender_pearl_timed_ticket_loads_simulates_resets_and_expires_like_vanilla() {
1300        let mut manager = ChunkTicketManager::new();
1301        let mut timed_tickets = TimedChunkTickets::default();
1302        let center = ChunkPos::new(0, 0);
1303
1304        add_ender_pearl_ticket(&mut manager, &mut timed_tickets, center);
1305        add_ender_pearl_ticket(&mut manager, &mut timed_tickets, center);
1306        manager.run_all_updates();
1307
1308        assert_eq!(timed_tickets.len(), 1);
1309        assert_eq!(manager.ticket_count(), 1);
1310        assert!(is_full(
1311            manager.get_level(center).expect("ticket should load")
1312        ));
1313        assert!(is_full(
1314            manager
1315                .get_level(ChunkPos::new(i32::from(ENDER_PEARL_TICKET_RADIUS), 0))
1316                .expect("ender pearl ticket should load the full outer ring")
1317        ));
1318        assert!(
1319            !manager
1320                .get_level(ChunkPos::new(i32::from(ENDER_PEARL_TICKET_RADIUS) + 1, 0))
1321                .is_some_and(is_full)
1322        );
1323
1324        for _ in 0..ENDER_PEARL_TICKET_TIMEOUT_TICKS {
1325            tick_timed_tickets(&mut manager, &mut timed_tickets, |_| true);
1326        }
1327        manager.run_all_updates();
1328        assert_eq!(manager.ticket_count(), 1);
1329
1330        tick_timed_tickets(&mut manager, &mut timed_tickets, |_| true);
1331        manager.run_all_updates();
1332        assert_eq!(manager.ticket_count(), 0);
1333        assert_eq!(manager.get_level(center), None);
1334        assert_eq!(manager.get_simulation_level(center), None);
1335    }
1336
1337    #[test]
1338    fn ender_pearl_timed_ticket_does_not_age_until_chunk_can_expire() {
1339        let mut manager = ChunkTicketManager::new();
1340        let mut timed_tickets = TimedChunkTickets::default();
1341        let center = ChunkPos::new(0, 0);
1342
1343        add_ender_pearl_ticket(&mut manager, &mut timed_tickets, center);
1344        for _ in 0..=ENDER_PEARL_TICKET_TIMEOUT_TICKS {
1345            tick_timed_tickets(&mut manager, &mut timed_tickets, |_| false);
1346        }
1347        manager.run_all_updates();
1348        assert_eq!(manager.ticket_count(), 1);
1349
1350        for _ in 0..=ENDER_PEARL_TICKET_TIMEOUT_TICKS {
1351            tick_timed_tickets(&mut manager, &mut timed_tickets, |_| true);
1352        }
1353        manager.run_all_updates();
1354        assert_eq!(manager.ticket_count(), 0);
1355    }
1356
1357    #[test]
1358    fn ender_pearl_timed_ticket_propagates_like_a_manual_simulated_ticket() {
1359        let mut timed = ChunkTicketManager::new();
1360        let mut timed_tickets = TimedChunkTickets::default();
1361        let mut manual = ChunkTicketManager::new();
1362        let pos = ChunkPos::new(0, 0);
1363        add_ender_pearl_ticket(&mut timed, &mut timed_tickets, pos);
1364        manual.add_ticket(
1365            pos,
1366            ChunkTicket::simulated_full_chunks(ENDER_PEARL_TICKET_RADIUS),
1367        );
1368        timed.run_all_updates();
1369        manual.run_all_updates();
1370
1371        for dx in -i32::from(ENDER_PEARL_TICKET_RADIUS)..=i32::from(ENDER_PEARL_TICKET_RADIUS) {
1372            for dz in -i32::from(ENDER_PEARL_TICKET_RADIUS)..=i32::from(ENDER_PEARL_TICKET_RADIUS) {
1373                let p = ChunkPos::new(dx, dz);
1374                assert_eq!(timed.get_level(p), manual.get_level(p));
1375                assert_eq!(
1376                    timed.get_simulation_level(p),
1377                    manual.get_simulation_level(p)
1378                );
1379            }
1380        }
1381    }
1382
1383    #[test]
1384    fn ender_pearl_timed_ticket_is_not_persisted() {
1385        let mut manager = ChunkTicketManager::new();
1386        let mut timed_tickets = TimedChunkTickets::default();
1387
1388        add_portal_ticket(&mut manager, &mut timed_tickets, ChunkPos::new(0, 0));
1389        add_ender_pearl_ticket(&mut manager, &mut timed_tickets, ChunkPos::new(1, 0));
1390
1391        assert_eq!(
1392            timed_tickets.to_persistent(),
1393            PersistentChunkTickets {
1394                tickets: vec![PersistentChunkTicket {
1395                    kind: PersistentChunkTicketKind::Portal,
1396                    chunk_x: 0,
1397                    chunk_z: 0,
1398                    ticks_left: PORTAL_TICKET_TIMEOUT_TICKS,
1399                }],
1400            }
1401        );
1402    }
1403}