From 52838d945b54f7d5d18152e32d9e121186d2fb35 Mon Sep 17 00:00:00 2001 From: Aalivexy Date: Sat, 4 Jul 2026 18:21:46 +0800 Subject: [PATCH] fix: trigger PlayerChangeWorldEvent on cross-dimension respawn (#2228) (#2250) Rebased onto current master after #2296 (PlayerRespawnEvent). - Fire PlayerChangeWorldEvent (cancellable) before the cross-dimension transfer in World::respawn_player; a plugin may redirect new_world, override position/yaw/pitch, or cancel to keep the player in the current world. - Use the safe transfer ordering (remove_player -> unload_watched_chunks -> change_world -> set_world -> publish) so no observer sees the player in a world whose chunk manager doesn't match, and update the entity's world reference (set_world) to fix the latent stale-world bug. - When cancelled or the target world can't be resolved, fall back to the current world's spawn. - The non-cancellable PlayerRespawnEvent (#2296) now fires after this event and observes the resolved world automatically; document the ordering and mutation-vs-cancellation semantics on both events. --- .../api/events/player/player_change_world.rs | 15 ++- .../api/events/player/player_respawn.rs | 9 ++ pumpkin/src/world/mod.rs | 105 ++++++++++++------ 3 files changed, 94 insertions(+), 35 deletions(-) diff --git a/pumpkin/src/plugin/api/events/player/player_change_world.rs b/pumpkin/src/plugin/api/events/player/player_change_world.rs index 15a9e5b20..9a0df6dbf 100644 --- a/pumpkin/src/plugin/api/events/player/player_change_world.rs +++ b/pumpkin/src/plugin/api/events/player/player_change_world.rs @@ -8,7 +8,20 @@ use super::PlayerEvent; /// An event that occurs when a player gets teleported to another world. /// -/// This event contains information about the player changing worlds. +/// `new_world` and the position/yaw/pitch fields are mutable; when not +/// cancelled the modified values are applied as-is to the transfer and the +/// following [`PlayerRespawnEvent`](super::player_respawn::PlayerRespawnEvent). +/// Cancelling drops the whole event: the world stays unchanged and any edits +/// are dropped, with the respawn using the values resolved before the event +/// fired. To keep the player in the current world at a chosen position, don't +/// cancel — set `new_world` to `previous_world` (matching UUID) and override +/// the position/yaw/pitch fields; the transfer is skipped but those values +/// take effect. When redirecting to a different world, set position/yaw/pitch +/// valid for it. +/// +/// On a cross-dimension respawn this fires before the non-cancellable +/// [`PlayerRespawnEvent`](super::player_respawn::PlayerRespawnEvent), which +/// then observes the resolved world. #[cancellable] #[derive(Event, Clone)] pub struct PlayerChangeWorldEvent { diff --git a/pumpkin/src/plugin/api/events/player/player_respawn.rs b/pumpkin/src/plugin/api/events/player/player_respawn.rs index 0aea71520..69ef18bc5 100644 --- a/pumpkin/src/plugin/api/events/player/player_respawn.rs +++ b/pumpkin/src/plugin/api/events/player/player_respawn.rs @@ -10,6 +10,15 @@ use super::PlayerEvent; /// /// This is a notification fired once a respawn destination (world, position and /// rotation) has been determined. It is not cancellable. +/// +/// On a cross-dimension respawn the cancellable +/// [`PlayerChangeWorldEvent`](super::player_change_world::PlayerChangeWorldEvent) +/// fires first, before the world transfer; this event fires after the transfer +/// and reflects the world the player actually ended up in — including +/// `PlayerChangeWorldEvent` redirects and the no-op fallback (player stays in +/// the previous world) when that event is cancelled. See that event's docs for +/// the mutation-vs-cancellation semantics; in particular, edits made by a +/// cancelled `PlayerChangeWorldEvent` do not carry over here. #[derive(Event, Clone)] pub struct PlayerRespawnEvent { /// The player who respawned. diff --git a/pumpkin/src/world/mod.rs b/pumpkin/src/world/mod.rs index e23314388..096801da9 100644 --- a/pumpkin/src/world/mod.rs +++ b/pumpkin/src/world/mod.rs @@ -43,8 +43,8 @@ use crate::{ plugin::{ block::block_break::BlockBreakEvent, player::{ - player_join::PlayerJoinEvent, player_leave::PlayerLeaveEvent, - player_respawn::PlayerRespawnEvent, + player_change_world::PlayerChangeWorldEvent, player_join::PlayerJoinEvent, + player_leave::PlayerLeaveEvent, player_respawn::PlayerRespawnEvent, }, }, server::Server, @@ -3292,11 +3292,10 @@ impl World { ) }; - // Get target world (may be different from current world for cross-dimension respawn) - let target_world = if respawn_dimension == self.dimension { + // Candidate destination world for a cross-dimension respawn. + let candidate_world = if respawn_dimension == self.dimension { None } else { - // Cross-dimension respawn: get target world from server self.server.upgrade().map_or_else( || { warn!("Could not get server for cross-dimension respawn"); @@ -3312,38 +3311,76 @@ impl World { ) }; - // Handle cross-dimension transfer if we found a different target world - let (target_world, position) = if let Some(ref new_world) = target_world { - debug!( - "Cross-dimension respawn: {} -> {}", - self.dimension.minecraft_name, new_world.dimension.minecraft_name - ); + // Fire PlayerChangeWorldEvent (cancellable) before the transfer; it runs before + // the non-cancellable PlayerRespawnEvent, which observes the resolved world. + let (resolved_world, position, yaw, pitch) = if let Some(new_world) = candidate_world { + if let Some(server) = self.server.upgrade() { + let event = server + .plugin_manager + .fire(PlayerChangeWorldEvent { + player: player.clone(), + previous_world: self.clone(), + new_world: new_world.clone(), + position, + yaw, + pitch, + cancelled: false, + }) + .await; - // Remove player from current world - self.remove_player(player, false).await; - new_world.players.rcu(|current_list| { - let mut new_list = (**current_list).clone(); - new_list.push(player.clone()); - new_list - }); + if event.cancelled { + (None, position, yaw, pitch) + } else { + let destination = event.new_world; + let position = event.position; + let yaw = event.yaw; + let pitch = event.pitch; - // Update chunk manager to target world - player - .chunk_manager - .lock() - .await - .change_world(&self.level, new_world.clone()); + // Skip the transfer if redirected back to the current world. + if destination.uuid != self.uuid { + debug!( + "Cross-dimension respawn: {} -> {}", + self.dimension.minecraft_name, destination.dimension.minecraft_name + ); - // Unload watched chunks from current world - player.unload_watched_chunks(self).await; + // Detach from the old world before publishing into the new one, so no + // observer sees the player in a world whose chunk manager doesn't match. + self.remove_player(player, false).await; + player.unload_watched_chunks(self).await; + player + .chunk_manager + .lock() + .await + .change_world(&self.level, destination.clone()); + player.living_entity.entity.set_world(destination.clone()); + destination.players.rcu(|current_list| { + let mut new_list = (**current_list).clone(); + new_list.push(player.clone()); + new_list + }); + } - (new_world.clone(), position) + (Some(destination), position, yaw, pitch) + } + } else { + warn!("Server dropped during cross-dimension respawn"); + (None, position, yaw, pitch) + } + } else { + if respawn_dimension != self.dimension { + warn!( + "Target world {:?} not found, using world spawn in {:?}", + respawn_dimension, self.dimension + ); + } + (None, position, yaw, pitch) + }; + + // Cancelled or unresolved cross-dimension respawns fall back to the current + // world's spawn below; otherwise the resolved values from the event apply. + let (target_world, position, yaw, pitch) = if let Some(ref new_world) = resolved_world { + (new_world.clone(), position, yaw, pitch) } else if respawn_dimension != self.dimension { - // Cross-dimension failed - fall back to current world's spawn - warn!( - "Target world {:?} not found, using world spawn in {:?}", - respawn_dimension, self.dimension - ); // FIXME: This spawn position calculation is incorrect. Should use vanilla's // proper spawn position calculation (see #1381). let chunk_pos = Vector2::new(spawn_x >> 4, spawn_z >> 4); @@ -3354,9 +3391,9 @@ impl World { (top + 1).into(), f64::from(spawn_z) + 0.5, ); - (self.clone(), fallback_pos) + (self.clone(), fallback_pos, spawn_yaw, spawn_pitch) } else { - (self.clone(), position) + (self.clone(), position, yaw, pitch) }; // Notify plugins that the player has respawned (non-cancellable).