Files
horizon/tools/server_ops_e2e.py
[email protected]andCodex 42a5981e94 refactor(client): isolate gameplay from the reusable client and probes
Keep native hook targets and source paths stable. Run gameplay tests in the gameplay library, and build ctl/probes from horizon-client-lib without gameplay. Update build/test selection and the fixed mixin-tool pin.

Co-Authored-By: Codex <[email protected]>
2026-11-01 12:05:47 +01:00

167 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""An operator's commands on Horizon's dedicated server, typed by a Horizon player, end to end.
Vanilla decides what an operator may run by its permission level (ops.json, `op-permission-level`):
`/kick`, `/ban`, `/ban-ip`, `/pardon`, `/pardon-ip`, `/banlist`, `/op`, `/deop` and `/whitelist` need 3 (admins),
`/tp` and `/gamemode` need 2 (game masters). A server's operators moderate it with them; the console is not the
only way.
python3 tools/server_ops_e2e.py --bin-dir target/fast
A survival dedicated server (offline mode, a flat world) with the player Opal in ops.json, at level 2, 3 and 4 in
turn. Opal joins with Horizon's game, driven by horizon-ctl, and types each command in the chat as a player does;
horizon-bots players stand in as the moderated ones. Each command her level allows must give vanilla's feedback in
her chat and change what it changes (the bot leaves, banned-players.json, banned-ips.json, whitelist.json,
ops.json); at level 2 the admins' commands must do nothing.
The game and the server run with fresh HORIZON_HOME, launcher and XDG folders and without audio. Logs go to --out.
Build first:
cargo build --profile fast -p horizon-client -p horizon-client-lib --bins
cargo build --profile fast -p horizon-bots
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
from agent_control_e2e import Failed # noqa: E402
from lan_e2e import Game # noqa: E402
from server_bench import DONE, Server # noqa: E402
EXE = '.exe' if os.name == 'nt' else ''
PLAYER = 'Opal'
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'gamemode': 'survival',
'spawn-protection': '0',
'view-distance': '3',
'simulation-distance': '3',
}
IP = '203.0.113.7'
# (command, the level it needs, a line Opal's chat must show when hers is enough).
COMMANDS = [
('tp Kim0 0.5 -60 8.5', 2, 'Teleported Kim0 to 0.500000, -60.000000, 8.500000'),
('gamemode creative Kim0', 2, "Set Kim0's game mode to Creative Mode"),
('op Kim0', 3, 'Made Kim0 a server operator'),
('deop Kim0', 3, 'Made Kim0 no longer a server operator'),
('whitelist add Kim0', 3, 'Added Kim0 to the whitelist'),
('whitelist remove Kim0', 3, 'Removed Kim0 from the whitelist'),
('ban-ip ' + IP + ' spam bots', 3, f'Banned IP {IP}: spam bots'),
('banlist ips', 3, 'There are 1 ban(s):'),
('pardon-ip ' + IP, 3, f'Unbanned IP {IP}'),
('kick Kim0 spamming chat', 3, 'Kicked Kim0: spamming chat'),
('ban Lou0 cheating', 3, 'Banned Lou0: cheating'),
('banlist', 3, 'There are 1 ban(s):'),
('pardon Lou0', 3, 'Unbanned Lou0'),
]
def type_command(game, command):
"""Opens the chat with `/`, types the rest and sends it, as a player does."""
game.raw('key', 'slash', 'tap')
game.until(lambda: game('screen').get('screen'), lambda s: s == 'chat', ticks=10)
game.raw('type', command)
game.raw('key', 'enter', 'tap')
game.wait(10)
def chat(game):
return game.raw('messages', 50)['chat']
def bot(bins, port, name, seconds):
return subprocess.Popen([bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
'--behaviour', 'stand', '--seconds', str(seconds), '--name', name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def run(bins, level, out):
work = Path(tempfile.mkdtemp(prefix=f'hz-server-ops-{level}-'))
folder = work / 'server'
folder.mkdir()
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')
(folder / 'ops.json').write_text(json.dumps([{'uuid': str(lab.offline_uuid(PLAYER)), 'name': PLAYER,
'level': level, 'bypassesPlayerLimit': False}]))
command = [bins / f'horizon-client{EXE}', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
server = Server(command, folder, out / f'server-{level}.log')
game = Game(f'opal{level}', bins / 'horizon-ctl', lab.RESOURCES.resolve())
for key in ('XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'HORIZON_LAUNCHER_HOME'):
game.d.env[key] = str(game.work / key.lower())
game.d.env['HORIZON_LAUNCHER_NO_SELF_UPDATE'] = '1'
bots = []
failed = []
try:
server.wait_for(DONE, 300)
game('launch', wait=False, offscreen=sys.platform == 'darwin')
game.raw('connect', f'127.0.0.1:{port}', PLAYER)
game.raw('wait-for', 'loaded', '--timeout', 300)
server.wait_for(rf'{PLAYER} joined the game', 60)
bots = [bot(bins, port, name, 600) for name in ('Kim', 'Lou')]
for name in ('Kim0', 'Lou0'):
server.wait_for(rf'{name} joined the game', 120)
game.wait(20)
for command, needs, expected in COMMANDS:
type_command(game, command)
lines = chat(game)
shown = any(expected in line for line in lines)
print(f'[level {level}] /{command}: {"ran" if shown else "did not run"}')
if shown != (level >= needs):
failed.append(f'level {level}: /{command} {"ran" if shown else "gave no " + repr(expected)}; '
f'last chat: {lines[-3:]}')
files = {name: json.loads((folder / name).read_text()) if (folder / name).exists() else []
for name in ('banned-players.json', 'banned-ips.json', 'whitelist.json', 'ops.json')}
if [e['name'] for e in files['ops.json']] != [PLAYER]:
failed.append(f'level {level}: ops.json after /op and /deop: {files["ops.json"]}')
if files['banned-players.json'] or files['banned-ips.json'] or files['whitelist.json']:
failed.append(f'level {level}: bans or whitelist left after pardon and remove: {files}')
log = server.since(0)
for name, how in [('Kim0', 'lost connection: spamming chat'), ('Lou0', 'lost connection')]:
if any(f'{name} {how}' in line for line in log) != (level >= 3):
failed.append(f'level {level}: {name} {"left" if level < 3 else "stayed"}')
finally:
(out / f'client-{level}.log').write_text(game.close())
for b in bots:
b.kill()
server.stop()
shutil.rmtree(game.work, ignore_errors=True)
shutil.rmtree(work, ignore_errors=True)
return 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-ops-e2e'))
parser.add_argument('--level', type=int, action='append', choices=[2, 3, 4],
help='only this ops.json level (repeatable; default 2, 3 and 4)')
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())
failed = []
for level in args.level or [2, 3, 4]:
try:
failed += run(bins, level, out)
except (Failed, AssertionError, RuntimeError) as e:
failed.append(f'level {level}: {e}')
if failed:
sys.exit(f'server_ops_e2e failed (logs in {out}):\n' + '\n'.join(failed))
print('server_ops_e2e: operators at levels 3 and 4 ran every moderation command, one at level 2 none of them')
if __name__ == '__main__':
main()