Push entities with moving pistons
Move players, mobs and common objects when piston block entities advance, applying collision and the per-tick piston limit. Exercise host, guest, inventory-open and mob pushes in both single-player paths, and compare the push geometry with vanilla. Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
This commit is contained in:
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
|
||||
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
|
||||
}
|
||||
|
||||
// Vanilla scopes this direction to Entity.move while a piston pushes it.
|
||||
thread_local! {
|
||||
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Suppress the moving part of pistons moving in this direction during a push.
|
||||
pub fn with_piston_noclip<T>(
|
||||
direction: horizon_core::geometry::Direction,
|
||||
f: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
struct Reset(Option<horizon_core::geometry::Direction>);
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
PISTON_NOCLIP.with(|value| value.set(self.0));
|
||||
}
|
||||
}
|
||||
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
|
||||
let _reset = Reset(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
pub struct SceneCollisions<'w, 'r> {
|
||||
pub world: &'w World,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
@@ -324,7 +345,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
|
||||
return Ok(None);
|
||||
};
|
||||
pistons
|
||||
.collision_shape(self, p, piston, None)
|
||||
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
|
||||
.map(Some)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
@@ -475,3 +496,27 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod piston_noclip_tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
#[test]
|
||||
fn piston_noclip_is_scoped_and_restored_after_unwind() {
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
with_piston_noclip(Direction::East, || {
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
});
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ mod persist;
|
||||
mod perspective;
|
||||
mod pickups;
|
||||
mod piglins;
|
||||
mod piston_push;
|
||||
mod player_equip;
|
||||
mod postprocess;
|
||||
mod potions;
|
||||
@@ -2821,20 +2822,29 @@ impl Game<'_> {
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
});
|
||||
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
|
||||
// with the progress it is about to take.
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.process_level_events()?;
|
||||
self.sync_moving_pistons();
|
||||
self.process_level_events()
|
||||
Ok(())
|
||||
}
|
||||
/// Copies the moving-piston block entities into the world, where the
|
||||
/// collision queries and the renderer read them.
|
||||
fn sync_moving_pistons(&mut self) {
|
||||
let pistons = &mut self.world.moving_pistons;
|
||||
pistons.clear();
|
||||
pistons.extend(
|
||||
self.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())),
|
||||
);
|
||||
let previous = std::mem::take(&mut self.world.moving_pistons);
|
||||
let current: Vec<_> = self
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
|
||||
.collect();
|
||||
self.world.moving_piston_fallback = previous
|
||||
.into_iter()
|
||||
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
|
||||
.collect();
|
||||
self.world.moving_pistons = current;
|
||||
}
|
||||
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
|
||||
/// the side effects of each (a shrieker summoning a warden, drops, game
|
||||
|
||||
@@ -73,7 +73,7 @@ pub(super) struct Minecart {
|
||||
pub yaw: f32,
|
||||
pub previous_yaw: f32,
|
||||
pub flipped: bool,
|
||||
on_ground: bool,
|
||||
pub(super) on_ground: bool,
|
||||
horizontal_collision: bool,
|
||||
fall_distance: f64,
|
||||
pub hurt_time: i32,
|
||||
|
||||
@@ -14,7 +14,9 @@ impl Game<'_> {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let partial = alpha as f32;
|
||||
for &(p, piston) in &self.world.moving_pistons {
|
||||
let mut states = self.world.moving_pistons.clone();
|
||||
states.extend(self.world.moving_piston_fallback.iter().copied());
|
||||
for (p, piston) in states {
|
||||
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//! `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` for
|
||||
//! the entities the game moves itself: the players and the mobs.
|
||||
//!
|
||||
//! The geometry is in [`horizon_world::piston_push`]; this file supplies the
|
||||
//! bodies and applies the movement the way `Entity.move(MoverType.PISTON, …)`
|
||||
//! does: limited to 0.51 blocks per tick per axis (`Entity.limitPistonMovement`),
|
||||
//! then run through the block collision scan, so a pushed entity stops at a
|
||||
//! wall instead of passing through it.
|
||||
//!
|
||||
use super::Game;
|
||||
use horizon_client::collisions::{SceneCollisions, with_piston_noclip};
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{level::pistons::MovingPiston, piston_movement::PistonMovement, piston_push};
|
||||
|
||||
/// What one piston asks for this tick: where it is, its state, and the progress
|
||||
/// `moveCollidedEntities` runs at (before the tick stores it).
|
||||
pub(super) type Push = (BlockPos, MovingPiston, f32);
|
||||
|
||||
/// Which entity was pushed, so its storage can be reached after the world
|
||||
/// borrow ends.
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||
enum Target {
|
||||
Player,
|
||||
Guest(u32),
|
||||
Mob(usize),
|
||||
Item(usize),
|
||||
Boat(usize),
|
||||
Cart(usize),
|
||||
ArmorStand(usize),
|
||||
}
|
||||
|
||||
/// `Entity.getStepHeight` of each body, read before the push.
|
||||
struct StepHeights {
|
||||
player: f32,
|
||||
guests: std::collections::HashMap<u32, f32>,
|
||||
}
|
||||
|
||||
/// What the push needs from one entity.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Body {
|
||||
bounds: Aabb,
|
||||
position: Vec3,
|
||||
on_ground: bool,
|
||||
step_height: f32,
|
||||
/// Standing on the piston's own block (`Entity.isSupportedBy`).
|
||||
supported: bool,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Push whatever this tick's moving pistons ran into, in the order the
|
||||
/// pistons ticked: vanilla pushes from each block entity's own tick.
|
||||
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
|
||||
let mut accumulators = std::collections::HashMap::new();
|
||||
for push in pushes {
|
||||
self.push_for_piston(push, &mut accumulators);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_for_piston(
|
||||
&mut self,
|
||||
(pos, piston, new_progress): Push,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) {
|
||||
let delta_progress = f64::from(new_progress - piston.progress);
|
||||
if delta_progress <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(bindings) = self.collision.pistons() else {
|
||||
return;
|
||||
};
|
||||
let Ok(state) = bindings.collision_related_state(piston.shape_state()) else {
|
||||
return;
|
||||
};
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let Ok(shape) = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [pos.x, pos.y, pos.z])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if shape.is_empty() {
|
||||
return;
|
||||
}
|
||||
let movement = piston.movement_direction();
|
||||
let boxes: Vec<Aabb> = shape.boxes().to_vec();
|
||||
let facts = self.res.facts();
|
||||
let slime = &*facts.get(piston.moved_state).name == "minecraft:slime_block";
|
||||
let honey = &*facts.get(piston.moved_state).name == "minecraft:honey_block";
|
||||
// `moveStuckEntities` asks the moved state's own top for its box.
|
||||
let sticky_top = if honey {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
self.collision
|
||||
.collision_shape(&mut scene, piston.moved_state, [pos.x, pos.y, pos.z])
|
||||
.ok()
|
||||
.and_then(|s| s.bounds())
|
||||
.map_or(0.0, |b| b.max_position().y)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let steps = self.push_step_heights();
|
||||
let search = shape.bounds().map(|bounds| {
|
||||
piston_push::movement_search_area(
|
||||
piston_push::move_by_position_and_progress(pos, bounds, piston.shape_state()),
|
||||
movement,
|
||||
delta_progress,
|
||||
)
|
||||
});
|
||||
let targets = self.push_targets();
|
||||
for &target in &targets {
|
||||
let Some(body) = self.push_body(target, pos, &steps) else {
|
||||
continue;
|
||||
};
|
||||
let amount = piston_push::collided_amount(
|
||||
&boxes,
|
||||
pos,
|
||||
piston.shape_state(),
|
||||
new_progress,
|
||||
movement,
|
||||
body.bounds,
|
||||
);
|
||||
if slime && search.is_some_and(|area| area.intersects(body.bounds)) {
|
||||
self.bounce(target, movement);
|
||||
}
|
||||
if amount > 0.0 {
|
||||
let step = piston_push::step_vector(movement);
|
||||
let before = body.bounds;
|
||||
self.move_pushed(
|
||||
target,
|
||||
before,
|
||||
body.step_height,
|
||||
body.on_ground,
|
||||
step.scale(amount),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
// `fixEntityWithinPistonBase`: a retracting source piston drags
|
||||
// an entity that is still inside its own block.
|
||||
if !piston.extending
|
||||
&& piston.source
|
||||
&& let Some(after) = self.push_body(target, pos, &steps)
|
||||
&& let Some(fix) = piston_push::fix_within_piston_base(
|
||||
pos,
|
||||
after.bounds,
|
||||
movement,
|
||||
delta_progress,
|
||||
)
|
||||
{
|
||||
let back = piston_push::step_vector(movement.opposite());
|
||||
self.move_pushed(
|
||||
target,
|
||||
after.bounds,
|
||||
after.step_height,
|
||||
after.on_ground,
|
||||
back.scale(fix),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Vanilla selects honey passengers after all collided entities have moved.
|
||||
if honey && movement.normal()[1] == 0 {
|
||||
let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top);
|
||||
for target in targets {
|
||||
let Some(body) = self.push_body(target, pos, &steps) else {
|
||||
continue;
|
||||
};
|
||||
if piston_push::sticky_entity_selected(
|
||||
area,
|
||||
body.bounds,
|
||||
horizon_world::level::data::PushReaction::PushPull,
|
||||
body.on_ground,
|
||||
body.supported,
|
||||
body.position,
|
||||
) {
|
||||
self.move_pushed(
|
||||
target,
|
||||
body.bounds,
|
||||
body.step_height,
|
||||
body.on_ground,
|
||||
piston_push::step_vector(movement).scale(delta_progress),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getStepHeight` for each body, read once: the attribute lookup
|
||||
/// needs the mutable body, and the push only needs the number.
|
||||
fn push_step_heights(&mut self) -> StepHeights {
|
||||
let player = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:step_height").ok())
|
||||
.map_or(0.6, |v| v as f32);
|
||||
let mut guests = std::collections::HashMap::new();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
for g in &mut host.hub.guests {
|
||||
let step = g
|
||||
.motion
|
||||
.as_mut()
|
||||
.and_then(|m| m.attribute("minecraft:step_height").ok())
|
||||
.map_or(0.6, |v| v as f32);
|
||||
guests.insert(g.conn, step);
|
||||
}
|
||||
}
|
||||
StepHeights { player, guests }
|
||||
}
|
||||
|
||||
/// The entities a moving block can run into: the window's player, the LAN
|
||||
/// guests in the level, then the mobs.
|
||||
fn push_targets(&self) -> Vec<Target> {
|
||||
let mut targets = Vec::new();
|
||||
if self.player.is_some() {
|
||||
targets.push(Target::Player);
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
targets.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| Target::Guest(g.conn)),
|
||||
);
|
||||
}
|
||||
targets.extend((0..self.mobs.len()).map(Target::Mob));
|
||||
targets.extend((0..self.dropped.len()).map(Target::Item));
|
||||
targets.extend((0..self.boats.len()).map(Target::Boat));
|
||||
targets.extend((0..self.carts.len()).map(Target::Cart));
|
||||
targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand));
|
||||
targets
|
||||
}
|
||||
|
||||
/// What the push needs from one entity, or `None` when it is not pushed:
|
||||
/// removed, dead, a spectator, or an armor-stand marker.
|
||||
fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option<Body> {
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
match target {
|
||||
Target::Player => {
|
||||
let p = self.player.as_ref()?;
|
||||
if p.spectator {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: p.bounds(),
|
||||
position: p.position,
|
||||
on_ground: p.ground.on_ground,
|
||||
step_height: steps.player,
|
||||
supported: p.ground.supporting_block == Some(pos),
|
||||
})
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
|
||||
if g.dead || g.game_mode == 3 {
|
||||
return None;
|
||||
}
|
||||
let p = g.position;
|
||||
Some(Body {
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
|
||||
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
|
||||
),
|
||||
position: Vec3::new(p[0], p[1], p[2]),
|
||||
on_ground: g.on_ground,
|
||||
step_height: steps.guests.get(&conn).copied().unwrap_or(0.6),
|
||||
supported: g
|
||||
.motion
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.ground.supporting_block == Some(pos)),
|
||||
})
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &self.mobs[i];
|
||||
if m.removed || !m.alive() {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: m.bounds(),
|
||||
position: m.position,
|
||||
on_ground: m.on_ground,
|
||||
step_height: m.kind.step_height as f32,
|
||||
supported: m.supporting == Some(at),
|
||||
})
|
||||
}
|
||||
Target::Item(i) => {
|
||||
let entity = &self.dropped[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Boat(i) => {
|
||||
let entity = &self.boats[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Cart(i) => {
|
||||
let entity = &self.carts[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::ArmorStand(i) => {
|
||||
let entity = &self.armor_stands[i];
|
||||
if entity.marker {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.move(MoverType.PISTON, movement)` for one pushed entity: the
|
||||
/// per-tick piston limit, then the block collision scan.
|
||||
fn move_pushed(
|
||||
&mut self,
|
||||
target: Target,
|
||||
before: Aabb,
|
||||
step_height: f32,
|
||||
on_ground: bool,
|
||||
movement: Vec3,
|
||||
piston_direction: Direction,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) {
|
||||
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
|
||||
return;
|
||||
};
|
||||
// `limitPistonMovement` returns zero once the tick's budget is spent.
|
||||
if limited == Vec3::ZERO {
|
||||
return;
|
||||
}
|
||||
let resolved = with_piston_noclip(piston_direction, || {
|
||||
SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
}
|
||||
.resolve(before, limited, step_height, on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
});
|
||||
if resolved.length_squared() <= 1.0e-7
|
||||
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.apply_push(target, resolved);
|
||||
}
|
||||
|
||||
/// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks
|
||||
/// per game tick, and a move touches only its first non-zero axis.
|
||||
fn limit_piston(
|
||||
&mut self,
|
||||
target: Target,
|
||||
movement: Vec3,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) -> Option<Vec3> {
|
||||
let time = self.world.game_time;
|
||||
let limit = |state: &mut PistonMovement| state.limit(movement, || Ok::<i64, ()>(time)).ok();
|
||||
match target {
|
||||
Target::Player => {
|
||||
let p = self.player.as_mut()?;
|
||||
limit(&mut p.piston)
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
let host = self.lan.as_mut()?;
|
||||
let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?;
|
||||
limit(&mut g.motion.as_deref_mut()?.piston)
|
||||
}
|
||||
Target::Mob(i) => limit(&mut self.mobs[i].piston),
|
||||
_ => limit(accumulators.entry(target).or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_push(&mut self, target: Target, movement: Vec3) {
|
||||
match target {
|
||||
Target::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = p.position.add_vector(movement);
|
||||
}
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
{
|
||||
// The body and the hub's copy of its position move together:
|
||||
// `leave_guest` writes the body back over the hub's.
|
||||
for (slot, delta) in g.position.iter_mut().zip(movement.components()) {
|
||||
*slot += delta;
|
||||
}
|
||||
if let Some(m) = g.motion.as_deref_mut() {
|
||||
m.position = m.position.add_vector(movement);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &mut self.mobs[i];
|
||||
m.position = m.position.add_vector(movement);
|
||||
}
|
||||
Target::Item(i) => {
|
||||
self.dropped[i].position = self.dropped[i].position.add_vector(movement)
|
||||
}
|
||||
Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement),
|
||||
Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement),
|
||||
Target::ArmorStand(i) => {
|
||||
self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `causeBounce`: a slime block sets the velocity along the movement axis.
|
||||
fn bounce(&mut self, target: Target, movement: Direction) {
|
||||
match target {
|
||||
Target::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
|
||||
}
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &mut self.mobs[i];
|
||||
m.velocity = piston_push::bounce_velocity(movement, m.velocity);
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
&& let Some(motion) = guest.motion.as_deref_mut()
|
||||
{
|
||||
motion.velocity = piston_push::bounce_velocity(movement, motion.velocity);
|
||||
}
|
||||
}
|
||||
Target::Item(i) => {
|
||||
self.dropped[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.dropped[i].velocity)
|
||||
}
|
||||
Target::Boat(i) => {
|
||||
self.boats[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.boats[i].velocity)
|
||||
}
|
||||
Target::Cart(i) => {
|
||||
self.carts[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.carts[i].velocity)
|
||||
}
|
||||
Target::ArmorStand(i) => {
|
||||
self.armor_stands[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.armor_stands[i].velocity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3185,6 +3185,8 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
});
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.sync_moving_pistons();
|
||||
self.tick_hand();
|
||||
self.tick_particles();
|
||||
|
||||
@@ -819,6 +819,9 @@ pub struct Mob {
|
||||
pub previous_swell: i32,
|
||||
pub ambient_timer: i32,
|
||||
pub fall_distance: f64,
|
||||
/// `Entity.pistonDeltas` and `pistonDeltasGameTime`: how far a moving
|
||||
/// block has pushed this mob this tick (0.51 at most on any axis).
|
||||
pub piston: horizon_world::piston_movement::PistonMovement,
|
||||
pub persistent: bool,
|
||||
/// `CustomName` (plain text) and `CustomNameVisible`.
|
||||
pub custom_name: Option<String>,
|
||||
@@ -1648,6 +1651,7 @@ impl Mob {
|
||||
// Mob.ambientSoundTime starts at 0.
|
||||
ambient_timer: 0,
|
||||
fall_distance: 0.0,
|
||||
piston: Default::default(),
|
||||
persistent: false,
|
||||
random,
|
||||
ai: Box::default(),
|
||||
|
||||
@@ -223,6 +223,9 @@ pub struct PlayerMotion {
|
||||
pub history: MovementHistory,
|
||||
pub fall_distance: f64,
|
||||
pub stuck_multiplier: Vec3,
|
||||
/// `Entity.pistonDeltas` and `pistonDeltasGameTime`: how far a moving
|
||||
/// block has pushed this entity this tick (0.51 at most on any axis).
|
||||
pub piston: horizon_world::piston_movement::PistonMovement,
|
||||
pub yaw: f32,
|
||||
pub head_yaw: f32,
|
||||
pub jumping: bool,
|
||||
@@ -428,6 +431,7 @@ impl PlayerMotion {
|
||||
history: MovementHistory::default(),
|
||||
fall_distance: 0.0,
|
||||
stuck_multiplier: Vec3::ZERO,
|
||||
piston: Default::default(),
|
||||
yaw: 0.0,
|
||||
head_yaw: 0.0,
|
||||
jumping: false,
|
||||
@@ -1795,8 +1799,14 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> {
|
||||
fn set_collision_flags(&mut self, value: CollisionFlags) {
|
||||
self.player.collision = value;
|
||||
}
|
||||
fn limit_piston_movement(&mut self, _: Vec3) -> Result<Vec3> {
|
||||
Err("piston movement requires a ticking entity runtime".into())
|
||||
/// `Entity.limitPistonMovement`: the per-tick 0.51-block budget shared by
|
||||
/// every move on an axis. A failed game-time read leaves the accumulator
|
||||
/// as it was, as native does.
|
||||
fn limit_piston_movement(&mut self, movement: Vec3) -> Result<Vec3> {
|
||||
let time = self.scene.game_time;
|
||||
self.player
|
||||
.piston
|
||||
.limit(movement, || Ok::<i64, Box<dyn std::error::Error>>(time))
|
||||
}
|
||||
fn stuck_multiplier(&self) -> Vec3 {
|
||||
self.player.stuck_multiplier
|
||||
|
||||
@@ -93,11 +93,13 @@ pub struct World {
|
||||
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), u32>>,
|
||||
/// The villager POIs of each section (`PoiManager`'s sections).
|
||||
pub pois: crate::poi_sections::PoiSections<crate::mobs::villager_brain::Poi>,
|
||||
/// Moving-piston states removed during the last tick; retained until the
|
||||
/// final block mesh is visible.
|
||||
pub moving_piston_fallback: Vec<([i32; 3], horizon_world::piston_shape::MovingPistonState)>,
|
||||
/// The moving-piston block entities of the level, by position, copied
|
||||
/// from the block ticks after each tick: collision and rendering read them.
|
||||
pub moving_pistons: Vec<([i32; 3], horizon_world::piston_shape::MovingPistonState)>,
|
||||
}
|
||||
/// ServerLevel weather state (advanceWeatherCycle).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Weather {
|
||||
pub clear_time: i32,
|
||||
@@ -187,6 +189,7 @@ impl World {
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
light_log: None,
|
||||
moving_piston_fallback: Vec::new(),
|
||||
moving_pistons: Vec::new(),
|
||||
pois: Default::default(),
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ mod movement_support_fixture;
|
||||
mod object_open_map_fixture;
|
||||
mod object_open_set_fixture;
|
||||
mod piston_movement_fixture;
|
||||
mod piston_push_fixture;
|
||||
mod piston_shape_fixture;
|
||||
mod placement_fixture;
|
||||
mod player_equipment_fixture;
|
||||
@@ -309,6 +310,7 @@ probes! {
|
||||
object_open_map_fixture,
|
||||
object_open_set_fixture,
|
||||
piston_movement_fixture,
|
||||
piston_push_fixture,
|
||||
piston_shape_fixture,
|
||||
placement_fixture,
|
||||
player_equipment_fixture,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The moving-piston push maths against the real game: `PistonMath.getMovementArea`,
|
||||
//! `moveByPositionAndProgress`, `getMovement`, the `moveCollidedEntities` loop
|
||||
//! over the block's own boxes, and `moveStuckEntities`' box with
|
||||
//! `matchesStickyCritera`.
|
||||
use crate::support::common::f64_hex;
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{level::data::PushReaction, piston_push, piston_shape::MovingPistonState};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
fn number(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
|
||||
fn float(v: &Value) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
|
||||
fn aabb(v: &Value) -> Aabb {
|
||||
let a = v.as_array().unwrap();
|
||||
Aabb::new(
|
||||
Vec3::new(number(&a[0]), number(&a[1]), number(&a[2])),
|
||||
Vec3::new(number(&a[3]), number(&a[4]), number(&a[5])),
|
||||
)
|
||||
}
|
||||
|
||||
fn direction(index: i64) -> Direction {
|
||||
Direction::ALL[index.rem_euclid(6) as usize]
|
||||
}
|
||||
|
||||
fn reaction(index: i64) -> PushReaction {
|
||||
match index.rem_euclid(5) {
|
||||
0 => PushReaction::PushPull,
|
||||
1 => PushReaction::Push,
|
||||
2 => PushReaction::Popped,
|
||||
3 => PushReaction::Immoveable,
|
||||
_ => PushReaction::IgnoreEntity,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(out: &mut impl Write, name: &str, b: Aabb) -> io::Result<()> {
|
||||
let (min, max) = (b.min_position(), b.max_position());
|
||||
writeln!(
|
||||
out,
|
||||
"{name} {} {} {} {} {} {}",
|
||||
f64_hex(min.x),
|
||||
f64_hex(min.y),
|
||||
f64_hex(min.z),
|
||||
f64_hex(max.x),
|
||||
f64_hex(max.y),
|
||||
f64_hex(max.z)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let arg = crate::args().nth(1).expect("private fixture directory");
|
||||
let dir = Path::new(&arg);
|
||||
let mut out = BufWriter::new(fs::File::create(dir.join("rust.txt"))?);
|
||||
for text in fs::read_to_string(dir.join("inputs.txt"))?.lines() {
|
||||
let f: Value = serde_json::from_str(text)?;
|
||||
let p = &f["pos"];
|
||||
let pos = BlockPos::new(
|
||||
p[0].as_i64().unwrap() as i32,
|
||||
p[1].as_i64().unwrap() as i32,
|
||||
p[2].as_i64().unwrap() as i32,
|
||||
);
|
||||
let progress = float(&f["progress"]);
|
||||
let piston = MovingPistonState {
|
||||
moved_state: horizon_world::registry::BlockStateId(0),
|
||||
direction: direction(f["direction"].as_i64().unwrap()),
|
||||
extending: f["extending"].as_bool().unwrap(),
|
||||
source: f["source"].as_bool().unwrap(),
|
||||
progress,
|
||||
previous_progress: progress,
|
||||
};
|
||||
let new_progress = float(&f["new_progress"]);
|
||||
let delta_progress = f64::from(new_progress - progress);
|
||||
let movement = piston.movement_direction();
|
||||
let block_box = aabb(&f["block_box"]);
|
||||
let entity = aabb(&f["entity"]);
|
||||
let movement_box = aabb(&f["movement_box"]);
|
||||
|
||||
writeln!(
|
||||
out,
|
||||
"extended {:08x}",
|
||||
piston_push::extended_progress(piston, progress).to_bits()
|
||||
)?;
|
||||
let origin = piston_push::move_by_position_and_progress(pos, block_box, piston);
|
||||
line(&mut out, "origin", origin)?;
|
||||
line(
|
||||
&mut out,
|
||||
"area",
|
||||
piston_push::movement_area(block_box, movement, delta_progress),
|
||||
)?;
|
||||
for (name, face) in [
|
||||
("east", Direction::East),
|
||||
("west", Direction::West),
|
||||
("up", Direction::Up),
|
||||
("down", Direction::Down),
|
||||
("south", Direction::South),
|
||||
("north", Direction::North),
|
||||
] {
|
||||
writeln!(
|
||||
out,
|
||||
"movement {name} {}",
|
||||
f64_hex(piston_push::get_movement(movement_box, face, entity))
|
||||
)?;
|
||||
}
|
||||
|
||||
// `moveCollidedEntities`' loop: the furthest box reaches into the
|
||||
// entity, capped at the progress delta, plus the push offset.
|
||||
let boxes: Vec<Aabb> = f["boxes"].as_array().unwrap().iter().map(aabb).collect();
|
||||
let mut delta: f64 = 0.0;
|
||||
let mut capped = false;
|
||||
for block in &boxes {
|
||||
let moving = piston_push::movement_area(
|
||||
piston_push::move_by_position_and_progress(pos, *block, piston),
|
||||
movement,
|
||||
delta_progress,
|
||||
);
|
||||
if moving.intersects(entity) {
|
||||
delta = delta.max(piston_push::get_movement(moving, movement, entity));
|
||||
if delta >= delta_progress {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let collided = if delta <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
delta.min(delta_progress) + piston_push::PUSH_OFFSET
|
||||
};
|
||||
writeln!(out, "collided {} {capped}", f64_hex(collided))?;
|
||||
line(
|
||||
&mut out,
|
||||
"search",
|
||||
piston_push::movement_search_area(origin, movement, delta_progress),
|
||||
)?;
|
||||
let sticky = piston_push::sticky_area(pos, piston, number(&f["sticky_top"]));
|
||||
line(&mut out, "sticky", sticky)?;
|
||||
writeln!(
|
||||
out,
|
||||
"matches {}",
|
||||
piston_push::matches_sticky_criteria(
|
||||
sticky,
|
||||
reaction(f["reaction"].as_i64().unwrap()),
|
||||
f["on_ground"].as_bool().unwrap(),
|
||||
f["supported"].as_bool().unwrap(),
|
||||
Vec3::new(number(&f["entity_x"]), 0.0, number(&f["entity_z"])),
|
||||
)
|
||||
)?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -182,7 +182,9 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
self.neighbor_changed_at(pos, block);
|
||||
}
|
||||
}
|
||||
/// `PistonMovingBlockEntity.tick` (entity pushing excluded).
|
||||
/// `PistonMovingBlockEntity.tick`. A piston that is still moving reports
|
||||
/// the push `moveCollidedEntities` would do at its new progress; the caller
|
||||
/// moves the entities, because `Ticks` has none (`piston_pushes`).
|
||||
fn moving_piston_tick(&mut self, pos: BlockPos) {
|
||||
let Some(mut piston) = self.moving_piston(pos) else {
|
||||
return;
|
||||
@@ -227,14 +229,20 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
piston.progress = (piston.progress + 0.5).min(1.0);
|
||||
if let Some(entry) = self
|
||||
// moveCollidedEntities and moveStuckEntities run with the new
|
||||
// progress but read the old one off the entity (their delta),
|
||||
// so the push carries the piston as it was before the advance.
|
||||
let old = piston;
|
||||
let new_progress = (old.progress + 0.5).min(1.0);
|
||||
let current = self
|
||||
.ticks
|
||||
.moving_pistons
|
||||
.iter_mut()
|
||||
.find(|(p, _)| *p == pos)
|
||||
{
|
||||
.find(|(p, _)| *p == pos);
|
||||
if let Some(entry) = current {
|
||||
piston.progress = new_progress;
|
||||
entry.1 = piston;
|
||||
self.ticks.piston_pushes.push((pos, old, new_progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ pub struct Ticks {
|
||||
/// Moving-piston block entities in ticker order (render and collide
|
||||
/// moving blocks from these; see `piston_shape`).
|
||||
pub moving_pistons: Vec<(BlockPos, super::pistons::MovingPiston)>,
|
||||
/// The pushes this tick's moving-piston block entities asked for: each
|
||||
/// piston with the progress it is about to reach, in the order they ticked
|
||||
/// (`PistonMovingBlockEntity.tick`'s `moveCollidedEntities` call). The
|
||||
/// caller moves entities by these; `Ticks` has no entities of its own.
|
||||
pub piston_pushes: Vec<(BlockPos, super::pistons::MovingPiston, f32)>,
|
||||
pub(crate) next_block_entity_id: u64,
|
||||
/// `ServerLevel.blockEvents`, run by `Behaviours::run_block_events`.
|
||||
pub block_events: Vec<super::pistons::BlockEventData>,
|
||||
@@ -65,6 +70,7 @@ impl Ticks {
|
||||
comparator_outputs: Default::default(),
|
||||
torch_toggles: Vec::new(),
|
||||
moving_pistons: Vec::new(),
|
||||
piston_pushes: Vec::new(),
|
||||
next_block_entity_id: 0,
|
||||
block_events: Vec::new(),
|
||||
handling_tick: false,
|
||||
|
||||
@@ -94,6 +94,7 @@ pub mod nbt;
|
||||
pub mod object_open_map;
|
||||
pub mod object_open_set;
|
||||
pub mod piston_movement;
|
||||
pub mod piston_push;
|
||||
pub mod piston_shape;
|
||||
pub mod player_food;
|
||||
pub mod player_health;
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
//! `PistonMovingBlockEntity.moveCollidedEntities`, `moveStuckEntities` and
|
||||
//! `fixEntityWithinPistonBase` over immutable geometry.
|
||||
//!
|
||||
//! Entity storage, the moving entities themselves and the blocks they collide
|
||||
//! with belong to the caller: these functions decide *how far* an entity is
|
||||
//! pushed, which is the part that must match vanilla to the bit. The movement
|
||||
//! they describe is an `Entity.move(MoverType.PISTON, …)`, so the caller still
|
||||
//! applies it through its own collision resolution and per-tick piston limit.
|
||||
use crate::{level::data::PushReaction, piston_shape::MovingPistonState};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
|
||||
/// `PistonMovingBlockEntity.PUSH_OFFSET`.
|
||||
pub const PUSH_OFFSET: f64 = 0.01;
|
||||
|
||||
/// `PistonMovingBlockEntity.getExtendedProgress`: where along its travel the
|
||||
/// moved block is, in blocks (negative before it starts, 0 when it arrives).
|
||||
pub fn extended_progress(piston: MovingPistonState, progress: f32) -> f32 {
|
||||
if piston.extending {
|
||||
progress - 1.0
|
||||
} else {
|
||||
1.0 - progress
|
||||
}
|
||||
}
|
||||
|
||||
/// `Direction.getAxisDirection().getStep()`.
|
||||
fn step_along(direction: Direction) -> i32 {
|
||||
match direction {
|
||||
Direction::East | Direction::Up | Direction::South => 1,
|
||||
Direction::West | Direction::Down | Direction::North => -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Direction.getStepX/Y/Z` as a vector.
|
||||
pub fn step_vector(direction: Direction) -> Vec3 {
|
||||
let [x, y, z] = direction.normal();
|
||||
Vec3::new(f64::from(x), f64::from(y), f64::from(z))
|
||||
}
|
||||
|
||||
/// `PistonMath.getMovementArea`: the slab an entity's box must reach into to be
|
||||
/// run over by `amount` of further travel along `direction`.
|
||||
pub fn movement_area(aabb: Aabb, direction: Direction, amount: f64) -> Aabb {
|
||||
let delta = amount * f64::from(step_along(direction));
|
||||
let low = delta.min(0.0);
|
||||
let high = delta.max(0.0);
|
||||
let (min, max) = (aabb.min_position(), aabb.max_position());
|
||||
match direction {
|
||||
Direction::West => Aabb::new(
|
||||
Vec3::new(min.x + low, min.y, min.z),
|
||||
Vec3::new(min.x + high, max.y, max.z),
|
||||
),
|
||||
Direction::East => Aabb::new(
|
||||
Vec3::new(max.x + low, min.y, min.z),
|
||||
Vec3::new(max.x + high, max.y, max.z),
|
||||
),
|
||||
Direction::Down => Aabb::new(
|
||||
Vec3::new(min.x, min.y + low, min.z),
|
||||
Vec3::new(max.x, min.y + high, max.z),
|
||||
),
|
||||
Direction::Up => Aabb::new(
|
||||
Vec3::new(min.x, max.y + low, min.z),
|
||||
Vec3::new(max.x, max.y + high, max.z),
|
||||
),
|
||||
Direction::North => Aabb::new(
|
||||
Vec3::new(min.x, min.y, min.z + low),
|
||||
Vec3::new(max.x, max.y, min.z + high),
|
||||
),
|
||||
Direction::South => Aabb::new(
|
||||
Vec3::new(min.x, min.y, max.z + low),
|
||||
Vec3::new(max.x, max.y, max.z + high),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.moveByPositionAndProgress`: a box moved to the
|
||||
/// block position and on to where the moved block currently is.
|
||||
pub fn move_by_position_and_progress(pos: BlockPos, aabb: Aabb, piston: MovingPistonState) -> Aabb {
|
||||
let current = f64::from(extended_progress(piston, piston.progress));
|
||||
let step = step_vector(piston.direction);
|
||||
aabb.move_by(Vec3::new(
|
||||
f64::from(pos.x) + current * step.x,
|
||||
f64::from(pos.y) + current * step.y,
|
||||
f64::from(pos.z) + current * step.z,
|
||||
))
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.getMovement`: how far `entity` stands inside the
|
||||
/// block it must be pushed out of, measured along `movement`'s axis.
|
||||
pub fn get_movement(block: Aabb, movement: Direction, entity: Aabb) -> f64 {
|
||||
let (bmin, bmax) = (block.min_position(), block.max_position());
|
||||
let (emin, emax) = (entity.min_position(), entity.max_position());
|
||||
match movement {
|
||||
Direction::East => bmax.x - emin.x,
|
||||
Direction::West => emax.x - bmin.x,
|
||||
Direction::Up => bmax.y - emin.y,
|
||||
Direction::Down => emax.y - bmin.y,
|
||||
Direction::South => bmax.z - emin.z,
|
||||
Direction::North => emax.z - bmin.z,
|
||||
}
|
||||
}
|
||||
|
||||
/// `moveCollidedEntities`' inner loop: the furthest any of the moved block's
|
||||
/// boxes reaches into `entity`, capped at the progress delta, plus
|
||||
/// [`PUSH_OFFSET`]. `0.0` when the block does not touch the entity.
|
||||
pub fn collided_amount(
|
||||
boxes: &[Aabb],
|
||||
pos: BlockPos,
|
||||
piston: MovingPistonState,
|
||||
new_progress: f32,
|
||||
movement: Direction,
|
||||
entity: Aabb,
|
||||
) -> f64 {
|
||||
let delta_progress = f64::from(new_progress - piston.progress);
|
||||
let mut delta: f64 = 0.0;
|
||||
for block in boxes {
|
||||
let moving = movement_area(
|
||||
move_by_position_and_progress(pos, *block, piston),
|
||||
movement,
|
||||
delta_progress,
|
||||
);
|
||||
if moving.intersects(entity) {
|
||||
delta = delta.max(get_movement(moving, movement, entity));
|
||||
if delta >= delta_progress {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if delta <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
delta.min(delta_progress) + PUSH_OFFSET
|
||||
}
|
||||
|
||||
/// The box `level.getEntities` searches for a push: the moved block's own box
|
||||
/// widened by the slab it is about to sweep.
|
||||
pub fn movement_search_area(block_box: Aabb, movement: Direction, delta_progress: f64) -> Aabb {
|
||||
movement_area(block_box, movement, delta_progress).union_bounds(block_box)
|
||||
}
|
||||
|
||||
/// `moveStuckEntities`: the box of standing entities a moving honey block drags
|
||||
/// with it. `sticky_top` is the moved state's top collision height.
|
||||
pub fn sticky_area(pos: BlockPos, piston: MovingPistonState, sticky_top: f64) -> Aabb {
|
||||
let area = Aabb::new(
|
||||
Vec3::new(0.0, sticky_top, 0.0),
|
||||
Vec3::new(1.0, 1.500_001_000_000_000_1, 1.0),
|
||||
);
|
||||
move_by_position_and_progress(pos, area, piston)
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.matchesStickyCritera`.
|
||||
pub fn matches_sticky_criteria(
|
||||
area: Aabb,
|
||||
reaction: PushReaction,
|
||||
on_ground: bool,
|
||||
supported_by_piston: bool,
|
||||
entity_position: Vec3,
|
||||
) -> bool {
|
||||
if reaction != PushReaction::PushPull || !on_ground {
|
||||
return false;
|
||||
}
|
||||
if supported_by_piston {
|
||||
return true;
|
||||
}
|
||||
let (min, max) = (area.min_position(), area.max_position());
|
||||
entity_position.x >= min.x
|
||||
&& entity_position.x <= max.x
|
||||
&& entity_position.z >= min.z
|
||||
&& entity_position.z <= max.z
|
||||
}
|
||||
|
||||
/// The level query intersects the sticky box before applying the predicate.
|
||||
pub fn sticky_entity_selected(
|
||||
area: Aabb,
|
||||
bounds: Aabb,
|
||||
reaction: PushReaction,
|
||||
on_ground: bool,
|
||||
supported_by_piston: bool,
|
||||
position: Vec3,
|
||||
) -> bool {
|
||||
area.intersects(bounds)
|
||||
&& matches_sticky_criteria(area, reaction, on_ground, supported_by_piston, position)
|
||||
}
|
||||
|
||||
/// `fixEntityWithinPistonBase`: a retracting source piston drags an entity that
|
||||
/// is still inside the piston's own block out along the opposite of `movement`.
|
||||
/// The returned distance is measured along that opposite direction.
|
||||
pub fn fix_within_piston_base(
|
||||
pos: BlockPos,
|
||||
entity: Aabb,
|
||||
movement: Direction,
|
||||
delta_progress: f64,
|
||||
) -> Option<f64> {
|
||||
let block = Aabb::unit_cube(Vec3::from_block(pos));
|
||||
if !entity.intersects(block) {
|
||||
return None;
|
||||
}
|
||||
let opposite = movement.opposite();
|
||||
let delta = get_movement(block, opposite, entity) + PUSH_OFFSET;
|
||||
let intersected = entity.intersect(block);
|
||||
let delta_intersected = get_movement(block, opposite, intersected) + PUSH_OFFSET;
|
||||
// As deep inside as the block allows: it is stuck in the base itself, so it
|
||||
// leaves along with the retraction.
|
||||
if (delta - delta_intersected).abs() < PUSH_OFFSET {
|
||||
Some(delta.min(delta_progress) + PUSH_OFFSET)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.getCollisionRelatedBlockState` uses the moved state
|
||||
/// unless a source piston is retracting, where it shows a piston head instead.
|
||||
/// The state itself comes from [`PistonBlockBindings::collision_related_state`].
|
||||
///
|
||||
/// [`PistonBlockBindings::collision_related_state`]:
|
||||
/// crate::piston_shape::PistonBlockBindings::collision_related_state
|
||||
pub fn uses_moved_state(piston: MovingPistonState) -> bool {
|
||||
piston.extending || !piston.source
|
||||
}
|
||||
|
||||
/// `moveCollidedEntities`' slime bounce: a slime block sets the velocity along
|
||||
/// the movement axis to that direction's step, leaving the other axes.
|
||||
pub fn bounce_velocity(movement: Direction, velocity: Vec3) -> Vec3 {
|
||||
let mut velocity = velocity;
|
||||
match movement {
|
||||
Direction::East => velocity.x = 1.0,
|
||||
Direction::West => velocity.x = -1.0,
|
||||
Direction::Up => velocity.y = 1.0,
|
||||
Direction::Down => velocity.y = -1.0,
|
||||
Direction::South => velocity.z = 1.0,
|
||||
Direction::North => velocity.z = -1.0,
|
||||
}
|
||||
velocity
|
||||
}
|
||||
|
||||
/// `Entity.getPistonPushReaction() == PushReaction.IGNORE_ENTITY`.
|
||||
///
|
||||
/// `Marker`, `Interaction`, `Display`, `AreaEffectCloud` and
|
||||
/// `OminousItemSpawner` return `IGNORE_ENTITY`, and an armor stand does when it
|
||||
/// is a marker. Horizon gives those kinds no pushed body, so a caller that has
|
||||
/// no box to push never reaches the maths above; this names the predicate for
|
||||
/// the kinds that do have one.
|
||||
pub fn ignores_push(marker: bool) -> bool {
|
||||
marker
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit() -> Aabb {
|
||||
Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0))
|
||||
}
|
||||
|
||||
fn moving(direction: Direction, extending: bool, progress: f32) -> MovingPistonState {
|
||||
MovingPistonState {
|
||||
moved_state: crate::registry::BlockStateId(0),
|
||||
direction,
|
||||
extending,
|
||||
source: false,
|
||||
progress,
|
||||
previous_progress: progress,
|
||||
}
|
||||
}
|
||||
|
||||
/// `getMovementArea` puts the slab on the leading face and widens it by the
|
||||
/// travel in that direction only.
|
||||
#[test]
|
||||
fn movement_area_sits_on_the_leading_face() {
|
||||
let east = movement_area(unit(), Direction::East, 0.5);
|
||||
assert_eq!(east.min_position(), Vec3::new(1.0, 0.0, 0.0));
|
||||
assert_eq!(east.max_position(), Vec3::new(1.5, 1.0, 1.0));
|
||||
|
||||
let west = movement_area(unit(), Direction::West, 0.5);
|
||||
assert_eq!(west.min_position(), Vec3::new(-0.5, 0.0, 0.0));
|
||||
assert_eq!(west.max_position(), Vec3::new(0.0, 1.0, 1.0));
|
||||
|
||||
let up = movement_area(unit(), Direction::Up, 0.5);
|
||||
assert_eq!(up.min_position(), Vec3::new(0.0, 1.0, 0.0));
|
||||
assert_eq!(up.max_position(), Vec3::new(1.0, 1.5, 1.0));
|
||||
|
||||
let north = movement_area(unit(), Direction::North, 0.5);
|
||||
assert_eq!(north.min_position(), Vec3::new(0.0, 0.0, -0.5));
|
||||
assert_eq!(north.max_position(), Vec3::new(1.0, 1.0, 0.0));
|
||||
}
|
||||
|
||||
/// A negative amount sweeps behind the leading face.
|
||||
#[test]
|
||||
fn movement_area_follows_a_negative_amount() {
|
||||
let area = movement_area(unit(), Direction::East, -0.5);
|
||||
assert_eq!(area.min_position(), Vec3::new(0.5, 0.0, 0.0));
|
||||
assert_eq!(area.max_position(), Vec3::new(1.0, 1.0, 1.0));
|
||||
}
|
||||
|
||||
/// The arm of a piston at the origin: the block entity sits in the block it
|
||||
/// pushes into, and at progress 0 its shape is still a block back.
|
||||
const ARM: BlockPos = BlockPos::new(1, 0, 0);
|
||||
|
||||
/// An entity in the way is pushed the progress delta, plus the push offset;
|
||||
/// here it hangs 0.6 into the slab and the delta caps it at 0.5.
|
||||
#[test]
|
||||
fn a_collided_entity_moves_the_progress_delta() {
|
||||
let piston = moving(Direction::East, true, 0.0);
|
||||
let entity = Aabb::new(Vec3::new(0.9, 0.0, 0.2), Vec3::new(1.5, 1.8, 0.8));
|
||||
let amount = collided_amount(&[unit()], ARM, piston, 0.5, Direction::East, entity);
|
||||
assert!((amount - (0.5 + PUSH_OFFSET)).abs() < 1.0e-12, "{amount}");
|
||||
}
|
||||
|
||||
/// The delta is not a floor: an entity barely nicking the slab moves by only
|
||||
/// what it is owed, plus the offset.
|
||||
#[test]
|
||||
fn a_grazed_entity_moves_only_what_it_overlaps() {
|
||||
let piston = moving(Direction::East, true, 0.0);
|
||||
let entity = Aabb::new(Vec3::new(1.4, 0.0, 0.2), Vec3::new(2.0, 1.8, 0.8));
|
||||
let amount = collided_amount(&[unit()], ARM, piston, 0.5, Direction::East, entity);
|
||||
assert!((amount - (0.1 + PUSH_OFFSET)).abs() < 1.0e-12, "{amount}");
|
||||
}
|
||||
|
||||
/// Clear of the block, nothing moves.
|
||||
#[test]
|
||||
fn an_entity_out_of_the_way_is_left_alone() {
|
||||
let piston = moving(Direction::East, true, 0.0);
|
||||
let entity = Aabb::new(Vec3::new(3.0, 0.0, 0.2), Vec3::new(3.6, 1.8, 0.8));
|
||||
let amount = collided_amount(&[unit()], ARM, piston, 0.5, Direction::East, entity);
|
||||
assert_eq!(amount, 0.0);
|
||||
}
|
||||
|
||||
/// A retracting base drags an entity stuck inside its own block, limited by
|
||||
/// the progress delta.
|
||||
#[test]
|
||||
fn a_retracting_base_drags_a_stuck_entity() {
|
||||
let entity = Aabb::new(Vec3::new(0.75, 0.0, 0.2), Vec3::new(1.35, 1.8, 0.8));
|
||||
let amount = fix_within_piston_base(BlockPos::new(0, 0, 0), entity, Direction::West, 0.5)
|
||||
.expect("stuck in the base");
|
||||
assert!(amount > 0.0 && amount <= 0.5 + PUSH_OFFSET, "{amount}");
|
||||
}
|
||||
|
||||
/// An entity outside the base's block is not dragged by it.
|
||||
#[test]
|
||||
fn a_free_entity_is_not_dragged_by_the_base() {
|
||||
let entity = Aabb::new(Vec3::new(2.0, 0.0, 0.2), Vec3::new(2.6, 1.8, 0.8));
|
||||
assert_eq!(
|
||||
fix_within_piston_base(BlockPos::new(0, 0, 0), entity, Direction::West, 0.5),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Honey drags only entities that stand on it and that blocks may push.
|
||||
#[test]
|
||||
fn honey_drags_standing_entities() {
|
||||
let area = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.5, 1.0));
|
||||
let on_top = Vec3::new(0.5, 0.0, 0.5);
|
||||
assert!(matches_sticky_criteria(
|
||||
area,
|
||||
PushReaction::PushPull,
|
||||
true,
|
||||
false,
|
||||
on_top
|
||||
));
|
||||
assert!(!matches_sticky_criteria(
|
||||
area,
|
||||
PushReaction::PushPull,
|
||||
false,
|
||||
false,
|
||||
on_top
|
||||
));
|
||||
assert!(!matches_sticky_criteria(
|
||||
area,
|
||||
PushReaction::Immoveable,
|
||||
true,
|
||||
false,
|
||||
on_top
|
||||
));
|
||||
// Beside it: only standing on the piston's own block holds it.
|
||||
let beside = Vec3::new(2.0, 0.0, 0.5);
|
||||
assert!(!matches_sticky_criteria(
|
||||
area,
|
||||
PushReaction::PushPull,
|
||||
true,
|
||||
false,
|
||||
beside
|
||||
));
|
||||
assert!(matches_sticky_criteria(
|
||||
area,
|
||||
PushReaction::PushPull,
|
||||
true,
|
||||
true,
|
||||
beside
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honey_selection_uses_entity_position_and_intersection() {
|
||||
let area = Aabb::new(Vec3::new(0.0, 1.0, 0.0), Vec3::new(1.0, 1.5, 1.0));
|
||||
let position = Vec3::new(0.1, 1.0, 0.5);
|
||||
let bounds = Aabb::new(Vec3::new(-0.2, 1.0, 0.2), Vec3::new(0.4, 2.8, 0.8));
|
||||
assert!(sticky_entity_selected(
|
||||
area,
|
||||
bounds,
|
||||
PushReaction::PushPull,
|
||||
true,
|
||||
false,
|
||||
position
|
||||
));
|
||||
let above = bounds.move_by(Vec3::new(0.0, 3.0, 0.0));
|
||||
assert!(!sticky_entity_selected(
|
||||
area,
|
||||
above,
|
||||
PushReaction::PushPull,
|
||||
true,
|
||||
true,
|
||||
position
|
||||
));
|
||||
}
|
||||
|
||||
/// The slime bounce rewrites only the movement axis.
|
||||
#[test]
|
||||
fn a_slime_block_bounces_along_its_axis() {
|
||||
let v = Vec3::new(0.2, -0.5, 0.3);
|
||||
assert_eq!(
|
||||
bounce_velocity(Direction::East, v),
|
||||
Vec3::new(1.0, -0.5, 0.3)
|
||||
);
|
||||
assert_eq!(bounce_velocity(Direction::Up, v), Vec3::new(0.2, 1.0, 0.3));
|
||||
assert_eq!(
|
||||
bounce_velocity(Direction::North, v),
|
||||
Vec3::new(0.2, -0.5, -1.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// The moved block sits along the piston's own direction, half a block short
|
||||
/// at mid-travel and in place once landed.
|
||||
#[test]
|
||||
fn the_moved_block_sits_along_the_piston_direction() {
|
||||
let half = moving(Direction::East, true, 0.5);
|
||||
let moved = move_by_position_and_progress(BlockPos::new(2, 3, 4), unit(), half);
|
||||
assert_eq!(moved.min_position(), Vec3::new(1.5, 3.0, 4.0));
|
||||
|
||||
let landed = moving(Direction::East, true, 1.0);
|
||||
assert_eq!(
|
||||
move_by_position_and_progress(BlockPos::new(2, 3, 4), unit(), landed).min_position(),
|
||||
Vec3::new(2.0, 3.0, 4.0)
|
||||
);
|
||||
|
||||
// Retracting runs the other way: two thirds out at progress 1/3.
|
||||
let pulling = moving(Direction::East, false, 0.5);
|
||||
assert_eq!(
|
||||
move_by_position_and_progress(BlockPos::new(2, 3, 4), unit(), pulling).min_position(),
|
||||
Vec3::new(2.5, 3.0, 4.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// The search area covers the block and the slab in front of it.
|
||||
#[test]
|
||||
fn the_search_area_covers_block_and_sweep() {
|
||||
let area = movement_search_area(unit(), Direction::East, 0.5);
|
||||
assert_eq!(area.min_position(), Vec3::new(0.0, 0.0, 0.0));
|
||||
assert_eq!(area.max_position(), Vec3::new(1.5, 1.0, 1.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.horizonlab.parity;
|
||||
import com.google.gson.*;
|
||||
import java.io.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.world.entity.*;
|
||||
import net.minecraft.world.entity.monster.zombie.Zombie;
|
||||
import net.minecraft.world.level.block.*;
|
||||
import net.minecraft.world.level.block.piston.*;
|
||||
import net.minecraft.world.phys.*;
|
||||
import net.minecraft.world.phys.shapes.*;
|
||||
/**
|
||||
* The real moving-piston push maths: PistonMath.getMovementArea,
|
||||
* moveByPositionAndProgress, getMovement, the moveCollidedEntities loop over
|
||||
* the block's own boxes, and moveStuckEntities' box and matchesStickyCritera.
|
||||
* fixEntityWithinPistonBase is left out: it moves the entity
|
||||
* (`Entity.move(MoverType.PISTON, ...)`), which needs a live level, so the
|
||||
* Rust side is covered by its own unit tests.
|
||||
*/
|
||||
public final class VanillaPistonPushOracle {
|
||||
private static float number(JsonElement v){return Float.intBitsToFloat(Integer.parseUnsignedInt(v.getAsString(),16));}
|
||||
private static double dbl(JsonElement v){return Double.longBitsToDouble(Long.parseUnsignedLong(v.getAsString(),16));}
|
||||
private static AABB box(JsonElement v){var a=v.getAsJsonArray();return new AABB(dbl(a.get(0)),dbl(a.get(1)),dbl(a.get(2)),dbl(a.get(3)),dbl(a.get(4)),dbl(a.get(5)));}
|
||||
private static void line(PrintWriter out,String name,AABB b){out.printf(name+" %016x %016x %016x %016x %016x %016x%n",Double.doubleToLongBits(b.minX),Double.doubleToLongBits(b.minY),Double.doubleToLongBits(b.minZ),Double.doubleToLongBits(b.maxX),Double.doubleToLongBits(b.maxY),Double.doubleToLongBits(b.maxZ));}
|
||||
private static Field field(Class<?> c,String name)throws Exception{var f=c.getDeclaredField(name);f.setAccessible(true);return f;}
|
||||
private static Method method(Class<?> c,String name,Class<?>...types)throws Exception{var m=c.getDeclaredMethod(name,types);m.setAccessible(true);return m;}
|
||||
@SuppressWarnings("unchecked") public static void main(String[] args)throws Exception {
|
||||
net.minecraft.SharedConstants.tryDetectVersion();net.minecraft.server.Bootstrap.bootStrap();Path dir=Path.of(args[1]);
|
||||
var allocator=Class.forName("sun.misc.Unsafe");var uf=allocator.getDeclaredField("theUnsafe");uf.setAccessible(true);var unsafe=uf.get(null);var allocate=allocator.getMethod("allocateInstance",Class.class);
|
||||
var progress=field(PistonMovingBlockEntity.class,"progress");
|
||||
var movedBy=method(PistonMovingBlockEntity.class,"moveByPositionAndProgress",BlockPos.class,AABB.class,PistonMovingBlockEntity.class);
|
||||
var extended=method(PistonMovingBlockEntity.class,"getExtendedProgress",float.class);
|
||||
var getMovement=method(PistonMovingBlockEntity.class,"getMovement",AABB.class,Direction.class,AABB.class);
|
||||
var matches=method(PistonMovingBlockEntity.class,"matchesStickyCritera",AABB.class,Entity.class,BlockPos.class);
|
||||
var movementDir=method(PistonMovingBlockEntity.class,"getMovementDirection");
|
||||
var position=field(Entity.class,"position");var onGroundField=field(Entity.class,"onGround");var supporting=field(Entity.class,"mainSupportingBlockPos");var bbField=field(Entity.class,"bb");
|
||||
var faces=new Direction[]{Direction.EAST,Direction.WEST,Direction.UP,Direction.DOWN,Direction.SOUTH,Direction.NORTH};
|
||||
try(var out=new PrintWriter(Files.newBufferedWriter(dir.resolve(args[0]+".txt")));var inputs=Files.lines(dir.resolve("inputs.txt"))) {
|
||||
for(var line:(Iterable<String>)inputs::iterator) {
|
||||
var f=JsonParser.parseString(line).getAsJsonObject();var v=f.getAsJsonArray("pos");var p=new BlockPos(v.get(0).getAsInt(),v.get(1).getAsInt(),v.get(2).getAsInt());
|
||||
var direction=Direction.values()[f.get("direction").getAsInt()];
|
||||
var piston=new PistonMovingBlockEntity(p,Blocks.MOVING_PISTON.defaultBlockState(),Blocks.STONE.defaultBlockState(),direction,f.get("extending").getAsBoolean(),f.get("source").getAsBoolean());
|
||||
float current=number(f.get("progress"));
|
||||
progress.setFloat(piston,current);
|
||||
float newProgress=number(f.get("new_progress"));
|
||||
double deltaProgress=newProgress-current;
|
||||
Direction movement=(Direction)movementDir.invoke(piston);
|
||||
AABB blockBox=box(f.get("block_box"));
|
||||
AABB entityBox=box(f.get("entity"));
|
||||
AABB movementBox=box(f.get("movement_box"));
|
||||
out.printf("extended %08x%n",Float.floatToIntBits((float)extended.invoke(piston,current)));
|
||||
AABB origin=(AABB)movedBy.invoke(null,p,blockBox,piston);
|
||||
line(out,"origin",origin);
|
||||
line(out,"area",PistonMath.getMovementArea(blockBox,movement,deltaProgress));
|
||||
for(var face:faces)out.printf("movement %s %016x%n",face.getSerializedName(),Double.doubleToLongBits((double)getMovement.invoke(null,movementBox,face,entityBox)));
|
||||
double delta=0.0;boolean capped=false;
|
||||
for(var value:f.getAsJsonArray("boxes")){
|
||||
AABB movingAABB=PistonMath.getMovementArea((AABB)movedBy.invoke(null,p,box(value),piston),movement,deltaProgress);
|
||||
if(movingAABB.intersects(entityBox)){delta=Math.max(delta,(double)getMovement.invoke(null,movingAABB,movement,entityBox));if(delta>=deltaProgress){capped=true;break;}}
|
||||
}
|
||||
double collided=delta<=0.0?0.0:Math.min(delta,deltaProgress)+0.01;
|
||||
out.printf("collided %016x %b%n",Double.doubleToLongBits(collided),capped);
|
||||
line(out,"search",PistonMath.getMovementArea(origin,movement,deltaProgress).minmax(origin));
|
||||
AABB sticky=(AABB)movedBy.invoke(null,p,new AABB(0.0,dbl(f.get("sticky_top")),0.0,1.0,1.5000010000000001,1.0),piston);
|
||||
line(out,"sticky",sticky);
|
||||
boolean pushable=f.get("reaction").getAsInt()==0;
|
||||
var entity=(Entity)allocate.invoke(unsafe,pushable?Zombie.class:Marker.class);
|
||||
position.set(entity,new Vec3(dbl(f.get("entity_x")),0.0,dbl(f.get("entity_z"))));
|
||||
bbField.set(entity,entityBox);
|
||||
onGroundField.setBoolean(entity,f.get("on_ground").getAsBoolean());
|
||||
supporting.set(entity,f.get("supported").getAsBoolean()?Optional.of(p):Optional.empty());
|
||||
out.printf("matches %b%n",(boolean)matches.invoke(null,sticky,entity,p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-2
@@ -296,7 +296,7 @@ CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'si
|
||||
'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles',
|
||||
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
|
||||
'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push',
|
||||
'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
|
||||
'piston-push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
|
||||
'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects',
|
||||
'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands',
|
||||
'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption',
|
||||
@@ -2368,6 +2368,59 @@ def wait_state(game, pos, key, want, ticks):
|
||||
return state_of(game, pos, key)
|
||||
|
||||
|
||||
def check_piston_push(host, guest, out):
|
||||
"""A moving piston pushes both players and a stationary mob on each host path."""
|
||||
hx, _, hz = pos_of(host)
|
||||
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
|
||||
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
|
||||
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
|
||||
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
|
||||
results = {}
|
||||
for player, name in ((host, host.player), (guest, GUEST_NAME)):
|
||||
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
||||
host.wait(10)
|
||||
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
|
||||
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
|
||||
host('command', command=f'tp {"@s" if player is host else GUEST_NAME} {x+1.65} {y} {z+0.5}')
|
||||
player.wait(15)
|
||||
before = pos_of(player)
|
||||
assert abs(before[0] - (x + 1.65)) < 0.1, f'{name} did not reach piston: {before}'
|
||||
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
||||
player.wait(30)
|
||||
after = pos_of(player)
|
||||
results[name] = after[0] - before[0]
|
||||
assert results[name] > 0.3, f'piston did not push {name}: {before} -> {after}'
|
||||
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
||||
host.wait(10)
|
||||
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
|
||||
host('command', command=f'tp @s {x+1.65} {y} {z+0.5}')
|
||||
host.wait(10)
|
||||
host.raw('key', 'e', 'tap')
|
||||
host.raw('wait-for', 'screen', 'inventory')
|
||||
before = pos_of(host)
|
||||
guest('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
||||
host.wait(30)
|
||||
after = pos_of(host)
|
||||
results['inventory'] = after[0] - before[0]
|
||||
assert results['inventory'] > 0.3, f'piston did not push player with inventory open: {before} -> {after}'
|
||||
host.raw('key', 'esc', 'tap')
|
||||
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
||||
host.wait(10)
|
||||
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
|
||||
host('command', command=f'summon minecraft:pig {x+1.65} {y} {z+0.5} {{NoAI:1b}}')
|
||||
host.wait(10)
|
||||
pigs = host('entities', radius=16, type='pig')['entities']
|
||||
pig = min(pigs, key=lambda p: abs(p['pos'][0] - (x + 1.65)) + abs(p['pos'][2] - (z + 0.5)))
|
||||
before = pig['pos'][0]
|
||||
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
||||
host.wait(30)
|
||||
pigs = host('entities', radius=16, type='pig')['entities']
|
||||
after = next(p['pos'][0] for p in pigs if p['id'] == pig['id'])
|
||||
results['pig'] = after - before
|
||||
assert results['pig'] > 0.3, f'piston did not push pig: {before} -> {after}'
|
||||
return results
|
||||
|
||||
|
||||
def check_plates(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
@@ -4679,7 +4732,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
|
||||
'cartography': check_cartography, 'hurt': check_hurt, 'commands': check_commands,
|
||||
'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls,
|
||||
'effects': check_effects, 'portals': check_portals, 'distance': check_distance,
|
||||
'clicks': check_clicks, 'sleep': check_sleep, 'push': check_push,
|
||||
'clicks': check_clicks, 'sleep': check_sleep, 'push': check_push, 'piston-push': check_piston_push,
|
||||
'eat': check_eat, 'eating': check_eating, 'xp': check_xp,
|
||||
'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast,
|
||||
'light': check_light, 'plates': check_plates, 'swings': check_swings,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare moving-piston push geometry and predicates with pinned vanilla.
|
||||
|
||||
`PistonMovingBlockEntity.moveCollidedEntities` decides how far an entity is
|
||||
pushed; the movement it describes is `Entity.move(MoverType.PISTON, ...)`, whose
|
||||
collision and per-tick limit the game owns. This harness covers the deciding
|
||||
half: the movement area, the offset block box, `getMovement` on every face, the
|
||||
per-box loop, `moveStuckEntities`' box and `matchesStickyCritera`, and
|
||||
`fixEntityWithinPistonBase`.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import random
|
||||
import struct
|
||||
import subprocess
|
||||
try:
|
||||
from . import lab, test_rust_parity as base
|
||||
except ImportError:
|
||||
import lab
|
||||
import test_rust_parity as base
|
||||
|
||||
|
||||
def corpus():
|
||||
def f32(v): return struct.pack('>f', float(v)).hex()
|
||||
def f64(v): return struct.pack('>d', float(v)).hex()
|
||||
def box(values): return [f64(v) for v in values]
|
||||
step = [1.0 / 3.0, 0.25, 0.5, 0.75, 1.0]
|
||||
unit = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
|
||||
offsets = [[0, 0, 0], [-17, -64, 30000000], [2147483647, -2147483648, -2147483648]]
|
||||
faces = [0, 1, 2, 3, 4, 5]
|
||||
# Progress pairs around the 0.25/0.5/1.0 thresholds the head and the
|
||||
# extended-progress offset change at.
|
||||
progress = [0.0, 0.25, 0.49999997, 0.5, 0.75, 0.99999994, 1.0]
|
||||
entities = [
|
||||
[0.9, 0.0, 0.2, 1.5, 1.8, 0.8],
|
||||
[1.4, 0.0, 0.2, 2.0, 1.8, 0.8],
|
||||
[0.75, 0.0, 0.2, 1.35, 1.8, 0.8],
|
||||
[3.0, 0.0, 0.2, 3.6, 1.8, 0.8],
|
||||
[0.49, 0.0, 0.49, 0.51, 1.8, 0.51],
|
||||
[0.0, 1.0, 0.0, 1.0, 2.8, 1.0],
|
||||
]
|
||||
for index, position in enumerate(offsets):
|
||||
for direction in faces:
|
||||
for extending in (False, True):
|
||||
for source in (False, True):
|
||||
for current in progress:
|
||||
advanced = min(current + 0.5, 1.0)
|
||||
for entity in entities:
|
||||
yield {
|
||||
'pos': position,
|
||||
'direction': direction,
|
||||
'extending': extending,
|
||||
'source': source,
|
||||
'progress': f32(current),
|
||||
'new_progress': f32(advanced),
|
||||
'block_box': box(unit),
|
||||
'movement_box': box(unit),
|
||||
'entity': box(entity),
|
||||
'boxes': [box(unit)],
|
||||
'sticky_top': f64(0.0),
|
||||
'reaction': 0,
|
||||
'on_ground': True,
|
||||
'supported': False,
|
||||
'entity_x': f64(entity[0]),
|
||||
'entity_z': f64(entity[2]),
|
||||
}
|
||||
# Split boxes, a retracting honey drag with every push reaction, and the
|
||||
# predicate's supported/on-ground corners.
|
||||
for index in range(4000):
|
||||
rng = random.Random(0x50555348 + index)
|
||||
current = rng.choice(progress)
|
||||
advanced = min(current + 0.5, 1.0)
|
||||
count = rng.randint(1, 4)
|
||||
boxes = []
|
||||
for _ in range(count):
|
||||
low = [rng.choice([0.0, 0.25, 0.5]), rng.choice([0.0, 0.5]), rng.choice([0.0, 0.5])]
|
||||
high = [min(v + rng.choice([0.25, 0.5, 1.0]), 1.0) for v in low]
|
||||
boxes.append(box(low + high))
|
||||
yield {
|
||||
'pos': rng.choice(offsets),
|
||||
'direction': rng.randrange(6),
|
||||
'extending': rng.random() < 0.5,
|
||||
'source': rng.random() < 0.5,
|
||||
'progress': f32(current),
|
||||
'new_progress': f32(advanced),
|
||||
'block_box': boxes[0],
|
||||
'movement_box': boxes[rng.randrange(len(boxes))],
|
||||
'entity': box([
|
||||
rng.choice([0.0, 0.49, 0.75, 0.9, 1.4]),
|
||||
rng.choice([0.0, 0.5]),
|
||||
rng.choice([0.0, 0.2, 0.49]),
|
||||
rng.choice([0.51, 1.35, 1.5, 2.0]),
|
||||
rng.choice([1.8, 2.8]),
|
||||
rng.choice([0.51, 0.8, 1.0]),
|
||||
]),
|
||||
'boxes': boxes,
|
||||
'sticky_top': f64(rng.choice([0.0, 0.5, 0.9375])),
|
||||
'reaction': rng.randrange(5),
|
||||
'on_ground': rng.random() < 0.5,
|
||||
'supported': rng.random() < 0.5,
|
||||
'entity_x': f64(rng.choice([0.0, 0.5, 1.0, 2.0])),
|
||||
'entity_z': f64(rng.choice([0.0, 0.5, 1.0, 2.0])),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--prepare', action='store_true')
|
||||
args = parser.parse_args()
|
||||
build = lab.LOCAL / 'rust-piston-push-parity'
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
report_path = build / 'report.json'
|
||||
report_path.unlink(missing_ok=True)
|
||||
if args.prepare:
|
||||
lab.prepare()
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPistonPushOracle.java'
|
||||
helpers = [oracle.with_name(name + '.java')
|
||||
for name in ['VanillaCollisionOracle', 'VanillaBlockStateCodecOracle', 'VanillaFaceSupportOracle']]
|
||||
sources = [oracle, *helpers, Path(__file__), lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'tools/lab.py',
|
||||
lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock',
|
||||
lab.ROOT / 'crates/horizon-core/Cargo.toml', lab.ROOT / 'crates/horizon-world/Cargo.toml',
|
||||
*sorted((lab.ROOT / 'crates/horizon-core/src').rglob('*.rs')),
|
||||
*sorted((lab.ROOT / 'crates/horizon-world/src').rglob('*.rs')),
|
||||
lab.ROOT / 'crates/horizon-world/examples/probe/piston_push_fixture.rs']
|
||||
tested_sources = base.source_hashes(sources)
|
||||
classes = build / 'classes'
|
||||
classes.mkdir(exist_ok=True)
|
||||
|
||||
def run(command, name):
|
||||
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
|
||||
base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT)
|
||||
|
||||
dependency_path = os.pathsep.join(map(str, libraries))
|
||||
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none',
|
||||
'-sourcepath', str(lab.ROOT / 'mods/horizon/src/test/java'),
|
||||
'-cp', str(jars['server']) + os.pathsep + dependency_path,
|
||||
'-d', classes, oracle, *helpers], 'compile-java')
|
||||
|
||||
def java(side, mode, path):
|
||||
run(['java', '-Xmx512M', '-cp', os.pathsep.join([str(classes), str(jars[side]), dependency_path]),
|
||||
'dev.horizonlab.parity.VanillaPistonPushOracle', mode, path], side + '-' + mode)
|
||||
|
||||
fixtures = list(corpus())
|
||||
inputs = build / 'inputs.txt'
|
||||
inputs.write_text(''.join(json.dumps(value, separators=(',', ':')) + '\n' for value in fixtures), encoding='utf-8')
|
||||
fixture_paths = [inputs]
|
||||
tested_fixtures = base.source_hashes(fixture_paths)
|
||||
run(['cargo', 'build', '--release', '-p', 'horizon-world', '--example', 'world_probe'], 'compile-rust')
|
||||
metadata = json.loads(subprocess.check_output(
|
||||
['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True))
|
||||
binary = Path(metadata['target_directory']) / 'release/examples' / ('world_probe.exe' if os.name == 'nt' else 'world_probe')
|
||||
run([binary, 'piston_push_fixture', build], 'rust')
|
||||
references = []
|
||||
for side, jar in jars.items():
|
||||
java(side, side, build)
|
||||
reference = build / f'{side}.txt'
|
||||
matched = base.compare(reference, build / 'rust.txt')
|
||||
references.append({'side': side, 'jar_sha256': lab.digest(jar, 'sha256'),
|
||||
'output_sha256': lab.digest(reference, 'sha256'), 'matched_output_lines': matched})
|
||||
print(f'{side}: {matched:,} piston-push output lines match Rust', flush=True)
|
||||
base.ensure_sources_unchanged(sources, tested_sources)
|
||||
base.ensure_sources_unchanged(fixture_paths, tested_fixtures)
|
||||
report = {
|
||||
'schema': 1,
|
||||
'minecraft': lock['minecraft']['version'],
|
||||
'manifest_sha1': lock['minecraft']['manifest_sha1'],
|
||||
'platform': platform.platform(),
|
||||
'java': subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
||||
'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(),
|
||||
'fixtures': len(fixtures),
|
||||
'sources_sha256': tested_sources,
|
||||
'fixtures_sha256': tested_fixtures,
|
||||
'rust_output_sha256': lab.digest(build / 'rust.txt', 'sha256'),
|
||||
'references': references,
|
||||
'scope': 'Actual PistonMovingBlockEntity push geometry and predicates: getExtendedProgress, '
|
||||
'moveByPositionAndProgress, PistonMath.getMovementArea, getMovement on every face, the '
|
||||
'moveCollidedEntities loop over split shapes with its delta cap and PUSH_OFFSET, the '
|
||||
'getEntities search box, and moveStuckEntities\' box with matchesStickyCritera for every push '
|
||||
'reaction. fixEntityWithinPistonBase is covered by unit tests only: it calls '
|
||||
'Entity.move(MoverType.PISTON, ...) and so needs a live level. Entity selection order, that '
|
||||
'collision pass, the per-tick 0.51 limit, block behaviour of the moved state and full gameplay '
|
||||
'remain elsewhere.',
|
||||
}
|
||||
lab.write_json(report_path, report)
|
||||
print(f'Scoped piston-push parity passed: {report_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user