Skip to main content

steel_core/world/
portals.rs

1use super::*;
2
3pub(super) fn portal_candidate_distance_sqr(candidate: BlockPos, center: BlockPos) -> i64 {
4    let dx = i64::from(candidate.x()) - i64::from(center.x());
5    let dy = i64::from(candidate.y()) - i64::from(center.y());
6    let dz = i64::from(candidate.z()) - i64::from(center.z());
7    dx * dx + dy * dy + dz * dz
8}
9
10pub(super) fn dist_to_origin_center_sqr(pos: BlockPos) -> f64 {
11    let x = f64::from(pos.x()) + 0.5;
12    let y = f64::from(pos.y()) + 0.5;
13    let z = f64::from(pos.z()) + 0.5;
14    x * x + y * y + z * z
15}
16
17pub(super) fn closest_portal_candidate(
18    candidates: impl IntoIterator<Item = BlockPos>,
19    approximate_exit_pos: BlockPos,
20    is_valid: impl Fn(BlockPos) -> bool,
21) -> Option<BlockPos> {
22    candidates
23        .into_iter()
24        .filter(|pos| is_valid(*pos))
25        .min_by_key(|pos| {
26            (
27                portal_candidate_distance_sqr(*pos, approximate_exit_pos),
28                pos.y(),
29            )
30        })
31}
32
33const NETHER_PORTAL_CREATE_RADIUS: i32 = 16;
34const NETHER_PORTAL_FALLBACK_MIN_Y: i32 = 70;
35const NETHER_PORTAL_FALLBACK_MAX_Y_OFFSET: i32 = 9;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub(super) struct MissingPortalCreationChunk;
39
40pub(super) const fn nether_portal_frame_offset_pos(
41    origin: BlockPos,
42    direction: Direction,
43    width: i32,
44    height: i32,
45    offset: i32,
46) -> BlockPos {
47    let clockwise = direction.rotate_y_clockwise();
48    let (direction_x, _, direction_z) = direction.offset();
49    let (clockwise_x, _, clockwise_z) = clockwise.offset();
50    origin.offset(
51        direction_x * width + clockwise_x * offset,
52        height,
53        direction_z * width + clockwise_z * offset,
54    )
55}
56
57pub(super) fn nether_portal_creation_scan_origin(
58    column_pos: BlockPos,
59    direction: Direction,
60    height: i32,
61) -> BlockPos {
62    column_pos.relative(direction.opposite()).at_y(height)
63}
64
65impl World {
66    /// Finds the closest existing Nether portal POI using vanilla `PortalForcer` ordering.
67    ///
68    /// `to_nether` selects vanilla's 16-block Nether search radius; non-Nether targets use 128.
69    ///
70    /// # Panics
71    ///
72    /// Panics if vanilla POI registries were not initialized before portal lookup.
73    #[must_use]
74    pub fn find_closest_nether_portal_position(
75        &self,
76        approximate_exit_pos: BlockPos,
77        to_nether: bool,
78    ) -> Option<BlockPos> {
79        let radius = if to_nether { 16 } else { 128 };
80        let nether_portal_type = vanilla_poi_types::NETHER_PORTAL
81            .try_id()
82            .expect("vanilla nether portal POI type should be registered");
83        let candidates = self.poi_storage.lock().get_in_horizontal_square(
84            &|type_id| type_id == nether_portal_type,
85            approximate_exit_pos,
86            radius,
87            OccupationStatus::Any,
88        );
89
90        closest_portal_candidate(
91            candidates.into_iter().map(|(pos, _)| pos),
92            approximate_exit_pos,
93            |pos| {
94                self.is_block_within_world_border(pos)
95                    && self
96                        .get_block_state(pos)
97                        .try_get_value(&BlockStateProperties::HORIZONTAL_AXIS)
98                        .is_some()
99            },
100        )
101    }
102
103    /// Creates a Nether portal using vanilla `PortalForcer.createPortal` placement rules.
104    ///
105    /// The caller must keep the target search area loaded as full chunks before calling. Steel
106    /// returns `None` if any required chunk read or write is unavailable, rather than treating
107    /// unloaded chunks as replaceable air.
108    #[must_use]
109    pub fn create_nether_portal(
110        self: &Arc<Self>,
111        origin: BlockPos,
112        portal_axis: Axis,
113    ) -> Option<FoundRectangle> {
114        if portal_axis == Axis::Y {
115            return None;
116        }
117
118        let direction = Direction::positive_for_axis(portal_axis);
119        let max_placeable_y = self
120            .get_max_y()
121            .min(self.get_min_y() + self.dimension_type.logical_height - 1);
122
123        let portal_origin =
124            match self.find_nether_portal_creation_position(origin, direction, max_placeable_y) {
125                Ok(Some(pos)) => pos,
126                Ok(None) => {
127                    let fallback =
128                        self.fallback_nether_portal_position(origin, direction, max_placeable_y)?;
129                    if !self.can_write_nether_portal_fallback_box(fallback, direction) {
130                        return None;
131                    }
132                    if !self.clear_nether_portal_fallback_box(fallback, direction) {
133                        return None;
134                    }
135                    fallback
136                }
137                Err(MissingPortalCreationChunk) => return None,
138            };
139
140        if !self.can_write_nether_portal_rectangle(portal_origin, direction) {
141            return None;
142        }
143        if !self.place_nether_portal_frame_and_blocks(portal_origin, direction, portal_axis) {
144            return None;
145        }
146
147        Some(FoundRectangle {
148            min_corner: portal_origin,
149            axis1_size: 2,
150            axis2_size: 3,
151        })
152    }
153
154    /// Adds or refreshes vanilla's portal chunk ticket for a post-teleport entity.
155    pub(crate) fn place_portal_ticket(&self, ticket_position: BlockPos) {
156        self.chunk_map.place_portal_ticket(ticket_position);
157    }
158
159    pub(super) fn find_nether_portal_creation_position(
160        &self,
161        origin: BlockPos,
162        direction: Direction,
163        max_placeable_y: i32,
164    ) -> Result<Option<BlockPos>, MissingPortalCreationChunk> {
165        let mut closest_full_position: Option<(i64, BlockPos)> = None;
166        let mut closest_partial_position: Option<(i64, BlockPos)> = None;
167        let border = self.world_border_snapshot();
168
169        for column_pos in BlockPos::spiral_around(
170            origin,
171            NETHER_PORTAL_CREATE_RADIUS,
172            Direction::East,
173            Direction::South,
174        ) {
175            let height = self
176                .height_at(
177                    HeightmapType::MotionBlocking,
178                    column_pos.x(),
179                    column_pos.z(),
180                )
181                .ok_or(MissingPortalCreationChunk)?
182                .min(max_placeable_y);
183            if !border.is_block_within_bounds(column_pos)
184                || !border.is_block_within_bounds(column_pos.relative(direction))
185            {
186                continue;
187            }
188
189            let mut column_pos = nether_portal_creation_scan_origin(column_pos, direction, height);
190            let mut y = height;
191            while y >= self.get_min_y() {
192                column_pos = column_pos.at_y(y);
193                if self.can_nether_portal_replace_block(column_pos)? {
194                    let first_empty_y = y;
195
196                    while y > self.get_min_y()
197                        && self.can_nether_portal_replace_block(column_pos.below())?
198                    {
199                        y -= 1;
200                        column_pos = column_pos.below();
201                    }
202
203                    if y + 4 <= max_placeable_y {
204                        let delta_y = first_empty_y - y;
205                        if (delta_y <= 0 || delta_y >= 3)
206                            && self.can_host_nether_portal_frame(column_pos, direction, 0)?
207                        {
208                            let distance = portal_candidate_distance_sqr(column_pos, origin);
209                            let full_frame = self
210                                .can_host_nether_portal_frame(column_pos, direction, -1)?
211                                && self.can_host_nether_portal_frame(column_pos, direction, 1)?;
212
213                            if full_frame
214                                && closest_full_position
215                                    .is_none_or(|(closest_distance, _)| closest_distance > distance)
216                            {
217                                closest_full_position = Some((distance, column_pos));
218                            }
219
220                            if closest_full_position.is_none()
221                                && closest_partial_position
222                                    .is_none_or(|(closest_distance, _)| closest_distance > distance)
223                            {
224                                closest_partial_position = Some((distance, column_pos));
225                            }
226                        }
227                    }
228                }
229
230                y -= 1;
231            }
232        }
233
234        if closest_full_position.is_none() {
235            closest_full_position = closest_partial_position;
236        }
237
238        Ok(closest_full_position.map(|(_, pos)| pos))
239    }
240
241    pub(super) fn can_nether_portal_replace_block(
242        &self,
243        pos: BlockPos,
244    ) -> Result<bool, MissingPortalCreationChunk> {
245        let state = self
246            .loaded_block_state(pos)
247            .ok_or(MissingPortalCreationChunk)?;
248        Ok(state.is_replaceable() && state.get_fluid_state().is_empty())
249    }
250
251    pub(super) fn can_host_nether_portal_frame(
252        &self,
253        origin: BlockPos,
254        direction: Direction,
255        offset: i32,
256    ) -> Result<bool, MissingPortalCreationChunk> {
257        for width in -1..3 {
258            for height in -1..4 {
259                let pos = nether_portal_frame_offset_pos(origin, direction, width, height, offset);
260                if height < 0 {
261                    let state = self
262                        .loaded_block_state(pos)
263                        .ok_or(MissingPortalCreationChunk)?;
264                    if !state.is_solid() {
265                        return Ok(false);
266                    }
267                } else if !self.can_nether_portal_replace_block(pos)? {
268                    return Ok(false);
269                }
270            }
271        }
272
273        Ok(true)
274    }
275
276    pub(super) fn loaded_block_state(&self, pos: BlockPos) -> Option<BlockStateId> {
277        if !self.is_in_valid_bounds(pos) {
278            return Some(REGISTRY.blocks.get_base_state_id(&vanilla_blocks::AIR));
279        }
280
281        let chunk_pos = Self::chunk_pos_for_block(pos);
282        self.chunk_map
283            .with_full_chunk(chunk_pos, |chunk| chunk.get_block_state(pos))
284    }
285
286    pub(super) fn fallback_nether_portal_position(
287        &self,
288        origin: BlockPos,
289        direction: Direction,
290        max_placeable_y: i32,
291    ) -> Option<BlockPos> {
292        let min_start_y = (self.get_min_y() + 1).max(NETHER_PORTAL_FALLBACK_MIN_Y);
293        let max_start_y = max_placeable_y - NETHER_PORTAL_FALLBACK_MAX_Y_OFFSET;
294        if max_start_y < min_start_y {
295            return None;
296        }
297
298        let (direction_x, _, direction_z) = direction.offset();
299        let pos = BlockPos::new(
300            origin.x() - direction_x,
301            origin.y().clamp(min_start_y, max_start_y),
302            origin.z() - direction_z,
303        );
304
305        Some(self.world_border_snapshot().clamp_to_bounds(
306            f64::from(pos.x()),
307            f64::from(pos.y()),
308            f64::from(pos.z()),
309        ))
310    }
311
312    pub(super) fn can_write_nether_portal_fallback_box(
313        &self,
314        origin: BlockPos,
315        direction: Direction,
316    ) -> bool {
317        for box_offset in -1..2 {
318            for width in 0..2 {
319                for height in -1..3 {
320                    let pos = nether_portal_frame_offset_pos(
321                        origin, direction, width, height, box_offset,
322                    );
323                    if !self.can_write_loaded_block(pos) {
324                        return false;
325                    }
326                }
327            }
328        }
329
330        self.can_write_nether_portal_rectangle(origin, direction)
331    }
332
333    pub(super) fn can_write_nether_portal_rectangle(
334        &self,
335        origin: BlockPos,
336        direction: Direction,
337    ) -> bool {
338        for width in -1..3 {
339            for height in -1..4 {
340                let pos = nether_portal_frame_offset_pos(origin, direction, width, height, 0);
341                if !self.can_write_loaded_block(pos) {
342                    return false;
343                }
344            }
345        }
346
347        true
348    }
349
350    pub(super) fn can_write_loaded_block(&self, pos: BlockPos) -> bool {
351        if !self.is_in_valid_bounds(pos) {
352            return false;
353        }
354
355        let chunk_pos = Self::chunk_pos_for_block(pos);
356        self.chunk_map.with_full_chunk(chunk_pos, |_| ()).is_some()
357    }
358
359    /// Mirrors vanilla `EndPlatformFeature.createEndPlatform` for runtime End portal travel.
360    pub(crate) fn create_end_platform(self: &Arc<Self>, origin: BlockPos) -> bool {
361        let obsidian = vanilla_blocks::OBSIDIAN.default_state();
362        let air = vanilla_blocks::AIR.default_state();
363
364        for dz in -2..=2 {
365            for dx in -2..=2 {
366                for dy in -1..3 {
367                    let pos = origin.offset(dx, dy, dz);
368                    let state = if dy == -1 { obsidian } else { air };
369                    if self.get_block_state(pos).get_block() != state.get_block() {
370                        let _ = self.destroy_block(pos, true);
371                        if !self.set_block(pos, state, UpdateFlags::UPDATE_ALL) {
372                            return false;
373                        }
374                    }
375                }
376            }
377        }
378
379        true
380    }
381
382    pub(crate) fn is_end_gateway_chunk_empty(&self, chunk_pos: ChunkPos) -> Option<bool> {
383        self.chunk_map.with_full_chunk(chunk_pos, |chunk| {
384            chunk.highest_filled_section_index().is_none()
385        })
386    }
387
388    pub(crate) fn find_end_gateway_valid_spawn_in_chunk(
389        &self,
390        chunk_pos: ChunkPos,
391    ) -> Option<BlockPos> {
392        self.chunk_map
393            .with_full_chunk(chunk_pos, |chunk| {
394                let min_x = chunk_pos.0.x * 16;
395                let min_z = chunk_pos.0.y * 16;
396                let max_x = min_x + 15;
397                let max_z = min_z + 15;
398                let max_y = chunk.highest_section_position() + 16 - 1;
399                let min_y = 30.min(max_y);
400                let max_y = 30.max(max_y);
401                let mut closest = None;
402                let mut closest_dist = 0.0;
403
404                for z in min_z..=max_z {
405                    for y in min_y..=max_y {
406                        for x in min_x..=max_x {
407                            let pos = BlockPos::new(x, y, z);
408                            let state = chunk.get_block_state(pos);
409                            let above = pos.above();
410                            let above_two = pos.above_n(2);
411                            if state.get_block() != &vanilla_blocks::END_STONE
412                                || self.is_collision_shape_full_block_at(
413                                    above,
414                                    chunk.get_block_state(above),
415                                )
416                                || self.is_collision_shape_full_block_at(
417                                    above_two,
418                                    chunk.get_block_state(above_two),
419                                )
420                            {
421                                continue;
422                            }
423
424                            let dist = dist_to_origin_center_sqr(pos);
425                            if closest.is_none() || dist < closest_dist {
426                                closest = Some(pos);
427                                closest_dist = dist;
428                            }
429                        }
430                    }
431                }
432
433                closest
434            })
435            .flatten()
436    }
437
438    pub(crate) fn find_end_gateway_tallest_block(
439        &self,
440        around: BlockPos,
441        dist: i32,
442        allow_bedrock: bool,
443    ) -> BlockPos {
444        let mut tallest = None;
445
446        for dx in -dist..=dist {
447            for dz in -dist..=dist {
448                if dx == 0 && dz == 0 && !allow_bedrock {
449                    continue;
450                }
451
452                let min_y = tallest.map_or(self.get_min_y(), |pos: BlockPos| pos.y());
453                for y in (min_y + 1..=self.get_max_y()).rev() {
454                    let pos = BlockPos::new(around.x() + dx, y, around.z() + dz);
455                    let state = self.get_block_state(pos);
456                    if self.is_collision_shape_full_block_at(pos, state)
457                        && (allow_bedrock || state.get_block() != &vanilla_blocks::BEDROCK)
458                    {
459                        tallest = Some(pos);
460                        break;
461                    }
462                }
463            }
464        }
465
466        tallest.unwrap_or(around)
467    }
468
469    pub(super) fn is_collision_shape_full_block_at(
470        &self,
471        pos: BlockPos,
472        state: BlockStateId,
473    ) -> bool {
474        is_shape_full_block(self.block_collision_shape(pos, state))
475    }
476
477    /// Mirrors vanilla `EndIslandFeature.place` for runtime End gateway island creation.
478    pub(crate) fn create_end_island(self: &Arc<Self>, origin: BlockPos) -> bool {
479        let end_stone = vanilla_blocks::END_STONE.default_state();
480        let mut random = LegacyRandom::from_seed(PackedBlockPos::from(origin).as_raw() as u64);
481        let mut size = random.next_i32_bounded(3) as f32 + 4.0;
482        let mut y = 0;
483
484        while size > 0.5 {
485            let min = (-size).floor() as i32;
486            let max = size.ceil() as i32;
487            for x in min..=max {
488                for z in min..=max {
489                    if (x * x + z * z) as f32 <= (size + 1.0) * (size + 1.0)
490                        && !self.set_block(
491                            origin.offset(x, y, z),
492                            end_stone,
493                            UpdateFlags::UPDATE_CLIENTS,
494                        )
495                    {
496                        return false;
497                    }
498                }
499            }
500
501            size -= random.next_i32_bounded(2) as f32 + 0.5;
502            y -= 1;
503        }
504
505        true
506    }
507
508    /// Mirrors vanilla `EndGatewayFeature.place` for runtime End gateway creation.
509    pub(crate) fn create_end_gateway_portal(
510        self: &Arc<Self>,
511        origin: BlockPos,
512        exit: BlockPos,
513        exact: bool,
514    ) -> bool {
515        for dy in -2_i32..=2 {
516            for dx in -1..=1 {
517                for dz in -1..=1 {
518                    let same_x = dx == 0;
519                    let same_y = dy == 0;
520                    let same_z = dz == 0;
521                    let end = dy.abs() == 2;
522                    let state = if same_x && same_y && same_z {
523                        vanilla_blocks::END_GATEWAY.default_state()
524                    } else if same_y {
525                        vanilla_blocks::AIR.default_state()
526                    } else if (end && same_x && same_z) || ((same_x || same_z) && !end) {
527                        vanilla_blocks::BEDROCK.default_state()
528                    } else {
529                        vanilla_blocks::AIR.default_state()
530                    };
531
532                    if !self.set_block(origin.offset(dx, dy, dz), state, UpdateFlags::UPDATE_ALL) {
533                        return false;
534                    }
535                }
536            }
537        }
538
539        let Some(block_entity) = self.get_block_entity(origin) else {
540            return false;
541        };
542        let Some(gateway) = block_entity.downcast_ref::<EndGatewayBlockEntity>() else {
543            return false;
544        };
545        gateway.set_exit_position(exit, exact);
546        true
547    }
548
549    pub(super) fn clear_nether_portal_fallback_box(
550        self: &Arc<Self>,
551        origin: BlockPos,
552        direction: Direction,
553    ) -> bool {
554        let obsidian = vanilla_blocks::OBSIDIAN.default_state();
555        let air = vanilla_blocks::AIR.default_state();
556
557        for box_offset in -1..2 {
558            for width in 0..2 {
559                for height in -1..3 {
560                    let state = if height < 0 { obsidian } else { air };
561                    let pos = nether_portal_frame_offset_pos(
562                        origin, direction, width, height, box_offset,
563                    );
564                    if !self.set_block(pos, state, UpdateFlags::UPDATE_ALL) {
565                        return false;
566                    }
567                }
568            }
569        }
570
571        true
572    }
573
574    pub(super) fn place_nether_portal_frame_and_blocks(
575        self: &Arc<Self>,
576        origin: BlockPos,
577        direction: Direction,
578        portal_axis: Axis,
579    ) -> bool {
580        let obsidian = vanilla_blocks::OBSIDIAN.default_state();
581        for width in -1..3 {
582            for height in -1..4 {
583                if width == -1 || width == 2 || height == -1 || height == 3 {
584                    let pos = nether_portal_frame_offset_pos(origin, direction, width, height, 0);
585                    if !self.set_block(pos, obsidian, UpdateFlags::UPDATE_ALL) {
586                        return false;
587                    }
588                }
589            }
590        }
591
592        let portal_state = vanilla_blocks::NETHER_PORTAL
593            .default_state()
594            .set_value(&BlockStateProperties::HORIZONTAL_AXIS, portal_axis);
595        let portal_flags = UpdateFlags::UPDATE_CLIENTS | UpdateFlags::UPDATE_KNOWN_SHAPE;
596        for width in 0..2 {
597            for height in 0..3 {
598                let pos = nether_portal_frame_offset_pos(origin, direction, width, height, 0);
599                if !self.set_block(pos, portal_state, portal_flags) {
600                    return false;
601                }
602            }
603        }
604
605        true
606    }
607}