mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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]>
167 lines
7.6 KiB
Python
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()
|