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