1use std::{
2 mem,
3 sync::{Arc, Weak, mpsc},
4};
5
6use glam::DVec3;
7use tokio::task::JoinHandle;
8
9use crate::{
10 chunk::chunk_request::ChunkRequestState,
11 entity::{Entity, PendingWorldChangeToken},
12 player::{
13 DomainResidenceToken, Player, ResetReason, connection::NetworkConnection,
14 player_data::PersistentPlayerData, player_data_storage::GlobalPlayerData,
15 },
16 server::{
17 DomainPlayerData, DomainPlayerState, PlayerAdmissionState, PreparedSpawn, Server,
18 UnpreparedDomainPlayerData, UnpreparedDomainPlayerState, WorldEntry,
19 },
20 world::{
21 World,
22 player_spawn_finder::{POLL_TIME_BUDGET, PlayerSpawnSearch, PlayerSpawnSearchPoll},
23 },
24};
25
26use super::{JobPoll, ServerJob, ServerJobContext};
27
28pub(in crate::server) struct DomainSwitchJob {
29 server: Weak<Server>,
30 player: Arc<Player>,
31 source_domain: String,
32 source_data: Option<Arc<PersistentPlayerData>>,
33 target_domain: String,
34 entry: WorldEntry,
35 pending_token: PendingWorldChangeToken,
36 residence_token: DomainResidenceToken,
37 phase: DomainSwitchJobPhase,
38}
39
40enum DomainSwitchJobPhase {
41 WaitingForStorage {
42 receiver: mpsc::Receiver<Result<UnpreparedDomainPlayerState, String>>,
43 task: JoinHandle<()>,
44 },
45 SearchingSpawn {
46 world: Arc<World>,
47 data: UnpreparedDomainPlayerData,
48 rotation: (f32, f32),
49 search: PlayerSpawnSearch,
50 },
51 LoadingSpawn {
52 state: DomainPlayerState,
53 },
54 SavingGlobal {
55 receiver: mpsc::Receiver<Result<(), String>>,
56 task: JoinHandle<()>,
57 },
58 Transitioning,
59}
60
61impl DomainSwitchJob {
62 #[expect(
63 clippy::too_many_arguments,
64 reason = "the constructor makes all detached transition data and both ownership tokens explicit"
65 )]
66 pub(in crate::server) fn new(
67 server: &Arc<Server>,
68 player: Arc<Player>,
69 source_domain: String,
70 source_data: PersistentPlayerData,
71 target_domain: String,
72 target_world: Option<Arc<World>>,
73 entry: WorldEntry,
74 pending_token: PendingWorldChangeToken,
75 residence_token: DomainResidenceToken,
76 ) -> Self {
77 let (sender, receiver) = mpsc::channel();
78 let task_server = Arc::clone(server);
79 let task_player = Arc::clone(&player);
80 let task_source_domain = source_domain.clone();
81 let source_data = Arc::new(source_data);
82 let task_source_data = Arc::clone(&source_data);
83 let task_target_domain = target_domain.clone();
84 let task = tokio::spawn(async move {
85 let result = async {
86 task_server
87 .player_data_storage
88 .save_domain_data(
89 &task_source_domain,
90 task_player.gameprofile.id(),
91 task_source_data.as_ref(),
92 )
93 .await
94 .map_err(|error| format!("failed to save current domain data: {error}"))?;
95 task_server
96 .load_unprepared_domain_player_state(
97 &task_player,
98 &task_target_domain,
99 target_world,
100 )
101 .await
102 }
103 .await;
104 let _ = sender.send(result);
105 });
106
107 Self {
108 server: Arc::downgrade(server),
109 player,
110 source_domain,
111 source_data: Some(source_data),
112 target_domain,
113 entry,
114 pending_token,
115 residence_token,
116 phase: DomainSwitchJobPhase::WaitingForStorage { receiver, task },
117 }
118 }
119
120 fn abort_async_task(&self) {
121 match &self.phase {
122 DomainSwitchJobPhase::WaitingForStorage { task, .. }
123 | DomainSwitchJobPhase::SavingGlobal { task, .. } => task.abort(),
124 DomainSwitchJobPhase::SearchingSpawn { .. }
125 | DomainSwitchJobPhase::LoadingSpawn { .. }
126 | DomainSwitchJobPhase::Transitioning => {}
127 }
128 }
129
130 fn finish_source_disconnect(&mut self, error: Option<&str>) -> JobPoll {
131 self.abort_async_task();
132 if let Some(error) = error {
133 log::error!(
134 "Failed to switch {} domain: {error}",
135 self.player.gameprofile.name()
136 );
137 if !self.player.connection.closed() {
138 self.player.disconnect("Failed to switch domain");
139 }
140 }
141
142 let Some(source_data) = self.source_data.take() else {
143 self.player.finish_domain_switch(self.pending_token);
144 self.player.finish_pending_world_change(self.pending_token);
145 return JobPoll::Finished;
146 };
147 let Some(server) = self.server.upgrade() else {
148 self.player.finish_domain_switch(self.pending_token);
149 self.player.finish_pending_world_change(self.pending_token);
150 self.player.cleanup();
151 return JobPoll::Finished;
152 };
153 server.queue_relocating_player_disconnect(
154 Arc::clone(&self.player),
155 self.source_domain.clone(),
156 source_data,
157 self.pending_token,
158 );
159 JobPoll::Finished
160 }
161
162 fn phase_after_storage(
163 server: &Server,
164 target_domain: &str,
165 entry: WorldEntry,
166 state: UnpreparedDomainPlayerState,
167 ) -> Result<DomainSwitchJobPhase, String> {
168 let UnpreparedDomainPlayerState {
169 world,
170 explicit_target,
171 data,
172 } = state;
173 let rotation_override = match entry {
174 WorldEntry::Exact(destination) => {
175 return Self::phase_at_destination(world, data, destination);
176 }
177 WorldEntry::Rotation(rotation) => Some(rotation),
178 WorldEntry::Usual => None,
179 };
180 if let UnpreparedDomainPlayerData::SavedRestored { mut data } = data {
181 if let Some((yaw, pitch)) = rotation_override {
182 data.rotation = [yaw, pitch];
183 }
184 let spawn_position = DVec3::new(data.pos[0], data.pos[1], data.pos[2]);
185 let request = world.request_player_spawn_chunks(spawn_position);
186 return Ok(DomainSwitchJobPhase::LoadingSpawn {
187 state: DomainPlayerState {
188 world,
189 data: DomainPlayerData::SavedRestored { data },
190 spawn_chunk_request: request,
191 },
192 });
193 }
194
195 let (world, spawn_suggestion, rotation) = if explicit_target {
196 let (spawn, spawn_pos) = {
197 let level_data = world.level_data.read();
198 (
199 level_data.data().spawn.clone(),
200 level_data.data().spawn_pos(),
201 )
202 };
203 (world, spawn_pos, (spawn.angle, 0.0))
204 } else {
205 let (world, respawn_data) = server.respawn_world_and_data_for_domain(target_domain)?;
206 (
207 world,
208 respawn_data.pos(),
209 (respawn_data.yaw, respawn_data.pitch),
210 )
211 };
212 let rotation = rotation_override.unwrap_or(rotation);
213 let search = PlayerSpawnSearch::new(&world, spawn_suggestion, world.default_gamemode())?
214 .with_safe_fallback()
215 .with_prefetch(PlayerSpawnSearch::prefetch_spawn_area(
216 &world,
217 spawn_suggestion,
218 world.default_gamemode(),
219 ));
220 Ok(DomainSwitchJobPhase::SearchingSpawn {
221 world,
222 data,
223 rotation,
224 search,
225 })
226 }
227
228 fn phase_at_destination(
231 world: Arc<World>,
232 data: UnpreparedDomainPlayerData,
233 destination: PreparedSpawn,
234 ) -> Result<DomainSwitchJobPhase, String> {
235 let state = match data {
236 UnpreparedDomainPlayerData::SavedRestored { mut data } => {
237 data.pos = destination.position.to_array();
238 data.rotation = [destination.rotation.0, destination.rotation.1];
239 data.motion = [0.0; 3];
240 let request = world.request_player_spawn_chunks(destination.position);
241 DomainPlayerState {
242 world,
243 data: DomainPlayerData::SavedRestored { data },
244 spawn_chunk_request: request,
245 }
246 }
247 data => Self::state_after_spawn_search(world, data, destination)?,
248 };
249 Ok(DomainSwitchJobPhase::LoadingSpawn { state })
250 }
251
252 fn state_after_spawn_search(
253 world: Arc<World>,
254 data: UnpreparedDomainPlayerData,
255 spawn: PreparedSpawn,
256 ) -> Result<DomainPlayerState, String> {
257 let data = match data {
258 UnpreparedDomainPlayerData::SavedWithoutLocation { data } => {
259 DomainPlayerData::SavedWithoutLocation { data, spawn }
260 }
261 UnpreparedDomainPlayerData::FirstVisit => DomainPlayerData::FirstVisit { spawn },
262 UnpreparedDomainPlayerData::SavedRestored { .. } => {
263 return Err("saved domain location unexpectedly entered spawn search".to_owned());
264 }
265 };
266 let request = world.request_player_spawn_chunks(spawn.position);
267 Ok(DomainPlayerState {
268 world,
269 data,
270 spawn_chunk_request: request,
271 })
272 }
273
274 fn commit_target_state(&mut self, server: &Arc<Server>, state: DomainPlayerState) -> JobPoll {
275 if !self
276 .player
277 .is_domain_residence_current(self.residence_token)
278 {
279 return self.finish_source_disconnect(Some(
280 "domain switch lost its residence before target synchronization",
281 ));
282 }
283 if !self
284 .player
285 .mark_domain_switch_target_handshake(self.pending_token)
286 {
287 return self.finish_source_disconnect(Some(
288 "domain switch lost ownership before target synchronization",
289 ));
290 }
291
292 let mount_player = !matches!(self.entry, WorldEntry::Exact(_));
293 let restores = server.prepare_domain_restores(&self.player, &state, mount_player);
294 let restore_player = Arc::clone(&self.player);
295 let fall_flying = self.player.is_fall_flying();
296 self.player
297 .reset_after_detached_domain_restore(Arc::clone(&state.world), || {
298 Server::apply_domain_player_state(&restore_player, &state);
299 if matches!(self.entry, WorldEntry::Exact(_)) {
300 restore_player.set_fall_flying(fall_flying);
303 restore_player.set_on_ground(false);
304 restore_player.land_after_teleport();
305 }
306 });
307 if !Server::install_domain_restores(&self.player, self.residence_token, &restores) {
308 return self.finish_source_disconnect(Some(
309 "domain switch lost its residence before target restore installation",
310 ));
311 }
312 let pos = self.player.position();
313 let rotation = self.player.rotation();
314 if !self.player.spawn(pos, rotation, ResetReason::WorldChange) {
315 let target_data = PersistentPlayerData::from_player(&self.player);
316 self.source_data = None;
317 server.queue_relocating_player_disconnect(
318 Arc::clone(&self.player),
319 self.target_domain.clone(),
320 Arc::new(target_data),
321 self.pending_token,
322 );
323 return JobPoll::Finished;
324 }
325 self.source_data = None;
326 if !self.player.finish_pending_world_change(self.pending_token) {
327 tracing::error!(
328 player = %self.player.gameprofile.name(),
329 "Domain switch lost its relocation lease during target admission"
330 );
331 self.player.finish_domain_switch(self.pending_token);
332 self.player.connection.close();
333 server.release_player_admission(
334 self.player.gameprofile.id(),
335 PlayerAdmissionState::Relocating,
336 );
337 server.queue_player_disconnect(Arc::clone(&self.player));
338 return JobPoll::Finished;
339 }
340 if !self.player.mark_domain_switch_live(self.pending_token) {
341 tracing::error!(
342 player = %self.player.gameprofile.name(),
343 "Domain switch lost ownership after target insertion"
344 );
345 self.player.finish_domain_switch(self.pending_token);
346 self.player.connection.close();
347 server.release_player_admission(
348 self.player.gameprofile.id(),
349 PlayerAdmissionState::Relocating,
350 );
351 server.queue_player_disconnect(Arc::clone(&self.player));
352 return JobPoll::Finished;
353 }
354 server.release_player_admission(
355 self.player.gameprofile.id(),
356 PlayerAdmissionState::Relocating,
357 );
358 server.schedule_domain_restores(&self.player, self.residence_token, restores);
359
360 let (sender, receiver) = mpsc::channel();
361 let task_server = Arc::clone(server);
362 let task_target_domain = self.target_domain.clone();
363 let uuid = self.player.gameprofile.id();
364 let task = tokio::spawn(async move {
365 let result = task_server
366 .player_data_storage
367 .save_global(
368 uuid,
369 &GlobalPlayerData {
370 last_active_domain: task_target_domain,
371 },
372 )
373 .await
374 .map_err(|error| format!("failed to save active domain: {error}"));
375 let _ = sender.send(result);
376 });
377 self.phase = DomainSwitchJobPhase::SavingGlobal { receiver, task };
378 JobPoll::Pending
379 }
380}
381
382impl ServerJob for DomainSwitchJob {
383 #[expect(
384 clippy::too_many_lines,
385 reason = "the domain transition phases stay together so every state transfer remains explicit"
386 )]
387 fn poll(&mut self, context: &mut ServerJobContext) -> JobPoll {
388 if self.player.connection.closed() {
389 return self.finish_source_disconnect(None);
390 }
391 if self.source_data.is_some()
392 && (!self.player.is_domain_switch_detached(self.pending_token)
393 || !self
394 .player
395 .is_world_change_token_pending(self.pending_token)
396 || !self
397 .player
398 .is_domain_residence_current(self.residence_token))
399 {
400 return self.finish_source_disconnect(Some(
401 "domain switch no longer owns the detached player",
402 ));
403 }
404 let Some(server) = context.server() else {
405 return self.finish_source_disconnect(None);
406 };
407
408 loop {
409 let phase = mem::replace(&mut self.phase, DomainSwitchJobPhase::Transitioning);
410 match phase {
411 DomainSwitchJobPhase::WaitingForStorage { receiver, task } => {
412 let result = match receiver.try_recv() {
413 Ok(result) => result,
414 Err(mpsc::TryRecvError::Empty) => {
415 self.phase = DomainSwitchJobPhase::WaitingForStorage { receiver, task };
416 return JobPoll::Pending;
417 }
418 Err(mpsc::TryRecvError::Disconnected) => {
419 return self.finish_source_disconnect(Some(
420 "domain storage task ended without a result",
421 ));
422 }
423 };
424 drop(task);
425 let state = match result {
426 Ok(state) => state,
427 Err(error) => return self.finish_source_disconnect(Some(&error)),
428 };
429 self.phase = match Self::phase_after_storage(
430 &server,
431 &self.target_domain,
432 self.entry,
433 state,
434 ) {
435 Ok(phase) => phase,
436 Err(error) => return self.finish_source_disconnect(Some(&error)),
437 };
438 }
439 DomainSwitchJobPhase::SearchingSpawn {
440 world,
441 data,
442 rotation,
443 mut search,
444 } => match search.poll_with_time_budget(&world, POLL_TIME_BUDGET) {
445 PlayerSpawnSearchPoll::Pending => {
446 self.phase = DomainSwitchJobPhase::SearchingSpawn {
447 world,
448 data,
449 rotation,
450 search,
451 };
452 return JobPoll::Pending;
453 }
454 PlayerSpawnSearchPoll::Cancelled => {
455 return self.finish_source_disconnect(Some(
456 "spawn search chunk request was cancelled",
457 ));
458 }
459 PlayerSpawnSearchPoll::Ready(position) => {
460 let state = match Self::state_after_spawn_search(
461 world,
462 data,
463 PreparedSpawn { position, rotation },
464 ) {
465 Ok(state) => state,
466 Err(error) => return self.finish_source_disconnect(Some(&error)),
467 };
468 self.phase = DomainSwitchJobPhase::LoadingSpawn { state };
469 }
470 },
471 DomainSwitchJobPhase::LoadingSpawn { state } => {
472 match state.spawn_chunk_request.poll() {
473 ChunkRequestState::Pending { .. } => {
474 self.phase = DomainSwitchJobPhase::LoadingSpawn { state };
475 return JobPoll::Pending;
476 }
477 ChunkRequestState::Cancelled => {
478 return self.finish_source_disconnect(Some(
479 "player spawn chunk request was cancelled",
480 ));
481 }
482 ChunkRequestState::Ready => {
483 if state.spawn_chunk_request.ready_chunks().is_none() {
484 self.phase = DomainSwitchJobPhase::LoadingSpawn { state };
485 return JobPoll::Pending;
486 }
487 }
488 }
489 return self.commit_target_state(&server, state);
490 }
491 DomainSwitchJobPhase::SavingGlobal { receiver, task } => {
492 let result = match receiver.try_recv() {
493 Ok(result) => result,
494 Err(mpsc::TryRecvError::Empty) => {
495 self.phase = DomainSwitchJobPhase::SavingGlobal { receiver, task };
496 return JobPoll::Pending;
497 }
498 Err(mpsc::TryRecvError::Disconnected) => {
499 log::error!(
500 "Active-domain save task for {} ended without a result",
501 self.player.gameprofile.name()
502 );
503 self.player.finish_domain_switch(self.pending_token);
504 return JobPoll::Finished;
505 }
506 };
507 drop(task);
508 if let Err(error) = result {
509 log::error!(
510 "Failed to save global player data for {} after domain switch: {error}",
511 self.player.gameprofile.name()
512 );
513 }
514 self.player.finish_domain_switch(self.pending_token);
515 return JobPoll::Finished;
516 }
517 DomainSwitchJobPhase::Transitioning => {
518 return self.finish_source_disconnect(Some(
519 "domain switch entered an invalid transition state",
520 ));
521 }
522 }
523 }
524 }
525
526 fn cancel(&mut self) {
527 self.abort_async_task();
528 if self.source_data.is_some() {
529 if !self.player.connection.closed() {
530 self.player.connection.close();
531 }
532 let _ = self.finish_source_disconnect(None);
533 } else {
534 self.player.finish_domain_switch(self.pending_token);
535 self.player.finish_pending_world_change(self.pending_token);
536 if let Some(server) = self.server.upgrade() {
537 server.release_player_admission(
538 self.player.gameprofile.id(),
539 PlayerAdmissionState::Relocating,
540 );
541 }
542 }
543 }
544}