Merge remote-tracking branch 'origin/main' into refactor/entity-walk-fluids

This commit is contained in:
HrafnaValkyrja committed 2026-11-02 05:07:30 -05:00
commit b07a0ccaa4
17 files changed
+534 -24

No files matched your search

+4 -2
View File
@@ -5536,9 +5536,11 @@ impl Renderer {
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
/// The macros of the GL extensions this device gives shader packs: subgroups, when it
/// has them.
/// The macros this renderer gives shader packs: the GL extensions this device has
/// (subgroups, when it has them), and the vendor, renderer and mipmap levels
/// (`Macros::define_renderer`).
pub fn define_pack_macros(&self, macros: &mut horizon_shaders::Macros) {
macros.define_renderer(self.mipmaps);
if self.device.features().contains(wgpu::Features::SUBGROUP) {
for ext in [
"basic",
@@ -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) {
@@ -753,6 +753,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,
@@ -7233,6 +7234,7 @@ mod pearl_tests {
player: None,
nearest_player: None,
others: &[],
dragon: None,
leash_boats: &[],
others_grid: None,
others_index: None,
@@ -1129,6 +1129,11 @@ impl Game<'_> {
for b in &self.bolts {
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
}
// AreaEffectCloud.getDimensions: diameter follows synced radius,
// height is 0.5; EntityDimensions.scalable gives eyes at 85% height.
for c in &self.potions.clouds {
g.hitbox(c.at, (c.radius * 2.0, 0.5), 0.425, false, (0.0, 0.0));
}
// The dragon: 16 by 8, a living entity; its parts follow.
if let Some(d) = &self.dragon {
g.hitbox(
+50
View File
@@ -2188,6 +2188,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 {
@@ -2232,6 +2253,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;
@@ -16,8 +16,8 @@ use horizon_world::registry::BlockStateId;
/// An `AreaEffectCloud` a server tracks: what its client tick reads.
pub(super) struct RemoteCloud {
id: u32,
at: [f64; 3],
radius: f32,
pub(super) at: [f64; 3],
pub(super) radius: f32,
waiting: bool,
particle: ParticleOptions,
}
+4 -1
View File
@@ -99,7 +99,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};
@@ -2438,6 +2439,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.
/// The normal mob damage handler 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,
@@ -462,6 +467,7 @@ pub(crate) fn with_resource_test_world(
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,
+69 -18
View File
@@ -78,8 +78,9 @@ pub fn render_stage(name: &str) -> u32 {
impl Macros {
/// The standard macros that need no renderer (spec 1.5.1): `MC_VERSION`, this system's
/// `MC_OS_*`, the GL and GLSL versions Horizon's translation accepts, the render stages and
/// the host settings at their defaults. The GPU vendor and family arrive with the renderer.
/// `MC_OS_*`, the GL and GLSL versions Iris reports, the render stages and the host
/// settings at their defaults. The GPU vendor and family arrive with the renderer
/// (`define_renderer`).
pub fn standard() -> Self {
let mut m = Self::default();
m.define("MC_VERSION", MC_VERSION.to_string());
@@ -90,8 +91,11 @@ impl Macros {
_ => "MC_OS_OTHER",
};
m.define(os, "");
m.define("MC_GL_VERSION", "460");
m.define("MC_GLSL_VERSION", "460");
// What Iris 1.11.7 reports on 26.3, whatever the pack's `#version` (observed with a probe
// pack): its GL context's 3.3. Packs then take the paths they take there; the
// translation itself reads GLSL up to 4.60.
m.define("MC_GL_VERSION", "330");
m.define("MC_GLSL_VERSION", "330");
for (i, stage) in RENDER_STAGES.iter().enumerate() {
m.define(format!("MC_RENDER_STAGE_{stage}"), i.to_string());
}
@@ -106,9 +110,25 @@ impl Macros {
// Packs take Iris's extensions (compute programs, images, custom textures) where this
// is defined.
m.define("IS_IRIS", "");
// Iris 1.11.7's own, as it defines them on 26.3 (observed with a probe pack): its version
// (1.11.7 as 11107), and translucent terrain sorted back to front, as Horizon sorts it
// (vanilla's `VertexSorting.byDistance`, `gfx/translucency.rs`).
m.define("IRIS_VERSION", "11107");
m.define("IRIS_HAS_TRANSLUCENCY_SORTING", "");
m
}
/// The macros the renderer adds: the mipmap levels terrain is drawn with (`MC_MIPMAP_LEVEL`,
/// Iris's: it follows the Mipmap Levels option), and `OTHER` as the GL vendor and renderer
/// (OptiFine's shaders.txt, E and F: one of each is defined). Horizon draws through wgpu, not
/// a GL driver, so a pack's paths for one driver (an NVIDIA extension it takes behind
/// `MC_GL_VENDOR_NVIDIA`) are not there to take.
pub fn define_renderer(&mut self, mipmap_levels: u32) {
self.define("MC_GL_VENDOR_OTHER", "");
self.define("MC_GL_RENDERER_OTHER", "");
self.define("MC_MIPMAP_LEVEL", mipmap_levels.to_string());
}
/// `iris.features.optional`'s features Horizon has, as `IRIS_FEATURE_<name>`; a note for
/// each feature `iris.features.required` names that it lacks.
pub fn define_features(&mut self, properties: &crate::Properties) -> Vec<String> {
@@ -333,14 +353,9 @@ pub(crate) fn is_ident(c: char) -> bool {
mod tests {
use super::*;
/// Packs take their normal- and specular-map branches, as they do with the maps on: a
/// declaration made only there is seen.
#[test]
fn programs_take_the_normal_and_specular_map_branches() {
let fsh = "#version 330\nout vec4 c;\n\
#ifdef MC_NORMAL_MAP\nconst float bump = 1.0;\n#endif\n\
#ifdef MC_SPECULAR_MAP\nconst float shine = 2.0;\n#endif\n\
void main() { c = vec4(bump, shine, 0.0, 1.0); }\n";
/// Whether `fsh` translates with `m`: a program that declares what it reads only under
/// a macro's condition translates when the condition holds.
fn translates(fsh: &str, m: &Macros) -> Result<(), String> {
let vsh = "#version 330\nvoid main() { gl_Position = vec4(0.0); }\n";
let pack = crate::Pack::from_zip_bytes(
"P.zip",
@@ -350,13 +365,49 @@ mod tests {
]),
)
.unwrap();
let m = Macros::standard();
let program = crate::translate_composite(
&crate::preprocess(&pack, "composite.vsh", &m),
&crate::preprocess(&pack, "composite.fsh", &m),
crate::translate_composite(
&crate::preprocess(&pack, "composite.vsh", m),
&crate::preprocess(&pack, "composite.fsh", m),
crate::Target::Buffers,
);
assert!(program.is_ok(), "{:?}", program.err());
)
.map(|_| ())
.map_err(|e| format!("{e:?}"))
}
/// Packs see what Iris 1.11.7 defines on 26.3: GL and GLSL 3.3, its version and its sorted
/// translucent terrain, so they take the paths they take there.
#[test]
fn programs_see_the_versions_iris_reports() {
let fsh = "#version 330\nout vec4 c;\n\
#if MC_GL_VERSION == 330 && MC_GLSL_VERSION == 330 && IRIS_VERSION >= 11107 \\\n\
&& defined IRIS_HAS_TRANSLUCENCY_SORTING\nconst float v = 1.0;\n#endif\n\
void main() { c = vec4(v); }\n";
translates(fsh, &Macros::standard()).unwrap();
}
/// With the renderer's macros a pack sees one GL vendor and renderer, `OTHER`, and the
/// mipmap levels terrain is drawn with.
#[test]
fn programs_see_the_renderers_vendor_and_mipmap_levels() {
let fsh = "#version 330\nout vec4 c;\n\
#if defined MC_GL_VENDOR_OTHER && defined MC_GL_RENDERER_OTHER && MC_MIPMAP_LEVEL == 2\n\
const float v = 1.0;\n#endif\n\
#if defined MC_GL_VENDOR_NVIDIA || defined MC_GL_RENDERER_GEFORCE\nconst float v = 2.0;\n#endif\n\
void main() { c = vec4(v); }\n";
let mut m = Macros::standard();
m.define_renderer(2);
translates(fsh, &m).unwrap();
}
/// Packs take their normal- and specular-map branches, as they do with the maps on: a
/// declaration made only there is seen.
#[test]
fn programs_take_the_normal_and_specular_map_branches() {
let fsh = "#version 330\nout vec4 c;\n\
#ifdef MC_NORMAL_MAP\nconst float bump = 1.0;\n#endif\n\
#ifdef MC_SPECULAR_MAP\nconst float shine = 2.0;\n#endif\n\
void main() { c = vec4(bump, shine, 0.0, 1.0); }\n";
translates(fsh, &Macros::standard()).unwrap();
}
#[test]
@@ -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()