Credit the mob that swung for a guest's effect and sting hits

Only AttackPlayer passed the recorded id on, so a cave spider poisoning a
guest could name another mob at the same X and Z: the search by position
ignores height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rdbtCVSandClaude Opus 5.5 committed 2026-11-04 16:03:39 +00:00
1 parent b82a70f352
commit 3ab80dc9d0
4 files changed
+94 -152

No files matched your search

+79
View File
@@ -807,3 +807,82 @@ mod stuck_tests {
assert_eq!((s.arrows, s.stingers), (0, 0));
}
}
#[cfg(test)]
mod guest_hit_tests {
use super::*;
use crate::game::{Audio, Options, PlayerMotion, Progress, Settings, load_resources};
use horizon_client::mobs::{Mob, MobEvent};
use horizon_core::geometry::Vec3;
/// A bystander stands exactly under the mob that bites, listed first: the
/// search by position ignores height and would have named the bystander.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_effect_hit_on_a_guest_credits_the_mob_that_swung() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
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.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 100.0, 0.5)).unwrap());
for (id, kind, y) in [
(4, "minecraft:zombie", 90.0),
(5, "minecraft:cave_spider", 100.0),
] {
let kind = game.res.mob_types().get(kind).cloned().unwrap();
game.simulation
.mobs
.push(Mob::new(id, kind, Vec3::new(1.5, y, 0.5), 0.0));
}
let from = [1.5, 100.0, 0.5];
let hits = [
MobEvent::AttackPlayerWithEffect {
amount: 2.0,
from,
effect: "minecraft:poison",
ticks: 140,
amplifier: 0,
},
MobEvent::StingPlayer {
amount: 2.0,
from,
poison_ticks: 200,
},
];
for hit in hits {
game.combat.pending.clear();
game.guest_mob_event(5, hit);
let noted: Vec<u32> = game
.combat
.pending
.iter()
.map(|(_, (id, _), _)| *id)
.collect();
assert_eq!(noted, [5]);
assert_eq!(game.combat.melee_mob, None, "the hit used it up");
}
}
}
+15 -7
View File
@@ -134,17 +134,25 @@ impl Game<'_> {
return;
};
for (conn, mob, e) in events {
self.guest_hit_by(&mut host, conn, |g| {
// The mob swung: no guessing which one from where it stood.
if matches!(e, MobEvent::AttackPlayer(..)) {
g.combat.melee_mob = Some(mob);
}
let _ = g.handle_mob_events(vec![e]);
});
self.guest_hit_by(&mut host, conn, |g| g.guest_mob_event(mob, e));
}
self.lan = Some(host);
}
/// One mob event, with the guest swapped in. A hit knows the mob that swung,
/// so nothing guesses it from where the hit came from.
pub(super) fn guest_mob_event(&mut self, mob: u32, e: MobEvent) {
if matches!(
e,
MobEvent::AttackPlayer(..)
| MobEvent::AttackPlayerWithEffect { .. }
| MobEvent::StingPlayer { .. }
) {
self.combat.melee_mob = Some(mob);
}
let _ = self.handle_mob_events(vec![e]);
}
/// The guests' boxes for the blocks entities trigger (pressure plates,
/// tripwires, detector rails): `Entity.checkInsideBlocks` and
/// `Level.getEntities` see every player in the level, but not spectators
-90
View File
@@ -1,90 +0,0 @@
//! Benchmark actual Mob::seen projection; this does not measure a mob/server tick.
//! Build after horizon-client-lib (same features/profile), for example:
//! rustc --edition=2024 -O tools/bench_mob_snapshot.rs -L dependency=.local/parity-target/fast/deps \
//! --extern horizon_client_lib=<path-to-libhorizon_client_lib.rlib> -o /tmp/mob-snapshot-bench
//! Run: /tmp/mob-snapshot-bench baseline|serial|parallel <prepared-resource-root>
use horizon_client_lib::{
mobs::{Mob, MobTypes, Seen},
server_regions::{Regions, Workers},
};
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
fn main() {
let mode = std::env::args()
.nth(1)
.expect("baseline, serial, or parallel");
let workers = match mode.as_str() {
"baseline" | "serial" => 1,
"parallel" => 8,
_ => panic!("baseline, serial, or parallel"),
};
let root = std::env::args().nth(2).expect("prepared resource root");
let types = MobTypes::load_for_server(Path::new(&root)).expect("mob types");
let kinds: Vec<_> = ["minecraft:zombie", "minecraft:cow", "minecraft:villager"]
.map(|name| Arc::new(types.types.get(name).expect(name).clone()))
.into_iter()
.collect();
let mobs: Vec<_> = (0..7000)
.map(|i| {
let mut mob = Mob::new(
i + 1,
kinds[i as usize % kinds.len()].clone(),
Default::default(),
0.0,
);
mob.position.x = f64::from(i / 70) * 1600.0 + 0.5;
mob.position.y = 64.0;
mob.position.z = f64::from(i % 70) + 0.5;
mob.previous = mob.position;
mob
})
.collect();
let chunks =
(0..100).flat_map(|p| (-8..=8).flat_map(move |x| (-8..=8).map(move |z| (p * 100 + x, z))));
let mut regions = Regions::default();
regions.rebuild(1, chunks);
let players: Vec<_> = (0..100).map(|p| (p * 100, 0)).collect();
let expected: Vec<_> = mobs.iter().map(|m| m.seen(None)).collect();
let mut times = Vec::new();
let mut last = Vec::new();
for iteration in 0..120 {
let start = Instant::now();
let out: Vec<Seen> = if mode == "baseline" {
mobs.iter().map(|m| black_box(m).seen(None)).collect()
} else {
regions.index(
mobs.iter().map(|m| {
(
m.position.x.floor() as i32 >> 4,
m.position.z.floor() as i32 >> 4,
)
}),
players.iter().copied(),
);
regions.project(&mobs, Workers::new(workers), |m| black_box(m).seen(None))
};
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
assert_eq!(out.len(), mobs.len());
black_box(&out);
if iteration >= 20 {
times.push(elapsed);
}
last = out;
}
// Compare every observable snapshot field with the original serial method,
// outside the timing. Seen deliberately has no implementation-pinning Eq.
for (serial, projected) in expected.iter().zip(&last) {
assert_eq!(format!("{serial:?}"), format!("{projected:?}"));
}
times.sort_by(f64::total_cmp);
let percentile = |p: f64| times[((times.len() - 1) as f64 * p).ceil() as usize];
println!(
"mode={mode} samples={} mobs={} regions={} median_ms={:.3} p95_ms={:.3} p99_ms={:.3}",
times.len(),
mobs.len(),
regions.regions().len(),
percentile(0.5),
percentile(0.95),
percentile(0.99)
);
}
-55
View File
@@ -1,55 +0,0 @@
//! Standalone ownership/projection microbenchmark (not AI or a server tick).
//! Build: rustc --edition=2024 -O tools/bench_server_regions.rs -o /tmp/region-bench
//! Run each mode under /usr/bin/time -v for CPU and maximum resident memory.
#[path = "../crates/horizon-client/src/server_regions.rs"]
#[allow(dead_code)]
mod server_regions;
use server_regions::{Regions, Workers};
use std::{hint::black_box, time::Instant};
fn main() {
let mode = std::env::args().nth(1).unwrap_or_else(|| "baseline".into());
let workers = match mode.as_str() {
"baseline" | "serial" => 1,
"parallel" => 8,
_ => panic!("mode must be baseline, serial, or parallel"),
};
// 100 disconnected 17x17 loaded areas, 70 entities at each player.
// The projection copies 384 bytes/entity, approximating snapshot traffic;
// this deliberately does not pretend to model AI, navigation or combat.
let entities: Vec<[u64; 48]> = (0..7000).map(|i| [i; 48]).collect();
let chunks: Vec<_> = (0..100)
.flat_map(|p| (-8..=8).flat_map(move |x| (-8..=8).map(move |z| (p * 100 + x, z))))
.collect();
let positions: Vec<_> = (0..7000).map(|i| ((i / 70) * 100, 0)).collect();
let players: Vec<_> = (0..100).map(|p| (p * 100, 0)).collect();
let mut regions = Regions::default();
let started = Instant::now();
regions.rebuild(1, chunks.into_iter());
let topology_ms = started.elapsed().as_secs_f64() * 1000.0;
let mut times = Vec::new();
for tick in 0..350 {
let started = Instant::now();
let out: Vec<[u64; 48]> = if mode == "baseline" {
entities.iter().map(|e| *black_box(e)).collect()
} else {
regions.index(positions.iter().copied(), players.iter().copied());
regions.project(&entities, Workers::new(workers), |e| *black_box(e))
};
assert_eq!(black_box(&out), &entities);
if tick >= 50 {
times.push(started.elapsed().as_secs_f64() * 1000.0);
}
}
times.sort_by(f64::total_cmp);
let percentile = |p: f64| times[((times.len() - 1) as f64 * p).ceil() as usize];
println!(
"mode={mode} samples={} entities={} regions={} topology_ms={topology_ms:.3} median_ms={:.3} p95_ms={:.3} p99_ms={:.3}",
times.len(),
entities.len(),
regions.regions().len(),
percentile(0.5),
percentile(0.95),
percentile(0.99)
);
}