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_time_budget(&self.target_world, POLL_TIME_BUDGET) {
287                        PlayerSpawnSearchPoll::Pending => return JobPoll::Pending,
288                        PlayerSpawnSearchPoll::Cancelled => {
289                            self.finish_pending();
290                            return JobPoll::Finished;
291                        }
292                        PlayerSpawnSearchPoll::Ready(position) => {
293                            let spawn = DeathRespawnSpawn {
294                                position,
295                                rotation: self.rotation,
296                                anchor_deplete_sound_pos: None,
297                                missing_respawn_block: self.missing_respawn_block,
298                            };
299                            self.finish(spawn);
300                            return JobPoll::Finished;
301                        }
302                    }
303                }
304            }
305        }
306    }
307
308    fn cancel(&mut self) {
309        self.finish_pending();
310    }
311}
312
313impl PlayerRespawnJob {
314    fn finish(&mut self, spawn: DeathRespawnSpawn) {
315        let outcome = self.player.finish_respawn_replacement(
316            &self.source_world,
317            &self.target_world,
318            spawn,
319            self.pending_token,
320            self.kind,
321        );
322        self.player.finish_respawn_request(self.pending_token);
323
324        match outcome {
325            RespawnFinish::Completed => {
326                let removal_reason = match self.kind {
327                    RespawnRequestKind::Death => RemovalReason::Killed,
328                    RespawnRequestKind::EndCredits => RemovalReason::ChangedWorld,
329                };
330                self.player.set_removed(removal_reason);
331                self.finish_packet_transition();
332            }
333            RespawnFinish::Aborted => self.finish_packet_transition(),
334            RespawnFinish::Failed => {
335                self.discard_packet_transition();
336            }
337        }
338    }
339
340    fn resolve_personal_respawn(
341        world: &Arc<World>,
342        player: &Player,
343        config: &PlayerRespawnConfig,
344        consume_spawn_block: bool,
345    ) -> Option<DeathRespawnSpawn> {
346        let pos = config.respawn_data.pos();
347        let state = world.get_block_state(pos);
348
349        if RespawnAnchorBlock::can_use_for_respawn(world, pos, state, config.forced) {
350            let position = RespawnAnchorBlock::find_standup_position(world, player, pos)?;
351            if RespawnAnchorBlock::should_consume_charge_after_respawn(
352                config.forced,
353                consume_spawn_block,
354                true,
355            ) {
356                let _ = RespawnAnchorBlock::consume_charge(world, pos, state);
357            }
358            return Some(DeathRespawnSpawn {
359                position,
360                rotation: (calculate_respawn_look_at_yaw(position, pos), 0.0),
361                anchor_deplete_sound_pos: Some(pos),
362                missing_respawn_block: false,
363            });
364        }
365
366        if state.is_bed()
367            && Player::bed_rule_value_allows_in_world(
368                world,
369                Player::bed_rule_for(world, state).can_set_spawn,
370            )
371        {
372            let facing = state.get_value(&BlockStateProperties::HORIZONTAL_FACING);
373            let position = BedBlock::find_standup_position_with_yaw(
374                world,
375                player,
376                facing,
377                pos,
378                config.respawn_data.yaw,
379            )?;
380            return Some(DeathRespawnSpawn {
381                position,
382                rotation: (calculate_respawn_look_at_yaw(position, pos), 0.0),
383                anchor_deplete_sound_pos: None,
384                missing_respawn_block: false,
385            });
386        }
387
388        if !config.forced {
389            return None;
390        }
391        let top_state = world.get_block_state(pos.above());
392        Self::resolve_forced_respawn_fallback(
393            pos,
394            state,
395            top_state,
396            (config.respawn_data.yaw, config.respawn_data.pitch),
397        )
398    }
399
400    fn resolve_forced_respawn_fallback(
401        pos: BlockPos,
402        state: BlockStateId,
403        top_state: BlockStateId,
404        rotation: (f32, f32),
405    ) -> Option<DeathRespawnSpawn> {
406        if !state.is_possible_to_respawn_in_this() || !top_state.is_possible_to_respawn_in_this() {
407            return None;
408        }
409        Some(DeathRespawnSpawn {
410            position: DVec3::new(
411                f64::from(pos.x()) + 0.5,
412                f64::from(pos.y()) + 0.1,
413                f64::from(pos.z()) + 0.5,
414            ),
415            rotation,
416            anchor_deplete_sound_pos: None,
417            missing_respawn_block: false,
418        })
419    }
420}
421
422fn calculate_respawn_look_at_yaw(position: DVec3, look_at_block_pos: BlockPos) -> f32 {
423    let bed_center = DVec3::new(
424        f64::from(look_at_block_pos.x()) + 0.5,
425        f64::from(look_at_block_pos.y()),
426        f64::from(look_at_block_pos.z()) + 0.5,
427    );
428    let look_direction = (bed_center - position).normalize_or_zero();
429    wrap_degrees(
430        (look_direction.z.atan2(look_direction.x).to_degrees() - f64::from(DEGREE_90)) as f32,
431    )
432}
433
434impl Player {
435    fn begin_respawn_request(&self) -> Option<PendingWorldChangeToken> {
436        let token = self.base.begin_pending_player_respawn()?;
437        if self.begin_respawn_transition(token) {
438            return Some(token);
439        }
440
441        self.finish_pending_world_change(token);
442        None
443    }
444
445    fn finish_respawn_request(&self, token: PendingWorldChangeToken) {
446        self.finish_respawn_transition(token);
447        self.finish_pending_world_change(token);
448    }
449
450    /// Schedules a vanilla death respawn for this player.
451    pub fn respawn(&self) {
452        let health = self.get_health();
453        if !Self::should_process_respawn(health) {
454            return;
455        }
456
457        let source_world = self.get_world();
458        let Some(player_arc) = source_world.players.get_by_entity_id(self.id()) else {
459            return;
460        };
461        if !ptr::eq(player_arc.as_ref(), self) {
462            return;
463        }
464        let Some(pending_token) = self.begin_respawn_request() else {
465            if self.is_world_change_pending() {
466                self.defer_death_respawn();
467            }
468            return;
469        };
470
471        let Some(server) = self.server.upgrade() else {
472            self.finish_respawn_request(pending_token);
473            log::error!(
474                "Failed to schedule respawn for player {}: server is gone",
475                self.gameprofile.name()
476            );
477            return;
478        };
479        let (fallback_world, fallback_respawn_data) =
480            match server.respawn_world_and_data_for_domain(source_world.domain()) {
481                Ok(resolved) => resolved,
482                Err(error) => {
483                    self.finish_respawn_request(pending_token);
484                    log::error!(
485                        "Failed to schedule respawn for player {}: {error}",
486                        self.gameprofile.name()
487                    );
488                    return;
489                }
490            };
491        let personal_respawn = self.personal_respawn(&server, &source_world);
492
493        let mut job = match PlayerRespawnJob::new(
494            Arc::clone(&player_arc),
495            source_world,
496            fallback_world,
497            fallback_respawn_data,
498            personal_respawn,
499            RespawnRequestKind::Death,
500            pending_token,
501        ) {
502            Ok(job) => job,
503            Err(error) => {
504                self.finish_respawn_request(pending_token);
505                log::error!(
506                    "Failed to schedule respawn for player {}: {error}",
507                    self.gameprofile.name()
508                );
509                return;
510            }
511        };
512        let Some(packet_transition) = server.begin_player_packet_transition(&player_arc) else {
513            self.finish_respawn_request(pending_token);
514            return;
515        };
516        job.packet_transition = Some(packet_transition);
517        server.jobs.spawn(job);
518    }
519
520    fn personal_respawn(
521        &self,
522        server: &Server,
523        source_world: &World,
524    ) -> Option<(Arc<World>, PlayerRespawnConfig)> {
525        self.respawn_config().and_then(|config| {
526            server
527                .worlds
528                .get(config.respawn_data.dimension())
529                .filter(|world| world.domain() == source_world.domain())
530                .cloned()
531                .map(|world| (world, config))
532        })
533    }
534
535    #[expect(
536        clippy::too_many_lines,
537        reason = "keeping the respawn transaction linear makes commit and rollback order explicit"
538    )]
539    fn finish_respawn_replacement(
540        self: &Arc<Self>,
541        source_world: &Arc<World>,
542        target_world: &Arc<World>,
543        spawn: DeathRespawnSpawn,
544        pending_token: PendingWorldChangeToken,
545        kind: RespawnRequestKind,
546    ) -> RespawnFinish {
547        let Some(server) = self.server.upgrade() else {
548            return RespawnFinish::Aborted;
549        };
550        let valid_source = match kind {
551            RespawnRequestKind::Death => {
552                source_world.contains_player(self)
553                    && Self::should_process_respawn(self.get_health())
554            }
555            RespawnRequestKind::EndCredits => {
556                !source_world.contains_player(self)
557                    && Arc::ptr_eq(&self.get_world(), source_world)
558                    && self.has_won_game()
559            }
560        };
561        if self.connection.closed()
562            || !self.is_respawn_transition_pending(pending_token)
563            || !self.session.is_current_player(self)
564            || !server.owns_online_player(self)
565            || !valid_source
566        {
567            return RespawnFinish::Aborted;
568        }
569
570        assert_eq!(
571            self.remove_all_menus_with_disposition(MenuItemDisposition::Drop),
572            MenuRemovalStatus::Complete,
573            "respawn menu cleanup must run outside a menu callback"
574        );
575        self.detach_relationships_for_respawn();
576
577        if kind == RespawnRequestKind::EndCredits {
578            self.set_won_game(false);
579        }
580
581        let restore_all = kind == RespawnRequestKind::EndCredits;
582        let transfer_inventory = restore_all
583            || target_world.get_game_rule(&KEEP_INVENTORY)
584            || self.game_mode() == GameType::Spectator;
585        let replacement = self.new_respawn_replacement(
586            Arc::clone(target_world),
587            restore_all,
588            transfer_inventory,
589            !spawn.missing_respawn_block,
590        );
591
592        let same_world = Arc::ptr_eq(source_world, target_world);
593        if kind == RespawnRequestKind::Death
594            && !source_world.detach_player_for_respawn(self, same_world)
595        {
596            self.connection.close();
597            return RespawnFinish::Failed;
598        }
599
600        replacement.handle_missing_respawn_block(spawn.missing_respawn_block);
601        let reason = match kind {
602            RespawnRequestKind::Death => ResetReason::Respawn,
603            RespawnRequestKind::EndCredits => ResetReason::EndCredits,
604        };
605        replacement.prepare_respawn_replacement(reason);
606        replacement.experience.lock().dirty = true;
607        replacement.synchronize_respawn_replacement(spawn.position, spawn.rotation);
608        replacement.send_experience_packet_if_dirty();
609
610        let expected_world_player =
611            (kind == RespawnRequestKind::Death && same_world).then_some(self);
612        if !target_world.install_respawned_player(Arc::clone(&replacement), expected_world_player) {
613            self.connection.close();
614            return RespawnFinish::Failed;
615        }
616
617        if !server.replace_online_player(self, Arc::clone(&replacement)) {
618            self.connection.close();
619            if !target_world.remove_respawned_player(&replacement) {
620                tracing::error!(
621                    player = %self.gameprofile.name(),
622                    world = %target_world.key,
623                    "Failed to remove a respawn replacement after online-player commit failed"
624                );
625            }
626            return RespawnFinish::Failed;
627        }
628
629        let _ = replacement.mark_joined_world();
630        if !self.session.replace_player(self, &replacement) {
631            self.connection.close();
632            if !server.rollback_respawn_online_player(&replacement, Arc::clone(self))
633                && server.remove_online_player_sync(&replacement).is_none()
634            {
635                tracing::error!(
636                    player = %self.gameprofile.name(),
637                    "Failed to remove or roll back online-player ownership after respawn session bind failed"
638                );
639            }
640            if !target_world.remove_respawned_player(&replacement) {
641                tracing::error!(
642                    player = %self.gameprofile.name(),
643                    world = %target_world.key,
644                    "Failed to remove a respawn replacement after session bind failed"
645                );
646            }
647            return RespawnFinish::Failed;
648        }
649
650        self.rebind_ender_pearls_to(&replacement);
651
652        server.resend_player_context(&replacement);
653
654        if kind == RespawnRequestKind::Death
655            && let Some(pos) = spawn.anchor_deplete_sound_pos
656            && target_world.get_block_state(pos).get_block() == &vanilla_blocks::RESPAWN_ANCHOR
657        {
658            replacement.send_packet(CSound::new(
659                &sound_events::BLOCK_RESPAWN_ANCHOR_DEPLETE,
660                SoundSource::Blocks,
661                pos.0.as_dvec3(),
662                1.0,
663                1.0,
664                rand::random(),
665            ));
666        }
667
668        RespawnFinish::Completed
669    }
670
671    fn handle_missing_respawn_block(&self, missing_respawn_block: bool) {
672        if !missing_respawn_block {
673            return;
674        }
675        self.set_respawn_position(None, false);
676        self.send_packet(CGameEvent {
677            event: GameEventType::NoRespawnBlockAvailable,
678            data: 0.0,
679        });
680    }
681
682    fn detach_relationships_for_respawn(&self) {
683        for passenger in self.passengers() {
684            passenger.stop_riding();
685        }
686        self.stop_riding();
687        self.base.set_boarding_cooldown(0);
688    }
689
690    /// Handles client commands, requestStats and `RequestGameRuleValues` are still todo
691    pub fn handle_client_command(self: &Arc<Self>, action: ClientCommandAction) {
692        self.reset_last_action_time();
693        match action {
694            ClientCommandAction::PerformRespawn => {
695                if self.has_won_game() {
696                    self.respawn_after_end_credits();
697                } else {
698                    self.respawn();
699                }
700            }
701            ClientCommandAction::RequestStats => self.send_stats(),
702            ClientCommandAction::RequestGameRuleValues => {
703                // TODO: implement requesting for game rule values
704            }
705        }
706    }
707
708    /// Vanilla accepts a client respawn request only when player health is dead-or-dying.
709    /// Steel's death-processed guard is not respawn authority.
710    #[must_use]
711    pub(in crate::player) const fn should_process_respawn(health: f32) -> bool {
712        health <= 0.0
713    }
714
715    /// Returns whether this player has already seen the End credits screen.
716    #[must_use]
717    pub fn has_seen_credits(&self) -> bool {
718        *self.seen_credits.lock()
719    }
720
721    /// Sets whether this player has seen the End credits screen.
722    pub fn set_seen_credits(&self, seen_credits: bool) {
723        *self.seen_credits.lock() = seen_credits;
724    }
725
726    #[must_use]
727    pub(crate) fn has_won_game(&self) -> bool {
728        *self.won_game.lock()
729    }
730
731    fn set_won_game(&self, won_game: bool) {
732        *self.won_game.lock() = won_game;
733    }
734
735    /// Starts the vanilla End credits flow.
736    pub(crate) fn show_end_credits(&self) {
737        if self.has_won_game() {
738            return;
739        }
740
741        let world = self.get_world();
742        let Some(player) = world.players.get_by_entity_id(self.id()) else {
743            return;
744        };
745        if !ptr::eq(player.as_ref(), self) {
746            return;
747        }
748        let Some(pending_token) = player.begin_pending_world_change() else {
749            return;
750        };
751
752        assert_eq!(
753            player.remove_all_menus(),
754            MenuRemovalStatus::Complete,
755            "End credits menu removal must run outside a menu callback"
756        );
757        let detached = world.detach_player_for_respawn(&player, false);
758        player.finish_pending_world_change(pending_token);
759        if !detached {
760            return;
761        }
762
763        player.set_won_game(true);
764        player.send_packet(CGameEvent {
765            event: GameEventType::WinGame,
766            data: 0.0,
767        });
768        player.set_seen_credits(true);
769    }
770
771    fn respawn_after_end_credits(self: &Arc<Self>) {
772        if !self.has_won_game() {
773            return;
774        }
775
776        let source_world = self.get_world();
777        let Some(server) = self.server.upgrade() else {
778            log::error!(
779                "Failed to schedule End credits respawn for player {}: server is gone",
780                self.gameprofile.name()
781            );
782            return;
783        };
784        if !server.owns_online_player(self) {
785            return;
786        }
787        let Some(pending_token) = self.begin_respawn_request() else {
788            return;
789        };
790        let (target_world, respawn_data) =
791            match server.respawn_world_and_data_for_domain(source_world.domain()) {
792                Ok(resolved) => resolved,
793                Err(error) => {
794                    self.finish_respawn_request(pending_token);
795                    log::error!(
796                        "Failed to schedule End credits respawn for player {}: {error}",
797                        self.gameprofile.name()
798                    );
799                    return;
800                }
801            };
802        let personal_respawn = self.personal_respawn(&server, &source_world);
803
804        let mut job = match PlayerRespawnJob::new(
805            Arc::clone(self),
806            source_world,
807            target_world,
808            respawn_data,
809            personal_respawn,
810            RespawnRequestKind::EndCredits,
811            pending_token,
812        ) {
813            Ok(job) => job,
814            Err(error) => {
815                self.finish_respawn_request(pending_token);
816                log::error!(
817                    "Failed to schedule End credits respawn for player {}: {error}",
818                    self.gameprofile.name()
819                );
820                return;
821            }
822        };
823        let Some(packet_transition) = server.begin_player_packet_transition(self) else {
824            self.finish_respawn_request(pending_token);
825            return;
826        };
827        job.packet_transition = Some(packet_transition);
828        server.jobs.spawn(job);
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use steel_utils::{BlockPos, ChunkPos};
835
836    use super::PlayerRespawnJob;
837
838    #[test]
839    fn personal_respawn_loads_only_chunks_touched_by_standup_search() {
840        assert_eq!(
841            PlayerRespawnJob::personal_respawn_chunk_positions(BlockPos::new(8, 64, 8)),
842            [ChunkPos::new(0, 0)]
843        );
844
845        assert_eq!(
846            PlayerRespawnJob::personal_respawn_chunk_positions(BlockPos::new(13, 64, 13)),
847            [
848                ChunkPos::new(0, 0),
849                ChunkPos::new(0, 1),
850                ChunkPos::new(1, 0),
851                ChunkPos::new(1, 1),
852            ]
853        );
854    }
855}