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