Files
horizon-airdef/tools/test_world_reopen_parity.py
T
Ludovic Marie d271176f23 Tools and harnesses start the game in scratch folders, not a player's
The game logs into HORIZON_HOME and falls back to the player's own game folder when it is unset. Harnesses
that set only HORIZON_NO_AUDIO (and test_native_client, which set nothing) wrote logs, and on a world run
advancements, into ~/Library/Application Support/horizon.

lab.game_env gives every game a fresh HORIZON_HOME, HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME,
no audio and no launcher self-update; a folder the caller passes is kept. Every launcher of the game uses it,
parity.py gives each harness its own HORIZON_HOME, and tests/test_game_home.py fails for a script in tools/
that starts the game without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:34:32 +02:00

106 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""Leaving a world and opening it again keeps its entities as they were (#374).
Vanilla's integrated server unloads every entity with the level when the player saves
and quits (`MinecraftServer.stopServer`), so a world reopened in the same session holds
each entity once. Horizon kept non-mob entities (minecarts, boats, armor stands...) in
memory after Save and Quit to Title; opening the world loaded them again from disk and
the next save wrote both copies, doubling them each time, until the game crawled.
The game runs offscreen (macOS) or on the virtual screen (Linux) with a control socket:
a minecart is summoned beside the player (seed 7 also has mineshaft chest minecarts in
view), then the world is closed through the pause menu and reopened from the title three
times. The minecarts must be the same ones each time, each once.
test_world_reopen_parity.py [--bin-dir DIR] [--keep]
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
RESOURCES = ROOT / '.local' / 'horizon-client' / '26.3'
RUN = ROOT / '.local' / 'world-reopen'
CYCLES = 3
def game_env():
"""macOS renders offscreen; Linux uses the virtual screen (as test_save_roundtrip_parity)."""
env = lab.game_env()
if sys.platform == 'darwin':
return env, ['--offscreen']
subprocess.run(['vscreen', 'up'], check=False, capture_output=True)
text = subprocess.run(['vscreen', 'env'], capture_output=True, text=True, check=True).stdout
env.pop('DISPLAY', None)
for match in re.finditer(r'(\w+)=(\S+)', text.split('unset')[0]):
env[match.group(1)] = match.group(2)
return env, []
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build them with the fast profile)')
parser.add_argument('--keep', action='store_true', help='keep the run folder')
args = parser.parse_args()
bin_dir = Path(args.bin_dir).resolve() if args.bin_dir else None
if bin_dir is None:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'], cwd=ROOT, check=True)
bin_dir = ROOT / os.environ.get('CARGO_TARGET_DIR', 'target') / 'fast'
shutil.rmtree(RUN, ignore_errors=True)
RUN.mkdir(parents=True)
socket = Path(tempfile.mkdtemp(prefix='hzro')) / 'control.sock' # sockets need short paths
env, extra = game_env()
def ctl(*words, check=True):
result = subprocess.run([str(bin_dir / 'horizon-ctl'), '--socket', str(socket), '--timeout', '600', *words],
cwd=RUN, env=env, capture_output=True, text=True)
if check and result.returncode:
sys.exit(f'horizon-ctl {" ".join(words)} failed: {result.stdout}{result.stderr}')
return json.loads(result.stdout) if result.stdout.strip().startswith('{') else result.stdout
def carts():
found = ctl('status')['carts']
return sorted((c['kind'], *(round(v, 1) for v in c['position'])) for c in found)
ctl('launch', '--resources', str(RESOURCES), '--world', 'reopen', '--seed', '7', '--creative',
'--render-distance', '4', *extra)
failures = 0
try:
ctl('wait-ready', '300')
ctl('command', 'summon minecraft:minecart ~2 ~ ~2')
ctl('wait', '20')
expected = carts()
print('minecarts:', len(expected))
if not any(kind == 'minecraft:minecart' for kind, *_ in expected):
sys.exit(f'the summoned minecart is not there: {expected}')
for cycle in range(1, CYCLES + 1):
ctl('pause')
ctl('click-button', 'Save and Quit to Title')
ctl('wait-for', 'screen', 'title', '--timeout', '120')
ctl('open', 'reopen')
ctl('wait-for', 'loaded', '--timeout', '300')
ctl('wait', '20')
got = carts()
ok = got == expected
failures += not ok
print(f'reopen {cycle}: {len(got)} minecarts', 'ok' if ok else f'FAIL (expected {len(expected)})')
finally:
ctl('quit', check=False)
if not args.keep and not failures:
shutil.rmtree(RUN, ignore_errors=True)
print('world reopen:', 'PASS' if not failures else f'{failures} FAILED')
sys.exit(1 if failures else 0)
if __name__ == '__main__':
main()