mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Server: vanilla's reach on attacks and entity interactions, and blocks by attribute
A client could attack any player or mob from any distance: the server path (LAN host, the integrated server thread, dedicated servers) never checked ServerboundAttackPacket's range, so a hacked client hit people 50 blocks away. Now, as ServerGamePacketListenerImpl does: - handleAttack: Player.isWithinAttackRange(mainHandItem, targetBox, 3.0), the held item's attack_range or AttackRange.defaultFor (0 to the entity interaction range), for players, mobs, vehicles, dragon parts and projectiles alike; out of range the attack is dropped silently; - handleInteract: Player.isWithinEntityInteractionRange(targetBox, 3.0); - digging and handleUseItemOn read the guest's block_interaction_range attribute instead of a fixed 4.5 (5 in creative), so /attribute and items count, and handleUseItemOn logs vanilla's "Rejecting UseItemOnPacket ... too far away from hit block" line. The ranges are the player's attributes: creative's modifiers (+2, +0.5) are on them every tick. horizon-bots gets a stand behaviour, --attack NAME and the victim's health in its report; tools/server_reach_e2e.py proves it on a real dedicated server. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8f376a4712
commit
d39eb5d638
5 files changed
+355
-9
No files matched your search
@@ -46,6 +46,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
stand: stays on the ground where the server puts it (/tp), for a survival test.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
|
||||
@@ -59,6 +60,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
@@ -73,6 +77,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
Idle,
|
||||
Stand,
|
||||
Walk,
|
||||
Explore,
|
||||
FloodMove,
|
||||
@@ -91,6 +96,7 @@ impl Behaviour {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Idle => "idle",
|
||||
Self::Stand => "stand",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::FloodMove => "flood-move",
|
||||
@@ -117,6 +123,9 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
name: String,
|
||||
@@ -134,6 +143,8 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
name: "Bot".into(),
|
||||
@@ -167,16 +178,19 @@ fn options() -> Result<Options, String> {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
const BEHAVIOURS: [&str; 14] = [
|
||||
const BEHAVIOURS: [&str; 15] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"mixed",
|
||||
"stand",
|
||||
"flood-move",
|
||||
"flood-acks",
|
||||
"flood-packets",
|
||||
@@ -219,6 +233,11 @@ struct Bot {
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<(Option<u128>, Option<i32>)>,
|
||||
attacks: AtomicU64,
|
||||
/// The last SetHealth.
|
||||
health: Mutex<Option<f32>>,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -531,6 +550,21 @@ fn read(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::ADD_ENTITY => {
|
||||
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
|
||||
let mut target = lock(&bot.target);
|
||||
if target.0 == Some(p.uuid) {
|
||||
target.1 = Some(p.id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::SET_HEALTH => {
|
||||
if let Ok(play::Clientbound::SetHealth(p)) = play::Clientbound::decode(id, &body) {
|
||||
*lock(&bot.health) = Some(p.health);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::CHUNK_BATCH_START => {
|
||||
batch.started = Instant::now();
|
||||
Ok(())
|
||||
@@ -618,6 +652,7 @@ fn run_bot(
|
||||
};
|
||||
let (reader, writer) = connection.split();
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
@@ -665,6 +700,7 @@ fn run_bot(
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
let (joined, mut tick) = (Instant::now(), 0u64);
|
||||
loop {
|
||||
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
|
||||
break;
|
||||
@@ -675,7 +711,9 @@ fn run_bot(
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
// The abusive client doesn't wait for its chunk, as a real client must.
|
||||
if behaviour == Behaviour::FloodMove {
|
||||
if behaviour == Behaviour::Stand {
|
||||
// Where the server put it, on the ground.
|
||||
} else if behaviour == Behaviour::FloodMove {
|
||||
// The last of these moves goes out below, with the tick's end.
|
||||
pos[1] = cruise;
|
||||
for _ in 1..o.flood.max(1) {
|
||||
@@ -711,7 +749,7 @@ fn run_bot(
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
match behaviour {
|
||||
Behaviour::Idle => {}
|
||||
Behaviour::Idle | Behaviour::Stand => {}
|
||||
Behaviour::Walk => {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
@@ -776,12 +814,21 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let target = lock(&bot.target).1;
|
||||
if let Some(id) = target
|
||||
&& joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
{
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
tick += 1;
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
flags: u8::from(behaviour == Behaviour::Stand),
|
||||
})
|
||||
.encode(),
|
||||
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
|
||||
@@ -805,6 +852,7 @@ fn run_bot(
|
||||
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
match mode {
|
||||
"idle" => Behaviour::Idle,
|
||||
"stand" => Behaviour::Stand,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
@@ -918,6 +966,8 @@ fn main() {
|
||||
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
|
||||
"waited_ticks": b.waited.load(Ordering::Relaxed),
|
||||
"error": *lock(&b.error),
|
||||
"attacks": b.attacks.load(Ordering::Relaxed),
|
||||
"health": *lock(&b.health),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
|
||||
@@ -93,7 +93,63 @@ pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// `AttackRange.isInRange(attacker, box, buffer)` with the reach of
|
||||
/// `attack_reach` (or `AttackRange.defaultFor`'s, from the entity interaction
|
||||
/// range): the eye's distance to the box, inclusive.
|
||||
fn in_attack_range(distance: f64, (min, max, margin): (f32, f32, f32), buffer: f64) -> bool {
|
||||
distance >= f64::from(min - margin) - buffer && distance <= f64::from(max + margin) + buffer
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `getAttributeValue` of an interaction range of the player in place (a
|
||||
/// guest swapped in on a server): creative's modifiers are on it
|
||||
/// (`update_player_attributes`), as are `/attribute`'s and the items'.
|
||||
fn interaction_range(&mut self, attribute: &str, survival: f64, creative: f64) -> f64 {
|
||||
let otherwise = if self.creative { creative } else { survival };
|
||||
self.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute(attribute).ok())
|
||||
.unwrap_or(otherwise)
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handleAttack's
|
||||
/// `Player.isWithinAttackRange(mainHandItem, box, 3.0)` for the player in
|
||||
/// place: the held item's `attack_range`, else `AttackRange.defaultFor`
|
||||
/// (0 to the entity interaction range, no margin). Out of range, vanilla
|
||||
/// drops the attack without a word.
|
||||
pub(super) fn within_attack_range(&mut self, target: &Aabb) -> bool {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return false;
|
||||
};
|
||||
let range = self.held().and_then(|stack| {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
});
|
||||
let reach = match &range {
|
||||
Some(r) => attack_reach(r, self.creative),
|
||||
None => {
|
||||
let range = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
|
||||
(0.0, range as f32, 0.0)
|
||||
}
|
||||
};
|
||||
let distance = target.distance_squared_to_point(v3(eye)).sqrt();
|
||||
in_attack_range(distance, reach, 3.0)
|
||||
}
|
||||
|
||||
/// `Player.isWithinEntityInteractionRange(box, buffer)` for the player in
|
||||
/// place (ServerGamePacketListenerImpl.handleInteract, with 3).
|
||||
pub(super) fn within_entity_interaction_range(&mut self, target: &Aabb, buffer: f64) -> bool {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return false;
|
||||
};
|
||||
let range = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0) + buffer;
|
||||
target.distance_squared_to_point(v3(eye)) < range * range
|
||||
}
|
||||
|
||||
/// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to
|
||||
/// a new stack: the hand holding it compares it as another `ItemStack`
|
||||
/// (`shows_held_stack`) until the hand shows the held stack again.
|
||||
@@ -717,6 +773,24 @@ mod tests {
|
||||
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_takes_attacks_within_vanilla_reach_only() {
|
||||
// handleAttack: AttackRange.defaultFor (0 to the entity interaction
|
||||
// range, no margin) plus 3, inclusive: 6 in survival, 8 in creative.
|
||||
let survival = (0.0, 3.0, 0.0);
|
||||
assert!(in_attack_range(6.0, survival, 3.0));
|
||||
assert!(!in_attack_range(6.01, survival, 3.0));
|
||||
assert!(!in_attack_range(50.0, survival, 3.0));
|
||||
let creative = (0.0, 5.0, 0.0);
|
||||
assert!(in_attack_range(8.0, creative, 3.0));
|
||||
assert!(!in_attack_range(8.01, creative, 3.0));
|
||||
// A spear's own range, with its margin, from the server's side too.
|
||||
let spear = serde_json::json!({"min_reach": 2.0, "max_reach": 4.5, "hitbox_margin": 0.125});
|
||||
let reach = attack_reach(&spear, false);
|
||||
assert!(in_attack_range(7.625, reach, 3.0));
|
||||
assert!(!in_attack_range(7.7, reach, 3.0));
|
||||
}
|
||||
|
||||
fn stack(item: &str, count: u32, damage: u32) -> Option<ItemStack> {
|
||||
Some(ItemStack {
|
||||
item: item.into(),
|
||||
|
||||
@@ -1255,12 +1255,29 @@ impl Game<'_> {
|
||||
}
|
||||
S::UseItemOn(p) => {
|
||||
host.hub.ack(conn, p.sequence);
|
||||
// handleUseItemOn: within reach, and the hit point within the block.
|
||||
// handleUseItemOn: within reach, then the hit point within
|
||||
// a block of the block's centre (logged when it isn't).
|
||||
if !host.hub.guests[gi].may_reach_block(p.pos) {
|
||||
return;
|
||||
}
|
||||
let inside = p
|
||||
.cursor
|
||||
.iter()
|
||||
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
|
||||
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
|
||||
if !inside {
|
||||
let d = horizon_client::java_text::double_to_string;
|
||||
let at = |k: usize| d(f64::from(p.pos[k]) + f64::from(p.cursor[k]));
|
||||
let line = format!(
|
||||
"Rejecting UseItemOnPacket from {}: Location ({}, {}, {}) too far away from hit block BlockPos{{x={}, y={}, z={}}}.",
|
||||
host.hub.guests[gi].name,
|
||||
at(0),
|
||||
at(1),
|
||||
at(2),
|
||||
p.pos[0],
|
||||
p.pos[1],
|
||||
p.pos[2]
|
||||
);
|
||||
self.lan_log_event(horizon_client::lan::hub::HostEvent::Warn(line));
|
||||
return;
|
||||
}
|
||||
let face = direction(p.face);
|
||||
@@ -1341,6 +1358,15 @@ impl Game<'_> {
|
||||
}
|
||||
S::Attack(p) => self.guest_attack(host, conn, p.id),
|
||||
S::Interact(p) => {
|
||||
// handleInteract: within the entity interaction range plus 3
|
||||
// of the target's box, or nothing happens.
|
||||
if let Some(target) = self.guest_target_box(host, gi, p.id)
|
||||
&& self.as_guest(host, conn, |g| {
|
||||
g.within_entity_interaction_range(&target, 3.0)
|
||||
}) != Some(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let before = host.hub.guests[gi].vehicle;
|
||||
if self
|
||||
.look_at_entity(host, gi, p.id, Some(p.location))
|
||||
@@ -1928,6 +1954,42 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Level.getEntityOrPart(id).getBoundingBox()` for a guest's attack or
|
||||
/// interaction: the host, another player, a mob, a vehicle or object, a
|
||||
/// dragon part or crystal, a projectile. None for an id the level doesn't
|
||||
/// have (the handlers then find nothing either).
|
||||
fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
if id == host.host_entity {
|
||||
return self.player.as_ref().map(|p| p.bounds());
|
||||
}
|
||||
if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id == id && g.conn != conn && host.hub.here(g))
|
||||
{
|
||||
return Some(g.bounds());
|
||||
}
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id as i32 == id) {
|
||||
return Some(m.bounds());
|
||||
}
|
||||
if let Some((lo, hi, _)) = self.object_target(id) {
|
||||
return Some(Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
));
|
||||
}
|
||||
if let Some(b) = self.tracked_special_box(id) {
|
||||
return Some(b);
|
||||
}
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.find(|p| p.id != 0 && p.id as i32 == id)
|
||||
.map(|p| p.bounds())
|
||||
}
|
||||
|
||||
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
@@ -1935,6 +1997,13 @@ impl Game<'_> {
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return;
|
||||
}
|
||||
// handleAttack: `isWithinAttackRange(mainHandItem, box, 3.0)` before
|
||||
// anything else, players, mobs, objects and fireballs alike.
|
||||
if let Some(target) = self.guest_target_box(host, gi, id)
|
||||
&& self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(victim) = Self::pvp_victim(host, conn, id) {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
|
||||
@@ -494,13 +494,19 @@ impl Guest {
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 1.0)`, which the server
|
||||
/// checks a block action against (ServerPlayerGameMode.handleBlockBreakAction,
|
||||
/// ServerGamePacketListenerImpl.handleUseItemOn): from the eyes of the
|
||||
/// guest's body in its pose (crouching, swimming, gliding).
|
||||
pub fn may_reach_block(&self, pos: [i32; 3]) -> bool {
|
||||
/// guest's body in its pose (crouching, swimming, gliding), with its
|
||||
/// `block_interaction_range` (4.5, 5 in creative, more with `/attribute`).
|
||||
pub fn may_reach_block(&mut self, pos: [i32; 3]) -> bool {
|
||||
let eye = self
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or_else(|| self.eye_height(), |m| f64::from(m.dimensions.eye_height));
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
let otherwise = if self.creative() { 5.0 } else { 4.5 };
|
||||
let range = self
|
||||
.motion
|
||||
.as_mut()
|
||||
.and_then(|m| m.attribute("minecraft:block_interaction_range").ok())
|
||||
.unwrap_or(otherwise);
|
||||
within_block_range(
|
||||
[self.position[0], self.position[1] + eye, self.position[2]],
|
||||
range + 1.0,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vanilla's attack reach on Horizon's dedicated server, end to end.
|
||||
|
||||
ServerGamePacketListenerImpl.handleAttack drops an attack unless `Player.isWithinAttackRange(mainHandItem,
|
||||
targetBox, 3.0)`: without an `attack_range` item, the eyes within the entity interaction range (3, 5 in
|
||||
creative) plus 3 of the target's box. A client can send any entity id from any distance; the server must
|
||||
refuse what the range doesn't allow.
|
||||
|
||||
python3 tools/server_reach_e2e.py --bin-dir target/fast
|
||||
|
||||
A survival dedicated server in a flat world; pairs of horizon-bots stand on the ground (`--behaviour stand`),
|
||||
placed by the console's /tp 200 blocks from the other pairs (side by side first, so the attacker knows its
|
||||
victim's entity id, as a cheat would). In each pair the attacker sends
|
||||
ServerboundAttackPacket at its victim twice a second (`--attack`) from:
|
||||
|
||||
- 3 blocks (survival): hits;
|
||||
- 6 blocks (survival): hits; the victim's box is 5.7 from the eyes, within vanilla's 3 + 3;
|
||||
- 7 blocks (survival): refused (6.7 from the eyes);
|
||||
- 50 blocks (survival): refused;
|
||||
- 7 blocks (creative, 5 + 3): hits;
|
||||
- 9 blocks (creative): refused.
|
||||
|
||||
A hit shows in the victim's health (SetHealth, in the bots' report). The server runs in a fresh folder with
|
||||
fresh HORIZON_HOME and XDG folders (lab.game_env), on a free port. Build first:
|
||||
cargo build --profile fast -p horizon-client --bin horizon-client
|
||||
cargo build --profile fast -p horizon-bots
|
||||
"""
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import lab # noqa: E402
|
||||
from server_bench import DONE, Server # noqa: E402
|
||||
|
||||
EXE = '.exe' if os.name == 'nt' else ''
|
||||
PROPERTIES = {
|
||||
'online-mode': 'false',
|
||||
'white-list': 'false',
|
||||
'level-type': 'minecraft\\:flat',
|
||||
'generate-structures': 'false',
|
||||
'gamemode': 'survival',
|
||||
'difficulty': 'easy',
|
||||
'pvp': 'true',
|
||||
'spawn-protection': '0',
|
||||
'view-distance': '3',
|
||||
'simulation-distance': '3',
|
||||
}
|
||||
# (distance, attacker's game mode, hits)
|
||||
CASES = [
|
||||
(3, 'survival', True),
|
||||
(6, 'survival', True),
|
||||
(7, 'survival', False),
|
||||
(50, 'survival', False),
|
||||
(7, 'creative', True),
|
||||
(9, 'creative', False),
|
||||
]
|
||||
# A flat world's grass top is at y = -61.
|
||||
GROUND = -60
|
||||
SECONDS = 16
|
||||
|
||||
|
||||
class Failed(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def bot(bins, port, out, name, *extra):
|
||||
report = out / f'{name}.json'
|
||||
subprocess.run([bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
|
||||
'--behaviour', 'stand', '--seconds', str(SECONDS), '--name', name, '--report', report, *extra],
|
||||
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=SECONDS + 180)
|
||||
return json.loads(report.read_text())['per_bot'][0]
|
||||
|
||||
|
||||
def run(bins, folder, out):
|
||||
folder.mkdir(parents=True)
|
||||
port = lab.free_port()
|
||||
props = dict(PROPERTIES, **{'server-port': str(port)})
|
||||
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
|
||||
(folder / 'eula.txt').write_text('eula=true\n')
|
||||
command = [bins / f'horizon-client{EXE}', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
|
||||
server = Server(command, folder, out / 'server.log')
|
||||
try:
|
||||
server.wait_for(DONE, 300)
|
||||
with ThreadPoolExecutor(2 * len(CASES)) as pool:
|
||||
jobs = []
|
||||
for i, _ in enumerate(CASES):
|
||||
jobs.append((pool.submit(bot, bins, port, out, f'V{i}'),
|
||||
pool.submit(bot, bins, port, out, f'A{i}', '--attack', f'V{i}0',
|
||||
'--attack-after', '6')))
|
||||
# Bots are named PREFIX0: place each pair once both are in, side by side first, so the
|
||||
# attacker sees its victim (AddEntity gives it the id a cheat would have kept), then apart.
|
||||
for i, (_, mode, _) in enumerate(CASES):
|
||||
for name in (f'V{i}0', f'A{i}0'):
|
||||
server.wait_for(rf'{name} joined the game', 60)
|
||||
z = 200 * i + 0.5
|
||||
server.console(f'gamemode survival V{i}0')
|
||||
server.console(f'gamemode {mode} A{i}0')
|
||||
server.console(f'tp V{i}0 0.5 {GROUND} {z}')
|
||||
server.console(f'tp A{i}0 1.5 {GROUND} {z}')
|
||||
time.sleep(2)
|
||||
for i, (distance, _, _) in enumerate(CASES):
|
||||
server.console(f'tp A{i}0 {0.5 + distance} {GROUND} {200 * i + 0.5}')
|
||||
results = [(v.result(), a.result()) for v, a in jobs]
|
||||
finally:
|
||||
server.stop()
|
||||
failed = []
|
||||
for (distance, mode, hits), (victim, attacker) in zip(CASES, results):
|
||||
health = victim['health']
|
||||
hit = health is not None and health < 20
|
||||
line = (f'{mode} attacker at {distance} blocks: {attacker["attacks"]} attacks, victim health {health} '
|
||||
f'({"hit" if hit else "refused"}, expected {"hit" if hits else "refused"})')
|
||||
print(line)
|
||||
if attacker['attacks'] == 0 or health is None:
|
||||
failed.append(f'{line}: the bots never fought ({attacker["error"]}, {victim["error"]})')
|
||||
elif hit != hits:
|
||||
failed.append(line)
|
||||
if failed:
|
||||
raise Failed('\n'.join(failed))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast'))
|
||||
parser.add_argument('--out', default=str(lab.LOCAL / 'server-reach-e2e'))
|
||||
args = parser.parse_args()
|
||||
bins, out = Path(args.bin_dir).resolve(), Path(args.out)
|
||||
shutil.rmtree(out, ignore_errors=True)
|
||||
out.mkdir(parents=True)
|
||||
os.environ.update(lab.game_env())
|
||||
with tempfile.TemporaryDirectory(prefix='server-reach-e2e-') as scratch:
|
||||
try:
|
||||
run(bins, Path(scratch) / 'server', out)
|
||||
except (Failed, RuntimeError) as e:
|
||||
sys.exit(f'server_reach_e2e failed (logs in {out}):\n{e}')
|
||||
print('server_reach_e2e: every attack within vanilla\'s range hit, every one beyond it was refused')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user