ab8b379097
ServerResourcePackConfigurationTask: after the tags, the configuration phase sends the pack (id from the URL when none is given, hash, required, prompt) and waits for a final answer, with keepalives every 15 s. A declined required pack disconnects with multiplayer.requiredTexturePrompt.disconnect, in configuration or in play, with vanilla's log lines. tools/test_server_resource_pack_parity.py drives a vanilla 26.3 server and Horizon's with the same five players (declines, accepts then declines in play, fails the download, answers after 20 s, declines an optional pack) and compares what each one receives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
8.3 KiB
Python
173 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""A dedicated server's resource pack (server.properties `resource-pack`), against vanilla's, as vanilla clients see it.
|
|
|
|
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world
|
|
vanilla generated first, twice: once with a required pack (an id, a SHA-1 and a prompt), once with an optional one
|
|
(none of those: the id is derived from the URL). ServerPackClient, players made of vanilla's protocol code, join each
|
|
and answer the offer the way vanilla's client can:
|
|
|
|
- required: declined (disconnected with vanilla's reason), accepted then downloaded then loaded (joins, then a
|
|
declined answer in play disconnects it too), accepted then failed to download (joins: only declining is refused),
|
|
and loaded after 20 seconds (the server keeps the connection alive while the player reads the prompt);
|
|
- optional: declined (joins).
|
|
|
|
Compared exactly, per player: the configuration packets in order (runs counted), the offer (id, URL, hash,
|
|
required, the prompt as a component), whether a keep-alive came while the offer was open, and how the connection
|
|
ended. The server logs' "due to resource pack ... rejection" lines are compared too. No pack is downloaded: the
|
|
servers only offer it.
|
|
"""
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
try:
|
|
from . import lab, test_rust_parity as base
|
|
from .test_dedicated_server_parity import Server, free_port
|
|
from .test_locator_bar_parity import generate_world
|
|
except ImportError:
|
|
import lab
|
|
import test_rust_parity as base
|
|
from test_dedicated_server_parity import Server, free_port
|
|
from test_locator_bar_parity import generate_world
|
|
|
|
NAME = 'server-resource-pack-parity'
|
|
PROPERTIES = {
|
|
'online-mode': 'false',
|
|
'white-list': 'false',
|
|
'level-seed': '12345',
|
|
'level-type': 'minecraft\\:flat',
|
|
'generate-structures': 'false',
|
|
'view-distance': '2',
|
|
'simulation-distance': '2',
|
|
'max-players': '5',
|
|
'pause-when-empty-seconds': '-1',
|
|
}
|
|
# The URL is never fetched: the servers only offer it.
|
|
URL = 'http://127.0.0.1:9/horizon-test-pack.zip'
|
|
SERVERS = {
|
|
'required': {
|
|
'resource-pack': URL,
|
|
'resource-pack-id': '5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a',
|
|
'resource-pack-sha1': '0123456789abcdef0123456789abcdef01234567',
|
|
'require-resource-pack': 'true',
|
|
'resource-pack-prompt': '["Our pack: ",{"text":"blue dirt","color":"aqua","bold":true}]',
|
|
},
|
|
'optional': {
|
|
'resource-pack': URL,
|
|
'require-resource-pack': 'false',
|
|
},
|
|
}
|
|
# (server, player, seconds before answering, the answers, an answer in play or None).
|
|
PLAYERS = [
|
|
('required', 'Alice', 0, 'declined', None),
|
|
('required', 'Bob', 0, 'accepted,downloaded,successfully_loaded', 'declined'),
|
|
('required', 'Carol', 0, 'accepted,failed_download', None),
|
|
('required', 'Dave', 20, 'accepted,downloaded,successfully_loaded', None),
|
|
('optional', 'Erin', 0, 'declined', None),
|
|
]
|
|
REJECTION = re.compile(r'Disconnecting (\S+) due to resource pack (\S+) rejection')
|
|
|
|
|
|
def server_dir(directory, world, pack):
|
|
if directory.exists():
|
|
shutil.rmtree(directory)
|
|
directory.mkdir(parents=True)
|
|
port = free_port()
|
|
props = dict(PROPERTIES, **pack, **{'server-port': str(port)})
|
|
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()),
|
|
encoding='iso-8859-1')
|
|
(directory / 'eula.txt').write_text('eula=true\n')
|
|
shutil.copytree(world, directory / 'world')
|
|
(directory / 'world' / 'session.lock').unlink(missing_ok=True)
|
|
return port
|
|
|
|
|
|
def run_side(label, kind, server_command, client_command, build, world):
|
|
"""One server with this pack: every player's transcript, and the server's rejection log lines."""
|
|
directory = build / f'{label}-{kind}'
|
|
port = server_dir(directory, world, SERVERS[kind])
|
|
out = build / f'{label}-{kind}.txt'
|
|
out.unlink(missing_ok=True)
|
|
server = Server(server_command, directory, f'{label}-{kind}')
|
|
try:
|
|
server.wait_for(r'Done \(')
|
|
with (build / f'{label}-{kind}-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)
|
|
try:
|
|
for server_kind, name, delay, answers, in_play in PLAYERS:
|
|
if server_kind != kind:
|
|
continue
|
|
client.stdin.write(f'join {name} {delay} {answers}\n')
|
|
if in_play:
|
|
client.stdin.write(f'play {name} {in_play}\n')
|
|
client.stdin.write('end\n')
|
|
client.stdin.flush()
|
|
client.wait(timeout=180)
|
|
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}-{kind}-client.log)')
|
|
rejections = [' '.join(m.groups()) for m in map(REJECTION.search, server.lines) if m]
|
|
return out.read_text(encoding='utf-8').splitlines() + [f'log rejected {r}' for r in rejections]
|
|
|
|
|
|
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/ServerPackClient.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)
|
|
binary = lab.cargo_target() / '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.ServerPackClient']
|
|
|
|
world = generate_world(vanilla_server, build)
|
|
differ = []
|
|
for kind in SERVERS:
|
|
vanilla = run_side('vanilla', kind, vanilla_server, client, build, world)
|
|
horizon = run_side('horizon', kind, horizon_server, client, build, world)
|
|
if not any('offer ' in l for l in vanilla) or any(' error ' in f' {l} ' for l in vanilla):
|
|
raise AssertionError(f'{kind}: vanilla offered no pack or the client failed: {vanilla}')
|
|
if vanilla != horizon:
|
|
import difflib
|
|
differ.append(f'{kind}:\n' + '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon',
|
|
n=2, lineterm='')))
|
|
if differ:
|
|
raise AssertionError(f'the servers differ (see {build}):\n' + '\n'.join(differ)[:8000])
|
|
lab.write_json(report, dict(
|
|
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
|
|
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
|
players=len(PLAYERS),
|
|
scope='A dedicated server\'s resource pack as vanilla-codec players see it: the offer during configuration '
|
|
'(id, URL, hash, required, prompt), its place among the configuration packets, the keep-alives while '
|
|
'the player decides, and each answer\'s outcome (declined, loaded, failed download, declined in '
|
|
'play) for a required and an optional pack, with the server\'s rejection log lines.'))
|
|
print(f'server resource pack: {len(PLAYERS)} players identical')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except AssertionError as error:
|
|
sys.exit(f'{NAME}: {error}')
|