Skip to main content

steel_core/chunk/
chunk_ticket_storage.rs

1//! Authoritative chunk ticket source storage.
2
3use std::cmp::Ordering;
4
5use rustc_hash::FxHashMap;
6use serde::{Deserialize, Serialize};
7use steel_registry::{REGISTRY, RegistryExt, ticket_type::TicketTypeRef, vanilla_ticket_types};
8use steel_utils::{ChunkPos, Identifier};
9use thiserror::Error;
10
11use super::{chunk_ticket::ChunkTicket, chunk_ticket_manager::ChunkTicketLevel};
12
13pub(crate) const PORTAL_TICKET_RADIUS: u8 = 3;
14pub(crate) const ENDER_PEARL_TICKET_TIMEOUT_TICKS: i64 =
15    vanilla_ticket_types::ENDER_PEARL.timeout();
16const ENDER_PEARL_TICKET_RADIUS: u8 = 2;
17
18type StoredTickets = Vec<StoredChunkTicket>;
19
20/// Persistent chunk ticket saved data.
21#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub(crate) struct PersistentChunkTickets {
23    #[serde(default)]
24    tickets: Vec<PersistentChunkTicket>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28struct PersistentChunkTicket {
29    #[serde(rename = "type")]
30    ticket_type: Identifier,
31    chunk_x: i32,
32    chunk_z: i32,
33    level: u8,
34    #[serde(default)]
35    ticks_left: i64,
36}
37
38/// Invalid persisted chunk ticket data that prevents restoring the ticket storage.
39#[derive(Debug, Error, PartialEq, Eq)]
40pub(crate) enum ChunkTicketStorageLoadError {
41    #[error("unknown chunk ticket type `{0}`")]
42    UnknownTicketType(Identifier),
43    #[error("invalid chunk ticket level {level} for type `{ticket_type}`")]
44    InvalidTicketLevel { ticket_type: Identifier, level: u8 },
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48struct StoredChunkTicket {
49    ticket: ChunkTicket,
50    timed_generation: Option<u64>,
51}
52
53impl StoredChunkTicket {
54    const fn new(ticket: ChunkTicket, timed_generation: Option<u64>) -> Self {
55        Self {
56            ticket,
57            timed_generation,
58        }
59    }
60
61    fn to_persistent(self, pos: ChunkPos) -> Option<PersistentChunkTicket> {
62        let ticket_type = self.ticket.ticket_type();
63        ticket_type.persist().then(|| PersistentChunkTicket {
64            // Saved data owns its registry identifier snapshot.
65            ticket_type: ticket_type.key.clone(),
66            chunk_x: pos.0.x,
67            chunk_z: pos.0.y,
68            level: self.ticket.ticket_level().raw(),
69            ticks_left: self.ticket.ticks_left(),
70        })
71    }
72}
73
74/// One timed entry observed for the current world-tick expiration pass.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub(crate) struct TimedTicketExpiration {
77    pos: ChunkPos,
78    ticket: ChunkTicket,
79    generation: u64,
80}
81
82impl TimedTicketExpiration {
83    #[must_use]
84    pub(crate) const fn pos(self) -> ChunkPos {
85        self.pos
86    }
87
88    #[must_use]
89    pub(crate) const fn can_expire_if_unloaded(self) -> bool {
90        self.ticket.ticket_type().can_expire_if_unloaded()
91    }
92}
93
94#[must_use]
95const fn portal_ticket() -> ChunkTicket {
96    ChunkTicket::for_full_chunk_radius(&vanilla_ticket_types::PORTAL, PORTAL_TICKET_RADIUS)
97}
98
99#[must_use]
100const fn ender_pearl_ticket() -> ChunkTicket {
101    ChunkTicket::for_full_chunk_radius(
102        &vanilla_ticket_types::ENDER_PEARL,
103        ENDER_PEARL_TICKET_RADIUS,
104    )
105}
106
107/// One materialized source level for a propagation domain.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub(crate) struct SourceLevelUpdate {
110    pub(crate) pos: ChunkPos,
111    pub(crate) level: Option<ChunkTicketLevel>,
112}
113
114/// Source positions dirtied by one or more storage mutations.
115///
116/// Positions are present only when canonical source membership changed and may
117/// repeat when several mutations are merged.
118#[must_use = "dirty source positions must be forwarded to the propagation domains"]
119#[derive(Debug, Default, PartialEq, Eq)]
120pub(crate) struct SourceProjectionChanges {
121    pub(crate) load_positions: Vec<ChunkPos>,
122    pub(crate) simulation_positions: Vec<ChunkPos>,
123}
124
125impl SourceProjectionChanges {
126    fn for_ticket(pos: ChunkPos, ticket: ChunkTicket) -> Self {
127        let mut changes = Self::default();
128        if ticket.loading_level().is_some() {
129            changes.load_positions.push(pos);
130        }
131        if ticket.simulation_level().is_some() {
132            changes.simulation_positions.push(pos);
133        }
134        changes
135    }
136
137    pub(crate) fn merge(&mut self, mut other: Self) {
138        self.load_positions.append(&mut other.load_positions);
139        self.simulation_positions
140            .append(&mut other.simulation_positions);
141    }
142}
143
144/// Owns one logical ticket for each canonical type identity and level.
145#[derive(Debug, Default)]
146pub(crate) struct ChunkTicketStorage {
147    tickets: FxHashMap<ChunkPos, StoredTickets>,
148    timed_generation: u64,
149}
150
151impl ChunkTicketStorage {
152    #[must_use]
153    pub(crate) fn new() -> Self {
154        Self::default()
155    }
156
157    /// Restores registered ticket types from saved data.
158    pub(crate) fn from_persistent(
159        persistent: PersistentChunkTickets,
160    ) -> Result<Self, ChunkTicketStorageLoadError> {
161        let mut storage = Self::new();
162        for persistent_ticket in persistent.tickets {
163            storage.add_loaded_persistent_ticket(persistent_ticket)?;
164        }
165        Ok(storage)
166    }
167
168    /// Adds one canonical ticket or refreshes an existing matching type and level.
169    pub(crate) fn add_ticket(
170        &mut self,
171        pos: ChunkPos,
172        ticket: ChunkTicket,
173    ) -> SourceProjectionChanges {
174        let timed_generation = self.generation_for(ticket.ticket_type());
175        let tickets = self.tickets.entry(pos).or_default();
176
177        if let Some(stored) = tickets.iter_mut().find(|stored| stored.ticket == ticket) {
178            stored.ticket.reset_ticks_left();
179            stored.timed_generation = timed_generation;
180            return SourceProjectionChanges::default();
181        }
182
183        tickets.push(StoredChunkTicket::new(ticket, timed_generation));
184        SourceProjectionChanges::for_ticket(pos, ticket)
185    }
186
187    /// Removes the canonical ticket matching `ticket`'s type identity and level.
188    pub(crate) fn remove_ticket(
189        &mut self,
190        pos: ChunkPos,
191        ticket: ChunkTicket,
192    ) -> SourceProjectionChanges {
193        let Some(tickets) = self.tickets.get_mut(&pos) else {
194            return SourceProjectionChanges::default();
195        };
196        let Some(index) = tickets.iter().position(|stored| stored.ticket == ticket) else {
197            return SourceProjectionChanges::default();
198        };
199
200        let removed = tickets.swap_remove(index).ticket;
201        if tickets.is_empty() {
202            self.tickets.remove(&pos);
203        }
204        SourceProjectionChanges::for_ticket(pos, removed)
205    }
206
207    /// Returns the strongest loading ticket at `pos`.
208    #[must_use]
209    pub(crate) fn load_source_level(&self, pos: ChunkPos) -> Option<ChunkTicketLevel> {
210        self.tickets.get(&pos).and_then(|tickets| {
211            tickets
212                .iter()
213                .filter_map(|stored| stored.ticket.loading_level())
214                .min()
215        })
216    }
217
218    /// Returns the strongest simulation ticket supported by Steel's tracker at `pos`.
219    #[must_use]
220    pub(crate) fn simulation_source_level(&self, pos: ChunkPos) -> Option<ChunkTicketLevel> {
221        self.tickets.get(&pos).and_then(|tickets| {
222            tickets
223                .iter()
224                .filter_map(|stored| stored.ticket.simulation_level())
225                .filter(|level| level.is_block_ticking())
226                .min()
227        })
228    }
229
230    #[must_use]
231    pub(crate) fn load_source_update(&self, pos: ChunkPos) -> SourceLevelUpdate {
232        SourceLevelUpdate {
233            pos,
234            level: self.load_source_level(pos),
235        }
236    }
237
238    #[must_use]
239    pub(crate) fn simulation_source_update(&self, pos: ChunkPos) -> SourceLevelUpdate {
240        SourceLevelUpdate {
241            pos,
242            level: self.simulation_source_level(pos),
243        }
244    }
245
246    /// Enumerates the initial loading sources for propagation tracker seeding.
247    #[must_use]
248    pub(crate) fn initial_load_sources(&self) -> Vec<SourceLevelUpdate> {
249        self.initial_sources(Self::load_source_update)
250    }
251
252    /// Enumerates the initial simulation sources for propagation tracker seeding.
253    #[must_use]
254    pub(crate) fn initial_simulation_sources(&self) -> Vec<SourceLevelUpdate> {
255        self.initial_sources(Self::simulation_source_update)
256    }
257
258    fn initial_sources(
259        &self,
260        source_update: fn(&Self, ChunkPos) -> SourceLevelUpdate,
261    ) -> Vec<SourceLevelUpdate> {
262        let mut sources: Vec<_> = self
263            .tickets
264            .keys()
265            .copied()
266            .map(|pos| source_update(self, pos))
267            .filter(|update| update.level.is_some())
268            .collect();
269        sources.sort_unstable_by_key(|update| (update.pos.0.x, update.pos.0.y));
270        sources
271    }
272
273    /// Adds or refreshes Vanilla's post-portal ticket.
274    pub(crate) fn add_or_refresh_portal_ticket(
275        &mut self,
276        pos: ChunkPos,
277    ) -> SourceProjectionChanges {
278        self.add_ticket(pos, portal_ticket())
279    }
280
281    /// Adds or refreshes Vanilla's in-flight ender pearl ticket.
282    pub(crate) fn add_or_refresh_ender_pearl_ticket(
283        &mut self,
284        pos: ChunkPos,
285    ) -> SourceProjectionChanges {
286        self.add_ticket(pos, ender_pearl_ticket())
287    }
288
289    /// Snapshots the exact timed entries eligible for this world-tick pass.
290    #[must_use]
291    pub(crate) fn timed_ticket_expirations(&self) -> Vec<TimedTicketExpiration> {
292        let mut expirations = Vec::new();
293        for (&pos, tickets) in &self.tickets {
294            expirations.extend(tickets.iter().filter_map(|stored| {
295                stored
296                    .timed_generation
297                    .map(|generation| TimedTicketExpiration {
298                        pos,
299                        ticket: stored.ticket,
300                        generation,
301                    })
302            }));
303        }
304        expirations.sort_unstable_by(Self::compare_expirations);
305        expirations
306    }
307
308    /// Ages unchanged timed entries selected by the current world-tick pass.
309    pub(crate) fn tick_timed_tickets(
310        &mut self,
311        expirations: &[TimedTicketExpiration],
312    ) -> SourceProjectionChanges {
313        let mut changes = SourceProjectionChanges::default();
314
315        for &expiration in expirations {
316            let Some(tickets) = self.tickets.get_mut(&expiration.pos) else {
317                continue;
318            };
319            let Some(index) = tickets.iter().position(|stored| {
320                stored.ticket == expiration.ticket
321                    && stored.timed_generation == Some(expiration.generation)
322            }) else {
323                continue;
324            };
325
326            tickets[index].ticket.decrease_ticks_left();
327            if !tickets[index].ticket.is_timed_out() {
328                continue;
329            }
330
331            let expired = tickets.swap_remove(index).ticket;
332            if tickets.is_empty() {
333                self.tickets.remove(&expiration.pos);
334            }
335            changes.merge(SourceProjectionChanges::for_ticket(expiration.pos, expired));
336        }
337
338        changes
339    }
340
341    /// Converts every active ticket whose type has Vanilla's persist flag.
342    #[must_use]
343    pub(crate) fn to_persistent(&self) -> PersistentChunkTickets {
344        let mut tickets = Vec::new();
345        for (&pos, entries) in &self.tickets {
346            tickets.extend(
347                entries
348                    .iter()
349                    .filter_map(|stored| stored.to_persistent(pos)),
350            );
351        }
352        tickets.sort_unstable_by(Self::compare_persistent_tickets);
353        PersistentChunkTickets { tickets }
354    }
355
356    fn add_loaded_persistent_ticket(
357        &mut self,
358        persistent: PersistentChunkTicket,
359    ) -> Result<(), ChunkTicketStorageLoadError> {
360        let PersistentChunkTicket {
361            ticket_type,
362            chunk_x,
363            chunk_z,
364            level,
365            ticks_left,
366        } = persistent;
367        let Some(ticket_type_ref) = REGISTRY.ticket_types.by_key(&ticket_type) else {
368            return Err(ChunkTicketStorageLoadError::UnknownTicketType(ticket_type));
369        };
370        let Some(ticket_level) = ChunkTicketLevel::new(level) else {
371            return Err(ChunkTicketStorageLoadError::InvalidTicketLevel { ticket_type, level });
372        };
373
374        let ticket = ChunkTicket::from_saved(ticket_type_ref, ticket_level, ticks_left);
375        self.add_loaded_ticket(ChunkPos::new(chunk_x, chunk_z), ticket);
376        Ok(())
377    }
378
379    fn add_loaded_ticket(&mut self, pos: ChunkPos, ticket: ChunkTicket) {
380        let timed_generation = self.generation_for(ticket.ticket_type());
381        let tickets = self.tickets.entry(pos).or_default();
382        if let Some(stored) = tickets.iter_mut().find(|stored| stored.ticket == ticket) {
383            stored.ticket.reset_ticks_left();
384            stored.timed_generation = timed_generation;
385            return;
386        }
387
388        tickets.push(StoredChunkTicket::new(ticket, timed_generation));
389    }
390
391    fn generation_for(&mut self, ticket_type: TicketTypeRef) -> Option<u64> {
392        ticket_type
393            .has_timeout()
394            .then(|| self.allocate_timed_generation())
395    }
396
397    fn allocate_timed_generation(&mut self) -> u64 {
398        assert_ne!(
399            self.timed_generation,
400            u64::MAX,
401            "timed ticket generation exhausted"
402        );
403        self.timed_generation += 1;
404        self.timed_generation
405    }
406
407    fn compare_expirations(
408        left: &TimedTicketExpiration,
409        right: &TimedTicketExpiration,
410    ) -> Ordering {
411        (left.pos.0.x, left.pos.0.y)
412            .cmp(&(right.pos.0.x, right.pos.0.y))
413            .then_with(|| {
414                left.ticket
415                    .ticket_type()
416                    .key
417                    .cmp(&right.ticket.ticket_type().key)
418            })
419            .then_with(|| left.ticket.ticket_level().cmp(&right.ticket.ticket_level()))
420    }
421
422    fn compare_persistent_tickets(
423        left: &PersistentChunkTicket,
424        right: &PersistentChunkTicket,
425    ) -> Ordering {
426        (left.chunk_x, left.chunk_z)
427            .cmp(&(right.chunk_x, right.chunk_z))
428            .then_with(|| left.ticket_type.cmp(&right.ticket_type))
429            .then_with(|| left.level.cmp(&right.level))
430    }
431
432    #[cfg(test)]
433    fn ticket_count(&self) -> usize {
434        self.tickets.values().map(Vec::len).sum()
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use std::ptr;
441
442    use steel_registry::{init_vanilla_registry, steel_ticket_types};
443
444    use super::*;
445
446    fn init_registry() {
447        let _ = init_vanilla_registry();
448    }
449
450    #[test]
451    fn duplicate_add_refreshes_timeout_without_adding_multiplicity() {
452        let mut storage = ChunkTicketStorage::new();
453        let pos = ChunkPos::new(2, -3);
454        let ticket = portal_ticket();
455
456        let first = storage.add_ticket(pos, ticket);
457        let stale_expirations = storage.timed_ticket_expirations();
458        let _ = storage.tick_timed_tickets(&stale_expirations);
459        let duplicate = storage.add_ticket(pos, ticket);
460
461        assert_eq!(first.load_positions, vec![pos]);
462        assert_eq!(first.simulation_positions, vec![pos]);
463        assert_eq!(duplicate, SourceProjectionChanges::default());
464        assert_eq!(storage.ticket_count(), 1);
465        assert_eq!(storage.timed_ticket_expirations().len(), 1);
466
467        let _ = storage.tick_timed_tickets(&stale_expirations);
468        assert_eq!(
469            storage.tickets[&pos][0].ticket.ticks_left(),
470            vanilla_ticket_types::PORTAL.timeout()
471        );
472
473        let removal = storage.remove_ticket(pos, ticket);
474        assert_eq!(removal.load_positions, vec![pos]);
475        assert_eq!(removal.simulation_positions, vec![pos]);
476        assert_eq!(storage.ticket_count(), 0);
477    }
478
479    #[test]
480    fn type_flags_control_projections_persistence_and_expiration() {
481        init_registry();
482        let mut storage = ChunkTicketStorage::new();
483        let load_pos = ChunkPos::new(0, 0);
484        let simulation_pos = ChunkPos::new(1, 0);
485        let unknown_pos = ChunkPos::new(2, 0);
486        let level = ChunkTicketLevel::BLOCK_TICKING_CHUNK;
487
488        let loading = ChunkTicket::new(&vanilla_ticket_types::PLAYER_LOADING, level);
489        let simulation = ChunkTicket::new(&vanilla_ticket_types::PLAYER_SIMULATION, level);
490        let forced = ChunkTicket::new(&vanilla_ticket_types::FORCED, level);
491        let unknown = ChunkTicket::new(&vanilla_ticket_types::UNKNOWN, level);
492
493        assert_eq!(
494            storage.add_ticket(load_pos, loading).simulation_positions,
495            Vec::new()
496        );
497        assert_eq!(storage.load_source_level(load_pos), Some(level));
498        assert_eq!(storage.simulation_source_level(load_pos), None);
499
500        assert_eq!(
501            storage
502                .add_ticket(simulation_pos, simulation)
503                .load_positions,
504            Vec::new()
505        );
506        assert_eq!(storage.load_source_level(simulation_pos), None);
507        assert_eq!(storage.simulation_source_level(simulation_pos), Some(level));
508
509        let _ = storage.add_ticket(load_pos, forced);
510        let _ = storage.add_ticket(unknown_pos, unknown);
511        assert_eq!(storage.to_persistent().tickets.len(), 1);
512
513        let expirations = storage.timed_ticket_expirations();
514        let unknown_expiration = expirations
515            .iter()
516            .find(|expiration| expiration.pos() == unknown_pos)
517            .copied()
518            .expect("unknown ticket should be timed");
519        assert!(unknown_expiration.can_expire_if_unloaded());
520
521        let portal_pos = ChunkPos::new(3, 0);
522        let _ = storage.add_or_refresh_portal_ticket(portal_pos);
523        let portal_expiration = storage
524            .timed_ticket_expirations()
525            .into_iter()
526            .find(|expiration| expiration.pos() == portal_pos)
527            .expect("portal ticket should be timed");
528        assert!(!portal_expiration.can_expire_if_unloaded());
529
530        let pearl_pos = ChunkPos::new(4, 0);
531        let _ = storage.add_or_refresh_ender_pearl_ticket(pearl_pos);
532        let pearl_expiration = storage
533            .timed_ticket_expirations()
534            .into_iter()
535            .find(|expiration| expiration.pos() == pearl_pos)
536            .expect("ender pearl ticket should be timed");
537        assert!(!pearl_expiration.can_expire_if_unloaded());
538    }
539
540    #[test]
541    fn persistence_resolves_registered_type_and_rejects_invalid_values() {
542        init_registry();
543        let pos = ChunkPos::new(-8, 12);
544        let level = ChunkTicketLevel::BLOCK_TICKING_CHUNK;
545        let mut storage = ChunkTicketStorage::new();
546        let portal = ChunkTicket::from_saved(&vanilla_ticket_types::PORTAL, level, 123);
547        let forced = ChunkTicket::new(&vanilla_ticket_types::FORCED, level);
548        let internal = ChunkTicket::new(&steel_ticket_types::CHUNK_REQUEST, level);
549        let _ = storage.add_ticket(pos, portal);
550        let _ = storage.add_ticket(pos, forced);
551        let _ = storage.add_ticket(pos, internal);
552
553        let persistent = storage.to_persistent();
554        assert_eq!(persistent.tickets.len(), 2);
555        let restored = ChunkTicketStorage::from_persistent(persistent)
556            .expect("registered persistent ticket types should restore");
557        assert_eq!(restored.ticket_count(), 2);
558        let restored_tickets = &restored.tickets[&pos];
559        let forced_ticks_left = restored_tickets
560            .iter()
561            .find(|stored| {
562                ptr::eq(
563                    stored.ticket.ticket_type(),
564                    &raw const vanilla_ticket_types::FORCED,
565                )
566            })
567            .map(|stored| stored.ticket.ticks_left());
568        let portal_ticks_left = restored_tickets
569            .iter()
570            .find(|stored| {
571                ptr::eq(
572                    stored.ticket.ticket_type(),
573                    &raw const vanilla_ticket_types::PORTAL,
574                )
575            })
576            .map(|stored| stored.ticket.ticks_left());
577        assert_eq!(forced_ticks_left, Some(0));
578        assert_eq!(portal_ticks_left, Some(123));
579
580        let unknown = PersistentChunkTickets {
581            tickets: vec![PersistentChunkTicket {
582                ticket_type: Identifier::new_static("test", "missing"),
583                chunk_x: 0,
584                chunk_z: 0,
585                level: level.raw(),
586                ticks_left: 0,
587            }],
588        };
589        let error = ChunkTicketStorage::from_persistent(unknown)
590            .expect_err("unknown ticket type should be rejected");
591        assert_eq!(
592            error,
593            ChunkTicketStorageLoadError::UnknownTicketType(Identifier::new_static(
594                "test", "missing"
595            ))
596        );
597
598        let invalid_level = ChunkTicketLevel::MAX.raw() + 1;
599        let invalid = PersistentChunkTickets {
600            tickets: vec![PersistentChunkTicket {
601                ticket_type: Identifier::vanilla_static("forced"),
602                chunk_x: 0,
603                chunk_z: 0,
604                level: invalid_level,
605                ticks_left: 0,
606            }],
607        };
608        let error = ChunkTicketStorage::from_persistent(invalid)
609            .expect_err("out-of-range ticket level should be rejected");
610        assert_eq!(
611            error,
612            ChunkTicketStorageLoadError::InvalidTicketLevel {
613                ticket_type: Identifier::vanilla_static("forced"),
614                level: invalid_level,
615            }
616        );
617    }
618
619    #[test]
620    fn persistence_defaults_ticks_and_duplicate_activation_refreshes_timeout() {
621        init_registry();
622        let pos = ChunkPos::new(2, 3);
623        let level = ChunkTicketLevel::FULL_CHUNK;
624        let encoded = format!(
625            "tickets = [{{ type = \"minecraft:portal\", chunk_x = 2, chunk_z = 3, level = {} }}]",
626            level.raw()
627        );
628        let mut persistent: PersistentChunkTickets =
629            toml::from_str(&encoded).expect("ticket data without ticks_left should decode");
630        assert_eq!(persistent.tickets[0].ticks_left, 0);
631
632        persistent.tickets.push(PersistentChunkTicket {
633            ticket_type: Identifier::vanilla_static("portal"),
634            chunk_x: pos.0.x,
635            chunk_z: pos.0.y,
636            level: level.raw(),
637            ticks_left: 20,
638        });
639        let restored = ChunkTicketStorage::from_persistent(persistent)
640            .expect("duplicate registered tickets should restore");
641
642        assert_eq!(restored.ticket_count(), 1);
643        assert_eq!(
644            restored.tickets[&pos][0].ticket.ticks_left(),
645            vanilla_ticket_types::PORTAL.timeout()
646        );
647    }
648
649    #[test]
650    fn timed_decrement_wraps_like_java_long() {
651        init_registry();
652        let pos = ChunkPos::new(4, 5);
653        let persistent = PersistentChunkTickets {
654            tickets: vec![PersistentChunkTicket {
655                ticket_type: Identifier::vanilla_static("portal"),
656                chunk_x: pos.0.x,
657                chunk_z: pos.0.y,
658                level: ChunkTicketLevel::FULL_CHUNK.raw(),
659                ticks_left: i64::MIN,
660            }],
661        };
662        let mut storage =
663            ChunkTicketStorage::from_persistent(persistent).expect("portal ticket should restore");
664
665        let expirations = storage.timed_ticket_expirations();
666        let _ = storage.tick_timed_tickets(&expirations);
667
668        assert_eq!(storage.tickets[&pos][0].ticket.ticks_left(), i64::MAX);
669    }
670}