13102892c4
The copper golem takes its weathering's texture and glowing eyes, saves and loads next_weather_age and weather_state, starts with its weathering unset instead of weathering on its first tick, and throws what it carries to the player as BehaviorUtils.throwItem does. Iron golems offer their poppy to copper golems too (#candidate_for_iron_golem_gift) and put it on an empty antenna when the offer runs out beside one. The entity model harness now poses the copper golem with its idle and chest interaction animations (44 cases), and the entity death loot harness includes it (1,595 cases over 88 types); both match vanilla. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
12 KiB
Python
186 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare entity death drops (loot tables, equipment drops, experience) with vanilla.
|
|
|
|
Java (VanillaEntityDeathLootOracle) builds the real victim, killer and damage
|
|
source on a stand-in ServerLevel and runs LivingEntity.die's drop step; Rust
|
|
(entity_death_loot_fixture) runs horizon_client::mobs::drops on the same cases.
|
|
Every living entity type with a loot table is killed by a player with and
|
|
without looting, by nothing, on fire, with a smelting weapon, as a baby, with
|
|
luck and with worn equipment, plus each table's special conditions (sheep
|
|
colours, cube sizes, frog kills, skeleton and charged creeper kills, fireballs,
|
|
lightning, raid captains and vehicles). Loot draws come from the world's random
|
|
sequences; several victims are killed in turn per case.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import random
|
|
import subprocess
|
|
try:
|
|
from . import lab, test_rust_parity as base
|
|
except ImportError:
|
|
import lab
|
|
import test_rust_parity as base
|
|
|
|
# Living entity types with an entity loot table that the corpus skips: players
|
|
# and stands are not mobs, the dragon dies over 200 ticks, and the giant is
|
|
# not a Horizon mob type.
|
|
SKIPPED = {'player', 'mannequin', 'armor_stand', 'ender_dragon', 'giant'}
|
|
# Types Horizon keeps body armour for in the head slot: their corpus wears
|
|
# body items instead of helmets.
|
|
BODY = {'horse', 'donkey', 'mule', 'skeleton_horse', 'zombie_horse', 'camel', 'camel_husk', 'llama', 'trader_llama',
|
|
'happy_ghast', 'nautilus', 'zombie_nautilus', 'sulfur_cube'}
|
|
WEAPONS = ['minecraft:iron_sword', 'minecraft:diamond_sword;looting:1', 'minecraft:diamond_sword;looting:2',
|
|
'minecraft:netherite_sword;looting:3', 'minecraft:golden_sword;fire_aspect:2', 'minecraft:diamond_axe;looting:3;fire_aspect:1']
|
|
ARMOUR = {'head': 'minecraft:iron_helmet', 'chest': 'minecraft:chainmail_chestplate', 'legs': 'minecraft:golden_leggings',
|
|
'feet': 'minecraft:diamond_boots', 'mainhand': 'minecraft:iron_sword', 'offhand': 'minecraft:shield'}
|
|
DYES = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple',
|
|
'blue', 'brown', 'green', 'red', 'black']
|
|
|
|
|
|
def corpus(types):
|
|
rng = random.Random(0x4C4F4F54)
|
|
cases = []
|
|
|
|
def add(name, **kv):
|
|
kv.setdefault('seed', rng.randrange(-2**63, 2**63))
|
|
kv.setdefault('rand', rng.randrange(-2**47, 2**47))
|
|
cases.append(name + ''.join(f' {k}={v}' for k, v in kv.items()))
|
|
for t in types:
|
|
n = t.split(':')[1]
|
|
add(f'{n}-player', type=t, killer='player', weapon='minecraft:iron_sword', kills=4)
|
|
add(f'{n}-none', type=t, source='generic', kills=3)
|
|
add(f'{n}-mob', type=t, killer='minecraft:spider', kills=2)
|
|
for i, weapon in enumerate(WEAPONS[1:]):
|
|
add(f'{n}-weapon{i}', type=t, killer='player', weapon=weapon, kills=4)
|
|
add(f'{n}-fire', type=t, killer='player', fire=1, kills=3)
|
|
add(f'{n}-fire-nokill', type=t, fire=1, source='fire', kills=2)
|
|
add(f'{n}-baby', type=t, killer='player', baby=1, weapon='minecraft:iron_sword;looting:3', kills=3)
|
|
add(f'{n}-luck', type=t, killer='player', luck=rng.choice([1, 2, 5]), kills=3)
|
|
add(f'{n}-arrow', type=t, killer='player', source='arrow', weapon='minecraft:bow;looting:3', kills=2)
|
|
head = 'body' if n in BODY else 'head'
|
|
armour = dict(ARMOUR)
|
|
if n in BODY:
|
|
armour['body'] = armour.pop('head').replace('helmet', 'horse_armor')
|
|
add(f'{n}-gear', type=t, killer='player', weapon='minecraft:iron_sword;looting:3', kills=6, **armour)
|
|
add(f'{n}-gear-plain', type=t, killer='player', kills=6, mainhand='minecraft:stone_axe', chance_mainhand=0.5,
|
|
**{head: 'minecraft:leather_helmet', f'chance_{head}': 2.0})
|
|
add(f'{n}-gear-nokill', type=t, source='generic', kills=4, feet='minecraft:iron_boots', chance_feet=1.0,
|
|
**{head: 'minecraft:carved_pumpkin', f'chance_{head}': 2.0})
|
|
add(f'{n}-gear-mob', type=t, killer='minecraft:skeleton', source='arrow', kills=4, feet='minecraft:iron_boots', chance_feet=1.0,
|
|
weapon='minecraft:bow;looting:3')
|
|
for color in DYES:
|
|
add(f'sheep-{color}', type='minecraft:sheep', color=color, killer='player', kills=3)
|
|
add(f'sheep-{color}-sheared', type='minecraft:sheep', color=color, sheared=1, killer='player', kills=2)
|
|
for cube in ['minecraft:slime', 'minecraft:magma_cube', 'minecraft:sulfur_cube']:
|
|
for size in [1, 2, 3, 4]:
|
|
add(f'{cube[10:]}-size{size}', type=cube, size=size, killer='player', weapon='minecraft:iron_sword;looting:2', kills=4)
|
|
for frog in ['temperate', 'warm', 'cold']:
|
|
for size in [1, 2, 4]:
|
|
add(f'magma_cube-frog-{frog}-{size}', type='minecraft:magma_cube', size=size, killer='minecraft:frog', frog='minecraft:' + frog,
|
|
pkill=rng.choice([0, 1]), kills=3)
|
|
add('creeper-skeleton', type='minecraft:creeper', killer='minecraft:skeleton', source='arrow', pkill=1, kills=3)
|
|
add('creeper-stray', type='minecraft:creeper', killer='minecraft:stray', source='arrow', kills=2)
|
|
for victim in ['zombie', 'skeleton', 'creeper', 'wither_skeleton', 'piglin', 'zombie_villager', 'cow']:
|
|
add(f'charged-{victim}', type='minecraft:' + victim, killer='minecraft:creeper', charged=1, source='explosion', kills=2)
|
|
add(f'uncharged-{victim}', type='minecraft:' + victim, killer='minecraft:creeper', source='explosion', kills=2)
|
|
add('ghast-fireball', type='minecraft:ghast', killer='player', source='fireball', kills=4)
|
|
add('ghast-fireball-ghast', type='minecraft:ghast', killer='minecraft:ghast', source='fireball', kills=2)
|
|
add('turtle-lightning', type='minecraft:turtle', source='lightning', kills=3)
|
|
add('turtle-lightning-player', type='minecraft:turtle', source='lightning', pkill=1, kills=3)
|
|
add('pillager-captain', type='minecraft:pillager', captain=1, leader=1, killer='player', kills=3)
|
|
add('pillager-captain-none', type='minecraft:pillager', captain=1, leader=1, source='generic', kills=2)
|
|
add('pillager-captain-looting', type='minecraft:pillager', captain=1, leader=1, killer='player',
|
|
weapon='minecraft:iron_sword;looting:3', kills=4, chance_head=2.0)
|
|
add('pillager-banner-only', type='minecraft:pillager', captain=1, killer='player', kills=3)
|
|
add('pillager-leader-only', type='minecraft:pillager', leader=1, killer='player', kills=2)
|
|
add('vindicator-captain', type='minecraft:vindicator', captain=1, leader=1, killer='player', kills=2)
|
|
for animal in ['pig', 'strider', 'horse', 'camel', 'donkey']:
|
|
add(f'{animal}-saddle', type='minecraft:' + animal, saddle='minecraft:saddle', chance_saddle=2.0, killer='player', kills=2)
|
|
add(f'{animal}-saddle-default', type='minecraft:' + animal, saddle='minecraft:saddle', killer='player', kills=6)
|
|
add('zombie-horse', type='minecraft:zombie', vehicle='minecraft:zombie_horse', killer='player', kills=4)
|
|
add('zombie-baby-chicken', type='minecraft:zombie', baby=1, vehicle='minecraft:chicken', killer='player', kills=3)
|
|
add('zombie-baby-chicken-none', type='minecraft:zombie', baby=1, vehicle='minecraft:chicken', source='generic', kills=2)
|
|
add('husk-camel', type='minecraft:husk', vehicle='minecraft:camel_husk', killer='player', kills=3)
|
|
for weapon in WEAPONS:
|
|
add('wither-' + weapon.split(';')[-1].replace(':', ''), type='minecraft:wither', killer='player', weapon=weapon, kills=2)
|
|
return cases
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--prepare', action='store_true')
|
|
args = parser.parse_args()
|
|
build = lab.LOCAL / 'rust-entity-death-loot-parity'
|
|
build.mkdir(parents=True, exist_ok=True)
|
|
if args.prepare:
|
|
lab.prepare()
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
resources = lab.LOCAL / 'horizon-client/26.3'
|
|
tables = resources / 'data/minecraft/loot_table/entities'
|
|
types = sorted('minecraft:' + p.stem for p in tables.glob('*.json') if p.stem not in SKIPPED)
|
|
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaEntityDeathLootOracle.java'
|
|
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/entity_death_loot_fixture.rs',
|
|
lab.ROOT / 'crates/horizon-client/src/mobs/drops.rs', lab.ROOT / 'crates/horizon-world/src/loot.rs']
|
|
tested = base.source_hashes(sources)
|
|
inputs = build / 'inputs.txt'
|
|
cases = corpus(types)
|
|
inputs.write_text(''.join(line + '\n' for line in cases), encoding='utf-8')
|
|
classes = build / 'classes'
|
|
classes.mkdir(exist_ok=True)
|
|
deps = os.pathsep.join(map(str, libraries))
|
|
|
|
def run(command, name):
|
|
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
|
|
base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT)
|
|
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath', str(lab.ROOT / 'mods/horizon/src/test/java'),
|
|
'-cp', str(jars['server']) + os.pathsep + deps, '-d', classes, oracle], 'compile-java')
|
|
run(['java', '-Xmx2G', '-cp', os.pathsep.join([str(classes), str(jars['server']), deps]),
|
|
'dev.horizonlab.parity.VanillaEntityDeathLootOracle', inputs, build / 'server.txt'], 'server')
|
|
run(['cargo', 'build', '--release', '-p', 'horizon-client', '--example', 'entity_death_loot_fixture'], 'compile-rust')
|
|
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True))
|
|
binary = Path(metadata['target_directory']) / 'release/examples/entity_death_loot_fixture'
|
|
run([binary, resources, inputs, build / 'rust.txt'], 'rust')
|
|
errors = [l for l in (build / 'server.txt').read_text().splitlines() if l.startswith('error ')]
|
|
if errors:
|
|
raise SystemExit(f'oracle errors ({len(errors)}): {errors[:5]}')
|
|
try:
|
|
matched = base.compare(build / 'server.txt', build / 'rust.txt')
|
|
except AssertionError as error:
|
|
raise SystemExit(str(error) + explain(build)) from None
|
|
base.ensure_sources_unchanged(sources, tested)
|
|
lab.write_json(build / 'report.json', {
|
|
'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(), 'cases': len(cases),
|
|
'types': len(types), 'skipped': sorted(SKIPPED), 'matched_output_lines': matched, 'sources_sha256': tested,
|
|
'scope': 'LivingEntity.dropAllDeathLoot after the killer\'s killedEntity hook: entity loot tables under the world random '
|
|
'sequence, Mob.dropCustomDeathLoot equipment drops and durability, species death loot, experience orbs.'})
|
|
print(f'{len(cases):,} death-drop cases over {len(types)} entity types match vanilla ({matched:,} lines)')
|
|
|
|
|
|
def explain(build):
|
|
def sections(path):
|
|
out, name = {}, None
|
|
for line in path.read_text(encoding='utf-8', errors='replace').splitlines():
|
|
if line.startswith('== '):
|
|
name = line[3:]
|
|
out[name] = []
|
|
elif name is not None:
|
|
out[name].append(line)
|
|
return out
|
|
left, right = sections(build / 'server.txt'), sections(build / 'rust.txt')
|
|
bad = [n for n in left if left[n] != right.get(n)]
|
|
report = f'\n{len(bad)} differing cases'
|
|
for name in bad[:30]:
|
|
a, b = left[name], right.get(name, [])
|
|
diffs = [(x, y) for x, y in zip(a, b) if x != y][:3]
|
|
if len(a) != len(b):
|
|
diffs.append((f'<{len(a)} lines>', f'<{len(b)} lines>'))
|
|
report += f'\n {name}: {diffs}'
|
|
return report
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|