diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 477dda1047..7db36ed2ae 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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(); diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 0c7ac204e5..23d4634e15 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -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() { diff --git a/crates/horizon-client/src/game/nether_portal.rs b/crates/horizon-client/src/game/nether_portal.rs index 7f600cebc0..a1b7c1b939 100644 --- a/crates/horizon-client/src/game/nether_portal.rs +++ b/crates/horizon-client/src/game/nether_portal.rs @@ -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 = 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 { 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 { + 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])); diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index 86b67622d7..615a905798 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -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 = (0..self.mobs.len()) .filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive()) .collect(); diff --git a/tools/cart_portal_e2e.py b/tools/cart_portal_e2e.py new file mode 100644 index 0000000000..00e39209cb --- /dev/null +++ b/tools/cart_portal_e2e.py @@ -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())