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