Fix dragon contacts with living mobs and revenge target lookup

Include living mobs in the dragon's wing and head/neck contacts. Preserve
vanilla wing impulse, sitting behavior, team damage rules, hurt cooldowns,
and the dragon as the causing entity. Resolve the separately simulated
dragon in MobWorld so existing revenge and melee goals can target it.

Add a real-Java contact oracle, Rust transcript and repeatable parity
harness. Seven scenarios compare health, velocity and revenge attribution;
all JSON values matched exactly in the local Java 26.3 run. The normal
integrated-server check also passed (enderman health 30, dragon target).
Add an optional
integrated-server check for contact damage and enderman revenge.

The upstream damage-event/hurt-flash fix is already present and is retained.
This does not claim parity of the entire fight, pathfinding, or post-attack
enchantment effects. Full pr_ready currently cannot pass because Clippy is
not installed; individual verification: 1,934 Rust tests, 598 Python tests, rustfmt,
cargo check and the seven Java/Rust scenarios passed.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
AltMugenandCodex committed 2026-11-02 03:41:42 +03:00
1 parent 81e528f925
commit 670350d7e9
13 files changed
+454 -2

No files matched your search

@@ -1171,6 +1171,7 @@ pub fn main() -> horizon_client::Result<()> {
player: player.clone(),
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
others: $snapshot,
dragon: None,
leash_boats: &[],
others_grid: $grid,
others_index: None,
@@ -210,6 +210,12 @@ impl Game<'_> {
out.push('\n');
}
}
"mobs" => {
// Observe authoritative AI state without a control-socket race.
let record = self.harness_owner(|g| Ok(g.control_mobs(128.0)))?;
out.push_str(&record.to_string());
out.push('\n');
}
"tick" => {
self.chat_capture = Some(Vec::new());
for _ in 0..op["n"].as_u64().unwrap_or(1) {
@@ -765,6 +765,7 @@ impl Game<'_> {
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
player,
others,
dragon: self.dragon_target(),
leash_boats: &self.leash_boats,
others_grid: None,
others_index: None,
@@ -7073,6 +7074,7 @@ mod pearl_tests {
player: None,
nearest_player: None,
others: &[],
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
+50
View File
@@ -2095,6 +2095,27 @@ impl Game<'_> {
set(d, 3 + i, x, y, z);
}
}
/// `Level.getEntity`: revenge goals must resolve the dragon too.
pub(super) fn dragon_target(&self) -> Option<(u32, horizon_client::mobs::TargetInfo)> {
let d = self.dragon.as_ref().filter(|d| d.alive())?;
let p = d.position;
Some((
d.id,
horizon_client::mobs::TargetInfo {
position: p,
eye_y: p.y + 6.8,
bounds: Aabb::new(
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
),
alive: true,
can_be_seen_as_enemy: true,
is_player: false,
creative: false,
passenger: false,
},
))
}
/// Wing knockback, head and neck damage, and breaking blocks in the way.
fn dragon_contacts(&mut self) {
let Some(d) = self.dragon.as_ref() else {
@@ -2139,6 +2160,35 @@ impl Game<'_> {
}
}
}
// EnderDragon.aiStep queries living entities, not only players.
if let Some(d) = self.dragon.as_ref() {
let frame = horizon_client::mobs::dragon_contacts::Frame {
dragon_id: d.id,
origin: d.position,
center: (bc, bz),
wings: wings.map(|w| {
w.inflate(Vec3::new(4.0, 2.0, 4.0))
.move_by(Vec3::new(0.0, -2.0, 0.0))
}),
heads: heads.map(|h| h.inflate(Vec3::new(1.0, 1.0, 1.0))),
sitting,
recently_hurt,
};
// Keep team lookup in Game (including tamed pets' owner teams).
let allowed: Vec<bool> = self
.mobs
.iter()
.map(|m| {
horizon_client::scoreboard::teams_allow_damage(
self.mob_team(d.id),
self.mob_team_of(m),
)
})
.collect();
for (mob, allowed) in self.mobs.iter_mut().zip(allowed) {
horizon_client::mobs::dragon_contacts::contact(mob, &frame, allowed);
}
}
// checkWalls over head, neck and body.
let griefing = self.ui.rules.bool("mob_griefing");
let mut in_wall = false;
+4 -1
View File
@@ -98,7 +98,8 @@ pub mod warden_brain;
pub mod witch;
pub mod wither;
mod world;
pub use world::visual_clip;
pub use world::{TargetInfo, visual_clip};
pub mod dragon_contacts;
pub mod zombie_villager;
use crate::{blocks::BlockFacts, mesher::Vertex, world::World};
@@ -2430,6 +2431,8 @@ pub struct MobWorld<'a> {
/// player in the level stands, alive or not (the despawn distance).
pub nearest_player: Option<Vec3>,
pub others: &'a [Seen],
/// The separately simulated dragon, visible to revenge and melee goals.
pub dragon: Option<(u32, TargetInfo)>,
/// The boats and rafts a lead can hang on (`Holder::Boat`): id and state.
pub leash_boats: &'a [(u32, leash::HolderState)],
/// `others` by position, when the caller keeps one (the mob ticks).
@@ -0,0 +1,181 @@
//! `EnderDragon.knockBack` and `hurt` for living mobs.
//! The dragon lives outside the normal mob array; its contacts must still reach them.
// Contact rules compared with the installed vanilla Java 26.3 EnderDragon.
use crate::mobs::{Damage, DamageKind, Mob, Who};
use horizon_core::geometry::{Aabb, Vec3};
pub struct Frame {
pub dragon_id: u32,
pub origin: Vec3,
pub center: (f64, f64),
pub wings: [Aabb; 2],
pub heads: [Aabb; 2],
pub sitting: bool,
pub recently_hurt: bool,
}
/// Wings push even while perched and even when teams forbid damage.
/// Mob::damage owns armor, invulnerability frames and last-attacker bookkeeping.
pub fn contact(m: &mut Mob, f: &Frame, teams_allow: bool) {
if f.recently_hurt || m.removed || m.id == f.dragon_id {
return;
}
let bounds = m.bounds();
let source = Damage {
kind: DamageKind::MobAttack,
attacker: Some(Who::Mob(f.dragon_id)),
from: Some([f.origin.x, f.origin.y, f.origin.z]),
};
for wing in &f.wings {
if wing.intersects(bounds) {
let dx = m.position.x - f.center.0;
let dz = m.position.z - f.center.1;
let dd = (dx * dx + dz * dz).max(0.1);
m.velocity.x += dx / dd * 4.0;
m.velocity.y += f64::from(0.2_f32);
m.velocity.z += dz / dd * 4.0;
if !f.sitting
&& teams_allow
&& m.ai.last_hurt_by_time < (m.age as i32).saturating_sub(2)
{
m.damage(5.0, source);
}
}
}
if teams_allow {
for head in &f.heads {
if head.intersects(bounds) {
m.damage(10.0, source);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn box_at(x: f64) -> Aabb {
Aabb::new(Vec3::new(x, -1.0, -1.0), Vec3::new(x + 2.0, 3.0, 1.0))
}
fn setup() -> (Mob, Frame) {
let mut m = Mob::new(
22,
crate::mobs::sensor_scan_tests::kind(),
Vec3::new(1.0, 0.0, 0.0),
0.0,
);
m.health = 40.0;
m.age = 100;
let f = Frame {
dragon_id: 99,
origin: Vec3::ZERO,
center: (0.0, 0.0),
wings: [box_at(0.0), box_at(100.0)],
heads: [box_at(100.0), box_at(100.0)],
sitting: false,
recently_hurt: false,
};
(m, f)
}
#[test]
fn flying_wing_hurts_and_names_the_dragon() {
let (mut m, f) = setup();
contact(&mut m, &f, true);
assert_eq!(m.health, 35.0);
assert_eq!(m.ai.last_hurt_by, Some(Who::Mob(99)));
}
#[test]
fn perched_wing_pushes_without_damage() {
let (mut m, mut f) = setup();
f.sitting = true;
contact(&mut m, &f, true);
assert_eq!(m.health, 40.0);
assert_eq!(m.velocity, Vec3::new(4.0, f64::from(0.2_f32), 0.0));
}
#[test]
fn team_protection_keeps_push_but_blocks_damage() {
let (mut m, f) = setup();
contact(&mut m, &f, false);
assert_eq!(m.health, 40.0);
assert_eq!(m.velocity, Vec3::new(4.0, f64::from(0.2_f32), 0.0));
}
#[test]
fn hurt_dragon_does_not_contact_mobs() {
let (mut m, mut f) = setup();
f.recently_hurt = true;
contact(&mut m, &f, true);
assert_eq!(m.health, 40.0);
assert_eq!(m.velocity, Vec3::ZERO);
}
#[test]
fn head_hurts_even_when_perched() {
let (mut m, mut f) = setup();
f.sitting = true;
f.wings = [box_at(100.0), box_at(100.0)];
f.heads[0] = box_at(0.0);
contact(&mut m, &f, true);
assert_eq!(m.health, 30.0);
}
#[test]
fn recent_mob_attack_suppresses_wing_damage() {
let (mut m, f) = setup();
m.ai.last_hurt_by_time = 99;
contact(&mut m, &f, true);
assert_eq!(m.health, 40.0);
assert_eq!(m.velocity, Vec3::new(4.0, f64::from(0.2_f32), 0.0));
}
}
#[cfg(test)]
mod parity {
use super::*;
#[test]
#[ignore = "writes the vanilla parity transcript"]
fn contact_transcript() {
let mut rows = Vec::new();
for name in [
"flying",
"sitting",
"two_wings",
"team",
"head",
"hurt_dragon",
"recent_victim",
] {
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
kind.id = "minecraft:enderman".into();
kind.species = crate::mobs::Species::Enderman;
kind.max_health = 40.0;
kind.width = 0.6;
kind.height = 2.9;
let mut m = Mob::new(22, std::sync::Arc::new(kind), Vec3::new(1.0, 0.0, 0.0), 0.0);
m.age = 100;
let near = Aabb::new(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0));
let far = near.move_by(Vec3::new(100.0, 0.0, 0.0));
let mut f = Frame {
dragon_id: 99,
origin: Vec3::ZERO,
center: (0.0, 0.0),
wings: [near, far],
heads: [far, far],
sitting: name == "sitting",
recently_hurt: name == "hurt_dragon",
};
if name == "two_wings" {
f.wings = [near, near];
}
if name == "head" {
f.wings = [far, far];
f.heads = [near, far];
}
if name == "recent_victim" {
m.ai.last_hurt_by = Some(Who::Mob(99));
m.ai.last_hurt_by_time = 99;
}
contact(&mut m, &f, name != "team");
rows.push(serde_json::json!({"case":name,"health":m.health,"velocity":[m.velocity.x,m.velocity.y,m.velocity.z],"revenge":m.ai.last_hurt_by==Some(Who::Mob(99))}));
}
let path = std::env::var_os("HORIZON_DRAGON_CONTACT_OUTPUT").expect("parity output path");
std::fs::write(path, serde_json::to_vec_pretty(&rows).unwrap()).unwrap();
}
}
+2
View File
@@ -1734,6 +1734,7 @@ mod tests {
player: None,
nearest_player: None,
others: &[],
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
@@ -1844,6 +1845,7 @@ mod tests {
player: None,
nearest_player: None,
others: &[],
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
+6
View File
@@ -267,6 +267,11 @@ impl MobWorld<'_> {
if self.daylight { 0 } else { 11 }
}
pub fn target_info(&self, who: Who) -> Option<TargetInfo> {
if let Some((id, target)) = self.dragon
&& who == Who::Mob(id)
{
return Some(target);
}
match who {
Who::Player => self.player.as_ref().map(|p| TargetInfo {
position: p.position,
@@ -451,6 +456,7 @@ pub(crate) fn with_built_test_world_rules(
player: None,
nearest_player: None,
others,
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
@@ -204,6 +204,7 @@ mod tests {
player: None,
nearest_player: None,
others: &[],
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
@@ -222,6 +222,16 @@ public final class VanillaCommandExecOracle {
out.append(gson.toJson(record)).append('\n');
}
}
case "dragon_contacts" -> {
// Optional fixture: the shared runner still compiles on its own.
try {
Class<?> fixture = Class.forName("dev.horizonlab.parity.VanillaDragonContactsOracle");
var method = fixture.getDeclaredMethod("run", ServerLevel.class);
out.append(gson.toJson(method.invoke(null, level))).append('\n');
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
case "settle" -> settle();
case "break" -> {
JsonArray pos = op.getAsJsonArray("pos");
@@ -0,0 +1,76 @@
package dev.horizonlab.parity;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.boss.enderdragon.EnderDragon;
import net.minecraft.world.entity.boss.enderdragon.EnderDragonPart;
import net.minecraft.world.entity.boss.enderdragon.phases.EnderDragonPhase;
import net.minecraft.world.entity.monster.Enderman;
/** Calls the real 26.3 dragon contact methods; no entity or physics ticks intervene. */
public final class VanillaDragonContactsOracle {
static JsonArray run(ServerLevel level) {
try {
Method wing = EnderDragon.class.getDeclaredMethod("knockBack", ServerLevel.class, List.class);
Method head = EnderDragon.class.getDeclaredMethod("hurt", ServerLevel.class, List.class);
Field body = EnderDragon.class.getDeclaredField("body");
wing.setAccessible(true);
head.setAccessible(true);
body.setAccessible(true);
JsonArray rows = new JsonArray();
String[] names = {"flying", "sitting", "two_wings", "team", "head", "hurt_dragon", "recent_victim"};
for (String name : names) {
EnderDragon dragon = (EnderDragon) BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse("minecraft:ender_dragon")).create(level, EntitySpawnReason.COMMAND);
dragon.setPos(0, 0, 0);
((EnderDragonPart) body.get(dragon)).setPos(0, 0, 0);
dragon.getPhaseManager().setPhase(name.equals("sitting") ? EnderDragonPhase.HOVERING : EnderDragonPhase.HOLDING_PATTERN);
Enderman mob = (Enderman) BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse("minecraft:enderman")).create(level, EntitySpawnReason.COMMAND);
mob.setPos(1, 0, 0);
mob.setNoAi(true);
mob.tickCount = 100;
if (name.equals("recent_victim")) {
mob.tickCount = 99;
mob.setLastHurtByMob(dragon);
mob.tickCount = 100;
}
if (name.equals("team")) {
var board = level.getScoreboard();
var team = board.addPlayerTeam("contact");
team.setAllowFriendlyFire(false);
board.addPlayerToTeam(dragon.getScoreboardName(), team);
board.addPlayerToTeam(mob.getScoreboardName(), team);
}
if (!name.equals("hurt_dragon")) {
if (name.equals("head")) head.invoke(dragon, level, List.of(mob));
else {
wing.invoke(dragon, level, List.of(mob));
if (name.equals("two_wings")) wing.invoke(dragon, level, List.of(mob));
}
}
JsonObject row = new JsonObject();
row.addProperty("case", name);
row.addProperty("health", mob.getHealth());
JsonArray velocity = new JsonArray();
velocity.add(mob.getDeltaMovement().x);
velocity.add(mob.getDeltaMovement().y);
velocity.add(mob.getDeltaMovement().z);
row.add("velocity", velocity);
row.addProperty("revenge", mob.getLastHurtByMob() == dragon);
rows.add(row);
if (name.equals("team")) level.getScoreboard().removePlayerTeam(level.getScoreboard().getPlayerTeam("contact"));
mob.discard();
dragon.discard();
}
return rows;
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ EnderDragon.onFlap port crates/horizon-client/src/game/dragon.rs::Game::tick_dra
EnderDragon.defineSynchedData port crates/horizon-client/src/game/dragon.rs::Game::lan_dragon_state DATA_PHASE defined as hovering on the wire
EnderDragon.tickPart port crates/horizon-client/src/game/dragon.rs::Game::place_dragon_parts
EnderDragon.getHeadYOffset inline crates/horizon-client/src/game/dragon.rs::Game::place_dragon_parts sitting gives -1, else flight sample 5 minus sample 0
EnderDragon.knockBack port crates/horizon-client/src/game/dragon.rs::Game::dragon_contacts wing zones push players
EnderDragon.knockBack port crates/horizon-client/src/game/dragon.rs::Game::dragon_contacts wing zones push players and living mobs; head/neck contacts hurt living mobs
EnderDragon.handleKillingBlow inline crates/horizon-client/src/game/dragon.rs::Dragon::hurt health set to 1 and dying phase unless sitting
EnderDragon.knockback missing minor: a hit does not push the dragon back outside the sitting phase
EnderDragon.findClosestNode port crates/horizon-client/src/game/dragon.rs::Game::closest_node
1 WorldGenRegion.getChunk port crates/horizon-world/src/worldgen/feature_region.rs::TerrainRegion::chunk gap: minor: returns the resident chunk without the generating-step status check
53 EnderDragon.defineSynchedData port crates/horizon-client/src/game/dragon.rs::Game::lan_dragon_state DATA_PHASE defined as hovering on the wire
54 EnderDragon.tickPart port crates/horizon-client/src/game/dragon.rs::Game::place_dragon_parts
55 EnderDragon.getHeadYOffset inline crates/horizon-client/src/game/dragon.rs::Game::place_dragon_parts sitting gives -1, else flight sample 5 minus sample 0
56 EnderDragon.knockBack port crates/horizon-client/src/game/dragon.rs::Game::dragon_contacts wing zones push players wing zones push players and living mobs; head/neck contacts hurt living mobs
57 EnderDragon.handleKillingBlow inline crates/horizon-client/src/game/dragon.rs::Dragon::hurt health set to 1 and dying phase unless sitting
58 EnderDragon.knockback missing minor: a hit does not push the dragon back outside the sitting phase
59 EnderDragon.findClosestNode port crates/horizon-client/src/game/dragon.rs::Game::closest_node
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Compare living-mob dragon contacts with the real Java 26.3 methods.
Checks health, resulting velocity and revenge attribution for seven contact
scenarios. Does not claim parity of the whole fight, AI pathfinding, or gear
with post-attack enchantments. The game integration check is separate.
"""
import argparse
import json
import math
import os
from pathlib import Path
import subprocess
import tempfile
try:
from . import lab, test_rust_parity as base
from .integration_env import game_environment
except ImportError:
import lab
import test_rust_parity as base
from integration_env import game_environment
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--classpath', help='already verified vanilla server and libraries')
parser.add_argument('--resources', type=Path, help='also check the integrated-server game path')
parser.add_argument('--binary', type=Path, help='already built game for --resources')
args = parser.parse_args()
build = lab.LOCAL / 'dragon-contacts-parity'
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
cp = args.classpath
major = '25'
if not cp:
lock, jars, libraries = base.verify_artifacts()
cp = os.pathsep.join(map(str, [jars['server'], *libraries]))
major = str(lock['minecraft']['java_major'])
classes = build / 'classes'
classes.mkdir(exist_ok=True)
source = lab.ROOT / 'mods/horizon/src/test/java'
oracle = source / 'dev/horizonlab/parity'
def run(command, name, **kwargs):
with (build / (name + '.log')).open('w') as log:
subprocess.run(list(map(str, command)), stdout=log,
stderr=subprocess.STDOUT, check=True, timeout=240, **kwargs)
run(['javac', '--release', major, '-proc:none', '-cp', cp, '-d', classes,
oracle / 'VanillaCommandExecOracle.java',
oracle / 'VanillaDragonContactsOracle.java'], 'compile-java')
script = build / 'script.jsonl'
script.write_text('{"op":"dragon_contacts"}\n')
expected_path = build / 'java.json'
actual_path = build / 'rust.json'
for p in [expected_path, actual_path]:
p.unlink(missing_ok=True)
with tempfile.TemporaryDirectory(prefix='hz-dragon-contacts-') as scratch:
env = game_environment(os.environ, scratch)
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + cp,
'dev.horizonlab.parity.VanillaCommandExecOracle',
Path(scratch) / 'universe', script, expected_path, '1'], 'java', env=env)
run(['cargo', 'test', '--locked', '-p', 'horizon-client-lib', '--lib',
'mobs::dragon_contacts::', '--', '--include-ignored'], 'rust',
env=dict(os.environ, HORIZON_DRAGON_CONTACT_OUTPUT=str(actual_path)))
expected = json.loads(expected_path.read_text())
actual = json.loads(actual_path.read_text())
assert len(actual) == len(expected) == 7
for want, got in zip(expected, actual):
for key in ['case', 'health', 'revenge']:
assert want[key] == got[key], (key, want, got)
for a, b in zip(want['velocity'], got['velocity']):
assert math.isclose(a, b, rel_tol=0, abs_tol=1e-12), (want, got)
integration = False
if args.resources:
if not args.binary:
parser.error('--resources requires --binary')
ops = []
def cmd(text):
ops.append({'op': 'cmd', 'cmd': text, 'record': False})
for c in ['/gamemode creative', '/gamerule spawn_mobs false',
'/gamerule mob_griefing false',
'/execute in minecraft:the_end run tp @s 8.5 205 8.5']:
cmd(c)
ops += [{'op': 'settle'}, {'op': 'tick', 'n': 260}]
cmd('/kill @e[type=minecraft:ender_dragon]')
cmd('/summon minecraft:ender_dragon 8 200 8 {DragonPhase:10}')
cmd('/summon minecraft:enderman 8 200 1.5 {NoGravity:1b,Tags:["contact_test"]}')
ops.append({'op': 'tick', 'n': 1})
cmd('/data merge entity @e[tag=contact_test,limit=1] {Invulnerable:1b}')
ops += [{'op': 'tick', 'n': 24}, {'op': 'mobs'}]
game_script = build / 'game-script.jsonl'
game_script.write_text(''.join(json.dumps(op) + '\n' for op in ops))
game_output = build / 'game.jsonl'
with tempfile.TemporaryDirectory(prefix='hz-dragon-integration-') as scratch:
env = game_environment(os.environ, scratch)
env.update(HORIZON_COMMAND_SCRIPT=str(game_script),
HORIZON_COMMAND_OUTPUT=str(game_output), HORIZON_HARNESS_TIMEOUT='120')
run([args.binary.resolve(), '--resources', args.resources.resolve(),
'--world', 'contacts', '--seed', '1', '--radius', '4'], 'game', env=env, cwd=scratch)
observations = [json.loads(line) for line in game_output.read_text().splitlines()]
mobs = observations[-1]['near']
endermen = [m for m in mobs if m['kind'] == 'minecraft:enderman']
assert len(endermen) == 1, endermen
mob = endermen[0]
assert mob['health'] < 40 and str(mob['target']).startswith('Mob('), mob
integration = True
report.write_text(json.dumps({'passed': True, 'cases': len(actual),
'integrated_server': integration}, indent=2) + '\n')
print('dragon contacts: 7 Java/Rust cases match')
if __name__ == '__main__':
main()