1use 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#[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
28fn 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#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub(crate) struct SourceLevelUpdate {
131 pub(crate) pos: ChunkPos,
132 pub(crate) level: Option<ChunkTicketLevel>,
133}
134
135#[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#[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 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 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 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 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 #[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 #[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 #[must_use]
287 pub(crate) fn initial_load_sources(&self) -> Vec<SourceLevelUpdate> {
288 self.initial_sources(Self::load_source_update)
289 }
290
291 #[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 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 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 #[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 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 #[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 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 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 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}