Merge #10434 (boat paddles) into #10386

Both extend the boat authority fixture and harness: keep the paddle and
attack cases, both Case fields and both docstring paragraphs. The harness
keeps main's fast-profile build with #10434's --resources option.
Queued after #10434, so either landing first leaves the other mergeable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon-agentandClaude Opus 5.5 committed 2026-11-06 06:57:38 +01:00
commit c9b0011952
5 files changed
+171 -15

No files matched your search

@@ -1,5 +1,5 @@
//! The native parity harness runs the actual client boat ticker without a
//! renderer, on an empty world; movement ownership changes between ticks.
//! The native parity harness runs the client boat ticker and authoritative
//! boat attacks without a renderer, on an empty world.
use super::{Game, boats::Boat};
use horizon_client::Result;
use horizon_core::geometry::Vec3;
@@ -16,6 +16,20 @@ struct Case {
owned: Vec<bool>,
#[serde(default)]
paddles: Option<PaddleCase>,
#[serde(default)]
attack: Option<Attack>,
}
#[derive(Deserialize)]
struct Attack {
yaw: f32,
sprint: bool,
ticks: u32,
player_velocity: [f64; 3],
#[serde(default)]
knockback: u8,
#[serde(default)]
creative: bool,
}
#[derive(Deserialize)]
@@ -49,6 +63,38 @@ impl Game<'_> {
boat.velocity = Vec3::new(x, y, z);
boat.delta_rotation = case.spin;
self.boats.push(boat);
if let Some(attack) = case.attack {
let mut player =
horizon_client::motion::PlayerMotion::new(self.motion, Vec3::ZERO)?;
player.velocity = Vec3::new(
attack.player_velocity[0],
attack.player_velocity[1],
attack.player_velocity[2],
);
player.set_sprinting(attack.sprint)?;
self.player = Some(player);
self.inventory.fill(None);
self.selected = 0;
if attack.knockback > 0 {
self.inventory[0] = horizon_client::item_data::parse_stack(&format!(
r#"{{id:"minecraft:stick",count:1,components:{{"minecraft:enchantments":{{"minecraft:knockback":{}}}}}}}"#,
attack.knockback
))?;
}
self.yaw = attack.yaw;
self.attack_ticks = attack.ticks;
self.creative = attack.creative;
self.strike_boat(0);
let player = self.player.as_ref().unwrap();
output.push(json!({
"name": case.name,
"velocity": self.boats.first().map(|boat| boat.velocity.components().map(|v| format!("{:016x}", v.to_bits()))),
"destroyed": self.boats.is_empty(),
"player_velocity": player.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
"sprint": player.sprinting,
}));
continue;
}
let mut steps = Vec::new();
if let Some(paddles) = case.paddles {
self.riding = None;
+35 -7
View File
@@ -1439,16 +1439,40 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
self.strike_boat(i);
self.swing_arm();
true
}
/// The accepted boat target, shared by aimed attacks and headless parity.
pub(super) fn strike_boat(&mut self, i: usize) {
let area = self.boats[i].bounds();
// Player.attack's full damage against the boat's entity type.
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
let (damage, _, _, magic) = self.melee_damage(&kind, false);
self.hurt_boat(i, damage, self.creative);
let (damage, scale, _, magic) = self.melee_damage(&kind, false);
let hurt = self.hurt_boat_result(i, damage, self.creative);
self.vehicle_attack_sounds(area, magic.then_some(id));
if damage > 0.0
&& let Some(destroyed) = hurt
{
let (knockback, _) = self.extra_knockback(&kind, scale);
if knockback > 0.0 {
// Player.causeExtraKnockback uses Entity.push for a boat,
// not LivingEntity.knockback (no resistance or velocity halving).
if !destroyed {
let (x, z) = self.extra_knockback_direction();
self.boats[i].velocity.x += f64::from(-(x as f32) * knockback);
self.boats[i].velocity.y += 0.1;
self.boats[i].velocity.z += f64::from(-(z as f32) * knockback);
}
if let Some(player) = self.player.as_mut() {
player.velocity.x *= horizon_client::attack_strength::ATTACKER_SLOWDOWN;
player.velocity.z *= horizon_client::attack_strength::ATTACKER_SLOWDOWN;
}
self.stop_sprint_after_extra_knockback();
}
}
// Player.onAttack: the hit starts the attack strength again.
self.attack_ticks = 0;
self.swing_arm();
true
}
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
/// (`creative`: a creative player's hit discards it straight away).
@@ -1456,11 +1480,15 @@ impl Game<'_> {
/// reason that destroys the boat (DISCARDED included), while the boat item
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
/// a creative hit discards it. True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
self.hurt_boat_result(i, damage, creative).unwrap_or(false)
}
/// None means a canceled hit; Some records whether an accepted hit destroyed it.
fn hurt_boat_result(&mut self, i: usize, mut damage: f32, creative: bool) -> Option<bool> {
let mut cancel = false;
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
if cancel {
return false;
return None;
}
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
@@ -1499,7 +1527,7 @@ impl Game<'_> {
);
}
}
destroyed
Some(destroyed)
}
/// AbstractBoatRenderer.
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
+10 -2
View File
@@ -3333,7 +3333,10 @@ impl Game<'_> {
return;
}
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
if self.look_at_entity(host, gi, id, None).is_none() {
let boat = self.boats.iter().position(|boat| boat.id as i32 == id);
// A packet names its target already. Keep the attacker's actual yaw
// for the boat's knockback instead of aiming a ray at its center.
if boat.is_none() && self.look_at_entity(host, gi, id, None).is_none() {
return;
}
// A mace's smash sets the attacker's velocity and sends it at once, before the
@@ -3344,7 +3347,12 @@ impl Game<'_> {
.player
.as_mut()
.is_some_and(|p| std::mem::take(&mut p.health.sync_velocity));
g.attack();
if let Some(index) = boat {
g.strike_boat(index);
g.swing_arm();
} else {
g.attack();
}
let p = g.player.as_mut()?;
let sent = std::mem::replace(&mut p.health.sync_velocity, marked);
sent.then_some(p.velocity)
@@ -36,6 +36,7 @@ public final class VanillaBoatAuthorityOracle {
Lvl() { super(null, null, null, null, null, null, false, 0L, List.of(), false); }
@Override public boolean isClientSide() { return true; }
@Override public int getNextEntityId() { return next++; }
@Override public void gameEvent(net.minecraft.core.Holder<net.minecraft.world.level.gameevent.GameEvent> event, Vec3 position, net.minecraft.world.level.gameevent.GameEvent.Context context) {}
@Override public void sendPacketToServer(net.minecraft.network.protocol.Packet<?> packet) {}
@Override public RegistryAccess registryAccess() { return ParityData.access; }
@Override public BlockState getBlockState(BlockPos pos) { return Blocks.AIR.defaultBlockState(); }
@@ -58,6 +59,14 @@ public final class VanillaBoatAuthorityOracle {
@Override public net.minecraft.world.scores.PlayerTeam getTeam() { return null; }
}
static JsonArray bits(Vec3 velocity) {
JsonArray result = new JsonArray();
for (double value : new double[]{velocity.x, velocity.y, velocity.z}) {
result.add(String.format("%016x", Double.doubleToRawLongBits(value)));
}
return result;
}
public static void main(String[] args) throws Exception {
ParityData.init();
VanillaVehicleContainerOracle.reflect();
@@ -76,6 +85,49 @@ public final class VanillaBoatAuthorityOracle {
JsonArray velocity = c.getAsJsonArray("velocity");
boat.setDeltaMovement(new Vec3(velocity.get(0).getAsDouble(), velocity.get(1).getAsDouble(), velocity.get(2).getAsDouble()));
spin.setFloat(boat, c.get("spin").getAsFloat());
if (c.has("attack")) {
JsonObject attack = c.getAsJsonObject("attack");
var u = Class.forName("sun.misc.Unsafe");
VanillaRemoteAttackOracle.unsafe = VanillaRemoteAttackOracle.field(u, "theUnsafe").get(null);
VanillaRemoteAttackOracle.allocate = u.getMethod("allocateInstance", Class.class);
var client = VanillaRemoteAttackOracle.make(VanillaRemoteAttackOracle.Client.class);
client.registries = ParityData.access;
client.sources = new net.minecraft.world.damagesource.DamageSources(ParityData.access);
var player = VanillaRemoteAttackOracle.entity(VanillaRemoteAttackOracle.Attacker.class, EntityTypes.PLAYER, client);
VanillaRemoteAttackOracle.field(net.minecraft.world.entity.player.Player.class, "abilities")
.set(player, new net.minecraft.world.entity.player.Abilities());
VanillaRemoteAttackOracle.field(net.minecraft.world.entity.LivingEntity.class, "activeEffects")
.set(player, new java.util.HashMap<>());
player.map = new net.minecraft.world.entity.ai.attributes.AttributeMap(
net.minecraft.world.entity.player.Player.createAttributes().build());
player.hand = net.minecraft.world.item.ItemStack.EMPTY;
VanillaRemoteAttackOracle.field(net.minecraft.world.entity.LivingEntity.class, "attackStrengthTicker")
.setInt(player, attack.get("ticks").getAsInt());
player.sprint = attack.get("sprint").getAsBoolean();
player.setYRot(attack.get("yaw").getAsFloat());
var pv = attack.getAsJsonArray("player_velocity");
player.setDeltaMovement(new Vec3(pv.get(0).getAsDouble(), pv.get(1).getAsDouble(), pv.get(2).getAsDouble()));
// Player.attack adds 0.5 only to a charged sprint hit.
float strength = Math.min(1.0f, (attack.get("ticks").getAsInt() + 0.5f) / 5.0f);
float amount = (player.sprint && strength > 0.9f ? 0.5f : 0.0f)
+ attack.get("knockback").getAsFloat() / 2.0f;
if (attack.get("knockback").getAsInt() == 0 && !attack.get("creative").getAsBoolean()) {
player.attack(boat);
} else {
// Enchanted/creative controls isolate causeExtraKnockback;
// server enchantment lookup and hurt/removal are not simulated here.
player.causeExtraKnockback(boat, amount, boat.getDeltaMovement(), null, 1.0f, true);
}
JsonObject result = new JsonObject();
result.add("name", c.get("name"));
boolean destroyed = attack.get("creative").getAsBoolean();
result.add("velocity", destroyed ? com.google.gson.JsonNull.INSTANCE : bits(boat.getDeltaMovement()));
result.addProperty("destroyed", destroyed);
result.add("player_velocity", bits(player.getDeltaMovement()));
result.addProperty("sprint", player.sprint);
output.add(result);
continue;
}
JsonArray steps = new JsonArray();
if (c.has("paddles")) {
JsonObject paddles = c.getAsJsonObject("paddles");
+26 -4
View File
@@ -13,6 +13,13 @@ Horizon's controlled ticker and remote-copy ticker. Synced flag values and the
first passenger are controlled inputs, including stale true flags after a
player leaves and a non-player first passenger. These checks compare animation
phase and interpolation, not rendered screenshots or network packet delivery.
Bare-hand and enchanted-stick attacks additionally run Horizon's real boat strike and vanilla's
Player.attack for bare-hand hits and Player.causeExtraKnockback for the
enchanted/creative controls, checking retained boat motion, upward impulse, the
attacker's slowdown and sprint cancellation. The enchanted/creative Java
knockback amount and removal flag are controlled; the vanilla server attack
and enchantment lookup are not run.
"""
import argparse
import json
@@ -75,6 +82,22 @@ def cases():
dict(occupied=True, first_player=True, flags=[True, True]),
dict(occupied=False, flags=[True, True]),
])))
# Player.causeExtraKnockback's nonliving branch, through the actual boat
# attack in Horizon: uncharged/non-sprinting controls, axis and oblique yaw,
# retained target motion and the attacker's slowdown/sprint cancellation.
for yaw in (0.0, 90.0, 180.0, -90.0, 37.0):
for sprint in (False, True):
for ticks in (0, 4, 5, 100):
for velocity in ([0.0, 0.0, 0.0], [0.125, -0.0625, -0.25]):
for knockback in (0, 1, 2):
out.append(dict(name=f'attack-{len(out)}', spin=0, velocity=velocity, owned=[],
attack=dict(yaw=yaw, sprint=sprint, ticks=ticks, knockback=knockback, creative=False,
player_velocity=[0.25, 0.42, -0.1])))
for sprint in (False, True):
for knockback in (0, 1, 2):
out.append(dict(name=f'attack-destroyed-{len(out)}', spin=0, velocity=[0.0, 0.0, 0.0], owned=[],
attack=dict(yaw=0.0, sprint=sprint, ticks=100, knockback=knockback,
creative=True, player_velocity=[0.25, 0.42, -0.1])))
return out
@@ -114,11 +137,10 @@ def main():
'compile-java')
run(['java', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaBoatAuthorityOracle',
work / 'inputs.json', work / 'vanilla.json'], 'vanilla')
run(['cargo', 'build', '--locked', '-p', 'horizon-client', '--bin', 'horizon-client'], 'compile-rust')
binary = lab.cargo_target() / 'debug' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'], 'compile-rust')
binary = lab.cargo_target() / 'fast' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
with (work / 'rust.log').open('w') as log, tempfile.TemporaryDirectory(prefix='boat-authority-') as game_dir:
run_game([str(binary), '--headless', '--resources', str(args.resources.resolve()),
'--game-dir', game_dir], cwd=game_dir,
run_game([str(binary), '--headless', '--resources', str(args.resources.resolve()), '--game-dir', game_dir], cwd=game_dir,
env=lab.game_env(HORIZON_BOAT_AUTHORITY_FIXTURE=work), stdout=log,
stderr=subprocess.STDOUT, check=True, timeout=180)
native = json.loads((work / 'vanilla.json').read_text())