Skip to main content

steel_core/server/jobs/
teleport.rs

1use super::super::{
2    Arc, BlockPos, ChunkPos, ChunkRequest, ChunkRequestHandle, ChunkRequestState, ChunkStatus,
3    ChunkStorage, ChunkTicketKind, DVec3, DomainResidenceToken, EntityBase, NetworkConnection,
4    PendingWorldChangeToken, PersistentEntity, PersistentRootVehicle, Player, PlayerSpawnSearch,
5    PlayerSpawnSearchPoll, PreparedSpawn, RelativeMovement, RemovalReason, RespawnData, Server,
6    SharedEntity, TeleportPostTransition, TeleportTransition, Uuid, World, change_entity_world,
7    end_gateway, end_portal, is_allowed_to_enter_portal, nether_portal, vanilla_entities,
8};
9use super::{JobPoll, ServerJob, ServerJobContext};
10use crate::entity::LivingEntity as _;
11
12pub(in crate::server) struct RootVehicleRestoreJob {
13    player: Arc<Player>,
14    world: Arc<World>,
15    request: ChunkRequestHandle,
16    attach: [u8; 16],
17    root_uuid: [u8; 16],
18    residence_token: DomainResidenceToken,
19}
20
21impl RootVehicleRestoreJob {
22    pub(in crate::server) fn new(
23        player: Arc<Player>,
24        world: Arc<World>,
25        root_vehicle: &PersistentRootVehicle,
26        residence_token: DomainResidenceToken,
27    ) -> Option<Self> {
28        let root_chunk = persistent_entity_chunk(&root_vehicle.entity)?;
29        let request = world.chunk_map.request_chunk(
30            root_chunk,
31            ChunkStatus::StructureStarts,
32            ChunkTicketKind::PlayerSpawn,
33        );
34        Some(Self {
35            player,
36            world,
37            request,
38            attach: root_vehicle.attach,
39            root_uuid: root_vehicle.entity.uuid,
40            residence_token,
41        })
42    }
43}
44
45impl ServerJob for RootVehicleRestoreJob {
46    fn poll(&mut self, _context: &mut ServerJobContext) -> JobPoll {
47        let Some(player) = self.player.session.current_player() else {
48            return JobPoll::Finished;
49        };
50        if player.connection.closed() || !player.is_domain_residence_current(self.residence_token) {
51            return JobPoll::Finished;
52        }
53        let Some(server) = player.server.upgrade() else {
54            return JobPoll::Finished;
55        };
56        if !server.owns_online_player(&player) {
57            return JobPoll::Finished;
58        }
59        let Some(player_world) = server.live_world_for_player(&player) else {
60            // End credits temporarily detaches a connected player without ending
61            // their domain residence. Retain the payload and resume after respawn.
62            return JobPoll::Pending;
63        };
64        if !Arc::ptr_eq(&player_world, &self.world) {
65            return JobPoll::Finished;
66        }
67
68        match self.request.poll() {
69            ChunkRequestState::Pending { .. } => JobPoll::Pending,
70            ChunkRequestState::Cancelled => JobPoll::Finished,
71            ChunkRequestState::Ready => {
72                let Some(_ready) = self.request.ready_chunks() else {
73                    return JobPoll::Pending;
74                };
75                if let Some(root_vehicle) = player.take_matching_pending_root_vehicle(
76                    self.residence_token,
77                    &self.world,
78                    self.attach,
79                    self.root_uuid,
80                ) {
81                    restore_root_vehicle_for_player(&player, &self.world, root_vehicle);
82                }
83                JobPoll::Finished
84            }
85        }
86    }
87
88    fn cancel(&mut self) {
89        self.request.cancel();
90    }
91}
92
93pub(in crate::server) fn clear_pending_world_change(
94    entity: &SharedEntity,
95    pending_token: PendingWorldChangeToken,
96) {
97    if entity.finish_pending_world_change(pending_token)
98        && let Some(player) = entity.as_player()
99    {
100        player.retry_deferred_death_respawn();
101    }
102}
103
104fn finish_pending_world_change_after_transition(
105    entity: &SharedEntity,
106    pending_token: PendingWorldChangeToken,
107    changed_entity: Option<SharedEntity>,
108) {
109    match changed_entity {
110        Some(changed_entity) if Arc::ptr_eq(entity, &changed_entity) => {
111            clear_pending_world_change(&changed_entity, pending_token);
112        }
113        Some(_) => {}
114        None => {
115            clear_pending_world_change(entity, pending_token);
116        }
117    }
118}
119
120fn finish_portal_world_change(
121    entity: &SharedEntity,
122    pending_token: PendingWorldChangeToken,
123    changed_entity: Option<SharedEntity>,
124) -> JobPoll {
125    finish_pending_world_change_after_transition(entity, pending_token, changed_entity);
126    JobPoll::Finished
127}
128
129pub(in crate::server) fn portal_entity_still_valid(
130    entity: &SharedEntity,
131    source_world: &Arc<World>,
132    pending_token: PendingWorldChangeToken,
133) -> bool {
134    !entity.is_removed()
135        && entity.can_use_portal(false)
136        && entity.is_world_change_token_pending(pending_token)
137        && entity
138            .level()
139            .is_some_and(|world| Arc::ptr_eq(&world, source_world))
140        && source_world.contains_live_or_unloading_entity(entity)
141        && !entity
142            .as_player()
143            .is_some_and(|player| player.connection.closed())
144}
145
146fn poll_portal_chunks_until_ready(
147    request: &mut ChunkRequestHandle,
148    entity: &SharedEntity,
149    pending_token: PendingWorldChangeToken,
150) -> Option<JobPoll> {
151    match request.poll() {
152        ChunkRequestState::Pending { .. } => Some(JobPoll::Pending),
153        ChunkRequestState::Cancelled => {
154            clear_pending_world_change(entity, pending_token);
155            Some(JobPoll::Finished)
156        }
157        ChunkRequestState::Ready => {
158            if request.ready_chunks().is_some() {
159                None
160            } else {
161                Some(JobPoll::Pending)
162            }
163        }
164    }
165}
166
167const WORLD_SPAWN_SEARCH_READY_CANDIDATE_BUDGET: usize = 8;
168
169pub(in crate::server) struct WorldSpawnTeleportJob {
170    entity: SharedEntity,
171    source_world: Arc<World>,
172    target_world: Arc<World>,
173    pending_token: PendingWorldChangeToken,
174    rotation: (f32, f32),
175    phase: WorldSpawnTeleportPhase,
176}
177
178enum WorldSpawnTeleportPhase {
179    Searching(PlayerSpawnSearch),
180    Loading {
181        spawn: PreparedSpawn,
182        request: ChunkRequestHandle,
183    },
184}
185
186impl WorldSpawnTeleportJob {
187    pub(in crate::server) fn new(
188        entity: SharedEntity,
189        source_world: Arc<World>,
190        target_world: Arc<World>,
191        pending_token: PendingWorldChangeToken,
192    ) -> Result<Self, String> {
193        if entity.as_player().is_none() {
194            return Err("world spawn selection does not belong to a player".to_owned());
195        }
196        let (spawn_suggestion, rotation) = {
197            let level_data = target_world.level_data.read();
198            let data = level_data.data();
199            (data.spawn_pos(), (data.spawn.angle, 0.0))
200        };
201        let search = PlayerSpawnSearch::new(
202            &target_world,
203            spawn_suggestion,
204            target_world.default_gamemode,
205        )?;
206        Ok(Self {
207            entity,
208            source_world,
209            target_world,
210            pending_token,
211            rotation,
212            phase: WorldSpawnTeleportPhase::Searching(search),
213        })
214    }
215
216    fn still_valid(&self, server: &Server) -> bool {
217        let Some(player) = self.entity.as_player() else {
218            return false;
219        };
220        !self.entity.is_removed()
221            && !player.connection.closed()
222            && player.get_health() > 0.0
223            && !player.has_won_game()
224            && self
225                .entity
226                .is_world_change_token_pending(self.pending_token)
227            && self
228                .entity
229                .level()
230                .is_some_and(|world| Arc::ptr_eq(&world, &self.source_world))
231            && self.source_world.contains_player(player)
232            && self.source_world.domain() == self.target_world.domain()
233            && server
234                .worlds
235                .get(&self.target_world.key)
236                .is_some_and(|registered| Arc::ptr_eq(registered, &self.target_world))
237    }
238
239    fn finish_pending(&self) {
240        clear_pending_world_change(&self.entity, self.pending_token);
241    }
242}
243
244impl ServerJob for WorldSpawnTeleportJob {
245    fn poll(&mut self, context: &mut ServerJobContext) -> JobPoll {
246        let Some(server) = context.server() else {
247            self.finish_pending();
248            return JobPoll::Finished;
249        };
250        if !self.still_valid(&server) {
251            self.finish_pending();
252            return JobPoll::Finished;
253        }
254
255        loop {
256            match &mut self.phase {
257                WorldSpawnTeleportPhase::Searching(search) => {
258                    match search.poll_with_ready_candidate_budget(
259                        &self.target_world,
260                        WORLD_SPAWN_SEARCH_READY_CANDIDATE_BUDGET,
261                    ) {
262                        PlayerSpawnSearchPoll::Pending => return JobPoll::Pending,
263                        PlayerSpawnSearchPoll::Cancelled => {
264                            self.finish_pending();
265                            return JobPoll::Finished;
266                        }
267                        PlayerSpawnSearchPoll::Ready(position) => {
268                            let spawn = PreparedSpawn {
269                                position,
270                                rotation: self.rotation,
271                            };
272                            let request = self
273                                .target_world
274                                .request_player_spawn_chunks(spawn.position);
275                            self.phase = WorldSpawnTeleportPhase::Loading { spawn, request };
276                        }
277                    }
278                }
279                WorldSpawnTeleportPhase::Loading { spawn, request } => match request.poll() {
280                    ChunkRequestState::Pending { .. } => return JobPoll::Pending,
281                    ChunkRequestState::Cancelled => {
282                        self.finish_pending();
283                        return JobPoll::Finished;
284                    }
285                    ChunkRequestState::Ready => {
286                        if request.ready_chunks().is_none() {
287                            return JobPoll::Pending;
288                        }
289                        let transition = TeleportTransition {
290                            target_world: Arc::clone(&self.target_world),
291                            position: spawn.position,
292                            rotation: spawn.rotation,
293                            velocity: DVec3::ZERO,
294                            relatives: RelativeMovement::NONE,
295                            portal_cooldown: 0,
296                            as_passenger: false,
297                            post_transition: TeleportPostTransition::do_nothing(),
298                        };
299                        let changed_entity =
300                            change_entity_world(Arc::clone(&self.entity), &transition);
301                        return finish_portal_world_change(
302                            &self.entity,
303                            self.pending_token,
304                            changed_entity,
305                        );
306                    }
307                },
308            }
309        }
310    }
311
312    fn cancel(&mut self) {
313        self.finish_pending();
314        if let WorldSpawnTeleportPhase::Loading { request, .. } = &mut self.phase {
315            request.cancel();
316        }
317    }
318}
319
320pub(in crate::server) struct NetherPortalTeleportJob {
321    entity: SharedEntity,
322    source_world: Arc<World>,
323    target_world: Arc<World>,
324    portal_pos: BlockPos,
325    approximate_exit_pos: BlockPos,
326    to_nether: bool,
327    pending_token: PendingWorldChangeToken,
328    request: ChunkRequestHandle,
329}
330
331impl NetherPortalTeleportJob {
332    pub(in crate::server) fn new(
333        entity: SharedEntity,
334        source_world: Arc<World>,
335        target_world: Arc<World>,
336        portal_pos: BlockPos,
337        approximate_exit_pos: BlockPos,
338        to_nether: bool,
339        pending_token: PendingWorldChangeToken,
340    ) -> Self {
341        let request = target_world.chunk_map.request_square(
342            nether_portal::prewarm_center(approximate_exit_pos),
343            nether_portal::prewarm_chunk_radius(to_nether),
344            ChunkStatus::Full,
345            ChunkTicketKind::Portal,
346        );
347        Self {
348            entity,
349            source_world,
350            target_world,
351            portal_pos,
352            approximate_exit_pos,
353            to_nether,
354            pending_token,
355            request,
356        }
357    }
358
359    fn still_valid(&self) -> bool {
360        portal_entity_still_valid(&self.entity, &self.source_world, self.pending_token)
361    }
362
363    fn clear_pending(&self) {
364        clear_pending_world_change(&self.entity, self.pending_token);
365    }
366
367    fn finish_transition(&self, changed_entity: Option<SharedEntity>) {
368        finish_pending_world_change_after_transition(
369            &self.entity,
370            self.pending_token,
371            changed_entity,
372        );
373    }
374}
375
376impl ServerJob for NetherPortalTeleportJob {
377    fn poll(&mut self, context: &mut ServerJobContext) -> JobPoll {
378        if !self.still_valid() {
379            self.clear_pending();
380            return JobPoll::Finished;
381        }
382
383        if let Some(job_poll) =
384            poll_portal_chunks_until_ready(&mut self.request, &self.entity, self.pending_token)
385        {
386            return job_poll;
387        }
388
389        let Some(server) = context.server() else {
390            self.clear_pending();
391            return JobPoll::Finished;
392        };
393        if !is_allowed_to_enter_portal(&self.source_world, &self.target_world)
394            || !server.can_teleport_between_worlds(
395                self.entity.as_ref(),
396                &self.source_world,
397                &self.target_world,
398            )
399        {
400            self.clear_pending();
401            return JobPoll::Finished;
402        }
403        let Some(transition) = nether_portal::calculate_transition(
404            &self.source_world,
405            &self.target_world,
406            self.entity.as_ref(),
407            self.portal_pos,
408            self.approximate_exit_pos,
409            self.to_nether,
410        ) else {
411            self.clear_pending();
412            return JobPoll::Finished;
413        };
414        let changed_entity = change_entity_world(Arc::clone(&self.entity), &transition);
415        self.finish_transition(changed_entity);
416        JobPoll::Finished
417    }
418
419    fn cancel(&mut self) {
420        self.clear_pending();
421        self.request.cancel();
422    }
423}
424
425const END_PORTAL_RESPAWN_SEARCH_READY_CANDIDATE_BUDGET: usize = 8;
426
427struct EndPortalRespawnSpawn {
428    position: DVec3,
429    rotation: (f32, f32),
430}
431
432pub(in crate::server) struct EndPortalTeleportJob {
433    entity: SharedEntity,
434    source_world: Arc<World>,
435    pending_token: PendingWorldChangeToken,
436    phase: EndPortalTeleportPhase,
437}
438
439enum EndPortalTeleportPhase {
440    EntryToEnd {
441        target_world: Arc<World>,
442        request: ChunkRequestHandle,
443    },
444    ReturningEntity {
445        target_world: Arc<World>,
446        respawn_data: RespawnData,
447        request: ChunkRequestHandle,
448    },
449    SearchingPlayerRespawn {
450        target_world: Arc<World>,
451        respawn_data: RespawnData,
452        search: PlayerSpawnSearch,
453    },
454    LoadingPlayerRespawn {
455        target_world: Arc<World>,
456        spawn: EndPortalRespawnSpawn,
457        request: ChunkRequestHandle,
458    },
459}
460
461impl EndPortalTeleportJob {
462    pub(in crate::server) fn entry_to_end(
463        entity: SharedEntity,
464        source_world: Arc<World>,
465        target_world: Arc<World>,
466        pending_token: PendingWorldChangeToken,
467    ) -> Self {
468        let request = target_world.chunk_map.request_square(
469            end_portal::end_platform_prewarm_center(),
470            end_portal::end_platform_prewarm_chunk_radius(),
471            ChunkStatus::Full,
472            ChunkTicketKind::Portal,
473        );
474        Self {
475            entity,
476            source_world,
477            pending_token,
478            phase: EndPortalTeleportPhase::EntryToEnd {
479                target_world,
480                request,
481            },
482        }
483    }
484
485    pub(in crate::server) fn returning_entity(
486        entity: SharedEntity,
487        source_world: Arc<World>,
488        target_world: Arc<World>,
489        respawn_data: RespawnData,
490        pending_token: PendingWorldChangeToken,
491    ) -> Self {
492        let request = target_world.chunk_map.request_chunk(
493            end_portal::prewarm_center(respawn_data.pos()),
494            ChunkStatus::Full,
495            ChunkTicketKind::Portal,
496        );
497        Self {
498            entity,
499            source_world,
500            pending_token,
501            phase: EndPortalTeleportPhase::ReturningEntity {
502                target_world,
503                respawn_data,
504                request,
505            },
506        }
507    }
508
509    pub(in crate::server) fn returning_player(
510        entity: SharedEntity,
511        source_world: Arc<World>,
512        target_world: Arc<World>,
513        respawn_data: RespawnData,
514        pending_token: PendingWorldChangeToken,
515    ) -> Result<Self, String> {
516        let search = PlayerSpawnSearch::new(
517            &target_world,
518            respawn_data.pos(),
519            target_world.default_gamemode,
520        )?;
521        Ok(Self {
522            entity,
523            source_world,
524            pending_token,
525            phase: EndPortalTeleportPhase::SearchingPlayerRespawn {
526                target_world,
527                respawn_data,
528                search,
529            },
530        })
531    }
532
533    fn still_valid(&self) -> bool {
534        portal_entity_still_valid(&self.entity, &self.source_world, self.pending_token)
535    }
536
537    fn clear_pending(&self) {
538        clear_pending_world_change(&self.entity, self.pending_token);
539    }
540}
541
542impl ServerJob for EndPortalTeleportJob {
543    fn poll(&mut self, _context: &mut ServerJobContext) -> JobPoll {
544        if !self.still_valid() {
545            self.clear_pending();
546            return JobPoll::Finished;
547        }
548
549        let entity = Arc::clone(&self.entity);
550        let pending_token = self.pending_token;
551        loop {
552            match &mut self.phase {
553                EndPortalTeleportPhase::EntryToEnd {
554                    target_world,
555                    request,
556                } => {
557                    if let Some(job_poll) =
558                        poll_portal_chunks_until_ready(request, &entity, pending_token)
559                    {
560                        return job_poll;
561                    }
562                    let Some(transition) =
563                        end_portal::calculate_entry_transition(target_world, entity.as_ref())
564                    else {
565                        clear_pending_world_change(&entity, pending_token);
566                        return JobPoll::Finished;
567                    };
568                    let changed_entity = change_entity_world(Arc::clone(&entity), &transition);
569                    return finish_portal_world_change(&entity, pending_token, changed_entity);
570                }
571                EndPortalTeleportPhase::ReturningEntity {
572                    target_world,
573                    respawn_data,
574                    request,
575                } => {
576                    if let Some(job_poll) =
577                        poll_portal_chunks_until_ready(request, &entity, pending_token)
578                    {
579                        return job_poll;
580                    }
581                    let transition = end_portal::calculate_entity_return_transition(
582                        target_world,
583                        entity.as_ref(),
584                        respawn_data,
585                    );
586                    let changed_entity = change_entity_world(Arc::clone(&entity), &transition);
587                    return finish_portal_world_change(&entity, pending_token, changed_entity);
588                }
589                EndPortalTeleportPhase::SearchingPlayerRespawn {
590                    target_world,
591                    respawn_data,
592                    search,
593                } => match search.poll_with_ready_candidate_budget(
594                    target_world,
595                    END_PORTAL_RESPAWN_SEARCH_READY_CANDIDATE_BUDGET,
596                ) {
597                    PlayerSpawnSearchPoll::Pending => return JobPoll::Pending,
598                    PlayerSpawnSearchPoll::Cancelled => {
599                        clear_pending_world_change(&entity, pending_token);
600                        return JobPoll::Finished;
601                    }
602                    PlayerSpawnSearchPoll::Ready(position) => {
603                        let spawn = EndPortalRespawnSpawn {
604                            position,
605                            rotation: (respawn_data.yaw, respawn_data.pitch),
606                        };
607                        let request = target_world.request_player_spawn_chunks(position);
608                        self.phase = EndPortalTeleportPhase::LoadingPlayerRespawn {
609                            target_world: target_world.clone(),
610                            spawn,
611                            request,
612                        };
613                    }
614                },
615                EndPortalTeleportPhase::LoadingPlayerRespawn {
616                    target_world,
617                    spawn,
618                    request,
619                } => {
620                    if let Some(job_poll) =
621                        poll_portal_chunks_until_ready(request, &entity, pending_token)
622                    {
623                        return job_poll;
624                    }
625                    let transition = end_portal::calculate_player_return_transition(
626                        target_world,
627                        entity.as_ref(),
628                        spawn.position,
629                        spawn.rotation,
630                    );
631                    let changed_entity = change_entity_world(Arc::clone(&entity), &transition);
632                    return finish_portal_world_change(&entity, pending_token, changed_entity);
633                }
634            }
635        }
636    }
637
638    fn cancel(&mut self) {
639        self.clear_pending();
640        match &mut self.phase {
641            EndPortalTeleportPhase::EntryToEnd { request, .. }
642            | EndPortalTeleportPhase::ReturningEntity { request, .. }
643            | EndPortalTeleportPhase::LoadingPlayerRespawn { request, .. } => request.cancel(),
644            EndPortalTeleportPhase::SearchingPlayerRespawn { .. } => {}
645        }
646    }
647}
648
649pub(in crate::server) struct EndGatewayTeleportJob {
650    entity: SharedEntity,
651    source_world: Arc<World>,
652    portal_pos: BlockPos,
653    source_is_end: bool,
654    pending_token: PendingWorldChangeToken,
655    phase: EndGatewayTeleportPhase,
656}
657
658enum EndGatewayTeleportPhase {
659    LoadingReady { request: ChunkRequestHandle },
660    LoadingSearchPath { request: ChunkRequestHandle },
661}
662
663impl EndGatewayTeleportJob {
664    pub(in crate::server) fn new(
665        entity: SharedEntity,
666        source_world: Arc<World>,
667        portal_pos: BlockPos,
668        source_is_end: bool,
669        pending_token: PendingWorldChangeToken,
670    ) -> Option<Self> {
671        let preparation = end_gateway::initial_chunks(&source_world, portal_pos, source_is_end)?;
672        let phase = match preparation {
673            end_gateway::EndGatewayChunkPreparation::Ready(chunks) => {
674                EndGatewayTeleportPhase::LoadingReady {
675                    request: request_end_gateway_chunks(&source_world, chunks),
676                }
677            }
678            end_gateway::EndGatewayChunkPreparation::SearchPath(chunks) => {
679                EndGatewayTeleportPhase::LoadingSearchPath {
680                    request: request_end_gateway_chunks(&source_world, chunks),
681                }
682            }
683        };
684        Some(Self {
685            entity,
686            source_world,
687            portal_pos,
688            source_is_end,
689            pending_token,
690            phase,
691        })
692    }
693
694    fn still_valid(&self) -> bool {
695        portal_entity_still_valid(&self.entity, &self.source_world, self.pending_token)
696    }
697
698    fn clear_pending(&self) {
699        clear_pending_world_change(&self.entity, self.pending_token);
700    }
701}
702
703impl ServerJob for EndGatewayTeleportJob {
704    fn poll(&mut self, _context: &mut ServerJobContext) -> JobPoll {
705        if !self.still_valid() {
706            self.clear_pending();
707            return JobPoll::Finished;
708        }
709
710        let entity = Arc::clone(&self.entity);
711        let pending_token = self.pending_token;
712        let source_world = Arc::clone(&self.source_world);
713        let portal_pos = self.portal_pos;
714        let source_is_end = self.source_is_end;
715        loop {
716            match &mut self.phase {
717                EndGatewayTeleportPhase::LoadingReady { request } => match request.poll() {
718                    ChunkRequestState::Pending { .. } => return JobPoll::Pending,
719                    ChunkRequestState::Cancelled => {
720                        clear_pending_world_change(&entity, pending_token);
721                        return JobPoll::Finished;
722                    }
723                    ChunkRequestState::Ready => {
724                        let Some(_ready) = request.ready_chunks() else {
725                            return JobPoll::Pending;
726                        };
727                        let Some(transition) = end_gateway::calculate_transition(
728                            &source_world,
729                            entity.as_ref(),
730                            portal_pos,
731                            source_is_end,
732                        ) else {
733                            clear_pending_world_change(&entity, pending_token);
734                            return JobPoll::Finished;
735                        };
736                        let changed_entity = change_entity_world(Arc::clone(&entity), &transition);
737                        finish_pending_world_change_after_transition(
738                            &entity,
739                            pending_token,
740                            changed_entity,
741                        );
742                        return JobPoll::Finished;
743                    }
744                },
745                EndGatewayTeleportPhase::LoadingSearchPath { request } => match request.poll() {
746                    ChunkRequestState::Pending { .. } => return JobPoll::Pending,
747                    ChunkRequestState::Cancelled => {
748                        clear_pending_world_change(&entity, pending_token);
749                        return JobPoll::Finished;
750                    }
751                    ChunkRequestState::Ready => {
752                        let Some(_ready) = request.ready_chunks() else {
753                            return JobPoll::Pending;
754                        };
755                        let Some(chunks) = end_gateway::final_chunks_after_search(
756                            &source_world,
757                            portal_pos,
758                            source_is_end,
759                        ) else {
760                            clear_pending_world_change(&entity, pending_token);
761                            return JobPoll::Finished;
762                        };
763                        self.phase = EndGatewayTeleportPhase::LoadingReady {
764                            request: request_end_gateway_chunks(&source_world, chunks),
765                        };
766                    }
767                },
768            }
769        }
770    }
771
772    fn cancel(&mut self) {
773        self.clear_pending();
774        match &mut self.phase {
775            EndGatewayTeleportPhase::LoadingReady { request }
776            | EndGatewayTeleportPhase::LoadingSearchPath { request } => request.cancel(),
777        }
778    }
779}
780
781fn request_end_gateway_chunks(world: &Arc<World>, chunks: Vec<ChunkPos>) -> ChunkRequestHandle {
782    world.chunk_map.request_chunks(ChunkRequest {
783        status: ChunkStatus::Full,
784        positions: chunks,
785        ticket_kind: ChunkTicketKind::Portal,
786    })
787}
788
789fn persistent_entity_chunk(entity: &PersistentEntity) -> Option<ChunkPos> {
790    let pos = DVec3::new(entity.pos[0], entity.pos[1], entity.pos[2]);
791    if !pos.x.is_finite() || !pos.y.is_finite() || !pos.z.is_finite() {
792        tracing::warn!(
793            uuid = ?Uuid::from_bytes(entity.uuid),
794            "Skipping persisted entity with non-finite position {pos:?}",
795        );
796        return None;
797    }
798    Some(ChunkPos::from_entity_pos(pos))
799}
800
801fn restore_root_vehicle_for_player(
802    player: &Arc<Player>,
803    world: &Arc<World>,
804    root_vehicle: PersistentRootVehicle,
805) {
806    let Some(root_chunk) = persistent_entity_chunk(&root_vehicle.entity) else {
807        return;
808    };
809    let level = Arc::downgrade(world);
810    let entities =
811        ChunkStorage::persistent_to_entity_tree_at_level(&root_vehicle.entity, root_chunk, &level);
812    if entities.is_empty() {
813        tracing::warn!(
814            player = %player.gameprofile.name,
815            "Persisted RootVehicle did not recreate any runtime entities",
816        );
817        return;
818    }
819
820    let attach_uuid = Uuid::from_bytes(root_vehicle.attach);
821    let Some(attach_entity) = entities
822        .iter()
823        .find(|entity| entity.uuid() == attach_uuid)
824        .cloned()
825    else {
826        tracing::warn!(
827            player = %player.gameprofile.name,
828            attach = ?attach_uuid,
829            "Discarding persisted RootVehicle because the attach entity is missing",
830        );
831        discard_restored_entities(&entities);
832        return;
833    };
834
835    if let Err(error) = world.register_loaded_entity_tree(&entities) {
836        tracing::warn!(
837            player = %player.gameprofile.name,
838            attach = ?attach_uuid,
839            root = ?Uuid::from_bytes(root_vehicle.entity.uuid),
840            "Discarding persisted RootVehicle because its entity tree could not be registered: {error}",
841        );
842        discard_restored_entities(&entities);
843        return;
844    }
845
846    let player_entity: SharedEntity = player.clone();
847    EntityBase::restore_passenger_relationship(&attach_entity, &player_entity);
848    attach_entity.position_rider(player.as_ref());
849    player.send_restored_vehicle_mount_sync(attach_entity.as_ref());
850
851    world.mark_chunk_dirty(root_chunk);
852    for entity in &entities {
853        world.mark_chunk_dirty(ChunkPos::from_entity_pos(entity.position()));
854    }
855}
856
857fn discard_restored_entities(entities: &[SharedEntity]) {
858    for entity in entities {
859        entity.set_removed(RemovalReason::Discarded);
860    }
861}
862
863/// Re-spawns a single persisted ender pearl in its own world once the target
864/// chunk is loaded (vanilla `ServerPlayer.loadAndSpawnEnderPearl`).
865pub(in crate::server) struct EnderPearlRestoreJob {
866    player: Arc<Player>,
867    world: Arc<World>,
868    request: ChunkRequestHandle,
869    uuid: Uuid,
870    residence_token: DomainResidenceToken,
871}
872
873impl EnderPearlRestoreJob {
874    pub(in crate::server) fn new(
875        player: Arc<Player>,
876        world: Arc<World>,
877        entity: PersistentEntity,
878        residence_token: DomainResidenceToken,
879    ) -> Option<Self> {
880        let chunk = persistent_entity_chunk(&entity)?;
881        let uuid = Uuid::from_bytes(entity.uuid);
882        let request = world.chunk_map.request_chunk(
883            chunk,
884            ChunkStatus::StructureStarts,
885            ChunkTicketKind::PlayerSpawn,
886        );
887        Some(Self {
888            player,
889            world,
890            request,
891            uuid,
892            residence_token,
893        })
894    }
895}
896
897impl ServerJob for EnderPearlRestoreJob {
898    fn poll(&mut self, _context: &mut ServerJobContext) -> JobPoll {
899        let Some(player) = self.player.session.current_player() else {
900            return JobPoll::Finished;
901        };
902        // The pearl may live in another world in the same domain, so require a
903        // live same-domain owner rather than membership in the pearl's exact world.
904        if player.connection.closed() || !player.is_domain_residence_current(self.residence_token) {
905            return JobPoll::Finished;
906        }
907        let Some(server) = player.server.upgrade() else {
908            return JobPoll::Finished;
909        };
910        if !server.owns_online_player(&player) {
911            return JobPoll::Finished;
912        }
913        let Some(player_world) = server.live_world_for_player(&player) else {
914            // End credits temporarily detaches a connected player without ending
915            // their domain residence. Retain the payload and resume after respawn.
916            return JobPoll::Pending;
917        };
918        if player_world.domain() != self.world.domain() {
919            tracing::error!(
920                player = %player.gameprofile.name,
921                player_domain = player_world.domain(),
922                pearl_domain = self.world.domain(),
923                "Discarding a pending ender pearl whose owner changed domains without a new residence"
924            );
925            player.discard_pending_ender_pearl(self.residence_token, self.uuid);
926            return JobPoll::Finished;
927        }
928
929        match self.request.poll() {
930            ChunkRequestState::Pending { .. } => JobPoll::Pending,
931            ChunkRequestState::Cancelled => JobPoll::Finished,
932            ChunkRequestState::Ready => {
933                if self.request.ready_chunks().is_none() {
934                    return JobPoll::Pending;
935                }
936                let Some(pearl) = player.take_matching_pending_ender_pearl(
937                    self.residence_token,
938                    &self.world,
939                    self.uuid,
940                ) else {
941                    return JobPoll::Finished;
942                };
943                restore_ender_pearl_for_player(&player, &self.world, &pearl.entity);
944                JobPoll::Finished
945            }
946        }
947    }
948
949    fn cancel(&mut self) {
950        self.request.cancel();
951    }
952}
953
954fn restore_ender_pearl_for_player(
955    player: &Arc<Player>,
956    world: &Arc<World>,
957    entity: &PersistentEntity,
958) -> bool {
959    let Some(chunk) = persistent_entity_chunk(entity) else {
960        return false;
961    };
962    let level = Arc::downgrade(world);
963    let entities = ChunkStorage::persistent_to_entity_tree_at_level(entity, chunk, &level);
964    let Some(pearl) = entities.first().cloned() else {
965        tracing::warn!(
966            player = %player.gameprofile.name,
967            "Persisted ender pearl did not recreate a runtime entity",
968        );
969        return false;
970    };
971    if pearl.entity_type() != &vanilla_entities::ENDER_PEARL {
972        tracing::warn!(
973            player = %player.gameprofile.name,
974            entity_type = ?pearl.entity_type().key,
975            "Persisted ender pearl recreated a non-pearl root entity",
976        );
977        return false;
978    }
979
980    let owner: SharedEntity = player.clone();
981    for entity in &entities {
982        entity.restore_owner_reference(&owner);
983    }
984
985    if let Err(error) = world.register_loaded_entity_tree(&entities) {
986        tracing::warn!(
987            player = %player.gameprofile.name,
988            "Discarding persisted ender pearl because it could not be registered: {error}",
989        );
990        discard_restored_entities(&entities);
991        return false;
992    }
993
994    player.register_ender_pearl(&pearl);
995    world.chunk_map.place_ender_pearl_ticket(chunk);
996    world.mark_chunk_dirty(chunk);
997    true
998}