mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
@@ -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()
|
||||
Reference in new issue
Block a user