mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #4181 from horizon-rust/tools/bots-abusive-client
🤖 Agent: all checks green on 6edf1ff46 (horizon-bots only, skip-release). Queued.
This commit is contained in:
1 file changed
+255
-7
@@ -21,18 +21,19 @@ use horizon_protocol::{
|
||||
connection::{ConnWriter, Connection},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
item::Stack,
|
||||
packets::{
|
||||
Empty, configuration,
|
||||
handshake::{self, Intention},
|
||||
login::{self, ServerHello},
|
||||
play,
|
||||
play, recipes,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
Arc, LazyLock, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -44,7 +45,22 @@ 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
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
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),
|
||||
flood-packets (--flood rotations a tick),
|
||||
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
|
||||
moved-too-quickly check allows, never waiting for chunks),
|
||||
and --flood a tick of packets that cost the server work:
|
||||
flood-use (place and break stone around itself), flood-click (inventory
|
||||
clicks with a stale state), flood-suggest (command suggestions, every
|
||||
other one 30 000 characters long), flood-book (100 pages of 1024 characters
|
||||
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
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
--seconds N how long to run after the first bot joins; default 60
|
||||
--view-distance N what the bots ask for; default 10
|
||||
@@ -59,6 +75,16 @@ enum Behaviour {
|
||||
Idle,
|
||||
Walk,
|
||||
Explore,
|
||||
FloodMove,
|
||||
FloodAcks,
|
||||
FloodPackets,
|
||||
FloodChunks,
|
||||
FloodUse,
|
||||
FloodClick,
|
||||
FloodSuggest,
|
||||
FloodBook,
|
||||
FloodSign,
|
||||
FloodRecipe,
|
||||
}
|
||||
|
||||
impl Behaviour {
|
||||
@@ -67,6 +93,16 @@ impl Behaviour {
|
||||
Self::Idle => "idle",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::FloodMove => "flood-move",
|
||||
Self::FloodAcks => "flood-acks",
|
||||
Self::FloodPackets => "flood-packets",
|
||||
Self::FloodChunks => "flood-chunks",
|
||||
Self::FloodUse => "flood-use",
|
||||
Self::FloodClick => "flood-click",
|
||||
Self::FloodSuggest => "flood-suggest",
|
||||
Self::FloodBook => "flood-book",
|
||||
Self::FloodSign => "flood-sign",
|
||||
Self::FloodRecipe => "flood-recipe",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +117,8 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
name: String,
|
||||
report: Option<String>,
|
||||
}
|
||||
@@ -96,6 +134,8 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
};
|
||||
@@ -105,6 +145,10 @@ fn options() -> Result<Options, String> {
|
||||
print!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
if arg == "--no-read" {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
let value = args.next().ok_or(format!("{arg} needs a value"))?;
|
||||
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
|
||||
match arg.as_str() {
|
||||
@@ -122,14 +166,31 @@ fn options() -> Result<Options, String> {
|
||||
"--explore-speed" => {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
|
||||
const BEHAVIOURS: [&str; 14] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"mixed",
|
||||
"flood-move",
|
||||
"flood-acks",
|
||||
"flood-packets",
|
||||
"flood-chunks",
|
||||
"flood-use",
|
||||
"flood-click",
|
||||
"flood-suggest",
|
||||
"flood-book",
|
||||
"flood-sign",
|
||||
"flood-recipe",
|
||||
];
|
||||
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
|
||||
return Err(format!(
|
||||
"--behaviour {}: use idle, walk, explore or mixed",
|
||||
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
|
||||
o.behaviour
|
||||
));
|
||||
}
|
||||
@@ -156,6 +217,8 @@ struct Bot {
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -185,6 +248,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
w.data
|
||||
}
|
||||
|
||||
/// Item ids of the pinned version (26.3's item registry).
|
||||
const STONE: i32 = 1;
|
||||
const WRITABLE_BOOK: i32 = 1371;
|
||||
|
||||
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
|
||||
fn costly(
|
||||
behaviour: Behaviour,
|
||||
i: usize,
|
||||
sequence: i32,
|
||||
pos: [f64; 3],
|
||||
random: &mut Random,
|
||||
) -> Option<Vec<u8>> {
|
||||
use ids::play::serverbound as sb;
|
||||
static LONG_COMMAND: LazyLock<String> =
|
||||
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
|
||||
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
|
||||
play::Serverbound::EditBook(play::EditBook {
|
||||
slot: 0,
|
||||
pages: vec!["a".repeat(1024); 100],
|
||||
title: None,
|
||||
})
|
||||
.encode()
|
||||
});
|
||||
let block = pos.map(|v| v.floor() as i32);
|
||||
let packet = match behaviour {
|
||||
Behaviour::FloodUse => {
|
||||
// A block within reach: place stone there, or break what is there.
|
||||
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
|
||||
if i % 2 == 0 {
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
} else {
|
||||
play::Serverbound::PlayerAction(play::PlayerAction {
|
||||
action: 0,
|
||||
pos: at,
|
||||
direction: 1,
|
||||
sequence,
|
||||
})
|
||||
}
|
||||
}
|
||||
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
|
||||
container: 0,
|
||||
state: 0,
|
||||
slot: 36 + (i % 9) as i16,
|
||||
button: 0,
|
||||
input: 0,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
}),
|
||||
Behaviour::FloodSuggest => {
|
||||
let text = if i % 2 == 0 {
|
||||
"/"
|
||||
} else {
|
||||
LONG_COMMAND.as_str()
|
||||
};
|
||||
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
|
||||
w.varint(sequence);
|
||||
w.string(text);
|
||||
}));
|
||||
}
|
||||
Behaviour::FloodBook => return Some(BOOK.clone()),
|
||||
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
|
||||
pos: [block[0], block[1] - 1, block[2]],
|
||||
lines: std::array::from_fn(|_| "a".repeat(384)),
|
||||
front: true,
|
||||
}),
|
||||
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
|
||||
container: 0,
|
||||
recipe: (i % 2042) as i32,
|
||||
use_max: true,
|
||||
}),
|
||||
_ => return None,
|
||||
};
|
||||
Some(packet.encode())
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
@@ -339,6 +485,12 @@ fn read(
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
loop {
|
||||
if bot.deaf.load(Ordering::Relaxed) {
|
||||
while bot.online.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (id, body) = match reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
@@ -489,6 +641,24 @@ fn run_bot(
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
bot.deaf.store(o.no_read, Ordering::Relaxed);
|
||||
// What the costly floods need in hand (creative: the server takes the bot's word for it).
|
||||
let tool = match behaviour {
|
||||
Behaviour::FloodUse => Some(Stack::new(STONE, 64)),
|
||||
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
|
||||
_ => None,
|
||||
};
|
||||
if tool.is_some() {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot: 36,
|
||||
item: tool,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
let cruise = spawn[1] + 24.0;
|
||||
@@ -504,7 +674,38 @@ fn run_bot(
|
||||
pos = at;
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !lock(&bot.held).contains(&here) {
|
||||
// The abusive client doesn't wait for its chunk, as a real client must.
|
||||
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) {
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
let reach = 64.0 + random.unit() * 192.0;
|
||||
pos[0] += angle.cos() * reach;
|
||||
pos[2] += angle.sin() * reach;
|
||||
send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
let angle = random.unit() * std::f64::consts::TAU;
|
||||
pos[0] += angle.cos() * 128.0;
|
||||
pos[2] += angle.sin() * 128.0;
|
||||
} else if behaviour == Behaviour::FloodChunks {
|
||||
// Up to cruising height first, then just under the server's 10 blocks a tick:
|
||||
// every tick asks for new chunks.
|
||||
if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
pos[0] += heading.cos() * 9.9;
|
||||
pos[2] += heading.sin() * 9.9;
|
||||
}
|
||||
} else if !lock(&bot.held).contains(&here) {
|
||||
bot.waited.fetch_add(1, Ordering::Relaxed);
|
||||
} else if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
@@ -529,7 +730,37 @@ fn run_bot(
|
||||
pos[2] += dz / distance * step;
|
||||
}
|
||||
}
|
||||
Behaviour::Explore => {
|
||||
Behaviour::FloodMove
|
||||
| Behaviour::FloodChunks
|
||||
| Behaviour::FloodUse
|
||||
| Behaviour::FloodClick
|
||||
| Behaviour::FloodSuggest
|
||||
| Behaviour::FloodBook
|
||||
| Behaviour::FloodSign
|
||||
| Behaviour::FloodRecipe => {}
|
||||
Behaviour::FloodPackets => {
|
||||
for i in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
|
||||
yaw: (i % 360) as f32,
|
||||
pitch: 0.0,
|
||||
flags: 0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::FloodAcks | Behaviour::Explore => {
|
||||
if behaviour == Behaviour::FloodAcks {
|
||||
for _ in 0..o.flood {
|
||||
send(
|
||||
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
|
||||
chunks_per_tick: 64.0,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Flying in creative, by default about 22 blocks a second, which keeps new
|
||||
// chunks coming as fast as the server sends them.
|
||||
let step = o.explore_speed / 20.0;
|
||||
@@ -538,6 +769,13 @@ fn run_bot(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
@@ -569,6 +807,16 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
"idle" => Behaviour::Idle,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
"flood-acks" => Behaviour::FloodAcks,
|
||||
"flood-packets" => Behaviour::FloodPackets,
|
||||
"flood-chunks" => Behaviour::FloodChunks,
|
||||
"flood-use" => Behaviour::FloodUse,
|
||||
"flood-click" => Behaviour::FloodClick,
|
||||
"flood-suggest" => Behaviour::FloodSuggest,
|
||||
"flood-book" => Behaviour::FloodBook,
|
||||
"flood-sign" => Behaviour::FloodSign,
|
||||
"flood-recipe" => Behaviour::FloodRecipe,
|
||||
// mixed: of every five bots, three walk, one explores, one stands.
|
||||
_ => match index % 5 {
|
||||
0 | 2 | 4 => Behaviour::Walk,
|
||||
|
||||
Reference in new issue
Block a user