Skip to main content

steel_core/portal/
mod.rs

1//! World portal system for nether/end portals and future portal types.
2//!
3//! Vanilla commonly calls loaded worlds "dimensions". Steel uses "world" for
4//! loaded runtime worlds and reserves "dimension type" for the vanilla registry
5//! entry that defines world rules.
6
7use crate::chunk::chunk_request::ChunkRequestHandle;
8use crate::entity::{Entity, PendingWorldChangeToken};
9use crate::world::World;
10use glam::DVec3;
11use smallvec::SmallVec;
12use std::sync::Arc;
13use steel_math::{
14    DEGREE_90,
15    vector::{x_rot, y_rot},
16};
17use steel_protocol::packets::game::RelativeMovement;
18use steel_registry::game_rules::GameRuleRef;
19use steel_registry::vanilla_game_rules::{
20    PLAYERS_NETHER_PORTAL_CREATIVE_DELAY, PLAYERS_NETHER_PORTAL_DEFAULT_DELAY,
21};
22use steel_utils::BlockPos;
23
24pub(crate) mod end_gateway;
25pub(crate) mod end_portal;
26pub(crate) mod nether_portal;
27pub mod portal_shape;
28
29/// Vanilla portal behavior kind tracked by an entity while it is inside a portal.
30///
31/// Java stores a reference to the `Portal` block behavior object. Steel keeps a
32/// compact explicit kind here so entity state does not depend on block behavior
33/// object identity.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PortalKind {
36    /// Vanilla nether portal block.
37    Nether,
38    /// Vanilla end portal block.
39    End,
40    /// Vanilla end gateway block.
41    EndGateway,
42}
43
44impl PortalKind {
45    /// Returns the number of ticks an entity must stand in this portal before
46    /// it triggers, accounting for whether the entity is invulnerable.
47    #[must_use]
48    pub fn transition_time(self, world: &World, entity: &(impl Entity + ?Sized)) -> i32 {
49        let player_invulnerable = entity
50            .as_player()
51            .map(|player| player.abilities.lock().invulnerable);
52        self.transition_time_for_player_state(world, player_invulnerable)
53    }
54
55    /// Returns vanilla `Portal.getPortalTransitionTime` from object-safe entity state.
56    #[must_use]
57    pub fn transition_time_for_player_state(
58        self,
59        world: &World,
60        player_invulnerable: Option<bool>,
61    ) -> i32 {
62        match self {
63            Self::Nether => nether_portal_transition_time(world, player_invulnerable),
64            Self::End | Self::EndGateway => 0,
65        }
66    }
67}
68
69fn nether_portal_transition_time(world: &World, player_invulnerable: Option<bool>) -> i32 {
70    let Some(player_invulnerable) = player_invulnerable else {
71        return 0;
72    };
73
74    let rule = nether_portal_transition_rule(player_invulnerable);
75    let delay = portal_transition_game_rule(world, rule);
76    clamped_portal_transition_time(delay)
77}
78
79fn nether_portal_transition_rule(player_invulnerable: bool) -> GameRuleRef<i32> {
80    if player_invulnerable {
81        &PLAYERS_NETHER_PORTAL_CREATIVE_DELAY
82    } else {
83        &PLAYERS_NETHER_PORTAL_DEFAULT_DELAY
84    }
85}
86
87fn clamped_portal_transition_time(delay: i32) -> i32 {
88    delay.max(0)
89}
90
91fn portal_transition_game_rule(world: &World, rule: GameRuleRef<i32>) -> i32 {
92    world.get_game_rule(rule)
93}
94
95/// Result of advancing an entity's active portal process for one server tick.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum PortalProcessResult {
98    /// The entity has not reached the portal transition threshold.
99    Waiting,
100    /// The portal transition threshold was reached this tick.
101    Ready,
102}
103
104/// Per-entity portal timer state.
105///
106/// Mirrors vanilla `PortalProcessor`: the active portal kind, the entry block
107/// position, the accumulated portal time, and whether the entity touched the
108/// portal during the current tick.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct PortalProcessor {
111    portal: PortalKind,
112    entry_position: BlockPos,
113    portal_time: i32,
114    inside_portal_this_tick: bool,
115}
116
117impl PortalProcessor {
118    /// Creates a portal process for a freshly entered portal.
119    #[must_use]
120    pub const fn new(portal: PortalKind, entry_position: BlockPos) -> Self {
121        Self {
122            portal,
123            entry_position,
124            portal_time: 0,
125            inside_portal_this_tick: true,
126        }
127    }
128
129    /// Returns the tracked portal kind.
130    #[must_use]
131    pub const fn portal(self) -> PortalKind {
132        self.portal
133    }
134
135    /// Returns the portal block position the entity entered from.
136    #[must_use]
137    pub const fn entry_position(self) -> BlockPos {
138        self.entry_position
139    }
140
141    /// Returns the accumulated portal time.
142    #[must_use]
143    pub const fn portal_time(self) -> i32 {
144        self.portal_time
145    }
146
147    /// Returns whether the entity touched this portal during the current tick.
148    #[must_use]
149    pub const fn is_inside_portal_this_tick(self) -> bool {
150        self.inside_portal_this_tick
151    }
152
153    /// Returns true if this process tracks the same portal behavior.
154    #[must_use]
155    pub fn is_same_portal(self, portal: PortalKind) -> bool {
156        self.portal == portal
157    }
158
159    /// Marks this process as touched by the entity for the current tick.
160    pub const fn set_as_inside_portal(&mut self, entry_position: BlockPos) {
161        if !self.inside_portal_this_tick {
162            self.entry_position = entry_position;
163            self.inside_portal_this_tick = true;
164        }
165    }
166
167    /// Advances vanilla portal timing for one server tick.
168    pub fn process_portal_teleportation(
169        &mut self,
170        allowed_to_teleport: bool,
171        transition_time: i32,
172    ) -> PortalProcessResult {
173        if !self.inside_portal_this_tick {
174            self.decay_tick();
175            return PortalProcessResult::Waiting;
176        }
177
178        self.inside_portal_this_tick = false;
179        if !allowed_to_teleport {
180            return PortalProcessResult::Waiting;
181        }
182
183        let ready = self.portal_time >= transition_time;
184        self.portal_time += 1;
185        if ready {
186            PortalProcessResult::Ready
187        } else {
188            PortalProcessResult::Waiting
189        }
190    }
191
192    fn decay_tick(&mut self) {
193        self.portal_time = self.portal_time.saturating_sub(4).max(0);
194    }
195
196    /// Returns true when vanilla would clear the active portal process.
197    #[must_use]
198    pub const fn has_expired(self) -> bool {
199        self.portal_time <= 0
200    }
201}
202
203/// Describes a teleport transition to another loaded world.
204///
205/// Vanilla names loaded worlds "dimensions" in packets and saves. Steel uses
206/// "world" for runtime loaded world instances, reserving "dimension type" for
207/// the vanilla registry entry that defines height, skylight, ceiling, etc.
208#[derive(Clone)]
209pub struct TeleportTransition {
210    /// The target world to teleport into.
211    pub target_world: Arc<World>,
212    /// The position in the target world.
213    pub position: DVec3,
214    /// The rotation (yaw, pitch) values, interpreted by `relatives`.
215    pub rotation: (f32, f32),
216    /// The velocity component carried by this transition, interpreted by `relatives`.
217    pub velocity: DVec3,
218    /// Vanilla relative movement flags carried through to clientbound player position packets.
219    pub relatives: RelativeMovement,
220    /// Portal cooldown in ticks (prevents immediate re-entry).
221    pub portal_cooldown: i32,
222    /// Whether this transition is being applied recursively to a passenger.
223    pub as_passenger: bool,
224    /// Side effects vanilla runs after the entity has reached the target world.
225    pub post_transition: TeleportPostTransition,
226}
227
228/// Vanilla post-teleport side effects, composed in transition order.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct TeleportPostTransition {
231    actions: SmallVec<[TeleportPostAction; 2]>,
232}
233
234impl TeleportPostTransition {
235    /// No post-transition work.
236    #[must_use]
237    pub fn do_nothing() -> Self {
238        Self {
239            actions: SmallVec::new(),
240        }
241    }
242
243    /// Plays vanilla's portal travel level event for players.
244    #[must_use]
245    pub fn play_portal_sound() -> Self {
246        Self::single(TeleportPostAction::PlayPortalSound)
247    }
248
249    /// Places a portal chunk ticket after the transition.
250    #[must_use]
251    pub fn place_portal_ticket(target: PortalTicketTarget) -> Self {
252        Self::single(TeleportPostAction::PlacePortalTicket(target))
253    }
254
255    /// Appends another post-transition action sequence.
256    #[must_use]
257    pub fn then(mut self, next: Self) -> Self {
258        self.actions.extend(next.actions);
259        self
260    }
261
262    /// Returns post-transition actions in vanilla execution order.
263    #[must_use]
264    pub fn actions(&self) -> &[TeleportPostAction] {
265        self.actions.as_slice()
266    }
267
268    fn single(action: TeleportPostAction) -> Self {
269        let mut actions = SmallVec::new();
270        actions.push(action);
271        Self { actions }
272    }
273}
274
275impl Default for TeleportPostTransition {
276    fn default() -> Self {
277        Self::do_nothing()
278    }
279}
280
281/// A single post-teleport side effect.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum TeleportPostAction {
284    /// Send vanilla portal travel level event 1032 to the player.
285    PlayPortalSound,
286    /// Add a portal chunk ticket.
287    PlacePortalTicket(PortalTicketTarget),
288}
289
290/// Position used for vanilla portal ticket placement.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum PortalTicketTarget {
293    /// Use the entity's final block position after teleporting.
294    Destination,
295    /// Use a specific portal block position.
296    Block(BlockPos),
297}
298
299impl TeleportTransition {
300    /// Returns this transition with a new target position.
301    #[must_use]
302    pub fn with_position(&self, position: DVec3) -> Self {
303        Self {
304            target_world: Arc::clone(&self.target_world),
305            position,
306            rotation: self.rotation,
307            velocity: self.velocity,
308            relatives: self.relatives,
309            portal_cooldown: self.portal_cooldown,
310            as_passenger: self.as_passenger,
311            post_transition: self.post_transition.clone(),
312        }
313    }
314
315    /// Resolves this transition's position against the entity's current position.
316    #[must_use]
317    pub fn resolved_position(&self, current_position: DVec3) -> DVec3 {
318        resolve_position(self.position, self.relatives, current_position)
319    }
320
321    /// Resolves this transition's yaw and pitch against the entity's current rotation.
322    #[must_use]
323    pub fn resolved_rotation(&self, current_rotation: (f32, f32)) -> (f32, f32) {
324        resolve_rotation(self.rotation, self.relatives, current_rotation)
325    }
326
327    /// Resolves this transition's velocity against the entity's current motion and rotation.
328    #[must_use]
329    pub fn resolved_velocity(
330        &self,
331        current_velocity: DVec3,
332        current_rotation: (f32, f32),
333        resolved_rotation: (f32, f32),
334    ) -> DVec3 {
335        resolve_velocity(
336            self.velocity,
337            self.relatives,
338            current_velocity,
339            current_rotation,
340            resolved_rotation,
341        )
342    }
343}
344
345fn resolve_position(
346    position: DVec3,
347    relatives: RelativeMovement,
348    current_position: DVec3,
349) -> DVec3 {
350    DVec3::new(
351        if relatives.is_x_relative() {
352            current_position.x + position.x
353        } else {
354            position.x
355        },
356        if relatives.is_y_relative() {
357            current_position.y + position.y
358        } else {
359            position.y
360        },
361        if relatives.is_z_relative() {
362            current_position.z + position.z
363        } else {
364            position.z
365        },
366    )
367}
368
369fn resolve_rotation(
370    rotation: (f32, f32),
371    relatives: RelativeMovement,
372    current_rotation: (f32, f32),
373) -> (f32, f32) {
374    let yaw = if relatives.is_y_rot_relative() {
375        current_rotation.0 + rotation.0
376    } else {
377        rotation.0
378    };
379    let pitch = if relatives.is_x_rot_relative() {
380        current_rotation.1 + rotation.1
381    } else {
382        rotation.1
383    };
384    (yaw, clamp_pitch(pitch))
385}
386
387const fn clamp_pitch(pitch: f32) -> f32 {
388    pitch.clamp(-DEGREE_90, DEGREE_90)
389}
390
391fn resolve_velocity(
392    velocity: DVec3,
393    relatives: RelativeMovement,
394    current_velocity: DVec3,
395    current_rotation: (f32, f32),
396    resolved_rotation: (f32, f32),
397) -> DVec3 {
398    let current_velocity = if relatives.rotates_delta() {
399        let diff_yaw = current_rotation.0 - resolved_rotation.0;
400        let diff_pitch = current_rotation.1 - resolved_rotation.1;
401        y_rot(
402            x_rot(current_velocity, diff_pitch.to_radians()),
403            diff_yaw.to_radians(),
404        )
405    } else {
406        current_velocity
407    };
408
409    DVec3::new(
410        if relatives.is_delta_x_relative() {
411            current_velocity.x + velocity.x
412        } else {
413            velocity.x
414        },
415        if relatives.is_delta_y_relative() {
416            current_velocity.y + velocity.y
417        } else {
418            velocity.y
419        },
420        if relatives.is_delta_z_relative() {
421            current_velocity.z + velocity.z
422        } else {
423            velocity.z
424        },
425    )
426}
427
428/// A position and `(yaw, pitch)` rotation a player is placed at when entering a world.
429#[derive(Clone, Copy)]
430pub struct PreparedSpawn {
431    /// Where the player is placed.
432    pub position: DVec3,
433    /// The player's `(yaw, pitch)` in degrees.
434    pub rotation: (f32, f32),
435}
436
437/// Where and how a world selection places the player in the target world.
438#[derive(Clone, Copy)]
439pub enum WorldEntry {
440    /// The usual entry: a domain switch restores the saved location if there is one; otherwise
441    /// the spawn search places the player.
442    Usual,
443    /// The usual entry, then faces this `(yaw, pitch)`.
444    Rotation((f32, f32)),
445    /// Places the player exactly there, with no spawn search and no saved location. It lands like
446    /// `/teleport`, except that its velocity is always cleared, even on axes given as relative.
447    Exact(PreparedSpawn),
448}
449
450/// A player's selection of a loaded world, queued until the next tick's world changes.
451pub struct WorldSelection {
452    /// The target world to teleport into.
453    pub target_world: Arc<World>,
454    /// Runtime token proving this request still owns the player's relocation.
455    pub pending_token: PendingWorldChangeToken,
456    /// Chunks around the target's spawn, requested when the selection was queued so they
457    /// start loading in the same tick. `None` for an exact placement, which searches nothing, and
458    /// for a spawn search wider than one chunk.
459    pub prefetch: Option<ChunkRequestHandle>,
460    /// How the player enters the target world.
461    pub entry: WorldEntry,
462}
463
464/// A queued request to move an entity between loaded worlds.
465///
466/// Vanilla calls these world changes "dimension changes". Steel keeps the
467/// runtime API named after loaded worlds to avoid confusing worlds with vanilla
468/// dimension types.
469pub enum WorldChangeRequest {
470    /// Pre-computed transition (players after chunk pre-warming).
471    Computed(TeleportTransition),
472    /// Token-owned player selection of a loaded world's spawn.
473    WorldSpawn(WorldSelection),
474    /// Portal position — server computes portal-specific destination after chunk pre-warming.
475    Portal {
476        /// The portal behavior that produced this request.
477        portal: PortalKind,
478        /// The world the entity is currently in.
479        source_world: Arc<World>,
480        /// The portal block position.
481        portal_pos: BlockPos,
482        /// Runtime token proving this request still owns the entity's pending transition.
483        pending_token: PendingWorldChangeToken,
484    },
485}
486
487#[cfg(test)]
488mod tests {
489    use glam::DVec3;
490    use steel_protocol::packets::game::RelativeMovement;
491    use steel_registry::vanilla_game_rules::{
492        PLAYERS_NETHER_PORTAL_CREATIVE_DELAY, PLAYERS_NETHER_PORTAL_DEFAULT_DELAY,
493    };
494    use steel_utils::BlockPos;
495
496    use super::{
497        PortalKind, PortalProcessResult, PortalProcessor, PortalTicketTarget, TeleportPostAction,
498        TeleportPostTransition, clamped_portal_transition_time, nether_portal_transition_rule,
499        resolve_position, resolve_rotation, resolve_velocity,
500    };
501
502    #[test]
503    fn portal_processor_reaches_transition_after_vanilla_threshold() {
504        let mut processor = PortalProcessor::new(PortalKind::Nether, BlockPos::new(1, 64, 1));
505
506        assert_eq!(
507            processor.process_portal_teleportation(true, 2),
508            PortalProcessResult::Waiting
509        );
510        processor.set_as_inside_portal(BlockPos::new(1, 64, 1));
511        assert_eq!(
512            processor.process_portal_teleportation(true, 2),
513            PortalProcessResult::Waiting
514        );
515        processor.set_as_inside_portal(BlockPos::new(1, 64, 1));
516        assert_eq!(
517            processor.process_portal_teleportation(true, 2),
518            PortalProcessResult::Ready
519        );
520        assert_eq!(processor.portal_time(), 3);
521    }
522
523    #[test]
524    fn portal_processor_does_not_increment_when_teleport_is_disallowed() {
525        let mut processor = PortalProcessor::new(PortalKind::End, BlockPos::new(0, 80, 0));
526
527        assert_eq!(
528            processor.process_portal_teleportation(false, 0),
529            PortalProcessResult::Waiting
530        );
531
532        assert_eq!(processor.portal_time(), 0);
533        assert!(!processor.is_inside_portal_this_tick());
534    }
535
536    #[test]
537    fn portal_processor_decays_when_entity_leaves_portal() {
538        let mut processor = PortalProcessor::new(PortalKind::EndGateway, BlockPos::new(3, 70, 4));
539        for _ in 0..5 {
540            processor.set_as_inside_portal(BlockPos::new(3, 70, 4));
541            processor.process_portal_teleportation(true, 20);
542        }
543
544        assert_eq!(processor.portal_time(), 5);
545        processor.process_portal_teleportation(true, 20);
546        assert_eq!(processor.portal_time(), 1);
547        processor.process_portal_teleportation(true, 20);
548        assert_eq!(processor.portal_time(), 0);
549        assert!(processor.has_expired());
550    }
551
552    #[test]
553    fn portal_processor_updates_entry_position_only_after_tick_is_consumed() {
554        let mut processor = PortalProcessor::new(PortalKind::Nether, BlockPos::new(1, 64, 1));
555
556        processor.set_as_inside_portal(BlockPos::new(2, 64, 2));
557        assert_eq!(processor.entry_position(), BlockPos::new(1, 64, 1));
558
559        processor.process_portal_teleportation(true, 80);
560        processor.set_as_inside_portal(BlockPos::new(2, 64, 2));
561        assert_eq!(processor.entry_position(), BlockPos::new(2, 64, 2));
562    }
563
564    #[test]
565    fn nether_portal_transition_rule_matches_player_invulnerability() {
566        assert_eq!(
567            nether_portal_transition_rule(false).key(),
568            PLAYERS_NETHER_PORTAL_DEFAULT_DELAY.key()
569        );
570        assert_eq!(
571            nether_portal_transition_rule(true).key(),
572            PLAYERS_NETHER_PORTAL_CREATIVE_DELAY.key()
573        );
574    }
575
576    #[test]
577    fn portal_transition_time_is_clamped_non_negative() {
578        assert_eq!(clamped_portal_transition_time(-12), 0);
579        assert_eq!(clamped_portal_transition_time(0), 0);
580        assert_eq!(clamped_portal_transition_time(80), 80);
581    }
582
583    #[test]
584    fn relative_portal_transition_rotates_velocity_by_yaw_delta() {
585        let resolved_rotation =
586            resolve_rotation((90.0, 0.0), RelativeMovement::ROTATION, (0.0, 0.0));
587        let velocity = resolve_velocity(
588            DVec3::ZERO,
589            RelativeMovement::DELTA,
590            DVec3::new(1.0, 0.0, 0.0),
591            (0.0, 0.0),
592            resolved_rotation,
593        );
594
595        assert_eq!(resolved_rotation, (90.0, 0.0));
596        assert!((velocity - DVec3::new(0.0, 0.0, 1.0)).length_squared() < 1.0e-12);
597    }
598
599    #[test]
600    fn relative_position_transition_resolves_only_flagged_axes() {
601        assert_eq!(
602            resolve_position(
603                DVec3::new(1.0, 2.0, 3.0),
604                RelativeMovement::new(RelativeMovement::X | RelativeMovement::Z),
605                DVec3::new(10.0, 20.0, 30.0),
606            ),
607            DVec3::new(11.0, 2.0, 33.0)
608        );
609    }
610
611    #[test]
612    fn pitch_relative_transition_uses_absolute_yaw_and_relative_pitch() {
613        assert_eq!(
614            resolve_rotation(
615                (90.0, 0.0),
616                RelativeMovement::new(RelativeMovement::X_ROT),
617                (30.0, 15.0),
618            ),
619            (90.0, 15.0)
620        );
621    }
622
623    #[test]
624    fn resolved_rotation_clamps_pitch_like_vanilla() {
625        assert_eq!(
626            resolve_rotation((0.0, 30.0), RelativeMovement::ROTATION, (0.0, 80.0),),
627            (0.0, 90.0)
628        );
629        assert_eq!(
630            resolve_rotation((0.0, -120.0), RelativeMovement::NONE, (0.0, 0.0)),
631            (0.0, -90.0)
632        );
633    }
634
635    #[test]
636    fn absolute_transition_replaces_velocity_and_rotation() {
637        let resolved_rotation =
638            resolve_rotation((45.0, 10.0), RelativeMovement::NONE, (90.0, 20.0));
639
640        assert_eq!(resolved_rotation, (45.0, 10.0));
641        assert_eq!(
642            resolve_velocity(
643                DVec3::new(0.0, -0.1, 0.0),
644                RelativeMovement::NONE,
645                DVec3::new(1.0, 2.0, 3.0),
646                (90.0, 20.0),
647                resolved_rotation
648            ),
649            DVec3::new(0.0, -0.1, 0.0)
650        );
651    }
652
653    #[test]
654    fn post_transition_composition_preserves_vanilla_order() {
655        let transition = TeleportPostTransition::play_portal_sound().then(
656            TeleportPostTransition::place_portal_ticket(PortalTicketTarget::Block(BlockPos::new(
657                1, 64, 2,
658            ))),
659        );
660
661        assert_eq!(
662            transition.actions(),
663            &[
664                TeleportPostAction::PlayPortalSound,
665                TeleportPostAction::PlacePortalTicket(PortalTicketTarget::Block(BlockPos::new(
666                    1, 64, 2,
667                ))),
668            ]
669        );
670    }
671}