steel_core/world/player_spawn_finder/
mod.rs1use 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);
30pub(crate) const POLL_TIME_BUDGET: Duration = Duration::from_millis(1);
32
33pub(crate) enum PlayerSpawnSearchPoll {
34 Pending,
35 Ready(DVec3),
36 Cancelled,
37}
38
39pub(crate) struct PlayerSpawnSearch {
41 spawn_suggestion: BlockPos,
42 sequence: CandidateSequence,
43 next_candidate_index: i32,
44 pending: Option<PendingSpawnCandidate>,
45 loading: Option<ChunkLoad>,
46 fallback: Option<FallbackPass>,
48 prefetch: Option<ChunkRequestHandle>,
49}
50
51enum 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 #[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 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 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 #[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 #[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 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 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 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 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 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 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 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 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}