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::{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#[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#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub(crate) struct SourceLevelUpdate {
110 pub(crate) pos: ChunkPos,
111 pub(crate) level: Option<ChunkTicketLevel>,
112}
113
114#[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#[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 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 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 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 #[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 #[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 #[must_use]
248 pub(crate) fn initial_load_sources(&self) -> Vec<SourceLevelUpdate> {
249 self.initial_sources(Self::load_source_update)
250 }
251
252 #[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 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 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 #[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 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 #[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}