946bf84818
`/summon minecraft:tnt` and primed TNT in a saved chunk were kept as an unsimulated entity. They now become primed TNT with the fuse (80 when absent) and motion they were saved with (PrimedTnt.readAdditionalSaveData). The entity harness runs its scene for a fixed 200 ticks (`tick step`), so what still changes when the client stops recording (a fuse) ends the same on both servers. Its scene has two primed TNT (one falling), a floating falling block, an arrow that lands and an end crystal. Unit tests cover arrows' position and rotation, fireballs' bundled power and precise position syncs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
10 KiB
Python
186 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Entities other than mobs and items, tracked to a vanilla client, against vanilla's dedicated server.
|
|
|
|
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat
|
|
world vanilla generated first, with the same server.properties (offline, view distance 4). A vanilla-codec
|
|
client (VanillaProtocolClient in its entity mode) joins, and once its chunks are in it asks for the scene: the
|
|
console freezes the ticks, builds a wall and a rail and summons, around the spawn, every kind of minecart (one
|
|
set rolling along the rail), boats and a raft (one falling onto the ground), armor stands (posed, small, with
|
|
arms, wearing a helmet, one falling), a chicken jockey, item frames (one holding a turned diamond), paintings,
|
|
primed TNT (one falling), a floating falling block, an arrow that lands and an end crystal. The client records
|
|
for 20 seconds, the ticks running for 200 more after 3, what ClientPacketListener would make of the packets (EntityTranscript): each entity's spawn
|
|
packet (position, rotations, data, motion), the entity data it was paired with and all it was sent since, its
|
|
equipment and passengers, and where its position syncs leave it. Both transcripts must match exactly,
|
|
entity by entity (named by type and the block it was added in). Attributes are recorded but not compared yet:
|
|
Horizon sends no ClientboundUpdateAttributesPacket, for mobs either.
|
|
"""
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
try:
|
|
from . import lab, test_rust_parity as base
|
|
from .test_dedicated_server_parity import Server
|
|
from .test_locator_bar_parity import generate_world, server_dir
|
|
except ImportError:
|
|
import lab
|
|
import test_rust_parity as base
|
|
from test_dedicated_server_parity import Server
|
|
from test_locator_bar_parity import generate_world, server_dir
|
|
|
|
NAME = 'server-entities-parity'
|
|
# Ticks the scene runs for once it is summoned: 10 seconds, within the client's 20.
|
|
STEPS = 200
|
|
PLAYER = 'Tester'
|
|
|
|
# Around the spawn at 0 -60 0 on the flat world's grass: a wall to the north for the frames and paintings,
|
|
# a rail to the south.
|
|
SCENE = [
|
|
'fill -7 -60 -6 7 -57 -6 minecraft:stone',
|
|
'fill -7 -60 9 7 -60 9 minecraft:rail',
|
|
# Minecarts.
|
|
'summon minecraft:minecart -5.5 -60 3.5',
|
|
'summon minecraft:chest_minecart -3.5 -60 3.5',
|
|
'summon minecraft:furnace_minecart -1.5 -60 3.5',
|
|
'summon minecraft:hopper_minecart 1.5 -60 3.5',
|
|
'summon minecraft:tnt_minecart 3.5 -60 3.5',
|
|
'summon minecraft:command_block_minecart 5.5 -60 1.5 {Command:"say hi"}',
|
|
'summon minecraft:minecart -6.5 -60 9.5 {Motion:[0.5d,0d,0d]}',
|
|
# Boats.
|
|
'summon minecraft:oak_boat -5.5 -60 6.5 {Rotation:[45f,0f]}',
|
|
'summon minecraft:birch_chest_boat -2.5 -60 6.5',
|
|
'summon minecraft:bamboo_raft 0.5 -60 6.5',
|
|
'summon minecraft:oak_boat 4.5 -56 6.5',
|
|
# Armor stands.
|
|
'summon minecraft:armor_stand -2.5 -60 -2.5 {Rotation:[90f,0f]}',
|
|
'summon minecraft:armor_stand 6.5 -57 -2.5',
|
|
'summon minecraft:armor_stand -4.5 -60 -2.5 {ShowArms:1b,Small:1b,NoBasePlate:1b,'
|
|
'Pose:{Head:[10f,20f,0f],RightArm:[-30f,0f,15f]},equipment:{head:{id:"minecraft:diamond_helmet",count:1}}}',
|
|
# A chicken jockey (passengers).
|
|
'summon minecraft:chicken -6.5 -60 -2.5 {NoAI:1b,Passengers:[{id:"minecraft:husk",IsBaby:1b,NoAI:1b}]}',
|
|
# Frames and paintings on the wall's south face.
|
|
'summon minecraft:item_frame -3.5 -59 -4.97 {block_pos:[I;-4,-59,-5],Facing:3b,'
|
|
'Item:{id:"minecraft:diamond",count:1},ItemRotation:3b}',
|
|
'summon minecraft:glow_item_frame -1.5 -59 -4.97 {block_pos:[I;-2,-59,-5],Facing:3b}',
|
|
'summon minecraft:painting 1.5 -59 -4.97 {block_pos:[I;1,-59,-5],facing:0b,variant:"minecraft:kebab"}',
|
|
'summon minecraft:painting 4 -59 -4.97 {block_pos:[I;4,-59,-5],facing:0b,variant:"minecraft:courbet"}',
|
|
# Primed TNT that won't go off (one falling), a floating falling block, an arrow that lands, an end crystal.
|
|
'summon minecraft:tnt 6.5 -60 6.5 {fuse:2000s}',
|
|
'summon minecraft:tnt 6.5 -56 4.5 {fuse:2000s}',
|
|
'summon minecraft:falling_block 2.5 -58 -0.5 {BlockState:{Name:"minecraft:sand"},NoGravity:1b}',
|
|
'summon minecraft:arrow 0.5 -55 -1.5 {Motion:[0.3d,0d,0d]}',
|
|
'summon minecraft:end_crystal -6.5 -60 6.5 {ShowBottom:0b}',
|
|
]
|
|
|
|
|
|
def scene_lines(lines):
|
|
"""The transcript's entities in the scene's area (the world's own animals and slimes wander elsewhere),
|
|
without their attributes: ClientboundUpdateAttributesPacket isn't sent by Horizon yet, for mobs either."""
|
|
out = []
|
|
for line in lines:
|
|
m = re.match(r'play-entities entity \S+ (-?\d+),-?\d+,(-?\d+)', line)
|
|
if m and -8 <= int(m.group(1)) <= 8 and -7 <= int(m.group(2)) <= 10:
|
|
out.append(re.sub(r' \| attributes \{[^|]*\}', '', line))
|
|
return out
|
|
|
|
|
|
def run_side(label, server_command, client_command, build, world):
|
|
directory = build / label
|
|
port = server_dir(directory, world)
|
|
out = build / f'{label}-client.txt'
|
|
out.unlink(missing_ok=True)
|
|
server = Server(server_command, directory, label)
|
|
try:
|
|
server.wait_for(r'Done \(')
|
|
with (build / f'{label}-client.log').open('w') as log:
|
|
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), PLAYER, str(out), 'entities'],
|
|
cwd=lab.ROOT, stdout=log, stderr=subprocess.STDOUT, text=True)
|
|
try:
|
|
server.wait_for(rf'<{PLAYER}> summon the entities', timeout=180)
|
|
# Frozen, so that every entity is paired as it was summoned whenever each server tracks it in
|
|
# its tick; then they fall and roll.
|
|
server.console('tick freeze')
|
|
for command in SCENE:
|
|
server.console(command)
|
|
time.sleep(3)
|
|
# A fixed number of ticks, so that what still changes (a TNT fuse) ends the same.
|
|
server.console(f'tick step {STEPS}')
|
|
client.wait(timeout=120)
|
|
finally:
|
|
if client.poll() is None:
|
|
client.kill()
|
|
finally:
|
|
server.stop()
|
|
if not out.exists():
|
|
raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)')
|
|
lines = out.read_text(encoding='utf-8').splitlines()
|
|
(build / f'{label}.txt').write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
|
return lines
|
|
|
|
|
|
def main():
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
build = lab.LOCAL / NAME
|
|
build.mkdir(parents=True, exist_ok=True)
|
|
report = build / 'report.json'
|
|
report.unlink(missing_ok=True)
|
|
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java'
|
|
classes = build / 'classes'
|
|
shutil.rmtree(classes, ignore_errors=True)
|
|
classes.mkdir()
|
|
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
|
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath',
|
|
lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle],
|
|
cwd=lab.ROOT, check=True, capture_output=True)
|
|
# SERVER_ENTITIES_SIDES=vanilla (or horizon) runs one side and reads the other's last transcript.
|
|
sides = [s for s in os.environ.get('SERVER_ENTITIES_SIDES', 'vanilla,horizon').split(',') if s]
|
|
if 'horizon' in sides:
|
|
subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
|
|
check=True, capture_output=True)
|
|
import json
|
|
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
|
|
cwd=lab.ROOT, text=True))
|
|
binary = Path(metadata['target_directory']) / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
|
|
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
|
|
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
|
|
client = ['java', '-Xmx1g', '-Duser.timezone=UTC', '-cp', str(classes) + os.pathsep + dependencies,
|
|
'dev.horizonlab.parity.VanillaProtocolClient']
|
|
|
|
world = generate_world(vanilla_server, build)
|
|
results = {}
|
|
for label, command in [('vanilla', vanilla_server), ('horizon', horizon_server)]:
|
|
if label in sides:
|
|
results[label] = run_side(label, command, client, build, world)
|
|
else:
|
|
results[label] = (build / f'{label}.txt').read_text(encoding='utf-8').splitlines()
|
|
vanilla, horizon = scene_lines(results['vanilla']), scene_lines(results['horizon'])
|
|
errors = [l for l in results['vanilla'] + results['horizon'] if l.startswith('error')]
|
|
if errors or vanilla != horizon:
|
|
import difflib
|
|
diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=0, lineterm=''))
|
|
raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n'
|
|
+ '\n'.join(errors[:10]) + '\n' + diff[:8000])
|
|
if len(vanilla) < len(SCENE) - 2:
|
|
raise AssertionError(f'only {len(vanilla)} entities tracked: the scene did not run (see {build})')
|
|
lab.write_json(report, dict(
|
|
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
|
|
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
|
entities=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
|
|
scope='Minecarts (one rolling), boats (one falling), armor stands, a chicken jockey, item frames, paintings, primed '
|
|
'TNT, a falling block, an arrow and an end crystal tracked to a '
|
|
'vanilla-codec client by a dedicated server: spawn packets, entity data, equipment, passengers and the '
|
|
'positions the syncs leave them at.'))
|
|
print(f'server entities: {len(vanilla)} entities identical')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except AssertionError as error:
|
|
sys.exit(f'{NAME}: {error}')
|