mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Place boat items at the exact server POV hit point
Co-Authored-By: Codex <[email protected]>
This commit is contained in:
1 parent
b368b50c60
commit
cdd00e29a3
7 files changed
+282
-69
No files matched your search
@@ -2,6 +2,8 @@
|
||||
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
|
||||
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
|
||||
//! saving and `AbstractBoatRenderer` with animated paddles.
|
||||
mod placement;
|
||||
|
||||
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::{
|
||||
@@ -1251,48 +1253,7 @@ impl Game<'_> {
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return None;
|
||||
}
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
let block = self.pick().map(|h| {
|
||||
(
|
||||
h.point,
|
||||
(0..3)
|
||||
.map(|a| (h.point[a] - eye[a]).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt(),
|
||||
)
|
||||
});
|
||||
let mut fluid = None;
|
||||
let mut t = 0.0;
|
||||
while t < reach {
|
||||
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
|
||||
let (x, y, z) = (
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
);
|
||||
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
|
||||
&& p[1] <= f64::from(y) + f64::from(h)
|
||||
{
|
||||
fluid = Some((fluid_hit_point(p, y, h), t));
|
||||
break;
|
||||
}
|
||||
t += 0.05;
|
||||
}
|
||||
let hit = match (block, fluid) {
|
||||
(Some(b), Some(w)) => {
|
||||
if w.1 < b.1 {
|
||||
w.0
|
||||
} else {
|
||||
b.0
|
||||
}
|
||||
}
|
||||
(Some(b), None) => b.0,
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return None,
|
||||
};
|
||||
let hit = self.boat_item_hit()?;
|
||||
let mut boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
Arc::from(wood),
|
||||
@@ -1623,36 +1584,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a fluid pick lands in the cell the ray sample `p` is in. Vanilla's
|
||||
/// `ClipContext.Fluid.ANY` clips against the fluid shape: a full-height cell
|
||||
/// (the same fluid above it) is a full cube, so the hit is the ray's own
|
||||
/// point and a boat placed under water stays at the eye; a partial cell is
|
||||
/// clipped to its surface.
|
||||
fn fluid_hit_point(p: [f64; 3], y: i32, h: f32) -> [f64; 3] {
|
||||
if h >= 1.0 {
|
||||
p
|
||||
} else {
|
||||
[p[0], f64::from(y) + f64::from(h), p[2]]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `VoxelShape.clip` on a full-cube fluid cell keeps the true hit point
|
||||
/// (a ray starting inside hits at `from + delta * 0.001`); only a partial
|
||||
/// cell is clipped to its surface.
|
||||
#[test]
|
||||
fn a_full_height_fluid_cell_keeps_the_ray_point_and_a_partial_one_snaps_to_its_surface() {
|
||||
let eye = [0.5, 3.62, 0.5];
|
||||
assert_eq!(fluid_hit_point(eye, 3, 1.0), eye);
|
||||
assert_eq!(
|
||||
fluid_hit_point(eye, 3, 8.0 / 9.0),
|
||||
[0.5, 3.0 + f64::from(8.0f32 / 9.0), 0.5]
|
||||
);
|
||||
}
|
||||
|
||||
fn boat() -> Boat {
|
||||
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! BoatItem's server POV clip; deliberately separate from renderer/mining picks.
|
||||
use super::{Fluid, Game};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
registry::BlockStateId,
|
||||
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
|
||||
};
|
||||
use std::io;
|
||||
|
||||
struct BoatItemRay<'a, 'r>(&'a Game<'r>);
|
||||
impl RayWorld for BoatItemRay<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Block = BlockStateId;
|
||||
type Fluid = Option<(f32, bool, Fluid)>;
|
||||
fn block_state(&mut self, p: BlockPos) -> io::Result<BlockStateId> {
|
||||
Ok(self.0.world.block(p.x, p.y, p.z))
|
||||
}
|
||||
fn fluid_state(&mut self, p: BlockPos) -> io::Result<Self::Fluid> {
|
||||
Ok(self
|
||||
.0
|
||||
.fluid_height_of(p.x, p.y, p.z, None)
|
||||
.map(|(h, source)| {
|
||||
(
|
||||
h,
|
||||
source,
|
||||
self.0
|
||||
.res
|
||||
.facts()
|
||||
.get(self.0.world.block(p.x, p.y, p.z))
|
||||
.fluid,
|
||||
)
|
||||
}))
|
||||
}
|
||||
fn block_shape(
|
||||
&mut self,
|
||||
state: BlockStateId,
|
||||
p: BlockPos,
|
||||
_: RayBlockMode,
|
||||
) -> io::Result<VoxelShape> {
|
||||
// LiquidBlock.getShape is empty. Waterlogged blocks retain their outline.
|
||||
if matches!(
|
||||
&*self.0.res.facts().get(state).name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
) {
|
||||
return Ok(VoxelShape::empty());
|
||||
}
|
||||
let mut shape = VoxelShape::empty();
|
||||
for b in self.0.outline_boxes([p.x, p.y, p.z]) {
|
||||
let b: [f64; 6] = std::array::from_fn(|i| b[i] - f64::from([p.x, p.y, p.z][i % 3]));
|
||||
let part = VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(b[0], b[1], b[2]),
|
||||
Vec3::new(b[3], b[4], b[5]),
|
||||
))
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))?;
|
||||
shape = shape
|
||||
.join(&part, ShapeOp::OR)
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))?;
|
||||
}
|
||||
Ok(shape)
|
||||
}
|
||||
fn interaction_shape(&mut self, _: BlockStateId, _: BlockPos) -> io::Result<VoxelShape> {
|
||||
// Pinned vanilla's registered interaction shapes are all empty;
|
||||
// interaction_shapes::InteractionShapes validates that same table.
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid_is_source(&mut self, s: Self::Fluid) -> io::Result<bool> {
|
||||
Ok(s.is_some_and(|s| s.1))
|
||||
}
|
||||
fn fluid_is_empty(&mut self, s: Self::Fluid) -> io::Result<bool> {
|
||||
Ok(s.is_none())
|
||||
}
|
||||
fn fluid_is_water(&mut self, s: Self::Fluid) -> io::Result<bool> {
|
||||
Ok(s.is_some_and(|s| s.2 == Fluid::Water))
|
||||
}
|
||||
fn fluid_shape(&mut self, s: Self::Fluid, _: BlockPos) -> io::Result<VoxelShape> {
|
||||
match s {
|
||||
None => Ok(VoxelShape::empty()),
|
||||
Some((h, _, _)) => {
|
||||
VoxelShape::from_box(Aabb::new(Vec3::ZERO, Vec3::new(1.0, f64::from(h), 1.0)))
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Item.getPlayerPOVHitResult`: float view vector, OUTLINE and Fluid.ANY.
|
||||
pub(super) fn boat_item_hit(&self) -> Option<[f64; 3]> {
|
||||
let [x, y, z] = self.eye_position()?;
|
||||
let start = Vec3::new(x, y, z);
|
||||
let dir = horizon_world::view_direction::view_vector(self.pitch, self.yaw);
|
||||
let end = start.add_vector(dir.scale(self.reach()));
|
||||
let hit = clip_world(
|
||||
&mut BoatItemRay(self),
|
||||
start,
|
||||
end,
|
||||
RayBlockMode::Outline,
|
||||
RayFluidMode::Any,
|
||||
256,
|
||||
)
|
||||
.ok()?;
|
||||
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,52 @@ impl Game<'_> {
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
}
|
||||
"use_item" => {
|
||||
// Exercise the connected owner's normal server packet route.
|
||||
let sequence = op["sequence"].as_i64().unwrap_or(0) as i32;
|
||||
let yaw = op["yaw"].as_f64().unwrap_or(0.0) as f32;
|
||||
let pitch = op["pitch"].as_f64().unwrap_or(0.0) as f32;
|
||||
let owner = self
|
||||
.serving
|
||||
.as_ref()
|
||||
.ok_or("command harness: no server")?
|
||||
.owner;
|
||||
self.in_owner_level(|g| -> Result<()> {
|
||||
let conn = g
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|guest| guest.uuid == owner && guest.placed)
|
||||
.map(|guest| guest.conn)
|
||||
})
|
||||
.ok_or("command harness: owner is not connected")?;
|
||||
let mut host = g.lan.take().ok_or("command harness: no hub")?;
|
||||
g.lan_guest_packet(
|
||||
&mut host,
|
||||
conn,
|
||||
horizon_protocol::packets::play::Serverbound::UseItem(
|
||||
horizon_protocol::packets::play::UseItem {
|
||||
hand: 0,
|
||||
yaw,
|
||||
pitch,
|
||||
sequence,
|
||||
},
|
||||
),
|
||||
);
|
||||
let ack = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.is_some_and(|g| g.ack_up_to.get() >= sequence);
|
||||
g.lan = Some(host);
|
||||
if !ack {
|
||||
return Err("command harness: UseItem was not acknowledged".into());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"use_block" => {
|
||||
// Exercise the connected owner's normal server packet route.
|
||||
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
|
||||
|
||||
@@ -251,6 +251,19 @@ public final class VanillaCommandExecOracle {
|
||||
}
|
||||
}
|
||||
case "settle" -> settle();
|
||||
case "use_item" -> {
|
||||
player.connection.handleUseItem(new net.minecraft.network.protocol.game.ServerboundUseItemPacket(
|
||||
net.minecraft.world.InteractionHand.MAIN_HAND, op.get("sequence").getAsInt(),
|
||||
op.get("yaw").getAsFloat(), op.get("pitch").getAsFloat()));
|
||||
try {
|
||||
var ack = net.minecraft.server.network.ServerGamePacketListenerImpl.class.getDeclaredField("ackBlockChangesUpTo");
|
||||
ack.setAccessible(true);
|
||||
if (ack.getInt(player.connection) < op.get("sequence").getAsInt())
|
||||
throw new IllegalStateException("UseItem was not acknowledged");
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
case "use_block" -> {
|
||||
JsonArray p = op.getAsJsonArray("pos");
|
||||
BlockPos pos = new BlockPos(p.get(0).getAsInt(), p.get(1).getAsInt(), p.get(2).getAsInt());
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""BoatItem use: exact placement and one-tick identity, no ray mocks."""
|
||||
from command_exec_corpus import cmd, setup
|
||||
|
||||
|
||||
def boat_item_hits(motion_diagnostic=False):
|
||||
out = setup('/tick freeze', '/gamerule spawn_mobs false', '/gamerule advance_time false',
|
||||
'/scoreboard objectives add boat_hit dummy')
|
||||
out += [cmd('/forceload add 0 0 15 15', expect_result=1), {'op':'settle'},
|
||||
cmd('/execute if loaded 5 200 5',expect_result=1)]
|
||||
cases=[('stone',5.5,5.5,0,50,True),('turned',5.3,5.8,37.5,41.25,True),
|
||||
('water-near',5.5,5.5,0,38,False),('water-far',5.5,5.5,0,30,True),
|
||||
('slab',5.5,5.5,0,45,True),('pig',5.5,5.5,0,50,False),
|
||||
('wall',5.5,5.5,0,10,False)]
|
||||
for sequence,(name,x,z,yaw,pitch,placed) in enumerate(cases,1):
|
||||
out += setup('/gamemode creative','/kill @e[type=!player]', '/clear @s',
|
||||
'/fill 0 200 0 15 204 15 air','/fill 0 199 0 15 199 15 stone')
|
||||
if name.startswith('water'):
|
||||
out += [cmd('/fill 4 200 6 7 200 9 water[level=0]',expect_result=16),
|
||||
cmd('/execute if block 5 200 7 water[level=0]',expect_result=1)]
|
||||
if name=='slab':
|
||||
out += [cmd('/fill 4 200 6 7 200 9 stone_slab[type=bottom,waterlogged=false]',expect_result=16),
|
||||
cmd('/execute if block 5 200 7 stone_slab[type=bottom,waterlogged=false]',expect_result=1)]
|
||||
if name=='wall':
|
||||
out += [cmd('/fill 4 200 7 7 203 7 stone',expect_result=16),
|
||||
cmd('/execute if block 5 203 7 stone',expect_result=1)]
|
||||
if name=='pig':
|
||||
out += [cmd('/summon pig 5.5 200 6.9 {UUID:[I;310,0,0,1],NoAI:1b,Tags:["boat_blocker"]}',expect_result=1),
|
||||
cmd('/execute if entity @e[type=pig,tag=boat_blocker,nbt={UUID:[I;310,0,0,1],Health:10f,NoAI:1b}]',expect_result=1)]
|
||||
out += setup(f'/tp @s {x} 200 {z} {yaw} {pitch}', '/gamemode survival',
|
||||
'/item replace entity @s weapon.mainhand with oak_boat')
|
||||
out += [cmd('/execute if entity @s[type=player,gamemode=survival]',expect_result=1),
|
||||
cmd('/execute if data entity @s UUID',expect_result=1),
|
||||
cmd('/data get entity @s Pos'),cmd('/data get entity @s Rotation'),cmd('/data get entity @s Health',expect_result=20),
|
||||
cmd('/clear @s oak_boat 0',expect_result=1),cmd('/execute unless entity @e[type=oak_boat]',expect_result=1),
|
||||
cmd('/execute if block 5 199 5 stone',expect_result=1)]
|
||||
out += setup('/execute store result score start boat_hit run time query gametime')
|
||||
out += [{'op':'use_item','yaw':yaw,'pitch':pitch,'sequence':sequence},cmd('/execute if entity @s[type=player]',expect_result=1)]
|
||||
out += [cmd('/execute '+('if' if placed else 'unless')+' entity @e[type=oak_boat]',expect_result=1),
|
||||
cmd('/clear @s oak_boat 0')]
|
||||
if placed:
|
||||
out += [cmd('/tag @e[type=oak_boat,limit=1] add placed_boat',expect_result=1),
|
||||
cmd('/data modify storage boat_hit:identity uuid set from entity @e[tag=placed_boat,limit=1] UUID',expect_result=1),
|
||||
cmd('/data get entity @e[tag=placed_boat,limit=1] Pos'),cmd('/data get entity @e[tag=placed_boat,limit=1] Rotation')]
|
||||
if placed and motion_diagnostic:
|
||||
out.append(cmd('/data get entity @e[tag=placed_boat,limit=1] Motion'))
|
||||
out += setup('/execute store result score elapsed boat_hit run time query gametime','/scoreboard players operation elapsed boat_hit -= start boat_hit')
|
||||
out += [cmd('/scoreboard players get elapsed boat_hit',expect_result=0),cmd('/tick step 1',expect_result=1),{'op':'tick','n':1}]
|
||||
out += setup('/execute store result score elapsed boat_hit run time query gametime','/scoreboard players operation elapsed boat_hit -= start boat_hit')
|
||||
out += [cmd('/scoreboard players get elapsed boat_hit',expect_result=1)]
|
||||
if placed:
|
||||
out += [cmd('/execute if entity @e[type=oak_boat,tag=placed_boat]',expect_result=1),
|
||||
cmd('/data modify storage boat_hit:identity uuid set from entity @e[tag=placed_boat,limit=1] UUID'),
|
||||
cmd('/data get entity @e[tag=placed_boat,limit=1] Pos')]
|
||||
else:
|
||||
out += [cmd('/execute unless entity @e[type=oak_boat]',expect_result=1)]
|
||||
if placed and motion_diagnostic:
|
||||
out.append(cmd('/data get entity @e[tag=placed_boat,limit=1] Motion'))
|
||||
out += setup('/data remove storage boat_hit:identity uuid')
|
||||
return out
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BoatItem server UseItem: exact placement, consumption and one-tick identity.
|
||||
|
||||
Seven independently reset stone/turned/water/slab/entity/wall cases. The
|
||||
optional Motion observations are additive raw diagnostics for boat physics.
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', type=Path)
|
||||
parser.add_argument('--prepare', action='store_true')
|
||||
parser.add_argument('--motion-diagnostic', action='store_true')
|
||||
parser.add_argument('--reference-only', action='store_true')
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
folder = 'boat-item-hit-motion' if args.motion_diagnostic else 'boat-item-hit-parity'
|
||||
command = [sys.executable, str(root / 'tools/test_command_exec_parity.py'),
|
||||
'--boat-item-hit-fixture', '--work-dir', str(root / '.local' / folder)]
|
||||
if args.binary:
|
||||
command += ['--binary', str(args.binary.resolve())]
|
||||
if args.prepare:
|
||||
command.append('--prepare')
|
||||
if args.motion_diagnostic:
|
||||
command.append('--boat-motion-diagnostic')
|
||||
if args.reference_only:
|
||||
command.append('--reference-only')
|
||||
return subprocess.run(command, cwd=root).returncode
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -83,8 +83,12 @@ def main():
|
||||
parser.add_argument('--binary', type=Path, help='use an already built Horizon game instead of building it')
|
||||
parser.add_argument('--slot-codec-diagnostic', action='store_true',
|
||||
help='retain the separate invalid inline slot-range codec diagnostic')
|
||||
parser.add_argument("--boat-item-hit-fixture", action="store_true")
|
||||
parser.add_argument("--work-dir", type=Path)
|
||||
parser.add_argument("--reference-only", action="store_true")
|
||||
parser.add_argument("--boat-motion-diagnostic", action="store_true")
|
||||
args = parser.parse_args()
|
||||
build = lab.LOCAL / 'command-exec-parity'
|
||||
build = args.work_dir.resolve() if args.work_dir else lab.LOCAL / 'command-exec-parity'
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
report_path = build / 'report.json'
|
||||
report_path.unlink(missing_ok=True)
|
||||
@@ -115,6 +119,9 @@ def main():
|
||||
if args.sulfur_power_load_diagnostic:
|
||||
groups = [('setup', corpus_module.group_setup()),
|
||||
('sulfur powered-load diagnostic', corpus_module.sulfur_cube_fuse_ticks(True))]
|
||||
if args.boat_item_hit_fixture:
|
||||
from boat_item_hit_corpus import boat_item_hits
|
||||
groups = [('boat item hit', boat_item_hits(args.boat_motion_diagnostic))]
|
||||
if args.only:
|
||||
groups = [g for g in groups if args.only in g[0] or g[0] == 'setup']
|
||||
if args.slot_codec_diagnostic:
|
||||
@@ -138,6 +145,17 @@ def main():
|
||||
run(['java', '-Xmx3G', '-Duser.timezone=UTC', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]),
|
||||
'dev.horizonlab.parity.VanillaCommandExecOracle', vanilla_dir / 'universe', script_path, build / 'vanilla.jsonl', SEED], 'java', cwd=vanilla_dir)
|
||||
|
||||
if args.reference_only:
|
||||
reference = [json.loads(line) for line in (build / 'vanilla.jsonl').read_text().splitlines()]
|
||||
recorded = [op for op in script if op['op'] == 'cmd' and op.get('record', True)]
|
||||
if len(recorded) != len(reference):
|
||||
sys.exit('reference-only record count differs')
|
||||
for op, row in zip(recorded, reference):
|
||||
if 'expect_result' in op and row.get('results') != [[True, op['expect_result']]]:
|
||||
sys.exit(f'Java fixture guard failed: {op}: {row.get("results")}')
|
||||
print(f'Java reference: {len(reference)} records, fixture guards passed')
|
||||
return
|
||||
|
||||
if args.binary:
|
||||
binary = args.binary.resolve(strict=True)
|
||||
else:
|
||||
|
||||
Reference in new issue
Block a user