Files
horizon-airdef/tools/test_spawn_loot_parity.py
Ludovic Marie 4471e59132 Load the loot tables before a world's first chunks are read
A loaded container with a loot table is rolled as its chunk is decoded, but
the loot tables were loaded lazily by the first level tick. The spawn chunks
are decoded while the world opens, so a loot chest in them came out empty and
lost its table on the next save. WorldRequest::prepare, which every world
opens through (in-frame, server thread, dedicated server), now loads them
first.

spawn_loot: vanilla writes a flat world with a seeded loot chest by the
spawn and rolls it; Horizon opens it in a fresh process, in-frame and on the
server thread, and must hold the same stacks in the same slots. Without the
fix both modes hold nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:41:23 +01:00

118 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""A vanilla loot chest at spawn has its loot when Horizon opens the world.
Vanilla rolls a container's loot table when a player first opens it
(`RandomizableContainer.unpackLootTable`). Horizon rolls a loaded container with a loot table
and no items as its chunk is read from disk (`save::roll_loaded_loot`), so a container nobody
opens still saves as vanilla wrote it. The loot tables have to be loaded before the first chunks
are read: the spawn chunks are read while the world opens, and a chest read before the tables
came out empty, its loot table lost on the next save.
The vanilla 26.3 server writes a flat world with a seeded loot chest beside the world spawn, then
rolls the chest itself in a copy of that world (`execute if items` unpacks it). Horizon opens the
untouched world in a new process, so nothing was loaded before the world, and must hold the same
items in the same slots. Both single-player paths: the default server thread and in-frame.
test_spawn_loot_parity.py [--bin-dir DIR] [--keep]
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_save_roundtrip_parity import Vanilla # noqa: E402
from test_world_reopen_parity import game_env # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
RESOURCES = ROOT / '.local' / 'horizon-client' / '26.3'
RUN = ROOT / '.local' / 'spawn-loot'
CHEST = (2, -60, 1)
LOOT = '{LootTable:"minecraft:chests/simple_dungeon",LootTableSeed:42L}'
MODES = {'server thread': [], 'in-frame': ['--in-frame']}
def vanilla_world():
"""The world with the unopened chest, and what vanilla rolls from it: [(slot, id, count)]."""
server = Vanilla(RUN / 'v0')
try:
server.run('setworldspawn 0 -60 0')
server.run('forceload add 0 0')
time.sleep(2)
server.run('setblock {} {} {} minecraft:chest{}'.format(*CHEST, LOOT))
server.run('save-all flush')
time.sleep(2)
finally:
server.stop()
shutil.copytree(RUN / 'v0', RUN / 'vv')
server = Vanilla(RUN / 'vv')
try:
server.run('forceload add 0 0')
time.sleep(2)
server.run('execute if items block {} {} {} container.* *'.format(*CHEST))
text = ' '.join(server.run('data get block {} {} {} Items'.format(*CHEST)))
finally:
server.stop()
rolled = []
for item in re.finditer(r'\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}', text.split('data:', 1)[-1]):
found = item.group(0)
count = re.search(r'count: (\d+)', found)
rolled.append((int(re.search(r'Slot: (\d+)b', found).group(1)), re.search(r'id: "([^"]+)"', found).group(1),
int(count.group(1)) if count else 1))
return sorted(rolled)
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)
expected = vanilla_world()
print('vanilla rolls:', expected)
if not expected:
sys.exit('vanilla rolled nothing from the chest')
env, extra = game_env()
failures = 0
for index, (mode, flags) in enumerate(MODES.items()):
folder = RUN / f'h{index}'
shutil.copytree(RUN / 'v0' / 'world', folder / 'saves' / 'world')
socket = Path(tempfile.mkdtemp(prefix='hzsl')) / 'control.sock' # sockets need short paths
def ctl(*words, check=True):
result = subprocess.run([str(bin_dir / 'horizon-ctl'), '--socket', str(socket), '--timeout', '600',
*map(str, words)], cwd=folder, env=env, capture_output=True, text=True)
if check and result.returncode:
sys.exit(f'horizon-ctl {" ".join(map(str, words))} failed: {result.stdout}{result.stderr}')
return json.loads(result.stdout) if result.stdout.strip().startswith('{') else result.stdout
ctl('launch', '--resources', RESOURCES, '--world', 'world', '--render-distance', '4', *flags, *extra)
try:
ctl('wait-ready', '300')
slots = ctl('container', *CHEST)['slots']
finally:
ctl('quit', check=False)
got = sorted((slot, stack['item'], stack['count']) for slot, stack in enumerate(slots) if stack)
ok = got == expected
failures += not ok
print(f'{mode}: {len(got)} stacks', 'ok' if ok else f'FAIL: {got}')
if not args.keep and not failures:
shutil.rmtree(RUN, ignore_errors=True)
print('spawn loot:', 'PASS' if not failures else f'{failures} FAILED')
sys.exit(1 if failures else 0)
if __name__ == '__main__':
main()