Files
horizon-airdef/tools/test_server_player_targets_parity.py
T
Ludovic Marie cc84150f6c A player leaving a level loses its waypoints there
ServerWaypointManager.removePlayer disconnects every waypoint the player
was receiving: a player who goes to another level gets an untrack for each
player it tracked in the one it left. Horizon's per-level managers tick
with the players in their own level only, so the untrack had no connection
to go to and the player kept the old level's markers.

Each level's manager now also gets the connections of the players in the
other levels, for these untracks.

The player-targets and dimension-players harnesses compare the waypoints
as each step leaves them (the last packet for each waypoint): arriving in a
level, vanilla sends the same waypoint up to three times within the step,
and the packet by packet order is test_locator_bar_parity's. Both pass:
123 and 40 lines identical.

Part of #843

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:57:27 +02:00

211 lines
11 KiB
Python

#!/usr/bin/env python3
"""Commands acting on other players of a dedicated server, against vanilla's, seen by vanilla clients.
EntitySelector.findPlayers and EntityArgument's player targets: names, UUIDs, `@a`, `@p`, `@r`, `@s` under
`execute as`, from the console and from an operator, with `give`, `gamemode`, `tp` (to a place, to a player, with a
rotation, and into another level by `execute in`, the console's own level or the destination's), `effect give` and
`effect clear`, and `kill`.
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world
vanilla generated first, with Alice and Bob as operators in creative. LocatorBarClient joins them both (players made
of vanilla's protocol code) and records, step by step, the system chat each receives and what the server changes
about it: game mode events, teleports, inventory slots, effects, its death (with the death message), the other
players it is sent or loses and the levels it respawns into. Each player's lines in each step are compared exactly,
in sorted order (the packets of one step may leave in another order within the tick), with the locator bar's
waypoints as each step leaves them (one manager per level: a player in another level is untracked). What a death does besides its messages
is left to its own harness.
"""
import hashlib
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import time
import uuid as uuidlib
try:
from . import lab, test_rust_parity as base
from .test_dedicated_server_parity import Server
from .test_locator_bar_parity import generate_world, server_dir
except ImportError:
import lab
import test_rust_parity as base
from test_dedicated_server_parity import Server
from test_locator_bar_parity import generate_world, server_dir
NAME = 'server-player-targets-parity'
NETHER, OVERWORLD = 'minecraft:the_nether', 'minecraft:overworld'
def offline_uuid(name):
"""UUIDUtil.createOfflinePlayerUUID."""
digest = bytearray(hashlib.md5(f'OfflinePlayer:{name}'.encode()).digest())
digest[6] = (digest[6] & 0x0F) | 0x30
digest[8] = (digest[8] & 0x3F) | 0x80
return str(uuidlib.UUID(bytes=bytes(digest)))
# (label, what the console does, what the players do).
SCRIPT = [
('console-give', ['give Bob minecraft:diamond 3'], []),
('console-give-all', ['give @a minecraft:apple'], []),
('alice-give-bob', [], ['cmd Alice give Bob minecraft:stick 2']),
('console-give-uuid', [f'give {offline_uuid("Bob")} minecraft:iron_ingot'], []),
('console-give-nearest', ['give @p minecraft:emerald'], []),
('alice-give-random', [], ['cmd Alice give @r[name=Bob] minecraft:gold_ingot']),
('console-gamemode', ['gamemode survival Bob'], []),
('alice-gamemode-bob', [], ['cmd Alice gamemode creative Bob']),
('alice-gamemode-all', [], ['cmd Alice gamemode adventure @a']),
('console-gamemode-back', ['gamemode creative @a'], []),
('console-tp', ['tp Bob 8.5 -60 8.5'], []),
('alice-tp-bob-to-alice', [], ['cmd Alice tp Bob Alice']),
('console-tp-rotation', ['tp Bob 4.5 -60 0.5 90 10'], []),
('bob-tp-self', [], ['cmd Bob tp @s 6.5 -60 0.5']),
('console-execute-as', ['execute as Bob run tp @s ~ ~ ~2'], []),
('console-execute-at', ['execute at Bob run tp Alice ~1 ~ ~'], []),
('alice-execute-as-all', [], ['cmd Alice execute as @a run give @s minecraft:bread']),
('console-effect', ['effect give Bob minecraft:speed 30 1'], []),
('alice-effect-all', [], ['cmd Alice effect give @a minecraft:haste infinite']),
('console-effect-clear', ['effect clear Bob'], []),
('alice-effect-clear-all', [], ['cmd Alice effect clear @a minecraft:haste']),
('console-nether', [f'execute in {NETHER} run tp Bob 0.5 100 0.5'], []),
('console-tp-home', ['tp Bob 4.5 -60 0.5'], []),
('alice-nether', [], [f'cmd Alice execute in {NETHER} run tp Bob 0.5 100 0.5']),
('console-tp-to-alice', ['tp Bob Alice'], []),
('alice-kill-missing', [], ['cmd Alice kill Carol']),
('console-kill', ['kill Bob'], []),
]
# What the players' locator bars show has its own harness (test_locator_bar_parity), and what a death does
# besides its messages (drops, the body leaving) the player combat ones: here only the messages of `kill`.
DEATH_STEP = 'step=console-kill'
STEP_SECONDS = 2.5
def settled(lines):
"""One player's lines of one step, in order, with the waypoint packets reduced to the last for each waypoint:
what the locator bar shows once the step is over. Arriving in a level, vanilla sends the same waypoint more
than once on the way there; the packet by packet order is test_locator_bar_parity's."""
def waypoint(line):
parts = line.split(' ')
return parts[3] if len(parts) > 3 and parts[2] in ('track', 'untrack', 'update') else None
last = {waypoint(l): i for i, l in enumerate(lines) if waypoint(l)}
return [l for i, l in enumerate(lines) if not waypoint(l) or last[waypoint(l)] == i]
def run_side(label, server_command, client_command, build, world):
directory = build / label
port = server_dir(directory, world)
out = build / f'{label}.txt'
out.unlink(missing_ok=True)
server = Server(server_command, directory, label)
try:
server.wait_for(r'Done \(')
with (build / f'{label}-client.log').open('w') as log:
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)],
cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT,
text=True)
def tell(line):
client.stdin.write(line + '\n')
client.stdin.flush()
try:
for name, x in (('Alice', 0.5), ('Bob', 4.5)):
tell(f'join {name}')
server.wait_for(rf'{name} joined the game', timeout=120)
tell(f'fly {name} on')
tell(f'cmd {name} tp @s {x} -60 0.5 0 0')
time.sleep(STEP_SECONDS)
for flag in ('chat', 'state', 'players'):
tell(f'{flag} on')
for step, console, actions in SCRIPT:
tell(f'step {step}')
for command in console:
server.console(command)
for action in actions:
tell(action)
time.sleep(STEP_SECONDS)
for name in ('Alice', 'Bob'):
tell(f'still {name}')
tell('end')
client.wait(timeout=60)
finally:
if client.poll() is None:
client.kill()
finally:
server.stop()
if not out.exists():
raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)')
lines = out.read_text(encoding='utf-8').splitlines()
errors = [l for l in lines if ' error ' in f' {l} ' or 'disconnected' in l]
steps = {}
for line in lines:
who, step, rest = line.split(' ', 2)
# At the death, Alice picks up Bob's drops (and gets their advancements) in vanilla.
death_side = step == DEATH_STEP and (not rest.startswith(('chat ', 'killed '))
or 'chat.type.advancement' in rest)
if death_side:
continue
if step != 'step=start':
steps.setdefault((who, step), []).append(line)
order = {f'step={s}': i for i, (s, _, _) in enumerate(SCRIPT)}
keys = sorted(steps, key=lambda k: (k[0], order.get(k[1], -1)))
return [l for k in keys for l in sorted(settled(steps[k]))], errors
def main():
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / NAME
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java'
classes = build / 'classes'
shutil.rmtree(classes, ignore_errors=True)
classes.mkdir()
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath',
lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle],
cwd=lab.ROOT, check=True, capture_output=True)
subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
check=True, capture_output=True)
import json
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient']
world = generate_world(vanilla_server, build)
vanilla, vanilla_errors = run_side('vanilla', vanilla_server, client, build, world)
horizon, horizon_errors = run_side('horizon', horizon_server, client, build, world)
(build / 'vanilla.txt').write_text('\n'.join(vanilla) + '\n', encoding='utf-8')
(build / 'horizon.txt').write_text('\n'.join(horizon) + '\n', encoding='utf-8')
errors = vanilla_errors + horizon_errors
if errors or vanilla != horizon:
import difflib
diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm=''))
raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n'
+ '\n'.join(errors[:10]) + '\n' + diff[:8000])
for needle in ('gamemode 0', 'minecraft:diamond x3', 'effect minecraft:speed', 'respawn ' + NETHER,
'death.attack.genericKill'):
if not any(needle in l for l in vanilla):
raise AssertionError(f'vanilla never sent {needle!r}: the script did not run (see {build})')
lab.write_json(report, dict(
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
scope='Commands from the console and an operator acting on another player of a dedicated server: names, '
'UUIDs, @a, @p, @r and @s under execute as/at, with give, gamemode, tp (place, player, rotation, '
'execute in, across levels), effect give and clear and kill, as the players see them.'))
print(f'server player targets: {len(vanilla)} lines identical')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')