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    /// Mirrors vanilla `TheEndGatewayBlockEntity.isChunkEmpty`.
383    pub(crate) fn is_end_gateway_chunk_empty(&self, chunk_pos: ChunkPos) -> Option<bool> {
384        self.chunk_map.with_full_chunk(chunk_pos, |chunk| {
385            chunk.highest_filled_section_index().is_none()
386        })
387    }
388
389    /// Mirrors vanilla `TheEndGatewayBlockEntity.findValidSpawnInChunk`.
390    pub(crate) fn find_end_gateway_valid_spawn_in_chunk(
391        &self,
392        chunk_pos: ChunkPos,
393    ) -> Option<BlockPos> {
394        self.chunk_map
395            .with_full_chunk(chunk_pos, |chunk| {
396                let min_x = chunk_pos.0.x * 16;
397                let min_z = chunk_pos.0.y * 16;
398                let max_x = min_x + 15;
399                let max_z = min_z + 15;
400                let max_y = chunk.highest_section_position() + 16 - 1;
401                let min_y = 30.min(max_y);
402                let max_y = 30.max(max_y);
403                let mut closest = None;
404                let mut closest_dist = 0.0;
405
406                for z in min_z..=max_z {
407                    for y in min_y..=max_y {
408                        for x in min_x..=max_x {
409                            let pos = BlockPos::new(x, y, z);
410                            let state = chunk.get_block_state(pos);
411                            let above = pos.above();
412                            let above_two = pos.above_n(2);
413                            if state.get_block() != &vanilla_blocks::END_STONE
414                                || self.is_collision_shape_full_block_at(
415                                    above,
416                                    chunk.get_block_state(above),
417                                )
418                                || self.is_collision_shape_full_block_at(
419                                    above_two,
420                                    chunk.get_block_state(above_two),
421                                )
422                            {
423                                continue;
424                            }
425
426                            let dist = dist_to_origin_center_sqr(pos);
427                            if closest.is_none() || dist < closest_dist {
428                                closest = Some(pos);
429                                closest_dist = dist;
430                            }
431                        }
432                    }
433                }
434
435                closest
436            })
437            .flatten()
438    }
439
440    /// Mirrors vanilla `TheEndGatewayBlockEntity.findTallestBlock`.
441    pub(crate) fn find_end_gateway_tallest_block(
442        &self,
443        around: BlockPos,
444        dist: i32,
445        allow_bedrock: bool,
446    ) -> BlockPos {
447        let mut tallest = None;
448
449        for dx in -dist..=dist {
450            for dz in -dist..=dist {
451                if dx == 0 && dz == 0 && !allow_bedrock {
452                    continue;
453                }
454
455                let min_y = tallest.map_or(self.get_min_y(), |pos: BlockPos| pos.y());
456                for y in (min_y + 1..=self.get_max_y()).rev() {
457                    let pos = BlockPos::new(around.x() + dx, y, around.z() + dz);
458                    let state = self.get_block_state(pos);
459                    if self.is_collision_shape_full_block_at(pos, state)
460                        && (allow_bedrock || state.get_block() != &vanilla_blocks::BEDROCK)
461                    {
462                        tallest = Some(pos);
463                        break;
464                    }
465                }
466            }
467        }
468
469        tallest.unwrap_or(around)
470    }
471
472    pub(super) fn is_collision_shape_full_block_at(
473        &self,
474        pos: BlockPos,
475        state: BlockStateId,
476    ) -> bool {
477        is_shape_full_block(self.block_collision_shape(pos, state))
478    }
479
480    /// Mirrors vanilla `EndIslandFeature.place` for runtime End gateway island creation.
481    pub(crate) fn create_end_island(self: &Arc<Self>, origin: BlockPos) -> bool {
482        let end_stone = vanilla_blocks::END_STONE.default_state();
483        let mut random = LegacyRandom::from_seed(PackedBlockPos::from(origin).as_raw() as u64);
484        let mut size = random.next_i32_bounded(3) as f32 + 4.0;
485        let mut y = 0;
486
487        while size > 0.5 {
488            let min = (-size).floor() as i32;
489            let max = size.ceil() as i32;
490            for x in min..=max {
491                for z in min..=max {
492                    if (x * x + z * z) as f32 <= (size + 1.0) * (size + 1.0)
493                        && !self.set_block(
494                            origin.offset(x, y, z),
495                            end_stone,
496                            UpdateFlags::UPDATE_CLIENTS,
497                        )
498                    {
499                        return false;
500                    }
501                }
502            }
503
504            size -= random.next_i32_bounded(2) as f32 + 0.5;
505            y -= 1;
506        }
507
508        true
509    }
510
511    /// Mirrors vanilla `EndGatewayFeature.place` for runtime End gateway creation.
512    pub(crate) fn create_end_gateway_portal(
513        self: &Arc<Self>,
514        origin: BlockPos,
515        exit: BlockPos,
516        exact: bool,
517    ) -> bool {
518        for dy in -2_i32..=2 {
519            for dx in -1..=1 {
520                for dz in -1..=1 {
521                    let same_x = dx == 0;
522                    let same_y = dy == 0;
523                    let same_z = dz == 0;
524                    let end = dy.abs() == 2;
525                    let state = if same_x && same_y && same_z {
526                        vanilla_blocks::END_GATEWAY.default_state()
527                    } else if same_y {
528                        vanilla_blocks::AIR.default_state()
529                    } else if (end && same_x && same_z) || ((same_x || same_z) && !end) {
530                        vanilla_blocks::BEDROCK.default_state()
531                    } else {
532                        vanilla_blocks::AIR.default_state()
533                    };
534
535                    if !self.set_block(origin.offset(dx, dy, dz), state, UpdateFlags::UPDATE_ALL) {
536                        return false;
537                    }
538                }
539            }
540        }
541
542        let Some(block_entity) = self.get_block_entity(origin) else {
543            return false;
544        };
545        let Some(gateway) = block_entity.downcast_ref::<EndGatewayBlockEntity>() else {
546            return false;
547        };
548        gateway.set_exit_position(exit, exact);
549        true
550    }
551
552    pub(super) fn clear_nether_portal_fallback_box(
553        self: &Arc<Self>,
554        origin: BlockPos,
555        direction: Direction,
556    ) -> bool {
557        let obsidian = vanilla_blocks::OBSIDIAN.default_state();
558        let air = vanilla_blocks::AIR.default_state();
559
560        for box_offset in -1..2 {
561            for width in 0..2 {
562                for height in -1..3 {
563                    let state = if height < 0 { obsidian } else { air };
564                    let pos = nether_portal_frame_offset_pos(
565                        origin, direction, width, height, box_offset,
566                    );
567                    if !self.set_block(pos, state, UpdateFlags::UPDATE_ALL) {
568                        return false;
569                    }
570                }
571            }
572        }
573
574        true
575    }
576
577    pub(super) fn place_nether_portal_frame_and_blocks(
578        self: &Arc<Self>,
579        origin: BlockPos,
580        direction: Direction,
581        portal_axis: Axis,
582    ) -> bool {
583        let obsidian = vanilla_blocks::OBSIDIAN.default_state();
584        for width in -1..3 {
585            for height in -1..4 {
586                if width == -1 || width == 2 || height == -1 || height == 3 {
587                    let pos = nether_portal_frame_offset_pos(origin, direction, width, height, 0);
588                    if !self.set_block(pos, obsidian, UpdateFlags::UPDATE_ALL) {
589                        return false;
590                    }
591                }
592            }
593        }
594
595        let portal_state = vanilla_blocks::NETHER_PORTAL
596            .default_state()
597            .set_value(&BlockStateProperties::HORIZONTAL_AXIS, portal_axis);
598        let portal_flags = UpdateFlags::UPDATE_CLIENTS | UpdateFlags::UPDATE_KNOWN_SHAPE;
599        for width in 0..2 {
600            for height in 0..3 {
601                let pos = nether_portal_frame_offset_pos(origin, direction, width, height, 0);
602                if !self.set_block(pos, portal_state, portal_flags) {
603                    return false;
604                }
605            }
606        }
607
608        true
609    }
610}