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