Merge pull request #466 from ludovic111/fix/lan-mobs-target-guests

LAN: mobs target and attack guests
This commit is contained in:
Ludovic Marie
2026-10-16 13:02:41 +02:00
committed by GitHub
8 changed files with 391 additions and 11 deletions
+1
View File
@@ -62,6 +62,7 @@ mod jukebox;
mod lan;
mod lan_actions;
mod lan_crafting;
mod lan_mobs;
mod lan_screen;
mod lan_trading;
mod leads;
+66 -6
View File
@@ -514,6 +514,13 @@ impl Game<'_> {
let mut dead = Vec::new();
let mut died = std::mem::take(&mut self.died);
let ticking = self.entity_ticking_area();
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
let guests = self.lan_guest_views();
let mut bindings = match self.lan.as_mut() {
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
_ => Default::default(),
};
let mut guest_events = Vec::new();
let level_random;
{
// The level random, lent to the mobs (brain behaviours draw from
@@ -522,8 +529,24 @@ impl Game<'_> {
let mut pushes = Vec::new();
let mut keep = Vec::with_capacity(mobs.len());
for i in 0..mobs.len() {
let w = self.mob_world_base(&snapshot);
let mut w = self.mob_world_base(&snapshot);
let m = &mut mobs[i];
let seen_guest = if guests.is_empty() {
None
} else {
let bound = bindings.get(&m.id).copied();
let conn = super::lan_mobs::player_for(m, w.player.as_ref(), &guests, bound);
match conn {
Some(conn) => {
bindings.insert(m.id, conn);
w.player = guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone());
}
None => {
bindings.remove(&m.id);
}
}
conn
};
if !w
.world
.loaded(m.position.x.floor() as i32, m.position.z.floor() as i32)
@@ -547,6 +570,16 @@ impl Game<'_> {
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
if let Some(conn) = seen_guest {
let mut k = from;
while k < events.len() {
if super::lan_mobs::acts_on_player(&events[k]) {
guest_events.push((conn, events.remove(k)));
} else {
k += 1;
}
}
}
// Killed during its own tick (drowning, burning, falling...).
if !m.alive() && !m.removed && died.insert(m.id) {
dead.push(m.clone());
@@ -569,6 +602,12 @@ impl Game<'_> {
level_random = scratch;
}
self.world.random = level_random;
if let Some(host) = self.lan.as_mut()
&& !guests.is_empty()
{
bindings.retain(|id, _| mobs.iter().any(|m| m.id == *id));
host.mob_players = bindings;
}
self.mobs.append(&mut mobs);
self.died = died;
self.sync_riders();
@@ -581,6 +620,7 @@ impl Game<'_> {
self.mob_died(&m);
}
self.handle_mob_events(events)?;
self.lan_guest_mob_events(guest_events);
self.tick_projectiles()?;
self.tick_fangs();
self.tick_allays();
@@ -1536,12 +1576,24 @@ impl Game<'_> {
// Arrows also hit minecarts (VehicleEntity is pickable).
let mut arrow_targets = targets.clone();
arrow_targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
// Mobs' projectiles also hit the guests (game/lan_mobs.rs).
let guests = self.lan_projectile_targets();
let mut arrow_targets_guests = arrow_targets.clone();
arrow_targets_guests.extend(guests.iter().copied());
let mut targets_guests = targets.clone();
targets_guests.extend(guests);
let pw = ProjWorld { w: &w, targets };
let pw_guests = ProjWorld {
w: &w,
targets: targets_guests,
};
projectiles.retain_mut(|p| {
let (keep, hit) = if p.arrow.is_some() {
horizon_client::arrows::tick(p, &scene, &arrow_targets)
} else {
p.tick(&pw)
let mob_shot = !matches!(p.owner, Some(Who::Player));
let (keep, hit) = match (p.arrow.is_some(), mob_shot) {
(true, false) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
(true, true) => horizon_client::arrows::tick(p, &scene, &arrow_targets_guests),
(false, false) => p.tick(&pw),
(false, true) => p.tick(&pw_guests),
};
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
@@ -1722,7 +1774,7 @@ impl Game<'_> {
self.creatures.projectiles.push(arrow);
}
}
fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
self.combat.projectile_owner = match p.owner {
Some(Who::Mob(id)) => Some(id),
_ => None,
@@ -1731,6 +1783,14 @@ impl Game<'_> {
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
Hit::None => return Ok(()),
};
if let Hit::Entity {
who: Who::Mob(id),
at,
} = hit
&& self.lan_projectile_hit(p, id, at)
{
return Ok(());
}
if matches!(
p.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
+3
View File
@@ -53,6 +53,8 @@ pub(super) struct LanHost {
pub broken: Vec<[f64; 3]>,
/// Hurt and death state of the mobs guests see.
mob_state: HashMap<u32, (i32, bool)>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
pub mob_players: HashMap<u32, u32>,
}
/// A stable UUID for an entity that has none.
@@ -454,6 +456,7 @@ impl Game<'_> {
play: HashMap::new(),
broken: Vec::new(),
mob_state: HashMap::new(),
mob_players: HashMap::new(),
});
self.audio.capture = Some(Vec::new());
Ok(())
@@ -589,6 +589,10 @@ impl Game<'_> {
self.as_guest(host, conn, |g| {
g.attack();
});
// The mob now answers to this guest (game/lan_mobs.rs).
if let Ok(id) = u32::try_from(id) {
host.mob_players.insert(id, conn);
}
host.hub.guests[gi].yaw = old_yaw;
host.hub.guests[gi].pitch = old_pitch;
}
+263
View File
@@ -0,0 +1,263 @@
//! Mobs and the guests of an open world. The mob code sees one player
//! (`MobWorld.player`, `Who::Player`); with guests on, each mob sees the
//! player vanilla's `Level.getNearestPlayer` would give it, and keeps the
//! guest it targets until it lets go. What it then does to `Who::Player`
//! (hits, pushes, fire, its projectiles) runs with that guest swapped in.
use super::{Game, lan::LanHost};
use horizon_client::{
mobs::{Mob, MobEvent, PlayerView, Who},
projectiles::{Hit, Projectile},
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// The guests mobs can see this tick, by connection.
pub(super) type GuestViews = Vec<(u32, PlayerView)>;
/// The player a mob sees this tick: `None` for the host, else a guest.
/// A mob that targets a guest, or that a guest hurt in the last 100 ticks
/// (`LivingEntity.getLastHurtByMob`), keeps them while they are still a
/// valid target; otherwise it sees the nearest player
/// (`TargetingConditions`: alive, neither creative nor spectator), or the
/// nearest one that is not a spectator if none qualifies.
pub(super) fn player_for(
m: &Mob,
host: Option<&PlayerView>,
guests: &GuestViews,
bound: Option<u32>,
) -> Option<u32> {
let targeting = m.ai.target == Some(Who::Player)
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
choose(m.position, targeting, host, guests, bound)
}
fn choose(
at: Vec3,
targeting: bool,
host: Option<&PlayerView>,
guests: &GuestViews,
bound: Option<u32>,
) -> Option<u32> {
let targetable = |v: &PlayerView| v.alive && !v.creative && !v.spectator;
if targeting
&& let Some(conn) = bound
&& guests.iter().any(|(c, v)| *c == conn && targetable(v))
{
return Some(conn);
}
let d = |v: &PlayerView| at.distance_squared(v.position);
let nearest = |ok: &dyn Fn(&PlayerView) -> bool| {
let mut best = host.filter(|v| ok(v)).map(|v| (None, d(v)));
for (conn, v) in guests.iter().filter(|(_, v)| ok(v)) {
if best.is_none_or(|(_, b)| d(v) < b) {
best = Some((Some(*conn), d(v)));
}
}
best
};
nearest(&targetable)
.or_else(|| nearest(&|v: &PlayerView| v.alive && !v.spectator))
.and_then(|(conn, _)| conn)
}
/// The events of a mob's tick that act on the player it sees.
pub(super) fn acts_on_player(e: &MobEvent) -> bool {
matches!(
e,
MobEvent::AttackPlayer(..)
| MobEvent::DamagePlayer { .. }
| MobEvent::Push {
target: Who::Player,
..
}
| MobEvent::Ignite {
target: Who::Player,
..
}
| MobEvent::Launch {
target: Who::Player,
..
}
)
}
impl Game<'_> {
/// How mobs see each guest in play (`player_view` with the guest swapped
/// in; what the host alone tracks, such as its last attacker, stays out).
pub(super) fn lan_guest_views(&mut self) -> GuestViews {
let Some(mut host) = self.lan.take() else {
return Vec::new();
};
let conns: Vec<(u32, u128, [f64; 3])> = host
.hub
.guests
.iter()
.filter(|g| g.placed && g.loaded && !g.dead)
.map(|g| {
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
(g.conn, g.uuid, moved)
})
.collect();
let mut views = Vec::new();
for (conn, uuid, moved) in conns {
if let Some(Some(mut v)) = self.as_guest(&mut host, conn, |g| g.player_view()) {
v.hurt_by = None;
v.last_hurt = None;
v.hero = false;
v.velocity = Vec3::new(moved[0], moved[1], moved[2]);
v.uuid = horizon_client::lan::playerdata::uuid_ints(uuid);
v.allies = Default::default();
views.push((conn, v));
}
}
self.lan = Some(host);
views
}
/// Mob events aimed at a guest, run as that guest. Knockback reaches
/// their client (`hurtMarked`: ClientboundSetEntityMotionPacket).
pub(super) fn lan_guest_mob_events(&mut self, events: Vec<(u32, MobEvent)>) {
if events.is_empty() {
return;
}
let Some(mut host) = self.lan.take() else {
return;
};
for (conn, e) in events {
let pushed = self.as_guest(&mut host, conn, |g| {
let before = g.player.as_ref().map(|p| p.velocity);
let _ = g.handle_mob_events(vec![e]);
g.player
.as_ref()
.map(|p| p.velocity)
.filter(|v| Some(*v) != before)
});
if let Some(Some(v)) = pushed {
send_motion(&host, conn, v);
}
}
self.lan = Some(host);
}
/// The guests a projectile can hit, under their entity ids.
pub(super) fn lan_projectile_targets(&self) -> Vec<(Who, Aabb)> {
let Some(host) = self.lan.as_ref() else {
return Vec::new();
};
host.hub
.guests
.iter()
.filter(|g| g.placed && g.loaded && !g.dead && !g.spectator())
.map(|g| {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
let b = Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
);
(Who::Mob(g.entity_id as u32), b)
})
.collect()
}
/// A projectile that hit a guest: its hit runs as theirs. False when
/// `id` is not a guest.
pub(super) fn lan_projectile_hit(&mut self, p: &Projectile, id: u32, at: Vec3) -> bool {
let Some(mut host) = self.lan.take() else {
return false;
};
let conn = host
.hub
.guests
.iter()
.find(|g| g.entity_id as u32 == id)
.map(|g| g.conn);
if let Some(conn) = conn {
let pushed = self.as_guest(&mut host, conn, |g| {
let before = g.player.as_ref().map(|p| p.velocity);
let hit = Hit::Entity {
who: Who::Player,
at,
};
let _ = g.projectile_hit(p, hit);
g.player
.as_ref()
.map(|p| p.velocity)
.filter(|v| Some(*v) != before)
});
if let Some(Some(v)) = pushed {
send_motion(&host, conn, v);
}
}
self.lan = Some(host);
conn.is_some()
}
}
fn send_motion(host: &LanHost, conn: u32, v: Vec3) {
if let Some(g) = host.hub.guest(conn) {
g.send(&C::SetEntityMotion(play::SetEntityMotion {
id: g.entity_id,
velocity: [v.x, v.y, v.z],
}));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn view(x: f64, creative: bool) -> PlayerView {
PlayerView {
position: Vec3::new(x, 64.0, 0.0),
bounds: Aabb::new(Vec3::new(x, 64.0, 0.0), Vec3::new(x, 65.8, 0.0)),
eye: [x, 65.62, 0.0],
alive: true,
creative,
sneaking: false,
look: [0.0; 3],
main_hand: None,
off_hand: None,
head: None,
armor_cover: 0.0,
spectator: false,
gold_armor: false,
hurt_by: None,
last_hurt: None,
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
uuid: [0; 4],
allies: Default::default(),
}
}
const MOB: Vec3 = Vec3::new(0.0, 64.0, 0.0);
#[test]
fn the_nearest_survival_player_is_seen() {
let host = view(10.0, false);
let guests = vec![(7, view(3.0, false)), (8, view(-5.0, false))];
assert_eq!(choose(MOB, false, Some(&host), &guests, None), Some(7));
// A creative guest is passed over for the host further away.
let guests = vec![(7, view(3.0, true))];
assert_eq!(choose(MOB, false, Some(&host), &guests, None), None);
// Nobody targetable: the nearest one is still seen.
let host = view(10.0, true);
assert_eq!(choose(MOB, false, Some(&host), &guests, None), Some(7));
assert_eq!(choose(MOB, false, None, &Vec::new(), None), None);
}
#[test]
fn a_targeted_guest_stays_the_target() {
let host = view(1.0, false);
let guests = vec![(7, view(12.0, false))];
assert_eq!(choose(MOB, true, Some(&host), &guests, Some(7)), Some(7));
// Gone creative: the mob looks again.
let guests = vec![(7, view(12.0, true))];
assert_eq!(choose(MOB, true, Some(&host), &guests, Some(7)), None);
// Without a target the nearest player wins.
let guests = vec![(7, view(12.0, false))];
assert_eq!(choose(MOB, false, Some(&host), &guests, Some(7)), None);
}
}
+6
View File
@@ -1046,6 +1046,12 @@ impl Game<'_> {
}
self.remove_entity(p.item);
}
// ClientPacketListener.handleSetEntityMotion: knockback on this player.
C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
if let Some(pl) = self.player.as_mut() {
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
}
}
C::HurtAnimation(p) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
+2 -1
View File
@@ -18,7 +18,8 @@ pub fn path(world: &Path, uuid: u128) -> PathBuf {
.join(format!("{}.dat", uuid_string(uuid)))
}
fn uuid_ints(uuid: u128) -> [i32; 4] {
/// `UUIDUtil.uuidToIntArray`.
pub fn uuid_ints(uuid: u128) -> [i32; 4] {
[
(uuid >> 96) as i32,
(uuid >> 64) as i32,
+46 -4
View File
@@ -12,7 +12,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
- chest: a chest the guest opens opens on the guest's screen, its lid opens on both sides and
closes on both when the guest closes it;
- crafting: the guest crafts planks in the inventory's 2x2 grid and opens a crafting table;
- damage: a guest in survival takes fall damage.
- damage: a guest in survival takes fall damage;
- mobs: with the host in creative, a zombie and a skeleton go for the guest and hurt them.
Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of
both sides go to --out (host-*.png, guest-*.png).
@@ -39,7 +40,7 @@ from agent_control_e2e import Cli, Failed, game_env # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
CHECKS = ['tab', 'self', 'armour', 'nametags', 'chest', 'crafting', 'damage']
CHECKS = ['tab', 'self', 'armour', 'nametags', 'chest', 'crafting', 'damage', 'mobs']
class Game:
@@ -263,8 +264,45 @@ def check_damage(host, guest, out):
return {'before': before, 'after': after, 'water': water}
def first_hurt(guest, before, ticks):
"""The guest's health once it drops below `before`, waiting at most `ticks`."""
health = remote(guest)['health']
for _ in range(ticks // 10):
if health < before:
break
guest.wait(10)
health = remote(guest)['health']
return health
def check_mobs(host, guest, out):
host('command', command='gamemode creative')
host('command', command='difficulty normal')
host('command', command='time set midnight')
guest('command', command='gamemode survival')
guest.wait(10)
hurt = {}
try:
for mob, distance in (('zombie', 3), ('skeleton', 7)):
x, y, z = pos_of(guest)
before = remote(guest)['health']
host('command', command=f'summon minecraft:{mob} {x:.2f} {y:.2f} {z + distance:.2f}')
low = first_hurt(guest, before, 300)
guest.shot(out, f'mobs-{mob}')
host.shot(out, f'mobs-{mob}')
host('command', command=f'kill @e[type=minecraft:{mob}]')
hurt[mob] = (before, low)
finally:
host('command', command='gamemode survival')
host('command', command='time set day')
for mob, (before, low) in hurt.items():
assert low < before, f'the {mob} never hurt the guest: health {before} -> {low} (all: {hurt})'
return hurt
CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags,
'chest': check_chest, 'crafting': check_crafting, 'damage': check_damage}
'chest': check_chest, 'crafting': check_crafting, 'damage': check_damage,
'mobs': check_mobs}
def run(ctl, out, resources, checks):
@@ -272,8 +310,12 @@ def run(ctl, out, resources, checks):
results = {}
try:
host('launch', wait=False, offscreen=sys.platform == 'darwin')
host.raw('open', 'Lan', 5)
# Guests' commands need the world's Allow Commands as well as the LAN's (#372): a creative
# world has them; everyone plays in survival.
host.raw('open', 'Lan', 5, 'creative')
host('wait_ready', seconds=400)
host('command', command='defaultgamemode survival')
host('command', command='gamemode survival')
host('command', command='time set day')
# An open stone floor around the host, so the players see each other and nothing is in the way.
host('command', command='fill ~-8 ~-1 ~-8 ~8 ~-1 ~8 minecraft:stone')