mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Server packet rules: world border, carried item, game mode, beacon, pick block
ServerGamePacketListenerImpl, where the server differed: - handleAttack and handleInteract: a target outside the world border is ignored (WorldBorder.isWithinBounds(target.blockPosition())). - handleSetCarriedItem: a slot past the hotbar logs "<name> tried to set an invalid carried item"; a new slot stops only a main-hand use. - handleChangeGameMode without the permission logs "Player <name> tried to change game mode to <mode> without required permissions". - handleSetBeaconPacket: effects the beacon can't give, or no payment, disconnect (multiplayer.disconnect.generic) with "Player <name> tried to set invalid beacon effects". - handlePickItemFromBlock: only a loaded block. horizon-bots gains --raw TICK:ID:HEX, and tools/server_packet_rules_e2e.py checks the first four on a dedicated server. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
87a9d03fd8
commit
16fd80bdcf
4 files changed
+290
-16
No files matched your search
@@ -81,6 +81,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
registry id, count), to test what a server takes
|
||||
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
|
||||
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
|
||||
--raw TICK:ID:HEX,... raw serverbound play packets (id, body in hex) sent TICK ticks after joining,
|
||||
to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
";
|
||||
@@ -142,6 +144,8 @@ struct Options {
|
||||
no_loaded: bool,
|
||||
height: f64,
|
||||
ground: bool,
|
||||
/// --raw: (tick after joining, packet id, body).
|
||||
raw: Vec<(u64, i32, Vec<u8>)>,
|
||||
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
|
||||
give: Vec<(i16, i32, i32)>,
|
||||
/// --click: the block to open, then the slot, button and click type in its window.
|
||||
@@ -172,6 +176,7 @@ fn options() -> Result<Options, String> {
|
||||
no_loaded: false,
|
||||
height: 24.0,
|
||||
ground: false,
|
||||
raw: Vec::new(),
|
||||
give: Vec::new(),
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
@@ -246,6 +251,27 @@ fn options() -> Result<Options, String> {
|
||||
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
|
||||
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
|
||||
}
|
||||
"--raw" => {
|
||||
for part in value.split(',') {
|
||||
let bad = || number("TICK:ID:HEX");
|
||||
let [tick, id, hex] = part.split(':').collect::<Vec<_>>()[..] else {
|
||||
return Err(bad());
|
||||
};
|
||||
let body = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
hex.get(i..i + 2)
|
||||
.and_then(|b| u8::from_str_radix(b, 16).ok())
|
||||
})
|
||||
.collect::<Option<Vec<u8>>>()
|
||||
.ok_or_else(bad)?;
|
||||
o.raw.push((
|
||||
tick.parse().map_err(|_| bad())?,
|
||||
id.parse().map_err(|_| bad())?,
|
||||
body,
|
||||
));
|
||||
}
|
||||
}
|
||||
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
|
||||
"--timer" => {
|
||||
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
|
||||
@@ -935,6 +961,11 @@ fn run_bot(
|
||||
);
|
||||
}
|
||||
}
|
||||
for (at, id, body) in &o.raw {
|
||||
if *at == tick {
|
||||
send(raw(*id, |w| w.bytes(body)));
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
|
||||
@@ -1317,12 +1317,22 @@ impl Game<'_> {
|
||||
8 if !spectator => self.guest_jab(host, conn),
|
||||
_ => {}
|
||||
},
|
||||
// handleSetCarriedItem: a new slot stops a main-hand use.
|
||||
S::SetCarriedItem(p) => {
|
||||
if (0..9).contains(&p.slot) {
|
||||
host.hub.guests[gi].selected = p.slot as usize;
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.using = None;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !(0..9).contains(&p.slot) {
|
||||
eprintln!("{} tried to set an invalid carried item", g.name);
|
||||
return;
|
||||
}
|
||||
let changed = g.selected != p.slot as usize;
|
||||
g.selected = p.slot as usize;
|
||||
if let Some(play) = host.play.get_mut(&conn)
|
||||
&& changed
|
||||
&& play
|
||||
.using
|
||||
.is_some_and(|(slot, _)| slot != super::shield::OFFHAND_SLOT)
|
||||
{
|
||||
play.using = None;
|
||||
}
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handlePunch: the main hand swings
|
||||
@@ -1467,12 +1477,13 @@ 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.
|
||||
// handleInteract: a target inside the world border, within the entity
|
||||
// interaction range plus 3 of its 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)
|
||||
&& (!self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| {
|
||||
g.within_entity_interaction_range(&target, 3.0)
|
||||
}) != Some(true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1744,6 +1755,22 @@ impl Game<'_> {
|
||||
self.send_admin_feedback(host, Some(conn));
|
||||
}
|
||||
}
|
||||
S::ChangeGameMode(p) => {
|
||||
if let Some(mode) = usize::try_from(p.mode)
|
||||
.ok()
|
||||
.and_then(|index| horizon_client::command::args::GameMode::ALL.get(index))
|
||||
{
|
||||
let name = horizon_client::text::Text::translate(
|
||||
&format!("gameMode.{}", mode.name()),
|
||||
&[],
|
||||
);
|
||||
eprintln!(
|
||||
"Player {} tried to change game mode to {} without required permissions",
|
||||
host.hub.guests[gi].name,
|
||||
name.plain()
|
||||
);
|
||||
}
|
||||
}
|
||||
// handleEntityTagQuery: a game master's F3+I on an entity of their
|
||||
// level gets its Entity.saveWithoutId.
|
||||
S::EntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
@@ -1800,6 +1827,10 @@ impl Game<'_> {
|
||||
// data only with infinite materials.
|
||||
S::PickItemFromBlock(p) => {
|
||||
self.guest_pick_item(host, conn, |g| {
|
||||
// handlePickItemFromBlock: within reach and loaded.
|
||||
if !g.world.loaded(p.pos[0], p.pos[2]) {
|
||||
return None;
|
||||
}
|
||||
let eye = g.eye_position()?;
|
||||
let d2: f64 = (0..3)
|
||||
.map(|a| {
|
||||
@@ -2099,6 +2130,13 @@ impl Game<'_> {
|
||||
/// 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).
|
||||
/// `WorldBorder.isWithinBounds(target.blockPosition())` for an entity of this box:
|
||||
/// the block its feet are in (the box's bottom centre).
|
||||
fn inside_border(&self, target: &Aabb) -> bool {
|
||||
let feet = target.bottom_center();
|
||||
self.border.contains(feet.x.floor(), feet.z.floor())
|
||||
}
|
||||
|
||||
pub(super) 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 {
|
||||
@@ -2138,10 +2176,12 @@ impl Game<'_> {
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return;
|
||||
}
|
||||
// handleAttack: `isWithinAttackRange(mainHandItem, box, 3.0)` before
|
||||
// anything else, players, mobs, objects and fireballs alike.
|
||||
// handleAttack: a target inside the world border, then
|
||||
// `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)
|
||||
&& (!self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,13 +564,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let data = self.beacon_data(host, pos);
|
||||
let mut updated = false;
|
||||
let mut updated = None;
|
||||
self.lan_station_op(host, conn, None, |menu, env| {
|
||||
menu.data.copy_from_slice(&data);
|
||||
let power = |k: usize| powers[k].as_ref().map(|(id, n)| (*id, n.as_str()));
|
||||
updated = menu.beacon_update(env, power(0), power(1));
|
||||
updated = Some(menu.beacon_update(env, power(0), power(1)));
|
||||
});
|
||||
if !updated {
|
||||
// handleSetBeaconPacket: effects the beacon can't give, or no payment, disconnect.
|
||||
if updated == Some(false) {
|
||||
let name = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| g.name.clone());
|
||||
eprintln!(
|
||||
"Player {} tried to set invalid beacon effects",
|
||||
name.unwrap_or_default()
|
||||
);
|
||||
host.hub.kick(
|
||||
conn,
|
||||
horizon_protocol::text::translatable("multiplayer.disconnect.generic", &[]),
|
||||
);
|
||||
}
|
||||
if updated != Some(true) {
|
||||
return;
|
||||
}
|
||||
let [primary, secondary] = powers.map(|p| p.map(|(_, n)| n));
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small rules of vanilla's ServerGamePacketListenerImpl on Horizon's dedicated server, end to end.
|
||||
|
||||
python3 tools/server_packet_rules_e2e.py --bin-dir target/fast
|
||||
|
||||
A survival dedicated server in a flat world, a world border 20 blocks wide around 0 0 (no damage):
|
||||
- handleAttack: a player outside the world border isn't hit, from 3 blocks away; a pair inside it fights;
|
||||
- handleSetCarriedItem: hotbar slot 12 is refused, and the console says "<name> tried to set an invalid
|
||||
carried item";
|
||||
- handleChangeGameMode: a player without the permission asks for creative; the console says
|
||||
"Player <name> tried to change game mode to Creative Mode without required permissions";
|
||||
- handleSetBeaconPacket: a player opens a beacon and sets Speed without paying; vanilla disconnects them
|
||||
(multiplayer.disconnect.generic) and warns "Player <name> tried to set invalid beacon effects".
|
||||
|
||||
horizon-bots stand in for the clients (`--attack`, `--raw`). 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 re
|
||||
import shutil
|
||||
import struct
|
||||
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',
|
||||
}
|
||||
# A flat world's grass top is at y = -61.
|
||||
GROUND = -60
|
||||
BEACON = (2, GROUND, 0)
|
||||
SECONDS = 14
|
||||
SPEED = 0 # minecraft:speed in the mob_effect registry
|
||||
|
||||
|
||||
class Failed(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def serverbound_ids():
|
||||
"""The play state's serverbound packet ids, from horizon-protocol's table."""
|
||||
text = (lab.ROOT / 'crates/horizon-protocol/src/ids.rs').read_text()
|
||||
play = text[text.index('pub mod play {'):]
|
||||
block = play[play.index('pub mod serverbound {'):]
|
||||
block = block[:block.index('}')]
|
||||
return {m[1]: int(m[2]) for m in re.finditer(r'pub const (\w+): i32 = (\d+);', block)}
|
||||
|
||||
|
||||
def varint(n):
|
||||
out = b''
|
||||
n &= 0xFFFFFFFF
|
||||
while True:
|
||||
if n < 0x80:
|
||||
return out + bytes([n])
|
||||
out += bytes([n & 0x7F | 0x80])
|
||||
n >>= 7
|
||||
|
||||
|
||||
def block_pos(x, y, z):
|
||||
return struct.pack('>q', ((x & 0x3FFFFFF) << 38) | ((z & 0x3FFFFFF) << 12) | (y & 0xFFF))
|
||||
|
||||
|
||||
def bot(bins, port, out, name, behaviour, *extra):
|
||||
report = out / f'{name}.json'
|
||||
subprocess.run([bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
|
||||
'--behaviour', behaviour, '--height', '0', '--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):
|
||||
ids = serverbound_ids()
|
||||
use_on = (varint(0) + block_pos(*BEACON) + varint(1) + struct.pack('>fff', 0.5, 1.0, 0.5)
|
||||
+ b'\0\0' + varint(1))
|
||||
# Speed as the primary power, no secondary, and nothing paid.
|
||||
beacon = b'\1' + varint(SPEED) + b'\0'
|
||||
raw = ','.join([
|
||||
f'40:{ids["SET_CARRIED_ITEM"]}:{struct.pack(">h", 12).hex()}',
|
||||
f'40:{ids["CHANGE_GAME_MODE"]}:{varint(1).hex()}',
|
||||
f'60:{ids["USE_ITEM_ON"]}:{use_on.hex()}',
|
||||
f'80:{ids["SET_BEACON"]}:{beacon.hex()}',
|
||||
])
|
||||
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)
|
||||
for line in ('setworldspawn 0 -60 0', 'gamerule respawn_radius 0', 'worldborder center 0 0',
|
||||
'worldborder set 20', 'worldborder damage amount 0',
|
||||
'setblock {} {} {} minecraft:beacon'.format(*BEACON)):
|
||||
server.console(line)
|
||||
with ThreadPoolExecutor(5) as pool:
|
||||
# (victim, attacker): outside the border, then a pair inside it.
|
||||
pairs = [(pool.submit(bot, bins, port, out, f'V{i}', 'stand'),
|
||||
pool.submit(bot, bins, port, out, f'A{i}', 'stand', '--attack', f'V{i}0',
|
||||
'--attack-after', '6'))
|
||||
for i in range(2)]
|
||||
rules = pool.submit(bot, bins, port, out, 'R', 'idle', '--raw', raw)
|
||||
# Side by side first, so each attacker sees its victim (and its entity id), then apart.
|
||||
for i in range(2):
|
||||
for name in (f'V{i}0', f'A{i}0'):
|
||||
server.wait_for(rf'{name} joined the game', 60)
|
||||
z = 5.5 * (1 if i else -1)
|
||||
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)
|
||||
server.console(f'tp V00 12.5 {GROUND} -5.5')
|
||||
server.console(f'tp A00 9.5 {GROUND} -5.5')
|
||||
server.console(f'tp V10 -3.5 {GROUND} 5.5')
|
||||
server.console(f'tp A10 -0.5 {GROUND} 5.5')
|
||||
results = [(v.result(), a.result()) for v, a in pairs]
|
||||
rule_bot = rules.result()
|
||||
lines = server.since(0)
|
||||
finally:
|
||||
server.stop()
|
||||
failed = []
|
||||
for (victim, attacker), (where, hits) in zip(results, [('outside the border', False),
|
||||
('inside the border', True)]):
|
||||
health = victim['health']
|
||||
hit = health is not None and health < 20
|
||||
line = f'a victim {where}: {attacker["attacks"]} attacks, health {health}'
|
||||
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(f'{line}: expected {"a hit" if hits else "no hit"}')
|
||||
for want in ('R0 tried to set an invalid carried item',
|
||||
'Player R0 tried to change game mode to Creative Mode without required permissions',
|
||||
'Player R0 tried to set invalid beacon effects'):
|
||||
if any(want in l for l in lines):
|
||||
print(f'console: {want}')
|
||||
else:
|
||||
failed.append(f'the console never said "{want}"')
|
||||
if rule_bot['error']:
|
||||
print(f'R0 was disconnected: {rule_bot["error"]}')
|
||||
else:
|
||||
failed.append('R0 was not disconnected after the unpaid beacon')
|
||||
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-packet-rules-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-packet-rules-e2e-') as scratch:
|
||||
try:
|
||||
run(bins, Path(scratch) / 'server', out)
|
||||
except (Failed, RuntimeError) as e:
|
||||
sys.exit(f'server_packet_rules_e2e failed (logs in {out}):\n{e}')
|
||||
print('server_packet_rules_e2e: the border, carried item, game mode and beacon rules hold')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user