Ender pearls go through End gateways

A thrown ender pearl flew straight through an End gateway: the gateway
passes in game/end.rs only collected dropped items and mobs. Pearls now
join them (EndGatewayBlock.entityInside, Entity.handlePortal): the pearl
lands at the exit with no motion and rotation 0
(EndGatewayBlock.getPortalDestination for a ThrownEnderpearl), waits in
the arrivals queue while the exit loads, keeps its landing chunk loaded
for TicketType.PORTAL's 300 ticks (placePortalTicket), and then takes
its owner where it lands. ThrownEnderpearl.onInsideBlock fires the
owner's enter_block trigger (Remote Getaway), for a guest as them.

Part of #3514.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-30 14:44:47 +07:00
1 parent bb64067197
commit 95d9303204
2 files changed
+256 -50

No files matched your search

+99
View File
@@ -4,6 +4,7 @@
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use crate::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
@@ -65,6 +66,35 @@ pub fn swept_gateway(
found
}
/// The End gateway a thrown ender pearl passed through on its last move, from
/// where it started the tick to where it is (`Entity.checkInsideBlocks` from
/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles
/// are not looked at.
pub fn pearl_gateway(
pearl: &Projectile,
is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
if !matches!(pearl.kind, ProjectileKind::EnderPearl) {
return None;
}
let size = pearl.kind.size() as f32;
swept_gateway(pearl.previous, pearl.position, size, size, is_gateway)
}
/// A thrown ender pearl at its gateway's exit `at`
/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a
/// `TeleportTransition` with no motion, rotation 0 and nothing relative).
/// It falls from there and takes its owner where it lands.
pub fn place_pearl(pearl: &mut Projectile, at: Vec3) {
pearl.position = at;
pearl.previous = at;
pearl.velocity = Vec3::ZERO;
pearl.yaw = 0.0;
pearl.pitch = 0.0;
// A server tells its clients of the jump at once.
pearl.needs_sync = true;
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
@@ -150,6 +180,75 @@ mod tests {
assert_eq!(traveller.travel(false), None);
}
/// A world with no blocks in the way and nothing to hit: the gateway
/// block has no collision shape, so a projectile flies into it.
struct Open;
impl crate::projectiles::ProjectileWorld for Open {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Vec3) -> bool {
false
}
fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] {
&[]
}
}
/// The ticks a projectile flies, thrown east at the gateway, until its
/// move goes through one (`None` if it never does in 5 ticks).
fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option<BlockPos>) {
let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None);
shot.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
shot.tick(&Open);
if let Some(g) = pearl_gateway(&shot, is_gateway) {
return (shot, Some(g));
}
}
(shot, None)
}
#[test]
fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() {
let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl);
let gateway = BlockPos::new(702, 100, 0);
// ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside
// takes it in, and it goes through on the next tick.
assert_eq!(found, Some(gateway));
let mut traveller = Traveller::default();
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// getPortalDestination: at the exit, no motion and rotation 0.
let exit = bottom_center([8, 100, 0]);
place_pearl(&mut pearl, exit);
assert_eq!(pearl.position, exit);
assert_eq!(pearl.previous, exit);
assert_eq!(pearl.velocity, Vec3::ZERO);
assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0));
// It falls straight down from the exit (ThrowableProjectile gravity,
// then inertia), where it lands and takes its owner along.
pearl.tick(&Open);
assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5));
assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9);
// The pearl is out of the gateway: it does not go back at once.
assert_eq!(traveller.travel(true), None);
assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1);
}
#[test]
fn a_pearl_flying_past_the_gateway_stays_out_of_it() {
// Thrown one block higher, over the gateway block.
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None);
pearl.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
pearl.tick(&Open);
assert_eq!(pearl_gateway(&pearl, is_gateway), None);
}
// Only the pearl travels: a snowball on the same path is not taken in.
assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
+157 -50
View File
@@ -10,8 +10,10 @@ use horizon_client::{
environment::Environment,
items::ItemStack,
mesher::Vertex,
mobs::Who,
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
vanillagen::VanillaTerrain,
world_spawn::PlayerSpawnFinder,
};
@@ -29,6 +31,9 @@ fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// `TicketType.PORTAL`'s timeout, in ticks.
const PORTAL_TICKET: u32 = 300;
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
@@ -47,18 +52,27 @@ pub(super) enum GatewayJob {
Known { exit: [i32; 3], exact: bool },
}
/// An entity travelling through an End gateway: its saved compound and the
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
/// An entity travelling through an End gateway: the entity and the job that
/// finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
#[derive(Clone, Debug)]
struct GatewayArrival {
/// The entity's compound, as it waits for the exit's chunks.
tag: Tag,
/// The entity, as it waits for the exit's chunks.
traveller: Arriving,
/// The gateway's exit, or the search for one.
job: GatewayJob,
/// The spot to load before the job is resolved.
target: Vec3,
}
/// What waits at a gateway's exit.
#[derive(Clone, Debug)]
enum Arriving {
/// An item or a mob, as its saved compound.
Saved(Tag),
/// A thrown ender pearl (projectiles are not saved).
Pearl(Box<Projectile>),
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
@@ -120,6 +134,10 @@ pub(super) struct EndState {
arrivals: Vec<GatewayArrival>,
/// The chunks kept loaded for those arrivals.
areas: Vec<(i32, i32)>,
/// `Entity.placePortalTicket` of the pearls that came out of a gateway:
/// the chunk each landed in, kept loaded for the ticks left of its
/// `TicketType.PORTAL`, so that it can come down and take its owner there.
tickets: Vec<((i32, i32), u32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -1462,13 +1480,17 @@ impl Game<'_> {
// ---- End gateway travel for the entities ----
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
/// gateway blocks their box swept through. A gateway that is cooling down
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
/// `EndGatewayBlock.entityInside` for the items, mobs and thrown ender
/// pearls in the End: the gateway blocks their box swept through. A
/// gateway that is cooling down (`TheEndGatewayBlockEntity.triggerCooldown`)
/// takes nobody in.
pub(super) fn find_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let is_gateway = |game: &Self, p: BlockPos| {
&*game.res.facts().get(game.world.block(p.x, p.y, p.z)).name == "minecraft:end_gateway"
};
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
@@ -1484,13 +1506,28 @@ impl Game<'_> {
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
== "minecraft:end_gateway"
is_gateway(self, p)
});
if let Some(pos) = found {
inside.push((id, pos));
}
}
// A pearl's move this tick, from where it started it.
let pearls: Vec<(u32, Option<Who>, Option<u32>, BlockPos)> = self
.creatures
.projectiles
.iter()
.filter_map(|p| {
let pos = horizon_client::end_gateway::pearl_gateway(p, |b| is_gateway(self, b))?;
Some((p.id, p.owner, p.guest, pos))
})
.collect();
for (id, owner, guest, pos) in pearls {
if owner == Some(Who::Player) {
self.pearl_owner_in_gateway(guest, pos);
}
inside.push((id, pos));
}
for (id, pos) in inside {
// One entity at a time: the gateway cools down for 40 ticks.
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
@@ -1504,6 +1541,22 @@ impl Game<'_> {
}
}
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
/// `ServerPlayer.onInsideBlock`, the enter_block trigger, as if they had
/// walked in. A guest's pearl triggers theirs; one whose guest has left
/// has no owner.
fn pearl_owner_in_gateway(&mut self, guest: Option<u32>, pos: BlockPos) {
let state = self.world.block(pos.x, pos.y, pos.z);
let Some(conn) = guest else {
self.trigger_enter_block(state);
return;
};
if let Some(mut host) = self.lan.take() {
self.as_guest(&mut host, conn, |g| g.trigger_enter_block(state));
self.lan = Some(host);
}
}
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
@@ -1515,8 +1568,20 @@ impl Game<'_> {
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.creatures
.projectiles
.iter()
.filter(|p| matches!(p.kind, ProjectileKind::EnderPearl))
.map(|p| (p.id, true)),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
let mut present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
// A pearl waiting for its exit keeps its portal cooldown.
present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller {
Arriving::Pearl(p) => Some(p.id),
Arriving::Saved(_) => None,
}));
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
for (id, allowed) in ids {
@@ -1573,11 +1638,28 @@ impl Game<'_> {
self.forget_entity(g);
}
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
let pearl = Box::new(self.creatures.projectiles.remove(i));
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
let arrival = GatewayArrival {
traveller: Arriving::Pearl(pearl),
job,
target,
};
// It is at the exit before it moves this tick, once that has loaded.
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
return;
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival { tag, job, target });
self.end.arrivals.push(GatewayArrival {
traveller: Arriving::Saved(tag),
job,
target,
});
}
/// The entities that reached a gateway's exit: they land where the
@@ -1586,47 +1668,60 @@ impl Game<'_> {
if self.remote.is_some() || self.dim != Dim::End {
return Ok(());
}
let mut i = 0;
while i < self.end.arrivals.len() {
let (cx, cz) = (
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&(cx, cz)) {
i += 1;
continue;
}
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
let at = match self.resolve_gateway(job, target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
i += 1;
continue;
}
};
let mut tag = self.end.arrivals.remove(i).tag;
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
for arrival in std::mem::take(&mut self.end.arrivals) {
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
}
Ok(())
}
/// `Entity.teleport` of a gateway traveller once its exit's chunk has
/// loaded: an item or a mob joins the chunk it lands in, a pearl flies
/// on from there. The traveller comes back while it still waits.
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
if !self.world.chunks.contains_key(&chunk(arrival.target)) {
return Some(arrival);
}
let at = match self.resolve_gateway(arrival.job, arrival.target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
return Some(arrival);
}
};
let key = chunk(at);
match arrival.traveller {
Arriving::Saved(mut tag) => {
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Arriving::Pearl(mut pearl) => {
horizon_client::end_gateway::place_pearl(&mut pearl, at);
// ThrownEnderpearl.teleport: placePortalTicket where it came out.
self.end.tickets.retain(|(c, _)| *c != key);
self.end.tickets.push((key, PORTAL_TICKET));
self.add_projectile(*pearl);
}
}
None
}
/// The chunks the entities waiting to arrive need, kept loaded like the
/// areas a server keeps around its players.
pub(super) fn keep_gateway_destinations(&mut self) {
@@ -1634,12 +1729,24 @@ impl Game<'_> {
self.streaming
.extra
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
// The pearls' portal tickets hold End chunks, and run out there
// (TicketType.PORTAL's timeout).
let in_end = self.dim == Dim::End;
if in_end {
self.end.tickets.retain_mut(|(_, ticks)| {
*ticks = ticks.saturating_sub(1);
*ticks > 0
});
}
let mut areas = Vec::new();
for a in &self.end.arrivals {
let c = (
let arrivals = self.end.arrivals.iter().map(|a| {
(
(a.target.x.floor() as i32) >> 4,
(a.target.z.floor() as i32) >> 4,
);
)
});
let tickets = self.end.tickets.iter().filter(|_| in_end);
for c in arrivals.chain(tickets.map(|&(c, _)| c)) {
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));