874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
404 lines
19 KiB
Python
404 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Play with friends end to end, without the internet.
|
|
|
|
(a) The code alone: a host Horizon opens a world and shares it (`friends
|
|
host`) with no relay anywhere; a guest Horizon joins with the full invite
|
|
code (`connect friend:CODE`) by dialling the endpoints in it. (b) A
|
|
self-hosted relay run locally (`horizon-relay`): the host also gets a short
|
|
code from it, and a guest started with HORIZON_FRIENDS_PATH=relay joins with
|
|
the short code through it. Each time both sides must see the other's chat
|
|
line, the other moving, a block the host places and the block the guest
|
|
breaks; then the host stops sharing and a guest with the old code is told
|
|
why it can't join. `--baseline` first runs the same checks over plain LAN.
|
|
The router is never asked to open a port (HORIZON_FRIENDS_UPNP=0) and STUN
|
|
is off: nothing leaves the machine.
|
|
|
|
python3 tools/test_friends.py --client target/fast/horizon-client --relay target/fast/horizon-relay
|
|
|
|
The host runs on its integrated server thread by default (the window is a
|
|
client of it). `--in-frame` compares the fallback; `--server-thread` is accepted
|
|
for compatibility.
|
|
|
|
`--role host|guest` runs one side only (two machines, e.g. over a tailnet):
|
|
the host prints the code, the guest takes it with `--code` (`--relay-url`
|
|
for a relay elsewhere, `--path relay` to force it). Needs a display on Linux
|
|
(xvfb-run -a).
|
|
"""
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
|
|
try:
|
|
from . import lab
|
|
from .integration_env import game_environment, require_server_reports
|
|
except ImportError:
|
|
import lab
|
|
from integration_env import game_environment, require_server_reports
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def command(endpoint, line, check=True):
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
connection.settimeout(60)
|
|
connection.connect(str(endpoint))
|
|
connection.sendall((line + '\n').encode())
|
|
with connection.makefile('r') as reply:
|
|
result = json.loads(reply.readline())
|
|
if check:
|
|
assert 'error' not in result, (line, result)
|
|
return result
|
|
|
|
|
|
def wait_for(endpoint, line, predicate, timeout=60, what=None):
|
|
deadline = time.monotonic() + timeout
|
|
last = None
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
last = command(endpoint, line, check=False)
|
|
except (FileNotFoundError, ConnectionRefusedError):
|
|
time.sleep(0.1)
|
|
continue
|
|
try:
|
|
if predicate(last):
|
|
return last
|
|
except (KeyError, TypeError, IndexError):
|
|
pass
|
|
time.sleep(0.1)
|
|
raise AssertionError(f'timed out waiting for {what or line}: {last}')
|
|
|
|
|
|
def health(relay_url):
|
|
with urllib.request.urlopen(f'http://{relay_url}/health', timeout=5) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
class Game:
|
|
"""A Horizon client with a control socket in its own home."""
|
|
|
|
# Extra words before and after the client (`--xvfb`, `--offscreen`).
|
|
prefix = []
|
|
extra = []
|
|
# The host's world on its server thread (`--server-thread`), else in the window.
|
|
server_thread = True
|
|
|
|
def __init__(self, client, resources, work, name, env):
|
|
# Either way explicitly, whatever the game's default.
|
|
env = dict(env, HORIZON_SERVER_THREAD='1' if Game.server_thread and name == 'host' else '0')
|
|
self.home = work / name
|
|
self.home.mkdir()
|
|
self.endpoint = self.home / 'c.sock'
|
|
self.log = (self.home / 'client.log').open('w+')
|
|
self.process = subprocess.Popen(Game.prefix + [
|
|
str(client), '--resources', str(resources), '--render-distance', '2', '--test-terrain',
|
|
'--control-socket', str(self.endpoint), '--timeout-seconds', '600',
|
|
] + Game.extra, cwd=self.home,
|
|
env={**game_environment({**os.environ, **env}, self.home), 'HORIZON_HOME': str(self.home)},
|
|
stdout=self.log, stderr=subprocess.STDOUT)
|
|
wait_for(self.endpoint, 'screen', lambda v: v['screen'] == 'title', 120, f'{name} title')
|
|
|
|
def __call__(self, line, check=True):
|
|
return command(self.endpoint, line, check)
|
|
|
|
def wait(self, line, predicate, timeout=60, what=None):
|
|
return wait_for(self.endpoint, line, predicate, timeout, what)
|
|
|
|
def stop(self):
|
|
if self.process.poll() is None:
|
|
try:
|
|
self('quit', check=False)
|
|
self.process.wait(timeout=20)
|
|
except Exception:
|
|
self.process.terminate()
|
|
try:
|
|
self.process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
self.process.kill()
|
|
self.log.seek(0)
|
|
return self.log.read()
|
|
|
|
|
|
def host_world(host):
|
|
host('open Friends 0 creative')
|
|
host.wait('status', lambda v: v['player'] is not None and v['pending_chunks'] == 0, 120, 'host world')
|
|
host('friends host')
|
|
return host.wait('friends status', lambda v: v['code'], 30, 'invite code')['code']
|
|
|
|
|
|
def guest_pos(host, name):
|
|
guests = host('lan status')['guests']
|
|
return next(g['position'] for g in guests if g['name'] == name)
|
|
|
|
|
|
def open_ground(host, guest, name):
|
|
"""Puts the guest on a cleared, flat floor beside the host and returns where it stands.
|
|
|
|
A player new to the world spawns at a random free spot near the world spawn, as in vanilla
|
|
(`PlayerSpawnFinder`), often against a hill: walking and aiming need known, open ground.
|
|
Commands, not the control socket: the server has the last word on where the guest stands.
|
|
"""
|
|
hp = host('status')['player']['position']
|
|
x, y, z = math.floor(hp[0]) - 6, math.floor(hp[1]), math.floor(hp[2])
|
|
host(f'command fill {x - 2} {y - 1} {z - 2} {x + 4} {y - 1} {z + 8} minecraft:stone')
|
|
host(f'command fill {x - 2} {y} {z - 2} {x + 4} {y + 3} {z + 8} minecraft:air')
|
|
spot = [x + 0.5, float(y), z + 0.5]
|
|
host(f'command tp {name} {spot[0]} {spot[1]} {spot[2]} 0 0')
|
|
guest.wait('status', lambda v: math.dist(v['player']['position'], spot) < 0.01 and v['player']['on_ground'], 20,
|
|
'the guest on its ground')
|
|
return spot
|
|
|
|
|
|
def play_together(host, guest, name, round_index):
|
|
"""Chat, movement and blocks seen from both sides."""
|
|
host.wait('lan status', lambda v: any(g['name'] == name and g['placed'] for g in v['guests']), 60,
|
|
'the guest in the host world')
|
|
guest.wait('status', lambda v: v['server']['joined'] and v['player'] is not None, 60, 'guest joined')
|
|
# Chat both ways.
|
|
guest(f'chat hello from {name} {round_index}')
|
|
host.wait('chat-log 20', lambda v: any(f'hello from {name} {round_index}' in l for l in v['chat']), 30,
|
|
'guest chat on the host')
|
|
host(f'chat hello from the host {round_index}')
|
|
guest.wait('status', lambda v: any(f'hello from the host {round_index}' in l for l in v['server']['chat']), 30,
|
|
'host chat on the guest')
|
|
# The guest walks; the host sees it move.
|
|
open_ground(host, guest, name)
|
|
before = guest_pos(host, name)
|
|
guest('look 0 0')
|
|
guest('key w down')
|
|
try:
|
|
host.wait('lan status', lambda v: math.dist(before, next(g['position'] for g in v['guests'] if g['name'] == name)) > 1.0,
|
|
20, 'guest movement on the host')
|
|
finally:
|
|
guest('key w up')
|
|
# The host moves; the guest sees the host's player there.
|
|
hp = host('status')['player']['position']
|
|
target = [hp[0] + 3.0, hp[1], hp[2] + 1.0]
|
|
# The /tp command, not the control socket's local move: on the server thread the window is a
|
|
# client, and its server has the last word on where the host stands.
|
|
host(f'command tp @s {target[0]} {target[1]} {target[2]}')
|
|
guest.wait('status', lambda v: any(math.dist(p, target) < 0.6 for p in v['server']['player_positions']), 20,
|
|
'host movement on the guest')
|
|
# A block the host places, which the guest sees and then breaks.
|
|
gp = guest.wait('status', lambda v: v['player']['on_ground'] and math.hypot(v['player']['velocity'][0],
|
|
v['player']['velocity'][2]) < 1e-3, 10, 'the guest standing still')['player']['position']
|
|
feet = [math.floor(gp[0]), math.floor(gp[1]), math.floor(gp[2])]
|
|
block = [feet[0] + 2, feet[1] + 1, feet[2]]
|
|
# Clear the line of sight (the test terrain has hills), then the block at eye level.
|
|
# As commands, for the same reason as the /tp above.
|
|
host(f'command fill {feet[0] + 1} {feet[1]} {feet[2]} {feet[0] + 1} {feet[1] + 2} {feet[2]} minecraft:air')
|
|
host(f'command setblock {block[0]} {block[1]} {block[2]} minecraft:gold_block')
|
|
guest.wait(f'getblock {block[0]} {block[1]} {block[2]}', lambda v: v['block'] == 'minecraft:gold_block', 20,
|
|
'the host block on the guest')
|
|
eye = [gp[0], gp[1] + 1.62, gp[2]]
|
|
dx, dy, dz = block[0] + 0.5 - eye[0], block[1] + 0.5 - eye[1], block[2] + 0.5 - eye[2]
|
|
yaw = math.degrees(math.atan2(-dx, dz))
|
|
pitch = -math.degrees(math.atan2(dy, math.hypot(dx, dz)))
|
|
guest(f'look {yaw:.3f} {pitch:.3f}')
|
|
guest.wait('status', lambda v: v['target']['pos'] == block, 10, 'the guest aiming at the block')
|
|
# Held for a few ticks, as a player clicks (creative breaks at once).
|
|
guest('mouse left down')
|
|
try:
|
|
host.wait(f'getblock {block[0]} {block[1]} {block[2]}', lambda v: v['block'] == 'minecraft:air', 20,
|
|
'the guest breaking the block, on the host')
|
|
finally:
|
|
guest('mouse left up')
|
|
guest.wait(f'getblock {block[0]} {block[1]} {block[2]}', lambda v: v['block'] == 'minecraft:air', 20,
|
|
'the broken block on the guest')
|
|
|
|
|
|
def join(guest, code, name):
|
|
guest(f'connect friend:{code} {name}')
|
|
|
|
|
|
def leave(host, guest, name):
|
|
guest('disconnect')
|
|
host.wait('lan status', lambda v: all(g['name'] != name for g in v['guests']), 30, 'the guest leaving')
|
|
|
|
|
|
def start_relay(args, work):
|
|
"""A relay on a free local port, as anyone would run their own."""
|
|
port = lab.free_port()
|
|
url = f'127.0.0.1:{port}'
|
|
log = (work / 'relay.log').open('w+')
|
|
process = subprocess.Popen([str(args.relay.resolve())], env=dict(os.environ, PORT=str(port)),
|
|
stdout=log, stderr=subprocess.STDOUT)
|
|
deadline = time.monotonic() + 10
|
|
while True:
|
|
try:
|
|
health(url)
|
|
return process, log, url
|
|
except OSError:
|
|
assert time.monotonic() < deadline, 'the relay did not start'
|
|
time.sleep(0.1)
|
|
|
|
|
|
def set_relay(game, url):
|
|
"""What typing the relay on Join a friend saves (horizon.txt)."""
|
|
path = game.home / 'horizon.txt'
|
|
lines = [l for l in (path.read_text().splitlines() if path.exists() else []) if not l.startswith('friendsRelay:')]
|
|
path.write_text('\n'.join(lines + [f'friendsRelay:{url}']) + '\n')
|
|
|
|
|
|
def full(args, work):
|
|
# Tests never touch the real router or ask the internet. Both games run on
|
|
# this machine, so the code also holds 127.0.0.1 (a firewall such as
|
|
# macOS's may hold back connections to the LAN address from an unsigned
|
|
# binary started over SSH, which can't show its prompt).
|
|
env = {'HORIZON_FRIENDS_UPNP': '0', 'HORIZON_STUN_SERVERS': 'none', 'HORIZON_FRIENDS_LOOPBACK': '1'}
|
|
host = Game(args.client, args.resources, work, 'host', env)
|
|
games = [host]
|
|
relay = None
|
|
try:
|
|
# (a) The code alone: no relay anywhere.
|
|
code = host_world(host)
|
|
status = host('friends status')
|
|
assert status['short'] is None and len(code) > 30, status
|
|
print(f'host shares the world: a {len(code.replace("-", ""))}-symbol code, no relay; {status["summary"]}')
|
|
if args.baseline:
|
|
# The same checks over plain LAN, without the tunnel: the behaviour
|
|
# the tunnel must reproduce.
|
|
guest = Game(args.client, args.resources, work, 'guest-lan', env)
|
|
games.append(guest)
|
|
guest(f'connect 127.0.0.1:{host("lan status")["port"]} Plain')
|
|
play_together(host, guest, 'Plain', 0)
|
|
print('plain LAN: chat, movement and blocks both ways')
|
|
leave(host, guest, 'Plain')
|
|
guest.stop()
|
|
guest = Game(args.client, args.resources, work, 'guest-code', env)
|
|
games.append(guest)
|
|
join(guest, code, 'Guest')
|
|
play_together(host, guest, 'Guest', 1)
|
|
assert host('friends status')['direct'] == 1, host('friends status')
|
|
print('code alone: chat, movement and blocks both ways, no relay involved')
|
|
leave(host, guest, 'Guest')
|
|
host('friends stop')
|
|
assert host('friends status')['code'] is None
|
|
assert host('lan status') == {'open': False}, 'sharing opened LAN, so stopping closes it'
|
|
join(guest, code, 'Late')
|
|
reason = guest.wait('screen', lambda v: v['menu']['disconnected'], 30, 'the ended code')['menu']['disconnected']
|
|
assert "Couldn't reach your friend's game" in reason, reason
|
|
print(f'after stopping: "{reason}"')
|
|
guest.stop()
|
|
# (b) A self-hosted relay, run locally: the short code, relayed.
|
|
relay, relay_log, relay_url = start_relay(args, work)
|
|
set_relay(host, relay_url)
|
|
host('friends host')
|
|
status = host.wait('friends status', lambda v: v['short'], 30, 'the short code')
|
|
short = status['short']
|
|
guest = Game(args.client, args.resources, work, 'guest-relay', dict(env, HORIZON_FRIENDS_PATH='relay'))
|
|
games.append(guest)
|
|
set_relay(guest, relay_url)
|
|
before = health(relay_url)['relayed_bytes']
|
|
join(guest, short, 'Relayed')
|
|
play_together(host, guest, 'Relayed', 2)
|
|
relayed_bytes = health(relay_url)['relayed_bytes'] - before
|
|
status = host('friends status')
|
|
assert status['relayed'] == 1, status
|
|
# Render distance 2: the join is some tens of kB of compressed chunks.
|
|
assert relayed_bytes > 32 * 1024, f'the relayed session carried only {relayed_bytes} bytes'
|
|
print(f'self-hosted relay, short code {short[:4]}-****: chat, movement and blocks both ways; '
|
|
f'relay carried {relayed_bytes} bytes')
|
|
leave(host, guest, 'Relayed')
|
|
host('friends stop')
|
|
join(guest, short, 'Late')
|
|
reason = guest.wait('screen', lambda v: v['menu']['disconnected'], 30, 'the ended invite')['menu']['disconnected']
|
|
assert 'invite has ended' in reason, reason
|
|
print(f'after stopping: "{reason}"')
|
|
return code + short
|
|
except Exception:
|
|
for g in games:
|
|
print(f'--- {g.home.name} log tail ---')
|
|
print('\n'.join(g.stop().splitlines()[-25:]))
|
|
raise
|
|
finally:
|
|
for g in games:
|
|
g.stop()
|
|
if relay:
|
|
relay.terminate()
|
|
relay.wait(timeout=10)
|
|
relay_log.seek(0)
|
|
text = relay_log.read()
|
|
print(f'relay log: {text.splitlines()}')
|
|
# Counts only: no code, no address, no name.
|
|
for secret in ['127.0.0.1', 'Guest', 'Relayed', 'Late']:
|
|
assert secret not in text, f'the relay logged {secret!r}'
|
|
|
|
|
|
def one_side(args, work):
|
|
env = {'HORIZON_FRIENDS_UPNP': '0', 'HORIZON_STUN_SERVERS': 'none'}
|
|
if args.relay_url:
|
|
env['HORIZON_RELAY_URL'] = args.relay_url
|
|
if args.path:
|
|
env['HORIZON_FRIENDS_PATH'] = args.path
|
|
game = Game(args.client, args.resources, work, args.role, env)
|
|
try:
|
|
if args.role == 'host':
|
|
code = host_world(game)
|
|
print(f'CODE {code}', flush=True)
|
|
game.wait('lan status', lambda v: any(g['name'] == 'Remote' for g in v['guests']), args.wait, 'a guest')
|
|
game.wait('lan status', lambda v: all(g['name'] != 'Remote' for g in v['guests']), args.wait, 'the guest leaving')
|
|
print(f'host: {game("friends status")}')
|
|
else:
|
|
join(game, args.code, 'Remote')
|
|
game.wait('status', lambda v: v['server']['joined'] and v['player'] is not None, 60, 'joined')
|
|
game('chat hello across machines')
|
|
v = game.wait('status', lambda v: v['server']['chunks'] > 0, 30, 'chunks')
|
|
print(f'guest joined: {v["server"]["address"]}, {v["server"]["chunks"]} chunks')
|
|
game('key w down')
|
|
time.sleep(1.0)
|
|
game('key w up')
|
|
time.sleep(args.hold)
|
|
game('disconnect')
|
|
finally:
|
|
game.stop()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--client', type=Path, required=True)
|
|
parser.add_argument('--relay', type=Path, help='horizon-relay binary (started on a free local port)')
|
|
parser.add_argument('--relay-url', help='--role: a relay already running elsewhere, HOST:PORT')
|
|
parser.add_argument('--resources', type=Path, default=lab.RESOURCES)
|
|
parser.add_argument('--role', choices=['host', 'guest'])
|
|
parser.add_argument('--code')
|
|
parser.add_argument('--path', choices=['relay', 'direct'])
|
|
parser.add_argument('--wait', type=float, default=600)
|
|
parser.add_argument('--hold', type=float, default=0, help='guest: stay this many seconds before leaving')
|
|
parser.add_argument('--baseline', action='store_true', help='first run the same checks over plain LAN')
|
|
parser.add_argument('--offscreen', action='store_true', help='run the games without a window')
|
|
mode = parser.add_mutually_exclusive_group()
|
|
mode.add_argument('--server-thread', dest='server_thread', action='store_true', default=True,
|
|
help='host on its server thread (default; retained for existing callers)')
|
|
mode.add_argument('--in-frame', dest='server_thread', action='store_false',
|
|
help='host the world in the window, for comparison')
|
|
parser.add_argument('--xvfb', action='store_true', help='run the games under xvfb-run (headless Linux)')
|
|
args = parser.parse_args()
|
|
Game.prefix = ['xvfb-run', '-a'] if args.xvfb else []
|
|
Game.extra = ['--offscreen'] if args.offscreen else []
|
|
Game.server_thread = args.server_thread
|
|
args.client, args.resources = args.client.resolve(), args.resources.resolve()
|
|
try:
|
|
require_server_reports(args.resources)
|
|
except ValueError as error:
|
|
parser.error(str(error))
|
|
# Short: Unix socket paths are limited to about 100 bytes.
|
|
with tempfile.TemporaryDirectory(prefix='hzf-', dir='/tmp') as directory:
|
|
work = Path(directory)
|
|
print(f'Friends host mode: {"server-thread" if Game.server_thread else "in-frame"}')
|
|
if args.role:
|
|
return one_side(args, work)
|
|
assert args.relay, 'give --relay (the relay binary, run locally for the relay half)'
|
|
full(args, work)
|
|
print('Play with friends passed: the code alone and a self-hosted relay; chat, movement and blocks both ways')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|