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,
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}