Merge pull request #11133 from Ragbie/fix/enderman-hurt-teleport-now

An enderman hurt by the game layer teleports at the hurt, as vanilla's does
This commit is contained in:
rdbt authored and GitHub committed 2026-11-07 12:42:42 +00:00
commit 132aa6b682
8 files changed
+257

No files matched your search

+4
View File
@@ -125,6 +125,8 @@ mod enchant_effects;
mod end;
mod enderman;
#[cfg(test)]
mod enderman_hurt_teleport_tests;
#[cfg(test)]
mod enderman_placement_tests;
mod entity;
mod entity_culling;
@@ -8433,6 +8435,8 @@ impl Game<'_> {
horizon_client::spans::time("tick_block_entity_anims", || {
self.tick_block_entity_anims()
});
// A conduit's hurt teleports an enderman now (`Enderman.hurtServer`).
self.settle_hurt_teleports()?;
horizon_client::spans::time("tick_particles", || self.tick_particles());
self.tick_client_ambient();
}
@@ -502,6 +502,10 @@ impl Game<'_> {
for (mob, target) in reinforcements {
self.call_reinforcements(mob, target);
}
// Enderman.hurtServer's teleport, refused hit or not, before the command returns.
if let Err(e) = self.settle_hurt_teleports() {
log::warn!("Horizon: /damage: enderman teleport: {e}");
}
hurt
}
@@ -1014,6 +1014,9 @@ impl Game<'_> {
}
/// Advance every mob one tick, then apply their events.
pub(super) fn tick_creatures(&mut self) -> Result<()> {
// Hurts dealt since the last creature tick (explosions, commands, the
// systems after it) teleport an enderman before it ticks, not in its AI step.
self.settle_hurt_teleports()?;
self.attack_ticks = self.attack_ticks.saturating_add(1);
self.refresh_gear_knockback_resistance();
self.tick_leads();
@@ -1513,6 +1516,8 @@ impl Game<'_> {
queued.append(&mut m.outbox);
}
self.handle_mob_events(queued)?;
// The enderman teleports those hurts asked for (`Enderman.hurtServer`).
self.settle_hurt_teleports()?;
laps.lap("creature_outbox");
Ok(())
}
@@ -11,6 +11,50 @@ use horizon_world::{
};
impl Game<'_> {
/// `Enderman.hurtServer` teleports inside the hurt (`teleport()`,
/// `repeatedlyTryToTeleport()`). `Mob::damage` has no world, so it leaves
/// the tries in `counter2`; a hurt the game layer deals runs them here,
/// right after it, not on the enderman's next AI step.
pub(super) fn settle_hurt_teleports(&mut self) -> Result<()> {
let pending: Vec<u32> = self
.mobs
.iter()
.filter(|m| m.species() == Species::Enderman && m.extra.counter2 > 0)
.map(|m| m.id)
.collect();
for id in pending {
// What `LivingEntity.hurtServer` raised comes first, where the
// enderman was hurt and from its random's next draws: the hurt
// sound, `InfestedMobEffect.onMobHurt`'s silverfish.
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
continue;
};
let hurt = std::mem::take(&mut self.mobs[i].outbox);
if !hurt.is_empty() {
self.handle_mob_events(hurt)?;
}
// The world is read while the enderman moves: take it out, then
// put it back where it was (the mob order stays).
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
continue;
};
let mut mob = self.mobs.swap_remove(i);
let mut events = Vec::new();
{
let world = self.mob_world_base(&[]);
horizon_client::mobs::enderman::run_pending_teleports(
&mut mob,
&world,
&mut events,
);
}
self.mobs.push(mob);
let last = self.mobs.len() - 1;
self.mobs.swap(i, last);
self.handle_mob_events(events)?;
}
Ok(())
}
/// `EndermanLeaveBlockGoal.tick`: normalize, check, place, then clear.
pub(super) fn enderman_place_block(
&mut self,
@@ -0,0 +1,164 @@
//! `Enderman.hurtServer` teleports inside the hurt the game layer deals.
use super::{
Audio, Game, Options, Progress, Settings, explosion::BlockInteraction, load_resources,
};
use horizon_client::{
chunk::Chunk,
mobs::{Mob, MobEvent},
};
use horizon_core::{
geometry::Vec3,
position::ChunkPos,
random::{LegacyRandom, RandomSource},
};
use std::sync::Arc;
/// A game over 5x5 empty chunks around (0, 0) with a stone floor at y = 3:
/// every enderman teleport from (8.5, 4, 8.5) lands on it or fails.
fn floored_game(root: &std::path::Path) -> Game<'static> {
let settings = Settings::default();
let loaded = load_resources(
root.to_path_buf(),
None,
root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.to_path_buf();
options.execution_role = super::execution_role::ExecutionRole::IntegratedServer;
let resources = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(root),
None,
)
.unwrap();
for cx in -2..=2 {
for cz in -2..=2 {
game.world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(cx, cz))
.unwrap();
}
}
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
for x in -32..48 {
for z in -32..48 {
game.world.set(x, 3, z, stone).unwrap();
}
}
game
}
fn enderman(game: &Game, id: u32, at: Vec3, seed: i64) -> Mob {
let kind = game
.res
.mob_types()
.get("minecraft:enderman")
.unwrap()
.clone();
let mut mob = Mob::new(id, kind, at, 0.0);
mob.random = LegacyRandom::new(seed);
mob.persistent = true;
mob
}
/// `ServerExplosion.hurtEntities` -> `Enderman.hurtServer`: an enderman the
/// blast hurts has teleported (or drawn not to) when the explosion returns.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_explosion_teleports_endermen_before_it_returns() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let mut game = floored_game(&root);
let center = Vec3::new(8.5, 4.0, 8.5);
for k in 0..12 {
let angle = f64::from(k) * std::f64::consts::TAU / 12.0;
let at = Vec3::new(
center.x + 2.5 * angle.cos(),
center.y,
center.z + 2.5 * angle.sin(),
);
let mob = enderman(&game, 500 + k as u32, at, 900 + i64::from(k));
game.mobs.push(mob);
}
let before: Vec<Vec3> = game.mobs.iter().map(|m| m.position).collect();
game.server_explode(
[center.x, center.y, center.z],
4.0,
BlockInteraction::Keep,
false,
)
.unwrap();
let pending: Vec<u32> = game
.mobs
.iter()
.filter(|m| m.extra.counter2 > 0)
.map(|m| m.id)
.collect();
assert!(
pending.is_empty(),
"teleports left for the AI step: {pending:?}"
);
let moved = game
.mobs
.iter()
.zip(&before)
.filter(|(m, b)| m.position.distance(**b) > 3.0)
.count();
assert!(moved > 0, "no enderman teleported");
}
/// `LivingEntity.hurtServer` runs `InfestedMobEffect.onMobHurt` before
/// `Enderman.hurtServer` teleports: the silverfish come out where the enderman
/// was hurt, from its random's next draws.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_infested_endermans_silverfish_come_out_where_it_was_hurt() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let mut game = floored_game(&root);
let at = Vec3::new(8.5, 4.0, 8.5);
// A seed whose first draw passes the effect's 1-in-10 chance (`nextFloat() <= 0.1`).
let seed = (0..)
.find(|&s| LegacyRandom::new(s).next_float() <= 0.1)
.unwrap();
let mut mob = enderman(&game, 600, at, seed);
mob.add_effect("minecraft:infested", 200, 0);
// What `Mob::damage` leaves for a projectile's hit: the effect's spawn and
// `repeatedlyTryToTeleport`.
mob.outbox.push(MobEvent::Infested { mob: 600 });
mob.extra.counter2 = 64;
game.mobs.push(mob);
game.settle_hurt_teleports().unwrap();
let ender = game.mobs.iter().find(|m| m.id == 600).unwrap();
assert!(
ender.position.distance(at) > 3.0,
"the enderman did not teleport"
);
let silverfish: Vec<Vec3> = game
.mobs
.iter()
.filter(|m| m.kind.id == "minecraft:silverfish")
.map(|m| m.position)
.collect();
assert!(!silverfish.is_empty(), "no silverfish");
for p in silverfish {
assert!(p.distance(at) < 3.0, "silverfish at {p:?}, hurt at {at:?}");
}
}
@@ -672,6 +672,11 @@ impl Game<'_> {
// its base is 0 for every mob type, so iron golems and
// ravagers are pushed.
hurt_mob(m, hurtable, cause, (amount, source));
// Enderman.hurtServer teleports inside the hurt, before
// the push (the mob order stays as it was).
if let Err(e) = self.settle_hurt_teleports() {
log::warn!("Horizon: explosion: enderman teleport: {e}");
}
}
// `ItemEntity.hurtServer`: the damage comes off `Health`
// (5 to start) in f32 and the item is destroyed at zero, so
@@ -27,6 +27,10 @@ impl Game<'_> {
impact.amount,
mob_kind,
);
// Enderman.hurtServer: a falling block is no living attacker.
if let Err(e) = self.settle_hurt_teleports() {
log::warn!("Horizon: falling block: enderman teleport: {e}");
}
let kind = if stalactite {
"minecraft:falling_stalactite"
} else if anvil {
+27
View File
@@ -932,6 +932,32 @@ def item_command_damage():
return ops + setup('/kill @e[tag=item_hurt_actor]', '/scoreboard objectives remove item_hurt', '/tick unfreeze')
def enderman_command_damage():
"""DamageCommand -> Enderman.hurtServer: with no living entity behind the damage,
random.nextInt(10) != 0 teleports the enderman then and there (teleport()), also when
the hurt cooldown refuses the hit. The world is frozen, so no AI step does it later. The
enderman floats (NoGravity) 32 blocks over a stone floor, so its teleport lands on it."""
ops = setup('/forceload add 128 128 223 223', '/fill 144 199 144 208 199 208 minecraft:stone')
# Every chunk a target can land in holds its entities (else @e no longer sees it there).
ops += [{'op': 'settle', 'required_chunks': [[x, z] for x in range(9, 14) for z in range(9, 14)]}]
ops += setup('/tick freeze')
ender = '@e[tag=hurt_tp,limit=1]'
ops += setup('/summon enderman 176.5 232 176.5 {Tags:["hurt_tp"],NoGravity:1b,PersistenceRequired:1b}')
ops += [cmd(f'/execute if entity {ender}', expect_result=1)]
# Nine draws in ten teleport. Hit again only while it is still where it was (a second
# teleport from the floor could leave it): four leave it in place one time in 10^4.
origin = '/execute positioned 176.5 232 176.5 as @e[tag=hurt_tp,distance=..0.5] run '
ops += [cmd(f'/damage {ender} 1')]
ops += setup(*[origin + 'damage @s 1'] * 3)
ops += [cmd(f'/execute if entity {ender}', expect_result=1),
cmd('/execute if entity @e[tag=hurt_tp,x=144,y=200,z=144,dx=64,dy=0,dz=64]', expect_result=1),
cmd('/execute positioned 176.5 232 176.5 unless entity @e[tag=hurt_tp,distance=..0.5]',
expect_result=1)]
ops += setup('/kill @e[tag=hurt_tp]', '/fill 144 199 144 208 199 208 minecraft:air',
'/forceload remove 128 128 223 223', '/tick unfreeze')
return ops
def snbt_trailing_commas():
"""SnbtGrammar: a compound, a list and a typed array may end with a comma; an empty one
may not hold only a comma, and two commas in a row are an error."""
@@ -5990,6 +6016,7 @@ def groups():
('mannequin pose anchors', mannequin_pose_anchors()),
('item command damage', item_command_damage()),
('dragon command damage', dragon_command_damage()),
('enderman command damage', enderman_command_damage()),
('SNBT trailing commas', snbt_trailing_commas()),
('execute', execute()),
('projectile origin', projectile_origin()),