Skip to main content

steel_core/server/jobs/
domain_switch.rs

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    /// Enters exactly at `destination`, with no spawn search. A saved location is replaced; its
229    /// vehicle is restored at its old position without the player.
230    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                    // Like `/teleport`, the gliding state carries over. A glider stays airborne,
301                    // so it must not keep a saved `on_ground`.
302                    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}