Skip to main content

steel_core/player/lifecycle/
respawn.rs

1use std::ptr;
2
3use super::*;
4use crate::entity::PendingWorldChangeToken;
5use crate::player::connection::NetworkConnection as _;
6use crate::{
7    behavior::{
8        BlockStateBehaviorExt as _,
9        blocks::{BedBlock, RespawnAnchorBlock},
10    },
11    chunk::{
12        chunk_request::{ChunkRequest, ChunkTicketKind},
13        status::ChunkStatus,
14    },
15    player::Player,
16};
17use steel_math::{DEGREE_90, wrap_degrees};
18use steel_protocol::packets::game::{CSound, SoundSource};
19use steel_registry::blocks::{
20    block_state_ext::BlockStateExt as _, properties::BlockStateProperties,
21};
22use steel_registry::{sound_events, vanilla_blocks};
23
24// Bed candidates reach two blocks out and collision checks read one block farther.
25const PERSONAL_RESPAWN_SEARCH_BLOCK_RADIUS: i32 = 3;
26
27/// Vanilla per-player respawn configuration.
28#[derive(Debug, Clone, PartialEq)]
29pub struct PlayerRespawnConfig {
30    /// Dimension, bed/anchor position, yaw, and pitch used for respawn.
31    pub respawn_data: RespawnData,
32    /// Whether respawning is forced even if the spawn block is unavailable.
33    pub forced: bool,
34}
35
36impl PlayerRespawnConfig {
37    #[must_use]
38    pub(crate) const fn new(respawn_data: RespawnData, forced: bool) -> Self {
39        Self {
40            respawn_data,
41            forced,
42        }
43    }
44
45    #[must_use]
46    pub(crate) fn is_same_position(&self, other: Option<&Self>) -> bool {
47        other.is_some_and(|other| self.respawn_data.global_pos == other.respawn_data.global_pos)
48    }
49}
50
51#[derive(Clone, Copy)]
52struct DeathRespawnSpawn {
53    position: DVec3,
54    rotation: (f32, f32),
55    anchor_deplete_sound_pos: Option<BlockPos>,
56    missing_respawn_block: bool,
57}
58
59struct PlayerRespawnJob {
60    player: Arc<Player>,
61    source_world: Arc<World>,
62    fallback_respawn: Option<(Arc<World>, RespawnData)>,
63    target_world: Arc<World>,
64    rotation: (f32, f32),
65    missing_respawn_block: bool,
66    kind: RespawnRequestKind,
67    pending_token: PendingWorldChangeToken,
68    packet_transition: Option<PlayerPacketTransition>,
69    phase: PlayerRespawnJobPhase,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73enum RespawnRequestKind {
74    Death,
75    EndCredits,
76}
77
78enum RespawnFinish {
79    Completed,
80    Aborted,
81    Failed,
82}
83
84enum PlayerRespawnJobPhase {
85    LoadingPersonalRespawnBlock {
86        config: PlayerRespawnConfig,
87        request: ChunkRequestHandle,
88    },
89    Searching(PlayerSpawnSearch),
90}
91
92impl PlayerRespawnJob {
93    fn new(
94        player: Arc<Player>,
95        source_world: Arc<World>,
96        fallback_world: Arc<World>,
97        fallback_respawn_data: RespawnData,
98        personal_respawn: Option<(Arc<World>, PlayerRespawnConfig)>,
99        kind: RespawnRequestKind,
100        pending_token: PendingWorldChangeToken,
101    ) -> Result<Self, String> {
102        let fallback_rotation = (fallback_respawn_data.yaw, fallback_respawn_data.pitch);
103        let (target_world, rotation, phase, fallback_respawn) =
104            if let Some((personal_world, config)) = personal_respawn {
105                let pos = config.respawn_data.pos();
106                let request = Self::request_personal_respawn_chunks(&personal_world, pos);
107                (
108                    personal_world,
109                    (config.respawn_data.yaw, config.respawn_data.pitch),
110                    PlayerRespawnJobPhase::LoadingPersonalRespawnBlock { config, request },
111                    Some((fallback_world, fallback_respawn_data)),
112                )
113            } else {
114                let fallback_search = PlayerSpawnSearch::new(
115                    &fallback_world,
116                    fallback_respawn_data.pos(),
117                    fallback_world.default_gamemode,
118                )?;
119                (
120                    fallback_world,
121                    fallback_rotation,
122                    PlayerRespawnJobPhase::Searching(fallback_search),
123                    None,
124                )
125            };
126        Ok(Self {
127            player,
128            source_world,
129            fallback_respawn,
130            target_world,
131            rotation,
132            missing_respawn_block: false,
133            kind,
134            pending_token,
135            packet_transition: None,
136            phase,
137        })
138    }
139
140    fn request_personal_respawn_chunks(world: &Arc<World>, pos: BlockPos) -> ChunkRequestHandle {
141        world.chunk_map.request_chunks(ChunkRequest {
142            status: ChunkStatus::Full,
143            positions: Self::personal_respawn_chunk_positions(pos),
144            ticket_kind: ChunkTicketKind::PlayerSpawn,
145        })
146    }
147
148    fn personal_respawn_chunk_positions(pos: BlockPos) -> Vec<ChunkPos> {
149        let min = ChunkPos::from_block_pos(pos.offset(
150            -PERSONAL_RESPAWN_SEARCH_BLOCK_RADIUS,
151            0,
152            -PERSONAL_RESPAWN_SEARCH_BLOCK_RADIUS,
153        ));
154        let max = ChunkPos::from_block_pos(pos.offset(
155            PERSONAL_RESPAWN_SEARCH_BLOCK_RADIUS,
156            0,
157            PERSONAL_RESPAWN_SEARCH_BLOCK_RADIUS,
158        ));
159        let mut positions = Vec::new();
160        for x in min.0.x..=max.0.x {
161            for z in min.0.y..=max.0.y {
162                positions.push(ChunkPos::new(x, z));
163            }
164        }
165        positions
166    }
167
168    fn still_valid(&self) -> bool {
169        !self.player.connection.closed()
170            && self.player.session.is_current_player(&self.player)
171            && self
172                .player
173                .server
174                .upgrade()
175                .is_some_and(|server| server.owns_online_player(&self.player))
176            && self
177                .player
178                .is_respawn_transition_pending(self.pending_token)
179            && match self.kind {
180                RespawnRequestKind::Death => {
181                    self.source_world.contains_player(&self.player)
182                        && Player::should_process_respawn(self.player.get_health())
183                }
184                RespawnRequestKind::EndCredits => {
185                    Arc::ptr_eq(&self.player.get_world(), &self.source_world)
186                        && self.player.has_won_game()
187                }
188            }
189    }
190
191    fn finish_pending(&mut self) {
192        self.player.finish_respawn_request(self.pending_token);
193        self.finish_packet_transition();
194    }
195
196    fn finish_packet_transition(&mut self) {
197        let Some(transition) = self.packet_transition.take() else {
198            return;
199        };
200        let Some(server) = self.player.server.upgrade() else {
201            return;
202        };
203        let Some(current_player) = self.player.session.current_player() else {
204            server.discard_player_packets(&self.player);
205            return;
206        };
207        if current_player.connection.closed() || !server.owns_online_player(&current_player) {
208            server.discard_player_packets(&current_player);
209            return;
210        }
211        if !server.finish_player_packet_transition(transition) {
212            tracing::warn!(
213                player = %current_player.gameprofile.name,
214                "Player respawn packet transition was no longer current"
215            );
216        }
217    }
218
219    fn discard_packet_transition(&mut self) {
220        if self.packet_transition.take().is_some()
221            && let Some(server) = self.player.server.upgrade()
222        {
223            server.discard_player_packets(&self.player);
224        }
225    }
226}
227
228impl ServerJob for PlayerRespawnJob {
229    fn poll(&mut self, _context: &mut ServerJobContext) -> JobPoll {
230        if !self.still_valid() {
231            self.finish_pending();
232            return JobPoll::Finished;
233        }
234
235        loop {
236            match &mut self.phase {
237                PlayerRespawnJobPhase::LoadingPersonalRespawnBlock { config, request } => {
238                    match request.poll() {
239                        ChunkRequestState::Pending { .. } => return JobPoll::Pending,
240                        ChunkRequestState::Cancelled => {
241                            self.finish_pending();
242                            return JobPoll::Finished;
243                        }
244                        ChunkRequestState::Ready if request.ready_chunks().is_none() => {
245                            return JobPoll::Pending;
246                        }
247                        ChunkRequestState::Ready => {}
248                    }
249                    if let Some(spawn) = Self::resolve_personal_respawn(
250                        &self.target_world,
251                        &self.player,
252                        config,
253                        matches!(self.kind, RespawnRequestKind::Death),
254                    ) {
255                        self.finish(spawn);
256                        return JobPoll::Finished;
257                    }
258
259                    let Some((fallback_world, fallback_respawn_data)) =
260                        self.fallback_respawn.take()
261                    else {
262                        self.finish_pending();
263                        return JobPoll::Finished;
264                    };
265                    let fallback_search = match PlayerSpawnSearch::new(
266                        &fallback_world,
267                        fallback_respawn_data.pos(),
268                        fallback_world.default_gamemode,
269                    ) {
270                        Ok(search) => search,
271                        Err(error) => {
272                            log::error!(
273                                "Failed to prepare fallback respawn for player {}: {error}",
274                                self.player.gameprofile.name
275                            );
276                            self.finish_pending();
277                            return JobPoll::Finished;
278                        }
279                    };
280                    self.target_world = fallback_world;
281                    self.rotation = (fallback_respawn_data.yaw, fallback_respawn_data.pitch);
282                    self.missing_respawn_block = true;
283                    self.phase = PlayerRespawnJobPhase::Searching(fallback_search);
284                }
285                PlayerRespawnJobPhase::Searching(search) => {
286                    match search.poll_with_ready_candidate_budget(
287                        &self.target_world,
288                        RESPAWN_SEARCH_READY_CANDIDATE_BUDGET,
289                    ) {
290                        PlayerSpawnSearchPoll::Pending => return JobPoll::Pending,
291                        PlayerSpawnSearchPoll::Cancelled => {
292                            self.finish_pending();
293                            return JobPoll::Finished;
294                        }
295                        PlayerSpawnSearchPoll::Ready(position) => {
296                            let spawn = DeathRespawnSpawn {
297                                position,
298                                rotation: self.rotation,
299                                anchor_deplete_sound_pos: None,
300                                missing_respawn_block: self.missing_respawn_block,
301                            };
302                            self.finish(spawn);
303                            return JobPoll::Finished;
304                        }
305                    }
306                }
307            }
308        }
309    }
310
311    fn cancel(&mut self) {
312        self.finish_pending();
313    }
314}
315
316impl PlayerRespawnJob {
317    fn finish(&mut self, spawn: DeathRespawnSpawn) {
318        let outcome = self.player.finish_respawn_replacement(
319            &self.source_world,
320            &self.target_world,
321            spawn,
322            self.pending_token,
323            self.kind,
324        );
325        self.player.finish_respawn_request(self.pending_token);
326
327        match outcome {
328            RespawnFinish::Completed => {
329                let removal_reason = match self.kind {
330                    RespawnRequestKind::Death => RemovalReason::Killed,
331                    RespawnRequestKind::EndCredits => RemovalReason::ChangedWorld,
332                };
333                self.player.set_removed(removal_reason);
334                self.finish_packet_transition();
335            }
336            RespawnFinish::Aborted => self.finish_packet_transition(),
337            RespawnFinish::Failed => {
338                self.discard_packet_transition();
339            }
340        }
341    }
342
343    fn resolve_personal_respawn(
344        world: &Arc<World>,
345        player: &Player,
346        config: &PlayerRespawnConfig,
347        consume_spawn_block: bool,
348    ) -> Option<DeathRespawnSpawn> {
349        let pos = config.respawn_data.pos();
350        let state = world.get_block_state(pos);
351
352        if RespawnAnchorBlock::can_use_for_respawn(world, pos, state, config.forced) {
353            let position = RespawnAnchorBlock::find_standup_position(world, player, pos)?;
354            if RespawnAnchorBlock::should_consume_charge_after_respawn(
355                config.forced,
356                consume_spawn_block,
357                true,
358            ) {
359                let _ = RespawnAnchorBlock::consume_charge(world, pos, state);
360            }
361            return Some(DeathRespawnSpawn {
362                position,
363                rotation: (calculate_respawn_look_at_yaw(position, pos), 0.0),
364                anchor_deplete_sound_pos: Some(pos),
365                missing_respawn_block: false,
366            });
367        }
368
369        if state.is_bed()
370            && Player::bed_rule_value_allows_in_world(
371                world,
372                world.dimension_type.bed_rule.can_set_spawn,
373            )
374        {
375            let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING);
376            let position = BedBlock::find_standup_position_with_yaw(
377                world,
378                player,
379                facing,
380                pos,
381                config.respawn_data.yaw,
382            )?;
383            return Some(DeathRespawnSpawn {
384                position,
385                rotation: (calculate_respawn_look_at_yaw(position, pos), 0.0),
386                anchor_deplete_sound_pos: None,
387                missing_respawn_block: false,
388            });
389        }
390
391        if !config.forced {
392            return None;
393        }
394        let top_state = world.get_block_state(pos.above());
395        Self::resolve_forced_respawn_fallback(
396            pos,
397            state,
398            top_state,
399            (config.respawn_data.yaw, config.respawn_data.pitch),
400        )
401    }
402
403    fn resolve_forced_respawn_fallback(
404        pos: BlockPos,
405        state: BlockStateId,
406        top_state: BlockStateId,
407        rotation: (f32, f32),
408    ) -> Option<DeathRespawnSpawn> {
409        if !state.is_possible_to_respawn_in_this() || !top_state.is_possible_to_respawn_in_this() {
410            return None;
411        }
412        Some(DeathRespawnSpawn {
413            position: DVec3::new(
414                f64::from(pos.x()) + 0.5,
415                f64::from(pos.y()) + 0.1,
416                f64::from(pos.z()) + 0.5,
417            ),
418            rotation,
419            anchor_deplete_sound_pos: None,
420            missing_respawn_block: false,
421        })
422    }
423}
424
425fn calculate_respawn_look_at_yaw(position: DVec3, look_at_block_pos: BlockPos) -> f32 {
426    let bed_center = DVec3::new(
427        f64::from(look_at_block_pos.x()) + 0.5,
428        f64::from(look_at_block_pos.y()),
429        f64::from(look_at_block_pos.z()) + 0.5,
430    );
431    let look_direction = (bed_center - position).normalize_or_zero();
432    wrap_degrees(
433        (look_direction.z.atan2(look_direction.x).to_degrees() - f64::from(DEGREE_90)) as f32,
434    )
435}
436
437impl Player {
438    fn begin_respawn_request(&self) -> Option<PendingWorldChangeToken> {
439        let token = self.base.begin_pending_player_respawn()?;
440        if self.begin_respawn_transition(token) {
441            return Some(token);
442        }
443
444        self.finish_pending_world_change(token);
445        None
446    }
447
448    fn finish_respawn_request(&self, token: PendingWorldChangeToken) {
449        self.finish_respawn_transition(token);
450        self.finish_pending_world_change(token);
451    }
452
453    /// Schedules a vanilla death respawn for this player.
454    pub fn respawn(&self) {
455        let health = self.get_health();
456        if !Self::should_process_respawn(health) {
457            return;
458        }
459
460        let source_world = self.get_world();
461        let Some(player_arc) = source_world.players.get_by_entity_id(self.id()) else {
462            return;
463        };
464        if !ptr::eq(player_arc.as_ref(), self) {
465            return;
466        }
467        let Some(pending_token) = self.begin_respawn_request() else {
468            if self.is_world_change_pending() {
469                self.defer_death_respawn();
470            }
471            return;
472        };
473
474        let Some(server) = self.server.upgrade() else {
475            self.finish_respawn_request(pending_token);
476            log::error!(
477                "Failed to schedule respawn for player {}: server is gone",
478                self.gameprofile.name
479            );
480            return;
481        };
482        let (fallback_world, fallback_respawn_data) =
483            match server.respawn_world_and_data_for_domain(source_world.domain()) {
484                Ok(resolved) => resolved,
485                Err(error) => {
486                    self.finish_respawn_request(pending_token);
487                    log::error!(
488                        "Failed to schedule respawn for player {}: {error}",
489                        self.gameprofile.name
490                    );
491                    return;
492                }
493            };
494        let personal_respawn = self.personal_respawn(&server, &source_world);
495
496        let mut job = match PlayerRespawnJob::new(
497            Arc::clone(&player_arc),
498            source_world,
499            fallback_world,
500            fallback_respawn_data,
501            personal_respawn,
502            RespawnRequestKind::Death,
503            pending_token,
504        ) {
505            Ok(job) => job,
506            Err(error) => {
507                self.finish_respawn_request(pending_token);
508                log::error!(
509                    "Failed to schedule respawn for player {}: {error}",
510                    self.gameprofile.name
511                );
512                return;
513            }
514        };
515        let Some(packet_transition) = server.begin_player_packet_transition(&player_arc) else {
516            self.finish_respawn_request(pending_token);
517            return;
518        };
519        job.packet_transition = Some(packet_transition);
520        server.jobs.spawn(job);
521    }
522
523    fn personal_respawn(
524        &self,
525        server: &Server,
526        source_world: &World,
527    ) -> Option<(Arc<World>, PlayerRespawnConfig)> {
528        self.respawn_config().and_then(|config| {
529            server
530                .worlds
531                .get(config.respawn_data.dimension())
532                .filter(|world| world.domain() == source_world.domain())
533                .cloned()
534                .map(|world| (world, config))
535        })
536    }
537
538    #[expect(
539        clippy::too_many_lines,
540        reason = "keeping the respawn transaction linear makes commit and rollback order explicit"
541    )]
542    fn finish_respawn_replacement(
543        self: &Arc<Self>,
544        source_world: &Arc<World>,
545        target_world: &Arc<World>,
546        spawn: DeathRespawnSpawn,
547        pending_token: PendingWorldChangeToken,
548        kind: RespawnRequestKind,
549    ) -> RespawnFinish {
550        let Some(server) = self.server.upgrade() else {
551            return RespawnFinish::Aborted;
552        };
553        let valid_source = match kind {
554            RespawnRequestKind::Death => {
555                source_world.contains_player(self)
556                    && Self::should_process_respawn(self.get_health())
557            }
558            RespawnRequestKind::EndCredits => {
559                !source_world.contains_player(self)
560                    && Arc::ptr_eq(&self.get_world(), source_world)
561                    && self.has_won_game()
562            }
563        };
564        if self.connection.closed()
565            || !self.is_respawn_transition_pending(pending_token)
566            || !self.session.is_current_player(self)
567            || !server.owns_online_player(self)
568            || !valid_source
569        {
570            return RespawnFinish::Aborted;
571        }
572
573        assert_eq!(
574            self.remove_all_menus_with_disposition(MenuItemDisposition::Drop),
575            MenuRemovalStatus::Complete,
576            "respawn menu cleanup must run outside a menu callback"
577        );
578        self.detach_relationships_for_respawn();
579
580        if kind == RespawnRequestKind::EndCredits {
581            self.set_won_game(false);
582        }
583
584        let restore_all = kind == RespawnRequestKind::EndCredits;
585        let transfer_inventory = restore_all
586            || target_world.get_game_rule(&KEEP_INVENTORY)
587            || self.game_mode() == GameType::Spectator;
588        let replacement = self.new_respawn_replacement(
589            Arc::clone(target_world),
590            restore_all,
591            transfer_inventory,
592            !spawn.missing_respawn_block,
593        );
594
595        let same_world = Arc::ptr_eq(source_world, target_world);
596        if kind == RespawnRequestKind::Death
597            && !source_world.detach_player_for_respawn(self, same_world)
598        {
599            self.connection.close();
600            return RespawnFinish::Failed;
601        }
602
603        replacement.handle_missing_respawn_block(spawn.missing_respawn_block);
604        let reason = match kind {
605            RespawnRequestKind::Death => ResetReason::Respawn,
606            RespawnRequestKind::EndCredits => ResetReason::EndCredits,
607        };
608        replacement.prepare_respawn_replacement(reason);
609        replacement.experience.lock().dirty = true;
610        replacement.synchronize_respawn_replacement(spawn.position, spawn.rotation);
611        replacement.send_experience_packet_if_dirty();
612
613        let expected_world_player =
614            (kind == RespawnRequestKind::Death && same_world).then_some(self);
615        if !target_world.install_respawned_player(Arc::clone(&replacement), expected_world_player) {
616            self.connection.close();
617            return RespawnFinish::Failed;
618        }
619
620        if !server.replace_online_player(self, Arc::clone(&replacement)) {
621            self.connection.close();
622            if !target_world.remove_respawned_player(&replacement) {
623                tracing::error!(
624                    player = %self.gameprofile.name,
625                    world = %target_world.key,
626                    "Failed to remove a respawn replacement after online-player commit failed"
627                );
628            }
629            return RespawnFinish::Failed;
630        }
631
632        let _ = replacement.mark_joined_world();
633        if !self.session.replace_player(self, &replacement) {
634            self.connection.close();
635            if !server.rollback_respawn_online_player(&replacement, Arc::clone(self))
636                && server.remove_online_player_sync(&replacement).is_none()
637            {
638                tracing::error!(
639                    player = %self.gameprofile.name,
640                    "Failed to remove or roll back online-player ownership after respawn session bind failed"
641                );
642            }
643            if !target_world.remove_respawned_player(&replacement) {
644                tracing::error!(
645                    player = %self.gameprofile.name,
646                    world = %target_world.key,
647                    "Failed to remove a respawn replacement after session bind failed"
648                );
649            }
650            return RespawnFinish::Failed;
651        }
652
653        self.rebind_ender_pearls_to(&replacement);
654
655        server.resend_player_context(&replacement);
656
657        if kind == RespawnRequestKind::Death
658            && let Some(pos) = spawn.anchor_deplete_sound_pos
659            && target_world.get_block_state(pos).get_block() == &vanilla_blocks::RESPAWN_ANCHOR
660        {
661            replacement.send_packet(CSound::new(
662                &sound_events::BLOCK_RESPAWN_ANCHOR_DEPLETE,
663                SoundSource::Blocks,
664                pos.0.as_dvec3(),
665                1.0,
666                1.0,
667                rand::random(),
668            ));
669        }
670
671        RespawnFinish::Completed
672    }
673
674    fn handle_missing_respawn_block(&self, missing_respawn_block: bool) {
675        if !missing_respawn_block {
676            return;
677        }
678        self.set_respawn_position(None, false);
679        self.send_packet(CGameEvent {
680            event: GameEventType::NoRespawnBlockAvailable,
681            data: 0.0,
682        });
683    }
684
685    fn detach_relationships_for_respawn(&self) {
686        for passenger in self.passengers() {
687            passenger.stop_riding();
688        }
689        self.stop_riding();
690        self.base.set_boarding_cooldown(0);
691    }
692
693    /// Handles client commands, requestStats and `RequestGameRuleValues` are still todo
694    pub fn handle_client_command(self: &Arc<Self>, action: ClientCommandAction) {
695        self.reset_last_action_time();
696        match action {
697            ClientCommandAction::PerformRespawn => {
698                if self.has_won_game() {
699                    self.respawn_after_end_credits();
700                } else {
701                    self.respawn();
702                }
703            }
704            ClientCommandAction::RequestStats => self.send_stats(),
705            ClientCommandAction::RequestGameRuleValues => {
706                // TODO: implement requesting for game rule values
707            }
708        }
709    }
710
711    /// Vanilla accepts a client respawn request only when player health is dead-or-dying.
712    /// Steel's death-processed guard is not respawn authority.
713    #[must_use]
714    pub(in crate::player) const fn should_process_respawn(health: f32) -> bool {
715        health <= 0.0
716    }
717
718    /// Returns vanilla `ServerPlayer.seenCredits`.
719    #[must_use]
720    pub fn has_seen_credits(&self) -> bool {
721        *self.seen_credits.lock()
722    }
723
724    /// Sets vanilla `ServerPlayer.seenCredits`.
725    pub fn set_seen_credits(&self, seen_credits: bool) {
726        *self.seen_credits.lock() = seen_credits;
727    }
728
729    /// Returns vanilla `ServerPlayer.wonGame`.
730    #[must_use]
731    pub(crate) fn has_won_game(&self) -> bool {
732        *self.won_game.lock()
733    }
734
735    fn set_won_game(&self, won_game: bool) {
736        *self.won_game.lock() = won_game;
737    }
738
739    /// Starts the vanilla End credits flow.
740    pub(crate) fn show_end_credits(&self) {
741        if self.has_won_game() {
742            return;
743        }
744
745        let world = self.get_world();
746        let Some(player) = world.players.get_by_entity_id(self.id()) else {
747            return;
748        };
749        if !ptr::eq(player.as_ref(), self) {
750            return;
751        }
752        let Some(pending_token) = player.begin_pending_world_change() else {
753            return;
754        };
755
756        assert_eq!(
757            player.remove_all_menus(),
758            MenuRemovalStatus::Complete,
759            "End credits menu removal must run outside a menu callback"
760        );
761        let detached = world.detach_player_for_respawn(&player, false);
762        player.finish_pending_world_change(pending_token);
763        if !detached {
764            return;
765        }
766
767        player.set_won_game(true);
768        player.send_packet(CGameEvent {
769            event: GameEventType::WinGame,
770            data: 0.0,
771        });
772        player.set_seen_credits(true);
773    }
774
775    fn respawn_after_end_credits(self: &Arc<Self>) {
776        if !self.has_won_game() {
777            return;
778        }
779
780        let source_world = self.get_world();
781        let Some(server) = self.server.upgrade() else {
782            log::error!(
783                "Failed to schedule End credits respawn for player {}: server is gone",
784                self.gameprofile.name
785            );
786            return;
787        };
788        if !server.owns_online_player(self) {
789            return;
790        }
791        let Some(pending_token) = self.begin_respawn_request() else {
792            return;
793        };
794        let (target_world, respawn_data) =
795            match server.respawn_world_and_data_for_domain(source_world.domain()) {
796                Ok(resolved) => resolved,
797                Err(error) => {
798                    self.finish_respawn_request(pending_token);
799                    log::error!(
800                        "Failed to schedule End credits respawn for player {}: {error}",
801                        self.gameprofile.name
802                    );
803                    return;
804                }
805            };
806        let personal_respawn = self.personal_respawn(&server, &source_world);
807
808        let mut job = match PlayerRespawnJob::new(
809            Arc::clone(self),
810            source_world,
811            target_world,
812            respawn_data,
813            personal_respawn,
814            RespawnRequestKind::EndCredits,
815            pending_token,
816        ) {
817            Ok(job) => job,
818            Err(error) => {
819                self.finish_respawn_request(pending_token);
820                log::error!(
821                    "Failed to schedule End credits respawn for player {}: {error}",
822                    self.gameprofile.name
823                );
824                return;
825            }
826        };
827        let Some(packet_transition) = server.begin_player_packet_transition(self) else {
828            self.finish_respawn_request(pending_token);
829            return;
830        };
831        job.packet_transition = Some(packet_transition);
832        server.jobs.spawn(job);
833    }
834}
835
836#[cfg(test)]
837mod tests {
838    use steel_utils::{BlockPos, ChunkPos};
839
840    use super::PlayerRespawnJob;
841
842    #[test]
843    fn personal_respawn_loads_only_chunks_touched_by_standup_search() {
844        assert_eq!(
845            PlayerRespawnJob::personal_respawn_chunk_positions(BlockPos::new(8, 64, 8)),
846            [ChunkPos::new(0, 0)]
847        );
848
849        assert_eq!(
850            PlayerRespawnJob::personal_respawn_chunk_positions(BlockPos::new(13, 64, 13)),
851            [
852                ChunkPos::new(0, 0),
853                ChunkPos::new(0, 1),
854                ChunkPos::new(1, 0),
855                ChunkPos::new(1, 1),
856            ]
857        );
858    }
859}