mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
A hit on a boat, minecart or armor stand starts the attack strength again
Player.attack calls onAttack, which resets the attack strength ticker, for every entity that passes cannotAttack. Horizon did it for mobs, players and the dragon, but not for boats, minecarts and armor stands, so on a server only the client's swing packet reset it: a client that skipped the swing hit vehicles at full strength every time. A fixed item frame refuses the hit of a player who isn't in creative, and a cushion that of a player who may not build, so skipAttackInteraction is false there and they reset too. server_packet_rules_e2e checks it with two bots that hit a boat each in one go without swinging: eight hits leave one standing (it breaks on the fifth without the reset), 24 still break the other. horizon-bots' --attack takes a UUID, so a bot can hit an entity summoned with one, and --attack-rounds stops it after N rounds. The harness turns the anticheat off, which would drop those attacks, and force-loads the boats' chunks. Fixes #5631 Written with Claude Opus 5.5 in OpenCode.
This commit is contained in:
1 parent
ad5aef0c59
commit
8967e6abf1
8 files changed
+109
-9
No files matched your search
@@ -73,8 +73,11 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health
|
||||
the report gives each bot's last health. A UUID instead of a NAME: the
|
||||
entity with that UUID (a boat summoned with it, say)
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
|
||||
default no limit
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
@@ -153,9 +156,11 @@ struct Options {
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
pvp: bool,
|
||||
/// The player to attack, and how long after joining to start.
|
||||
/// The players or entity UUIDs to attack, how long after joining to start,
|
||||
/// and how many rounds.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
@@ -190,6 +195,7 @@ fn options() -> Result<Options, String> {
|
||||
pvp: true,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
@@ -247,6 +253,7 @@ fn options() -> Result<Options, String> {
|
||||
"--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"))?,
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
@@ -401,6 +408,16 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
|
||||
/// the offline UUID of the player with that name (a name has no `-`).
|
||||
fn target_uuid(name: &str) -> u128 {
|
||||
let hex: String = name.chars().filter(|&c| c != '-').collect();
|
||||
match u128::from_str_radix(&hex, 16) {
|
||||
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
|
||||
_ => offline_uuid(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -984,7 +1001,7 @@ fn run_bot(
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.map(|name| (target_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
@@ -1039,6 +1056,7 @@ fn run_bot(
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut rounds = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
|
||||
@@ -1202,8 +1220,13 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
&& rounds < o.attack_rounds
|
||||
{
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
// A round counts once a target is known.
|
||||
rounds += u32::from(!targets.is_empty());
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
@@ -1446,6 +1469,20 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// `--attack`: a UUID names that entity, anything else a player.
|
||||
#[test]
|
||||
fn attack_targets_a_uuid_or_a_player() {
|
||||
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
|
||||
assert_eq!(
|
||||
target_uuid("0000000a-0000-0001-0000-000000000002"),
|
||||
0x0000000a_0000_0001_0000_000000000002
|
||||
);
|
||||
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
|
||||
// A name of 32 hex digits is a name: a UUID is written with dashes.
|
||||
let name = "0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(target_uuid(name), offline_uuid(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_living_entities_only() {
|
||||
let mut e = Entities::default();
|
||||
|
||||
@@ -10461,7 +10461,7 @@ impl Game<'_> {
|
||||
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
|
||||
/// and can't be hit critically): knockback when sprinting at full
|
||||
/// strength, then strong or weak, or the sweep around the vehicle's
|
||||
/// box `area`. Before `swing_arm` resets the charge.
|
||||
/// box `area`. Before the hit resets the charge (`Player.onAttack`).
|
||||
pub(super) fn vehicle_attack_sounds(&mut self, area: Aabb, magic: Option<i32>) {
|
||||
self.attack_effects(area, None, mace::Spared::Nothing, magic);
|
||||
}
|
||||
|
||||
@@ -603,6 +603,9 @@ impl Game<'_> {
|
||||
magic.then_some(id as i32),
|
||||
);
|
||||
});
|
||||
// Player.onAttack: the hit starts the attack strength again, hurt or
|
||||
// not (skipAttackInteraction only asks mayInteract).
|
||||
self.attack_ticks = 0;
|
||||
if hurt {
|
||||
self.swing_arm();
|
||||
}
|
||||
|
||||
@@ -1291,6 +1291,8 @@ impl Game<'_> {
|
||||
let (damage, _, _, magic) = self.melee_damage(&kind, false);
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
// Player.onAttack: the hit starts the attack strength again.
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -359,8 +359,10 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// `skipAttackInteraction` when `mayInteract` is false.
|
||||
// `Cushion.hurtServer` refuses a player who may not build, so
|
||||
// skipAttackInteraction is false and Player.attack goes on to onAttack.
|
||||
if !self.may_build() {
|
||||
self.attack_ticks = 0;
|
||||
return true;
|
||||
}
|
||||
self.break_cushion(i, true);
|
||||
|
||||
@@ -255,6 +255,12 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if self.item_frames[i].fixed {
|
||||
// A fixed frame refuses the hit of a player who isn't in creative
|
||||
// (canHurtWhenFixed), so skipAttackInteraction is false and
|
||||
// Player.attack goes on to onAttack.
|
||||
if !self.creative {
|
||||
self.attack_ticks = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let with_frame = self.item_frames[i].item.is_none();
|
||||
|
||||
@@ -1626,6 +1626,8 @@ impl Game<'_> {
|
||||
let (damage, _, _, magic) = self.melee_damage(self.carts[i].kind.id(), false);
|
||||
self.hurt_cart(i, damage, self.creative, burning);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
// Player.onAttack: the hit starts the attack strength again.
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
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;
|
||||
- Player.onAttack: players hitting a boat without swinging: the attack itself starts the strength again,
|
||||
so of eight hits in a row the last seven are weak and the boat stands (24 in a row still break one);
|
||||
- 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
|
||||
@@ -52,6 +54,14 @@ GROUND = -60
|
||||
BEACON = (2, GROUND, 0)
|
||||
SECONDS = 14
|
||||
SPEED = 0 # minecraft:speed in the mob_effect registry
|
||||
# Two boats, each with its own UUID and its own bot (P0, Q0) 2 blocks north of it, which hits it this
|
||||
# many times in one go, without swinging. A bare hand hits for 0.2 + s^2 * 0.8 at strength s; a boat
|
||||
# takes ten times that, breaks past 40 and loses 1 a tick. The server takes one of a player's attacks a
|
||||
# tick, so after the first hit (10) the others come a tick apart: at strength 0.3 (2.72) when each hit
|
||||
# resets it, at full strength (10) when only a swing would. Q reaches about 22 with the reset, and
|
||||
# without it breaks on its fifth hit; P breaks either way (on its 19th hit with the reset), which shows
|
||||
# the hits land.
|
||||
BOATS = {'P': ((5.5, GROUND, 2.5), 10, 24), 'Q': ((5.5, GROUND, -3.5), 11, 8)}
|
||||
|
||||
|
||||
class Failed(Exception):
|
||||
@@ -77,6 +87,11 @@ def varint(n):
|
||||
n >>= 7
|
||||
|
||||
|
||||
def boat_uuid(n):
|
||||
"""The UUID `{UUID:[I;0,0,0,n]}` gives an entity, as a selector writes it."""
|
||||
return f'00000000-0000-0000-0000-{n:012x}'
|
||||
|
||||
|
||||
def block_pos(x, y, z):
|
||||
return struct.pack('>q', ((x & 0x3FFFFFF) << 38) | ((z & 0x3FFFFFF) << 12) | (y & 0xFFF))
|
||||
|
||||
@@ -107,21 +122,34 @@ def run(bins, folder, out):
|
||||
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')
|
||||
# Vanilla's rules only: Horizon's anticheat would drop the attacks the boats take without a swing.
|
||||
(folder / 'server.toml').write_text('[anticheat]\nenabled = false\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)):
|
||||
'setblock {} {} {} minecraft:beacon'.format(*BEACON),
|
||||
# The boats' chunks, so the boats are there before the bots come and after
|
||||
# they leave.
|
||||
'forceload add 0 -16 15 15'):
|
||||
server.console(line)
|
||||
with ThreadPoolExecutor(5) as pool:
|
||||
for tag, ((x, y, z), n, _) in BOATS.items():
|
||||
server.console(f'summon minecraft:oak_boat {x} {y} {z} {{UUID:[I;0,0,0,{n}]}}')
|
||||
server.console(f'execute if entity {boat_uuid(n)} run say boat {tag} is here')
|
||||
server.wait_for(rf'boat {tag} is here', 20)
|
||||
with ThreadPoolExecutor(7) 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)
|
||||
boats = {tag: pool.submit(bot, bins, port, out, tag, 'stand', '--attack',
|
||||
','.join([boat_uuid(n)] * hits), '--no-swing', '--attack-after', '11',
|
||||
'--attack-rounds', '1')
|
||||
for tag, (_, n, hits) in BOATS.items()}
|
||||
# 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'):
|
||||
@@ -129,6 +157,9 @@ def run(bins, folder, out):
|
||||
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}')
|
||||
for tag, ((x, y, z), _, _) in BOATS.items():
|
||||
server.wait_for(rf'{tag}0 joined the game', 60)
|
||||
server.console(f'tp {tag}0 {x} {y} {z - 2}')
|
||||
time.sleep(2)
|
||||
server.console(f'tp V00 12.5 {GROUND} -5.5')
|
||||
server.console(f'tp A00 9.5 {GROUND} -5.5')
|
||||
@@ -136,6 +167,11 @@ def run(bins, folder, out):
|
||||
server.console(f'tp A10 -0.5 {GROUND} 5.5')
|
||||
results = [(v.result(), a.result()) for v, a in pairs]
|
||||
rule_bot = rules.result()
|
||||
boat_bots = {tag: f.result() for tag, f in boats.items()}
|
||||
for tag, (_, n, _) in BOATS.items():
|
||||
server.console(f'execute if entity {boat_uuid(n)} run say boat {tag} stands')
|
||||
server.console(f'execute unless entity {boat_uuid(n)} run say boat {tag} broke')
|
||||
server.wait_for(rf'boat {tag} (stands|broke)', 20)
|
||||
lines = server.since(0)
|
||||
finally:
|
||||
server.stop()
|
||||
@@ -150,6 +186,18 @@ def run(bins, folder, out):
|
||||
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"}')
|
||||
stands = {tag: any(f'boat {tag} stands' in l for l in lines) for tag in BOATS}
|
||||
for tag, (_, _, hits) in BOATS.items():
|
||||
b = boat_bots[tag]
|
||||
line = (f'boat {tag}, {hits} hits in a row without a swing: {b["attacks"]} attacks, '
|
||||
f'{"stands" if stands[tag] else "broke"}')
|
||||
print(line)
|
||||
if b['attacks'] != hits or b['error']:
|
||||
failed.append(f'{line}: its bot sent {b["attacks"]} of its {hits} hits ({b["error"]})')
|
||||
if stands['P']:
|
||||
failed.append('boat P should break: the hits never landed')
|
||||
elif not stands['Q']:
|
||||
failed.append('boat Q should stand: its hits never started the attack strength again')
|
||||
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'):
|
||||
@@ -179,7 +227,7 @@ def main():
|
||||
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')
|
||||
print('server_packet_rules_e2e: the border, attack strength, carried item, game mode and beacon rules hold')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user