Skip to main content

steel_core/world/player_spawn_finder/
mod.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use glam::DVec3;
5use steel_registry::vanilla_entities;
6use steel_registry::vanilla_game_rules::RESPAWN_RADIUS;
7use steel_utils::{BlockPos, ChunkPos, WorldAabb, types::GameType};
8use tokio::time::sleep;
9
10use crate::behavior::BlockCollisionContext;
11use crate::chunk::chunk_request::{ChunkRequestHandle, ChunkRequestState, ChunkTicketKind};
12use crate::chunk::status::ChunkStatus;
13use crate::fluid::get_fluid_state;
14use crate::physics::{CollisionWorld as _, WorldCollisionProvider};
15use crate::world::World;
16
17use candidates::CandidateSequence;
18use chunk_load::{ChunkLoad, ChunkLoadState, PollBudget};
19use fallback::{FallbackPass, FallbackPoll};
20
21mod candidates;
22mod chunk_load;
23mod fallback;
24#[cfg(test)]
25mod tests;
26
27const PLAYER_SPAWN_CHUNK_RADIUS: u8 = 3;
28const BLOCKS_PER_CHUNK: i32 = 16;
29const CHUNK_REQUEST_POLL_DELAY: Duration = Duration::from_millis(10);
30/// Time budget per spawn search poll before yielding to the next tick.
31pub(crate) const POLL_TIME_BUDGET: Duration = Duration::from_millis(1);
32
33pub(crate) enum PlayerSpawnSearchPoll {
34    Pending,
35    Ready(DVec3),
36    Cancelled,
37}
38
39/// Evaluates candidates in vanilla's order, each once at its turn.
40pub(crate) struct PlayerSpawnSearch {
41    spawn_suggestion: BlockPos,
42    sequence: CandidateSequence,
43    next_candidate_index: i32,
44    pending: Option<PendingSpawnCandidate>,
45    loading: Option<ChunkLoad>,
46    /// Tries safe standing spots before the vanilla height fixup.
47    fallback: Option<FallbackPass>,
48    prefetch: Option<ChunkRequestHandle>,
49}
50
51/// Result of polling the chunk being read; a ready load is handed over.
52enum LoadPoll {
53    Ready(ChunkLoad),
54    Waiting,
55    Cancelled,
56}
57
58#[derive(Clone, Copy)]
59struct PendingSpawnCandidate {
60    index: i32,
61    kind: SpawnCandidateKind,
62    chunk: ChunkPos,
63}
64
65#[derive(Clone, Copy)]
66enum SpawnCandidateKind {
67    Candidate,
68    Fixup,
69}
70
71impl PlayerSpawnSearch {
72    pub(crate) fn new(
73        world: &Arc<World>,
74        spawn_suggestion: BlockPos,
75        game_type: GameType,
76    ) -> Result<Self, String> {
77        let sequence = match Self::search_radius(world, spawn_suggestion, game_type) {
78            Some(radius) => CandidateSequence::new(spawn_suggestion, radius)?,
79            None => CandidateSequence::empty(spawn_suggestion),
80        };
81        Ok(Self {
82            spawn_suggestion,
83            sequence,
84            next_candidate_index: 0,
85            pending: None,
86            loading: None,
87            fallback: None,
88            prefetch: None,
89        })
90    }
91
92    /// Tries a standing spot that avoids fluid and a ceiling dimension's roof before the
93    /// vanilla height fixup.
94    #[must_use]
95    pub(crate) fn with_safe_fallback(mut self) -> Self {
96        self.fallback = Some(FallbackPass::new(
97            usize::try_from(self.sequence.count()).unwrap_or_default(),
98        ));
99        self
100    }
101
102    /// The radius, in blocks, of the square searched around `spawn_suggestion`, or `None` when
103    /// the game mode skips the search.
104    fn search_radius(
105        world: &Arc<World>,
106        spawn_suggestion: BlockPos,
107        game_type: GameType,
108    ) -> Option<i32> {
109        if game_type == GameType::Adventure {
110            return None;
111        }
112        let mut radius = world.get_game_rule(&RESPAWN_RADIUS).max(0);
113        let border_distance = world
114            .world_border_snapshot()
115            .distance_to_border(
116                f64::from(spawn_suggestion.x()),
117                f64::from(spawn_suggestion.z()),
118            )
119            .floor() as i32;
120        if border_distance < radius {
121            radius = border_distance;
122        }
123        if border_distance <= 1 {
124            radius = 1;
125        }
126        Some(radius)
127    }
128
129    /// Requests the radius-3 chunk square around `spawn_suggestion` before the search starts. A
130    /// search wider than [`BLOCKS_PER_CHUNK`] blocks gets no prefetch. See [`Self::with_prefetch`].
131    pub(crate) fn prefetch_spawn_area(
132        world: &Arc<World>,
133        spawn_suggestion: BlockPos,
134        game_type: GameType,
135    ) -> Option<ChunkRequestHandle> {
136        let radius = Self::search_radius(world, spawn_suggestion, game_type);
137        if radius.is_some_and(|radius| radius > BLOCKS_PER_CHUNK) {
138            return None;
139        }
140        Some(world.request_player_spawn_chunks(block_bottom_center(spawn_suggestion)))
141    }
142
143    /// Adopts a request made by [`Self::prefetch_spawn_area`].
144    #[must_use]
145    pub(crate) fn with_prefetch(mut self, prefetch: Option<ChunkRequestHandle>) -> Self {
146        self.prefetch = prefetch;
147        self
148    }
149
150    #[must_use]
151    pub(crate) fn poll(&mut self, world: &Arc<World>) -> PlayerSpawnSearchPoll {
152        self.poll_with_time_budget(world, Duration::MAX)
153    }
154
155    /// Polls the search, evaluating candidates for about `time_budget`.
156    #[must_use]
157    pub(crate) fn poll_with_time_budget(
158        &mut self,
159        world: &Arc<World>,
160        time_budget: Duration,
161    ) -> PlayerSpawnSearchPoll {
162        let mut budget = PollBudget::new(time_budget);
163
164        loop {
165            let pending = if let Some(pending) = self.pending {
166                pending
167            } else {
168                let pending = self.next_candidate();
169                self.pending = Some(pending);
170                pending
171            };
172            if matches!(pending.kind, SpawnCandidateKind::Fixup) {
173                return self.poll_fixup(world, pending, &mut budget);
174            }
175
176            // A loaded chunk is read at once; any other chunk is loaded first, as in vanilla, and
177            // its request is held until the candidate has been evaluated.
178            let _load = if self.loading.is_some() || !world.has_full_chunk(pending.chunk) {
179                match self.poll_candidate_chunk(world, pending, &budget) {
180                    Ok(load) => Some(load),
181                    Err(poll) => return poll,
182                }
183            } else {
184                None
185            };
186            if budget.exhausted() {
187                return PlayerSpawnSearchPoll::Pending;
188            }
189            let spot = Self::evaluate_candidate(world, pending.index, &self.sequence);
190            budget.record_evaluation();
191            // A chunk that unloaded during the evaluation reads as invalid, so the candidate is
192            // evaluated again once its chunk is loaded.
193            if !world.has_full_chunk(pending.chunk) {
194                continue;
195            }
196            self.pending = None;
197            if let Some(pos) = spot {
198                return PlayerSpawnSearchPoll::Ready(block_bottom_center(pos));
199            }
200        }
201    }
202
203    /// Ends a search in which every candidate failed.
204    fn poll_fixup(
205        &mut self,
206        world: &Arc<World>,
207        pending: PendingSpawnCandidate,
208        budget: &mut PollBudget,
209    ) -> PlayerSpawnSearchPoll {
210        if self
211            .fallback
212            .as_ref()
213            .is_some_and(|fallback| !fallback.exhausted)
214        {
215            match self.poll_fallback_pass(world, budget) {
216                FallbackPoll::Found(pos) => {
217                    return PlayerSpawnSearchPoll::Ready(block_bottom_center(pos));
218                }
219                FallbackPoll::Waiting => return PlayerSpawnSearchPoll::Pending,
220                FallbackPoll::Cancelled => return PlayerSpawnSearchPoll::Cancelled,
221                FallbackPoll::Exhausted => {}
222            }
223        }
224        let _load = match self.poll_loading(world, pending.chunk, 0) {
225            LoadPoll::Ready(load) => load,
226            LoadPoll::Waiting => return PlayerSpawnSearchPoll::Pending,
227            LoadPoll::Cancelled => return PlayerSpawnSearchPoll::Cancelled,
228        };
229        let safe_pos = self
230            .fallback
231            .is_some()
232            .then(|| {
233                world.safe_spawn_in_column(self.spawn_suggestion.x(), self.spawn_suggestion.z())
234            })
235            .flatten();
236        PlayerSpawnSearchPoll::Ready(safe_pos.map_or_else(
237            || world.fixup_spawn_height(self.spawn_suggestion),
238            block_bottom_center,
239        ))
240    }
241
242    /// Whether the prefetch holds `chunk`: it is in the square and the tickets are committed.
243    fn chunk_is_held(&self, chunk: ChunkPos) -> bool {
244        self.prefetch.as_ref().is_some_and(|prefetch| {
245            prefetch.tickets_committed() && prefetch.positions().contains(&chunk)
246        })
247    }
248
249    /// Reuses a prefetched chunk; otherwise waits for a new request's tickets to commit.
250    fn poll_loading(&mut self, world: &Arc<World>, chunk: ChunkPos, next_scan: i32) -> LoadPoll {
251        let load = self
252            .loading
253            .take()
254            .unwrap_or_else(|| ChunkLoad::open(world, chunk, next_scan, self.chunk_is_held(chunk)));
255        match load.poll(world) {
256            ChunkLoadState::Ready => LoadPoll::Ready(load),
257            ChunkLoadState::Waiting => {
258                self.loading = Some(load);
259                LoadPoll::Waiting
260            }
261            ChunkLoadState::Cancelled => LoadPoll::Cancelled,
262        }
263    }
264
265    /// Waits for the candidate's chunk, keeping the load open while the budget is spent.
266    fn poll_candidate_chunk(
267        &mut self,
268        world: &Arc<World>,
269        pending: PendingSpawnCandidate,
270        budget: &PollBudget,
271    ) -> Result<ChunkLoad, PlayerSpawnSearchPoll> {
272        let load = match self.poll_loading(world, pending.chunk, pending.index) {
273            LoadPoll::Ready(load) => load,
274            LoadPoll::Waiting => return Err(PlayerSpawnSearchPoll::Pending),
275            LoadPoll::Cancelled => return Err(PlayerSpawnSearchPoll::Cancelled),
276        };
277        if budget.exhausted() {
278            self.loading = Some(load);
279            return Err(PlayerSpawnSearchPoll::Pending);
280        }
281        Ok(load)
282    }
283
284    fn evaluate_candidate(
285        world: &Arc<World>,
286        index: i32,
287        sequence: &CandidateSequence,
288    ) -> Option<BlockPos> {
289        let (x, z) = sequence.column(index);
290        world
291            .level_respawn_pos(x, z)
292            .filter(|&pos| world.no_collision_no_liquid(pos))
293    }
294
295    const fn next_candidate(&mut self) -> PendingSpawnCandidate {
296        if self.next_candidate_index < self.sequence.count() {
297            let index = self.next_candidate_index;
298            self.next_candidate_index += 1;
299
300            return PendingSpawnCandidate {
301                index,
302                kind: SpawnCandidateKind::Candidate,
303                chunk: self.sequence.chunk(index),
304            };
305        }
306
307        PendingSpawnCandidate {
308            index: self.sequence.count(),
309            kind: SpawnCandidateKind::Fixup,
310            chunk: ChunkPos::from_block_pos(self.spawn_suggestion),
311        }
312    }
313}
314
315impl World {
316    /// Finds the adjusted shared spawn position used for players entering this world's default spawn.
317    pub async fn find_adjusted_shared_spawn_pos(
318        self: &Arc<Self>,
319        spawn_suggestion: BlockPos,
320        game_type: GameType,
321    ) -> Result<DVec3, String> {
322        let mut search = PlayerSpawnSearch::new(self, spawn_suggestion, game_type)?;
323        loop {
324            match search.poll(self) {
325                PlayerSpawnSearchPoll::Pending => sleep(CHUNK_REQUEST_POLL_DELAY).await,
326                PlayerSpawnSearchPoll::Ready(position) => return Ok(position),
327                PlayerSpawnSearchPoll::Cancelled => {
328                    return Err("spawn search chunk request was cancelled".to_owned());
329                }
330            }
331        }
332    }
333
334    /// Loads the vanilla radius-3 full chunk square around a prepared player spawn.
335    pub async fn prepare_player_spawn_chunks(
336        self: &Arc<Self>,
337        spawn_position: DVec3,
338    ) -> Result<ChunkRequestHandle, String> {
339        let request = self.request_player_spawn_chunks(spawn_position);
340        Self::wait_for_chunk_request(&request).await?;
341        Ok(request)
342    }
343
344    pub(crate) fn request_player_spawn_chunks(
345        self: &Arc<Self>,
346        spawn_position: DVec3,
347    ) -> ChunkRequestHandle {
348        self.chunk_map.request_square(
349            ChunkPos::from_entity_pos(spawn_position),
350            PLAYER_SPAWN_CHUNK_RADIUS,
351            ChunkStatus::Full,
352            ChunkTicketKind::PlayerSpawn,
353        )
354    }
355
356    fn request_spawn_candidate_chunk(self: &Arc<Self>, chunk: ChunkPos) -> ChunkRequestHandle {
357        self.chunk_map
358            .request_chunk(chunk, ChunkStatus::Full, ChunkTicketKind::SpawnSearch)
359    }
360
361    async fn wait_for_chunk_request(request: &ChunkRequestHandle) -> Result<(), String> {
362        loop {
363            match request.poll() {
364                ChunkRequestState::Ready => return Ok(()),
365                ChunkRequestState::Cancelled => {
366                    return Err("chunk request was cancelled".to_owned());
367                }
368                ChunkRequestState::Pending { .. } => {
369                    sleep(CHUNK_REQUEST_POLL_DELAY).await;
370                }
371            }
372        }
373    }
374
375    fn fixup_spawn_height(self: &Arc<Self>, spawn_pos: BlockPos) -> DVec3 {
376        let mut pos = spawn_pos;
377
378        while !self.no_collision_no_liquid(pos) && pos.y() < self.get_max_y() {
379            pos = pos.above();
380        }
381
382        pos = pos.below();
383
384        while self.no_collision_no_liquid(pos) && pos.y() > self.get_min_y() {
385            pos = pos.below();
386        }
387
388        block_bottom_center(pos.above())
389    }
390
391    fn no_collision_no_liquid(self: &Arc<Self>, pos: BlockPos) -> bool {
392        let dimensions = vanilla_entities::PLAYER.dimensions;
393        let aabb = WorldAabb::entity_box(
394            f64::from(pos.x()) + 0.5,
395            f64::from(pos.y()),
396            f64::from(pos.z()) + 0.5,
397            f64::from(dimensions.half_width()),
398            f64::from(dimensions.height),
399        );
400        let collision_world = WorldCollisionProvider::new(self);
401
402        !collision_world.has_entity_collision(&aabb)
403            && !collision_world
404                .has_block_collision_with_context(&aabb, BlockCollisionContext::empty())
405            && !aabb_contains_any_liquid(self, aabb)
406    }
407}
408
409pub(crate) fn aabb_contains_any_liquid(world: &Arc<World>, aabb: WorldAabb) -> bool {
410    let min_x = aabb.min_x().floor() as i32;
411    let max_x = aabb.max_x().ceil() as i32;
412    let min_y = aabb.min_y().floor() as i32;
413    let max_y = aabb.max_y().ceil() as i32;
414    let min_z = aabb.min_z().floor() as i32;
415    let max_z = aabb.max_z().ceil() as i32;
416
417    for x in min_x..max_x {
418        for y in min_y..max_y {
419            for z in min_z..max_z {
420                if !get_fluid_state(world, BlockPos::new(x, y, z)).is_empty() {
421                    return true;
422                }
423            }
424        }
425    }
426
427    false
428}
429
430fn block_bottom_center(pos: BlockPos) -> DVec3 {
431    let (x, y, z) = pos.get_bottom_center();
432    DVec3::new(x, y, z)
433}