mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Minecarts and boats cross Nether portals with mob riders and land like vanilla
A boat or minecart with a mob on it never left the portal; Entity.teleportCrossDimension takes the passengers along and mounts them again. An empty minecart landed off-centre (AbstractMinecart/AbstractBoat.getRelativePortalPosition reset the forward offset), a tick late (handlePortal opens the cart's own tick; an arrival joins its level before that level's entities tick) and at full speed (getKnownMovement clamps to 0.4), so it fell onto the ground and halved its speed each tick. Adds tools/cart_portal_e2e.py, which replays the vanilla numbers on a dedicated server. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
This commit is contained in:
1 parent
f558e467eb
commit
c4842342cd
5 files changed
+429
-59
No files matched your search
@@ -7775,6 +7775,11 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
|
||||
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands());
|
||||
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
|
||||
// ServerLevel.tick: the arrivals of this server tick are in the level
|
||||
// (`addDuringTeleport`), and each vehicle handles its portal as its
|
||||
// own tick opens.
|
||||
self.land_portal_arrivals();
|
||||
self.handle_vehicle_portals();
|
||||
horizon_client::spans::time("tick_boats", || self.tick_boats());
|
||||
horizon_client::spans::time("tick_carts", || self.tick_carts());
|
||||
self.tick_vehicle_watch();
|
||||
|
||||
@@ -425,6 +425,7 @@ impl Game<'_> {
|
||||
let observe_level =
|
||||
running.is_some() && (self.serving.is_some() || self.options.dedicated.is_some());
|
||||
let dims = self.levels_to_tick();
|
||||
let order = dims.clone();
|
||||
let n = dims.len();
|
||||
let mut result = Ok(());
|
||||
for (k, dim) in dims.into_iter().enumerate() {
|
||||
@@ -467,6 +468,22 @@ impl Game<'_> {
|
||||
self.native_level_tick_post(dim.id());
|
||||
}
|
||||
self.publish_server_debug(dim);
|
||||
// An entity that crossed into a level that already ticked this
|
||||
// server tick is in it when the tick ends (`addDuringTeleport`).
|
||||
if running == Some(true) {
|
||||
let mut moved = false;
|
||||
for &earlier in &order[..k] {
|
||||
if !self.arrival_chunks(earlier).is_empty()
|
||||
&& self.enter_level(None, earlier).is_ok()
|
||||
{
|
||||
self.land_portal_arrivals();
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if moved {
|
||||
self.enter_level(None, dim)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
if result.is_err() {
|
||||
|
||||
@@ -142,6 +142,38 @@ fn projectile_box(p: &horizon_client::projectiles::Projectile) -> (f32, f32) {
|
||||
(size, size)
|
||||
}
|
||||
}
|
||||
/// `Entity.calculatePassengerTransition`: the riders under `tag`'s
|
||||
/// `Passengers`, and theirs, keep their place on the vehicle: they move by
|
||||
/// what it moved and turn by what it turned.
|
||||
fn move_passengers(tag: &mut Tag, by: Vec3, turn: f32) {
|
||||
let Tag::Compound(c) = tag else {
|
||||
return;
|
||||
};
|
||||
let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into()) else {
|
||||
return;
|
||||
};
|
||||
for rider in riders {
|
||||
if let Some(at) = horizon_client::entity_nbt::position(rider)
|
||||
&& let Tag::Compound(r) = rider
|
||||
{
|
||||
r.insert(
|
||||
"Pos".into(),
|
||||
doubles([at.x + by.x, at.y + by.y, at.z + by.z]),
|
||||
);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(rider);
|
||||
if let Tag::Compound(r) = rider {
|
||||
r.insert(
|
||||
"Rotation".into(),
|
||||
Tag::List(vec![
|
||||
Tag::Float((yaw + turn).to_bits()),
|
||||
Tag::Float(pitch.to_bits()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
move_passengers(rider, by, turn);
|
||||
}
|
||||
}
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
@@ -680,35 +712,56 @@ impl Game<'_> {
|
||||
/// at the start of their tick: an item or mob that stayed in a portal
|
||||
/// goes through (transition time 0) and waits for its dimension.
|
||||
pub(super) fn handle_entity_portals(&mut self) {
|
||||
self.handle_portals(false);
|
||||
}
|
||||
/// `Entity.handlePortal` of the boats and minecarts, which `tick_boats`
|
||||
/// and `tick_carts` move: it runs before them, from the position they
|
||||
/// ended the last tick at, as the start of a vehicle's own tick does.
|
||||
pub(super) fn handle_vehicle_portals(&mut self) {
|
||||
self.handle_portals(true);
|
||||
}
|
||||
fn handle_portals(&mut self, vehicles: bool) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
self.load_portal_arrivals();
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
self.portals.before.clear();
|
||||
for e in &self.dropped {
|
||||
self.portals.before.insert(e.id, e.position);
|
||||
}
|
||||
for m in &self.mobs {
|
||||
self.portals.before.insert(m.id, m.position);
|
||||
}
|
||||
for b in &self.boats {
|
||||
self.portals.before.insert(b.id, b.position);
|
||||
}
|
||||
for c in &self.carts {
|
||||
self.portals.before.insert(c.id, c.position);
|
||||
}
|
||||
for t in &self.tnts {
|
||||
self.portals.before.insert(t.id, t.position);
|
||||
}
|
||||
for o in &self.orbs {
|
||||
self.portals.before.insert(o.id, o.position);
|
||||
// The vehicles' pass opens the tick.
|
||||
if vehicles {
|
||||
self.portals.before.clear();
|
||||
for b in &self.boats {
|
||||
self.portals.before.insert(b.id, b.position);
|
||||
}
|
||||
for c in &self.carts {
|
||||
self.portals.before.insert(c.id, c.position);
|
||||
}
|
||||
} else {
|
||||
for e in &self.dropped {
|
||||
self.portals.before.insert(e.id, e.position);
|
||||
}
|
||||
for m in &self.mobs {
|
||||
self.portals.before.insert(m.id, m.position);
|
||||
}
|
||||
for t in &self.tnts {
|
||||
self.portals.before.insert(t.id, t.position);
|
||||
}
|
||||
for o in &self.orbs {
|
||||
self.portals.before.insert(o.id, o.position);
|
||||
}
|
||||
}
|
||||
// Only an entity in a portal, or cooling down from one, has a state
|
||||
// here: whether the others could use a portal is not worth asking
|
||||
// for each of them on every tick.
|
||||
let mut ids: Vec<(u32, bool)> = Vec::new();
|
||||
if !self.portals.entities.is_empty() {
|
||||
if !self.portals.entities.is_empty() && vehicles {
|
||||
let tracked = &self.portals.entities;
|
||||
let ridden = self.boats.iter().map(|b| b.id);
|
||||
for id in ridden.chain(self.carts.iter().map(|c| c.id)) {
|
||||
if tracked.contains_key(&id) {
|
||||
ids.push((id, self.vehicle_can_use_portal(id)));
|
||||
}
|
||||
}
|
||||
} else if !self.portals.entities.is_empty() {
|
||||
let tracked = &self.portals.entities;
|
||||
for e in &self.dropped {
|
||||
if tracked.contains_key(&e.id) {
|
||||
@@ -725,12 +778,6 @@ impl Game<'_> {
|
||||
ids.push((t.id, self.tnt_can_use_portal(t.id)));
|
||||
}
|
||||
}
|
||||
let vehicles = self.boats.iter().map(|b| b.id);
|
||||
for id in vehicles.chain(self.carts.iter().map(|c| c.id)) {
|
||||
if tracked.contains_key(&id) {
|
||||
ids.push((id, self.vehicle_can_use_portal(id)));
|
||||
}
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
if tracked.contains_key(&p.id) && (is_arrow(p) || is_portal_projectile(p)) {
|
||||
ids.push((p.id, true));
|
||||
@@ -753,7 +800,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
// Each pass keeps the other group's states.
|
||||
let (boats, carts) = (&self.boats, &self.carts);
|
||||
self.portals.entities.retain(|id, _| {
|
||||
let vehicle = boats.iter().any(|b| b.id == *id) || carts.iter().any(|c| c.id == *id);
|
||||
present.contains(id) || vehicle != vehicles
|
||||
});
|
||||
for (id, allowed) in ids {
|
||||
let Some(state) = self.portals.entities.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -822,20 +874,20 @@ impl Game<'_> {
|
||||
&& (!self.guest_rides(m.id) || self.carries_riders())
|
||||
&& m.species() != Species::Wither
|
||||
}
|
||||
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
|
||||
/// server players only, whom it carries along.
|
||||
/// `Entity.canUsePortal(false)` for a primed TNT: it carries no mob.
|
||||
fn tnt_can_use_portal(&self, id: u32) -> bool {
|
||||
// Id 0: not tracked yet (the in-frame path), nothing to key its portal state by.
|
||||
id != 0 && !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `canUsePortal(false)` of a boat or minecart: it is nobody's passenger;
|
||||
/// the mobs riding it, and on a server the players, cross with it.
|
||||
fn vehicle_can_use_portal(&self, id: u32) -> bool {
|
||||
if !self.vehicle_occupied(id) {
|
||||
return true;
|
||||
// The window's player is moved by its own trip, not by the vehicle's.
|
||||
if self.riding == Some(id) {
|
||||
return false;
|
||||
}
|
||||
self.carries_riders()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
// Mob riders always go along; server players only on a server.
|
||||
!self.ridden_by_guest(id) || self.carries_riders()
|
||||
}
|
||||
/// Whether a vehicle can take the server players riding it through a
|
||||
/// portal: only a server moves players between its levels. Elsewhere a
|
||||
@@ -853,27 +905,23 @@ impl Game<'_> {
|
||||
here.into_iter().map(|(_, id, entry)| (id, entry)).collect()
|
||||
}
|
||||
/// The plan of vehicle `id` through the portal at `entry`: from its own
|
||||
/// position and size (`getPortalDestination(level, vehicle)`).
|
||||
/// position and size (`getPortalDestination(level, vehicle)`). A boat or
|
||||
/// minecart, like a mob, lands in the middle of the exit portal's depth
|
||||
/// (`AbstractBoat` and `AbstractMinecart.getRelativePortalPosition`).
|
||||
pub(super) fn vehicle_plan(&self, id: u32, entry: BlockPos) -> Option<Plan> {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
return Some(self.entity_plan(entry, m.position, m.width(), m.height(), true));
|
||||
}
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
return Some(self.entity_plan(entry, b.position, width, height, false));
|
||||
return Some(self.entity_plan(entry, b.position, width, height, true));
|
||||
}
|
||||
let c = self.carts.iter().find(|c| c.id == id)?;
|
||||
let (width, height) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
Some(self.entity_plan(entry, c.position, width, height, false))
|
||||
}
|
||||
/// Whether the player, a server player or a mob rides vehicle `id`.
|
||||
fn vehicle_occupied(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
Some(self.entity_plan(entry, c.position, width, height, true))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
|
||||
/// for the items, mobs, empty boats and minecarts, and arrows that moved
|
||||
@@ -1145,18 +1193,8 @@ impl Game<'_> {
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
let mut gone = vec![id];
|
||||
let mut k = 0;
|
||||
while k < gone.len() {
|
||||
let v = gone[k];
|
||||
gone.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
self.mobs.retain(|m| !gone.contains(&m.id));
|
||||
gone.extend(self.remove_mob_riders(id));
|
||||
self.mobs.retain(|m| m.id != id);
|
||||
for g in gone {
|
||||
self.detach_entity(g);
|
||||
}
|
||||
@@ -1165,14 +1203,14 @@ impl Game<'_> {
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
let plan = self.entity_plan(entry, b.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, b.position, width, height, true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
self.release_vehicle_container(id);
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = &self.boats[i];
|
||||
let tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
let mut tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
@@ -1181,6 +1219,11 @@ impl Game<'_> {
|
||||
self.persist_raw(id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
// The mobs riding it cross with it and mount it again.
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
for rider in self.remove_mob_riders(id) {
|
||||
self.detach_entity(rider);
|
||||
}
|
||||
self.boats.remove(i);
|
||||
self.detach_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
@@ -1191,13 +1234,27 @@ impl Game<'_> {
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.entity_plan(entry, c.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, c.position, width, height, true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
self.release_vehicle_container(id);
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
|
||||
let mut tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
|
||||
// `PositionMoveRotation.of(this)` takes the cart's `getKnownMovement`:
|
||||
// `OldMinecartBehavior` clamps it to 0.4 either way along x and z.
|
||||
let m = horizon_client::entity_nbt::motion(&tag);
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert(
|
||||
"Motion".into(),
|
||||
doubles([m.x.clamp(-0.4, 0.4), m.y, m.z.clamp(-0.4, 0.4)]),
|
||||
);
|
||||
}
|
||||
// The mobs riding it cross with it and mount it again.
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
for rider in self.remove_mob_riders(id) {
|
||||
self.detach_entity(rider);
|
||||
}
|
||||
self.carts.remove(i);
|
||||
self.detach_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
@@ -1242,6 +1299,26 @@ impl Game<'_> {
|
||||
self.native_entity_portal(self.dim.id(), id, [entry.x, entry.y, entry.z]);
|
||||
}
|
||||
}
|
||||
/// Removes the mobs riding `vehicle`, and those riding them, from the
|
||||
/// level: `Entity.teleportCrossDimension` takes the whole tree with its
|
||||
/// root. Their ids.
|
||||
fn remove_mob_riders(&mut self, vehicle: u32) -> Vec<u32> {
|
||||
let mut tree = vec![vehicle];
|
||||
let mut k = 0;
|
||||
while k < tree.len() {
|
||||
let v = tree[k];
|
||||
tree.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
tree.remove(0);
|
||||
self.mobs.retain(|m| !tree.contains(&m.id));
|
||||
tree
|
||||
}
|
||||
/// The chunks of the arrivals still waiting to land in `dim`, for the
|
||||
/// streaming `extra` areas that light them: vanilla's portal ticket
|
||||
/// (`Entity.teleport` adds `TicketType.PORTAL` at the destination) keeps
|
||||
@@ -1254,6 +1331,20 @@ impl Game<'_> {
|
||||
.map(|a| (a.plan.approximate.x >> 4, a.plan.approximate.z >> 4))
|
||||
.collect()
|
||||
}
|
||||
/// `ServerLevel.addDuringTeleport`: the entities that arrive in the level in
|
||||
/// place join it now, before its entities tick, so a vehicle that crossed
|
||||
/// from an earlier level this server tick moves in its new level the same
|
||||
/// tick, and one that crossed from a later level stands there until the
|
||||
/// next. Only the portal arrivals join; the chunks' entities wait.
|
||||
pub(super) fn land_portal_arrivals(&mut self) {
|
||||
if self.remote.is_some() || self.portals.arrivals.is_empty() {
|
||||
return;
|
||||
}
|
||||
let waiting = std::mem::take(&mut self.world.loaded_entities);
|
||||
self.resolve_arrivals();
|
||||
self.spawn_loaded_entities();
|
||||
self.world.loaded_entities.extend(waiting);
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
@@ -1275,6 +1366,7 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
let motion = horizon_client::entity_nbt::motion(&tag);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
|
||||
let was_yaw = yaw;
|
||||
let (at, motion, [yaw, pitch]) = if plan.end {
|
||||
// Relative.DELTA: the motion is kept.
|
||||
let at = self.end_portal_exit(&plan);
|
||||
@@ -1291,6 +1383,10 @@ impl Game<'_> {
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
|
||||
};
|
||||
let yaw_turn = yaw - was_yaw;
|
||||
if let Some(was) = horizon_client::entity_nbt::position(&tag) {
|
||||
move_passengers(&mut tag, at.subtract(was), yaw_turn);
|
||||
}
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
|
||||
|
||||
@@ -768,7 +768,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
|
||||
/// passengers it kept from loading.
|
||||
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
pub(super) fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A minecart through a Nether portal on Horizon's dedicated server, tick by tick against vanilla's numbers.
|
||||
|
||||
python3 tools/cart_portal_e2e.py --bin-dir target/fast
|
||||
|
||||
PURPOSE: a minecart with a mob rider crosses a Nether portal with its rider (`Entity.teleportCrossDimension`
|
||||
takes the passengers along and mounts them again), and an empty one arrives where and when vanilla's does:
|
||||
the forward offset reset of `AbstractMinecart.getRelativePortalPosition`, the 0.4 clamp of its known movement
|
||||
(`OldMinecartBehavior.getKnownMovement`), and the tick the portal is handled in (`handlePortal` opens the
|
||||
minecart's own tick, and a cart that arrives in a level that ticks later in the same server tick moves in it
|
||||
that tick).
|
||||
WHY: before the fix a cart with a pig on it never left the portal, and an empty one landed 0.6 blocks off the
|
||||
exit portal's centre a tick late, fell onto the ground between the portal and the rails and lost half its
|
||||
speed each tick.
|
||||
REFS: the expected numbers are what the vanilla 26.3 server printed for the same scenes (stone floor at
|
||||
y=99, powered rails under the minecart and behind the exit portal, a minecart pushed at 0.4 blocks per
|
||||
tick along 10 rails into the portal, ticks frozen and stepped one at a time, no player).
|
||||
LOGS: the server's output goes to --out (a temporary folder by default), one file per lane.
|
||||
READS-BY: tools/pr_ready.py does not run it; run it by hand after changing game/nether_portal.rs.
|
||||
ENDS-WHEN: each lane has checked its ticks, or the first mismatch is printed (exit status 1).
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import lab # noqa: E402
|
||||
from server_bench import DONE, Server # noqa: E402
|
||||
|
||||
PROPERTIES = {
|
||||
'online-mode': 'false', 'level-type': 'minecraft:flat', 'spawn-protection': '0', 'view-distance': '4',
|
||||
'simulation-distance': '4', 'pause-when-empty-seconds': '-1', 'spawn-monsters': 'false',
|
||||
'spawn-animals': 'false', 'spawn-npcs': 'false', 'generate-structures': 'false', 'allow-flight': 'true',
|
||||
'difficulty': 'normal', 'enable-command-block': 'true',
|
||||
}
|
||||
NETHER = 'execute in minecraft:the_nether run '
|
||||
RAIL = 'minecraft:powered_rail[powered=true,shape=east_west]'
|
||||
TOL = 1e-5
|
||||
# Per lane: tick -> (cart x, y, speed along x, pig x, pig y or None). The numbers vanilla 26.3 printed. The pig's y
|
||||
# is left out on the tick it arrives unticked in the Overworld (vanilla 100.0, Horizon puts it 0.1875 above the
|
||||
# cart): it is the one value the rider's first, unticked sample does not match.
|
||||
LANES = {
|
||||
'overworld_to_nether_with_pig': {
|
||||
'rider': True, 'z': 4, 'zn': 0, 'dest_z': 0.5,
|
||||
'ticks': {
|
||||
21: (9.732167700000005, 100.0625, 1.5984504303618299, 9.732167700000005, 100.25),
|
||||
22: (1.9, 100.0225, 0.38, 1.9, 100.21),
|
||||
23: (2.2799999952316283, 100.0, 0.38, 2.2799999952316283, 100.1875),
|
||||
30: (4.867302549534948, 100.0625, 0.7883303007081885, 4.867302549534948, 100.25),
|
||||
40: (8.867302549534951, 100.0625, 1.3569615856515638, 8.867302549534951, 100.25),
|
||||
}},
|
||||
'overworld_to_nether_empty': {
|
||||
'rider': False, 'z': 4, 'zn': 0, 'dest_z': 0.5,
|
||||
'ticks': {
|
||||
21: (9.9, 100.0625, 1.0332454298233449, None, None),
|
||||
22: (1.9, 100.0225, 0.38, None, None),
|
||||
30: (5.0599999904632575, 100.0625, 0.6583788209356798, None, None),
|
||||
40: (9.059999990463261, 100.0625, 0.9404627730163926, None, None),
|
||||
}},
|
||||
'nether_to_overworld_with_pig': {
|
||||
'rider': True, 'z': 3, 'zn': 0, 'dest_z': 3.5,
|
||||
'ticks': {
|
||||
21: (9.732167700000005, 100.0625, 1.5984504303618299, 9.732167700000005, 100.25),
|
||||
22: (84.5, 100.0625, 0.4, 84.5, -1),
|
||||
23: (84.9, 100.0225, 0.38, 84.9, 100.21),
|
||||
30: (87.46730254953496, 100.0625, 0.7305218663071098, 87.46730254953496, 100.25),
|
||||
40: (91.46730254953502, 100.0625, 1.300864178186122, 91.46730254953502, 100.25),
|
||||
}},
|
||||
'nether_to_overworld_empty': {
|
||||
'rider': False, 'z': 3, 'zn': 0, 'dest_z': 3.5,
|
||||
'ticks': {
|
||||
21: (9.9, 100.0625, 1.0332454298233449, None, None),
|
||||
22: (84.5, 100.0625, 0.4, None, None),
|
||||
23: (84.9, 100.0225, 0.38, None, None),
|
||||
30: (87.65999999046329, 100.0625, 0.6233112718079998, None, None),
|
||||
40: (91.65999999046335, 100.0625, 0.9171487218920757, None, None),
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
class Console:
|
||||
"""The server's console: each command's output is the lines before a marker `say` that follows it."""
|
||||
|
||||
def __init__(self, server):
|
||||
self.server, self.n = server, 0
|
||||
|
||||
def __call__(self, command, timeout=60):
|
||||
# Horizon keeps the level an `execute in` named for the next plain command: name the Overworld.
|
||||
if command.split()[0] in ('forceload', 'fill', 'setblock', 'summon'):
|
||||
command = 'execute in minecraft:overworld run ' + command
|
||||
self.n += 1
|
||||
mark = f'CARTMARK{self.n}X'
|
||||
start = self.server.count()
|
||||
self.server.console(command)
|
||||
self.server.console('say ' + mark)
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
lines = self.server.since(start)
|
||||
for i, line in enumerate(lines):
|
||||
if mark in line:
|
||||
return lines[:i]
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError(f'no reply to {command!r} in {timeout} s')
|
||||
|
||||
def gametime(self):
|
||||
for line in self('time query gametime'):
|
||||
found = re.search(r'time is (\d+)', line)
|
||||
if found:
|
||||
return int(found.group(1))
|
||||
raise RuntimeError('cannot read the game time')
|
||||
|
||||
def step(self, n):
|
||||
before = self.gametime()
|
||||
self(f'tick step {n}')
|
||||
end = time.time() + 60
|
||||
while time.time() < end:
|
||||
if self.gametime() >= before + n:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(f'tick step {n} did not finish')
|
||||
|
||||
def loaded(self, dim, x, y, z):
|
||||
end = time.time() + 180
|
||||
while time.time() < end:
|
||||
if any('Test passed' in l for l in self(f'execute in minecraft:{dim} if loaded {x} {y} {z}')):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f'the chunk at {dim} {x} {y} {z} never loaded')
|
||||
|
||||
def numbers(self, selector, path):
|
||||
for line in self(f'data get entity {selector} {path}'):
|
||||
found = re.search(r'entity data: \[(.*)\]$', line)
|
||||
if found:
|
||||
return [float(v.rstrip('d')) for v in found.group(1).split(', ')]
|
||||
return None
|
||||
|
||||
|
||||
def build(console, name, lane):
|
||||
"""The scenes of the sbs cartA and cartB runs, one lane: rails 0..9 into a portal at x=10, a portal behind
|
||||
which rails run on in the other level."""
|
||||
z, zn = lane['z'], lane['zn']
|
||||
o2n = name.startswith('overworld')
|
||||
cart_dim = '' if o2n else NETHER
|
||||
far = (z, zn) if o2n else (zn, z)
|
||||
# The destination level's portal and rails; the Overworld's forced chunks come first.
|
||||
if o2n:
|
||||
console(NETHER + 'forceload add 0 -16 15 47')
|
||||
console(f'forceload add 0 {z} 15 {z}')
|
||||
for dim, x, zz in (('overworld', 8, z), ('the_nether', 8, -10)):
|
||||
console.loaded(dim, x, 100, zz)
|
||||
else:
|
||||
console(NETHER + 'forceload add 0 -16 15 47')
|
||||
console(f'forceload add 80 {z} 95 {z}')
|
||||
for dim, x, zz in (('overworld', 88, z), ('the_nether', 8, -10)):
|
||||
console.loaded(dim, x, 100, zz)
|
||||
console('tick freeze')
|
||||
if o2n:
|
||||
build_side(console, '', 0, 12, 10, z, rails=(0, 9))
|
||||
build_side(console, NETHER, 0, 14, 1, zn, rails=(2, 12), redstone=(6, zn + 1))
|
||||
console(f'setblock 4 100 {z + 1} minecraft:redstone_block')
|
||||
start, at_z = 1.5, z + 0.5
|
||||
run_in = ''
|
||||
else:
|
||||
build_side(console, '', 80, 95, 84, z, rails=(85, 95), redstone=(90, z + 1))
|
||||
build_side(console, NETHER, 0, 14, 10, zn, rails=(0, 9))
|
||||
console(NETHER + f'setblock 4 100 {zn + 1} minecraft:redstone_block')
|
||||
start, at_z = 1.5, zn + 0.5
|
||||
run_in = NETHER
|
||||
passenger = (',Passengers:[{id:"minecraft:pig",Tags:["L0"],NoAI:1b,Silent:1b,PersistenceRequired:1b}]'
|
||||
if lane['rider'] else '')
|
||||
console(run_in + f'summon minecraft:minecart {start} 100.1 {at_z} {{Tags:["L0"],Motion:[0.4d,0.0d,0.0d]{passenger}}}')
|
||||
del cart_dim, far
|
||||
|
||||
|
||||
def build_side(console, prefix, x0, x1, portal_x, z, rails, redstone=None):
|
||||
"""Stone floor, air above, an obsidian frame with a lit portal (axis z) at `portal_x`, powered rails."""
|
||||
console(prefix + f'fill {x0} 95 {z - 2} {x1 + 2} 106 {z + 3} minecraft:air')
|
||||
console(prefix + f'fill {x0} 99 {z - 2} {x1 + 2} 99 {z + 3} minecraft:stone')
|
||||
console(prefix + f'fill {portal_x} 99 {z - 1} {portal_x} 103 {z + 2} minecraft:obsidian')
|
||||
console(prefix + f'fill {portal_x} 100 {z} {portal_x} 102 {z + 1} minecraft:nether_portal[axis=z]')
|
||||
console(prefix + f'fill {rails[0]} 100 {z} {rails[1]} 100 {z} {RAIL}')
|
||||
if redstone:
|
||||
console(prefix + f'setblock {redstone[0]} 100 {redstone[1]} minecraft:redstone_block')
|
||||
|
||||
|
||||
def run_lane(bins, name, lane, out):
|
||||
work = Path(tempfile.mkdtemp(prefix='hz-cart-portal-'))
|
||||
folder = work / 'server'
|
||||
folder.mkdir()
|
||||
(folder / 'eula.txt').write_text('eula=true\n')
|
||||
props = dict(PROPERTIES, **{'server-port': str(lab.free_port()), 'server-ip': '127.0.0.1'})
|
||||
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
|
||||
command = [bins / 'horizon-client', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
|
||||
server = Server(command, folder, out / f'{name}.log')
|
||||
failed = []
|
||||
try:
|
||||
server.wait_for(DONE, 300)
|
||||
console = Console(server)
|
||||
console('forceload add -16 -16 16 16')
|
||||
time.sleep(3)
|
||||
build(console, name, lane)
|
||||
at, cart_sel, pig_sel = 0, '@e[type=minecraft:minecart,tag=L0,limit=1]', '@e[type=minecraft:pig,tag=L0,limit=1]'
|
||||
for tick, (x, y, speed, pig_x, pig_y) in lane['ticks'].items():
|
||||
console.step(tick - at)
|
||||
at = tick
|
||||
pos = console.numbers(cart_sel, 'Pos')
|
||||
motion = console.numbers(cart_sel, 'Motion')
|
||||
expect = [x, y, lane['dest_z'] if tick >= 22 else lane['z'] + 0.5 if name.startswith('overworld')
|
||||
else lane['zn'] + 0.5]
|
||||
if tick == 21:
|
||||
expect[2] = pos[2] if pos else 0.0
|
||||
if not pos or any(abs(a - b) > TOL for a, b in zip(pos, expect)):
|
||||
failed.append(f'{name} t{tick}: cart at {pos}, vanilla {expect}')
|
||||
if not motion or abs(motion[0] - speed) > TOL:
|
||||
failed.append(f'{name} t{tick}: cart speed {motion and motion[0]}, vanilla {speed}')
|
||||
if lane['rider'] and tick >= 22:
|
||||
pig = console.numbers(pig_sel, 'Pos')
|
||||
bad = not pig or abs(pig[0] - pig_x) > TOL or abs(pig[2] - lane['dest_z']) > TOL
|
||||
if pig_y is not None and pig_y > 0 and pig and abs(pig[1] - pig_y) > TOL:
|
||||
bad = True
|
||||
if bad:
|
||||
failed.append(f'{name} t{tick}: pig at {pig}, vanilla x {pig_x} y {pig_y} z {lane["dest_z"]}')
|
||||
print(f'{name} t{tick}: cart {pos} speed {motion and motion[0]}')
|
||||
finally:
|
||||
server.stop()
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
return failed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
||||
parser.add_argument('--bin-dir', default='target/fast', type=Path)
|
||||
parser.add_argument('--out', type=Path)
|
||||
parser.add_argument('--lane', action='append', choices=sorted(LANES))
|
||||
args = parser.parse_args()
|
||||
out = args.out or Path(tempfile.mkdtemp(prefix='cart-portal-logs-'))
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
failed = []
|
||||
for name in args.lane or LANES:
|
||||
failed += run_lane(args.bin_dir.resolve(), name, LANES[name], out)
|
||||
for line in failed:
|
||||
print('FAIL', line)
|
||||
print('cart portal: ' + ('FAILED' if failed else 'all lanes match vanilla'), f'(logs in {out})')
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user