4471e59132
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>
118 lines
5.2 KiB
Python
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()
|