874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
480 lines
26 KiB
Python
480 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare vanilla dispensers and droppers with Horizon's, one activation per case.
|
|
|
|
A dispenser or dropper at the origin faces a random direction and holds the
|
|
case's items; the block in front (and below it) comes from a palette of blocks
|
|
the behaviours care about, and some cases put a target entity in front. Vanilla
|
|
`DispenserBlock.dispenseFrom` runs over the headless level of the block update
|
|
oracle (`VanillaDispenserOracle`); Horizon runs its game glue as a component probe on a server thread
|
|
(`HORIZON_DISPENSE_FIXTURE`). Both print block changes, container contents,
|
|
spawned entities (type, position, motion), the targets afterwards, level events,
|
|
sounds, game events and the level random. Motion drawn from an entity's own
|
|
unseeded random (vanilla marks it "~") is compared within its spread.
|
|
"""
|
|
import json
|
|
import os
|
|
import random
|
|
import subprocess
|
|
try:
|
|
from . import block_update_harness as h, lab, test_rust_parity as base
|
|
from .integration_env import run_game
|
|
except ImportError:
|
|
from integration_env import run_game
|
|
import block_update_harness as h
|
|
import lab
|
|
import test_rust_parity as base
|
|
|
|
DIRECTIONS = ['down', 'up', 'north', 'south', 'west', 'east']
|
|
STEP = {'down': (0, -1, 0), 'up': (0, 1, 0), 'north': (0, 0, -1), 'south': (0, 0, 1), 'west': (-1, 0, 0), 'east': (1, 0, 0)}
|
|
|
|
|
|
def potion(item, name):
|
|
return '{id:"minecraft:%s",count:1,components:{"minecraft:potion_contents":{potion:"minecraft:%s"}}}' % (item, name)
|
|
|
|
|
|
def plain(item, count=1):
|
|
return '{id:"minecraft:%s",count:%d}' % (item, count)
|
|
|
|
|
|
WOODS = ['oak', 'spruce', 'birch', 'jungle', 'acacia', 'cherry', 'dark_oak', 'mangrove', 'pale_oak', 'poplar']
|
|
|
|
# Items with the blocks and entities in front that exercise their behaviour:
|
|
# one entry per DispenseItemBehavior.bootStrap registration family, plus the
|
|
# defaults of DispenserBlock.getDefaultDispenseMethod (equippables, sulfur
|
|
# cube swallowables, spawn eggs, a plain drop).
|
|
ITEMS = {
|
|
'drop': [plain('stone', 5), plain('diamond'), plain('stick', 64), plain('oak_sapling', 3), plain('redstone', 2), plain('lead'),
|
|
plain('wheat_seeds', 7)],
|
|
'projectile': [plain('arrow', 3), potion('tipped_arrow', 'poison'), plain('spectral_arrow'), plain('egg', 2), plain('blue_egg'),
|
|
plain('brown_egg'), plain('snowball', 4), plain('experience_bottle'), potion('splash_potion', 'healing'),
|
|
potion('lingering_potion', 'swiftness'), plain('firework_rocket', 2), plain('fire_charge', 2), plain('wind_charge', 3)],
|
|
'bucket': [plain('water_bucket'), plain('lava_bucket'), plain('powder_snow_bucket'), plain('salmon_bucket'), plain('cod_bucket'),
|
|
plain('pufferfish_bucket'), plain('tropical_fish_bucket'), plain('axolotl_bucket'), plain('tadpole_bucket'),
|
|
plain('sulfur_cube_bucket'), plain('bucket'), plain('bucket', 3)],
|
|
'block': [plain('bone_meal', 4), plain('tnt', 2), plain('flint_and_steel'), plain('glowstone', 3), plain('honeycomb', 2),
|
|
plain('glass_bottle', 2), potion('potion', 'water'), potion('potion', 'healing'), plain('shulker_box'),
|
|
plain('red_shulker_box'), plain('lime_shulker_box'), plain('carved_pumpkin'), plain('wither_skeleton_skull'),
|
|
plain('shears'), plain('brush')],
|
|
'vehicle': [plain('oak_boat'), plain('bamboo_raft'), plain('bamboo_chest_raft'), plain('minecart'), plain('chest_minecart'),
|
|
plain('furnace_minecart'), plain('tnt_minecart'), plain('hopper_minecart'), plain('command_block_minecart'),
|
|
plain('armor_stand', 2), 'BOAT', 'CHEST_BOAT'],
|
|
'spawn': [plain('pig_spawn_egg'), plain('sheep_spawn_egg', 2), plain('zombie_spawn_egg'), plain('cow_spawn_egg'),
|
|
plain('chicken_spawn_egg'), plain('skeleton_spawn_egg'), plain('creeper_spawn_egg'), plain('villager_spawn_egg'),
|
|
plain('wolf_spawn_egg'), plain('horse_spawn_egg'), plain('squid_spawn_egg'), plain('slime_spawn_egg')],
|
|
'equipment': [plain('iron_helmet'), plain('diamond_chestplate'), plain('leather_leggings'), plain('golden_boots'),
|
|
plain('chainmail_helmet'), plain('netherite_boots'), plain('turtle_helmet'), plain('saddle'),
|
|
plain('iron_horse_armor'), plain('diamond_horse_armor'), plain('leather_horse_armor'), plain('white_carpet'),
|
|
plain('red_carpet'), plain('chest'), plain('carved_pumpkin'), plain('elytra'), plain('wolf_armor'), plain('shears'),
|
|
plain('brush'), plain('player_head'), plain('zombie_head'), plain('white_harness')],
|
|
'swallow': [plain('slime_block'), plain('oak_planks', 3), plain('packed_ice'), plain('soul_sand'), plain('magma_block'),
|
|
plain('white_wool'), plain('mud', 2), plain('andesite'), plain('iron_block'), plain('mushroom_stem'),
|
|
plain('honeycomb_block'), plain('tube_coral_block'), plain('tnt'), plain('stone')],
|
|
}
|
|
|
|
FRONT = {
|
|
'drop': ['air', 'stone', 'water', 'chest', 'hopper'],
|
|
'projectile': ['air', 'air', 'water', 'stone', 'glass'],
|
|
'bucket': ['air', 'air', 'water', 'water', 'lava', 'powder_snow', 'oak_slab', 'seagrass', 'stone', 'cauldron', 'kelp',
|
|
'grass_block', 'fire'],
|
|
'block': ['air', 'grass_block', 'wheat', 'oak_sapling', 'tnt', 'campfire', 'candle', 'respawn_anchor', 'beehive', 'bee_nest',
|
|
'copper_block', 'exposed_cut_copper_stairs', 'dirt', 'coarse_dirt', 'rooted_dirt', 'water', 'seagrass', 'stone',
|
|
'sweet_berry_bush', 'cocoa', 'obsidian', 'soul_campfire', 'candle_cake', 'netherrack', 'moss_block'],
|
|
'vehicle': ['air', 'air', 'water', 'rail', 'powered_rail', 'activator_rail', 'stone', 'detector_rail'],
|
|
'spawn': ['air', 'air', 'water', 'stone', 'grass_block'],
|
|
'equipment': ['air'],
|
|
'swallow': ['air'],
|
|
}
|
|
# The blocks a behaviour reacts to, drawn for that item most of the time.
|
|
FRONT_FOR = {
|
|
'flint_and_steel': ['air', 'air', 'air', 'campfire', 'soul_campfire', 'candle', 'blue_candle', 'candle_cake',
|
|
'red_candle_cake', 'tnt', 'stone', 'obsidian', 'netherrack', 'water'],
|
|
'glowstone': ['respawn_anchor', 'respawn_anchor', 'respawn_anchor', 'air', 'stone', 'glowstone'],
|
|
'bone_meal': ['grass_block', 'wheat', 'carrots', 'potatoes', 'beetroots', 'oak_sapling', 'birch_sapling', 'spruce_sapling',
|
|
'cherry_sapling', 'mangrove_propagule', 'moss_block', 'sweet_berry_bush', 'cocoa', 'bamboo_sapling', 'bamboo',
|
|
'pumpkin_stem', 'melon_stem', 'torchflower_crop', 'pitcher_crop', 'rooted_dirt', 'glow_lichen', 'small_dripleaf',
|
|
'big_dripleaf', 'crimson_nylium', 'warped_fungus', 'brown_mushroom', 'kelp', 'sea_pickle', 'seagrass', 'water',
|
|
'short_grass', 'fern', 'dandelion', 'pink_petals', 'cave_vines', 'twisting_vines', 'weeping_vines', 'azalea',
|
|
'pale_moss_block', 'stone', 'air'],
|
|
'honeycomb': ['copper_block', 'exposed_copper', 'weathered_cut_copper', 'oxidized_cut_copper_slab', 'copper_door',
|
|
'exposed_copper_trapdoor', 'copper_bulb', 'lightning_rod', 'waxed_copper_block', 'copper_grate', 'stone', 'air'],
|
|
'glass_bottle': ['beehive', 'beehive', 'bee_nest', 'bee_nest', 'water', 'water', 'air', 'stone', 'seagrass'],
|
|
'potion': ['dirt', 'coarse_dirt', 'rooted_dirt', 'dirt', 'stone', 'air', 'mud', 'water'],
|
|
'shears': ['beehive', 'bee_nest', 'beehive', 'air', 'air', 'stone', 'pumpkin'],
|
|
'brush': ['air', 'air', 'suspicious_sand', 'stone'],
|
|
'tnt': ['air', 'air', 'stone', 'water', 'tnt'],
|
|
'shulker_box': ['air', 'air', 'stone', 'water', 'short_grass', 'snow'],
|
|
'carved_pumpkin': ['air', 'air', 'stone', 'snow'],
|
|
'wither_skeleton_skull': ['air', 'air', 'stone'],
|
|
'armor_stand': ['air', 'air', 'stone', 'water', 'short_grass'],
|
|
}
|
|
# Structures a behaviour completes (golems, the wither, a nether portal).
|
|
SCENES = {'carved_pumpkin': ['snow_golem', 'iron_golem', 'copper_golem', 'copper_golem'], 'wither_skeleton_skull': ['wither'],
|
|
'flint_and_steel': ['portal']}
|
|
COPPER_BLOCKS = ['copper_block', 'exposed_copper', 'weathered_copper', 'oxidized_copper', 'waxed_copper_block',
|
|
'waxed_exposed_copper', 'waxed_weathered_copper', 'waxed_oxidized_copper']
|
|
# Plants that need soil (or a log) under them to exist at all.
|
|
ROOTED = {'wheat', 'carrots', 'potatoes', 'beetroots', 'oak_sapling', 'birch_sapling', 'spruce_sapling', 'cherry_sapling',
|
|
'mangrove_propagule', 'sweet_berry_bush', 'cocoa', 'bamboo_sapling', 'bamboo', 'pumpkin_stem', 'melon_stem',
|
|
'torchflower_crop', 'pitcher_crop', 'small_dripleaf', 'big_dripleaf', 'warped_fungus', 'brown_mushroom', 'kelp',
|
|
'sea_pickle', 'seagrass', 'short_grass', 'fern', 'dandelion', 'pink_petals', 'twisting_vines', 'azalea'}
|
|
BELOW = ['air', 'stone', 'water', 'rail', 'grass_block', 'soul_sand', 'dirt', 'farmland', 'netherrack']
|
|
|
|
TARGETS = {
|
|
'iron_helmet': ['zombie', 'skeleton', 'armor_stand', 'pig', 'copper_golem'],
|
|
'chainmail_helmet': ['zombie', 'armor_stand', 'villager'],
|
|
'diamond_chestplate': ['zombie', 'armor_stand'],
|
|
'leather_leggings': ['skeleton', 'armor_stand'],
|
|
'golden_boots': ['zombie', 'armor_stand'],
|
|
'netherite_boots': ['zombie', 'piglin', 'armor_stand'],
|
|
'turtle_helmet': ['armor_stand', 'zombie'],
|
|
'saddle': ['pig', 'horse', 'strider', 'donkey', 'camel', 'llama', 'mule', 'skeleton_horse', 'nautilus'],
|
|
'iron_horse_armor': ['horse', 'donkey'],
|
|
'diamond_horse_armor': ['horse', 'zombie_horse'],
|
|
'leather_horse_armor': ['horse'],
|
|
'white_carpet': ['llama', 'horse', 'trader_llama'],
|
|
'red_carpet': ['llama', 'trader_llama'],
|
|
'chest': ['donkey', 'mule', 'llama', 'horse', 'trader_llama'],
|
|
'carved_pumpkin': ['zombie', 'armor_stand', 'snow_golem', 'copper_golem'],
|
|
'elytra': ['armor_stand', 'zombie'],
|
|
'wolf_armor': ['wolf'],
|
|
'white_harness': ['happy_ghast'],
|
|
'shears': ['sheep', 'sheep', 'mooshroom', 'bogged', 'snow_golem', 'sulfur_cube', 'pig', 'cow', 'copper_golem', 'copper_golem'],
|
|
'brush': ['armadillo'],
|
|
'tnt': ['sulfur_cube'],
|
|
'flint_and_steel': ['sulfur_cube'],
|
|
'player_head': ['armor_stand', 'zombie'],
|
|
'zombie_head': ['armor_stand', 'skeleton'],
|
|
}
|
|
for _block in ('stone', 'slime_block', 'oak_planks', 'packed_ice', 'soul_sand', 'magma_block', 'white_wool', 'mud', 'andesite',
|
|
'iron_block', 'mushroom_stem', 'honeycomb_block', 'tube_coral_block'):
|
|
TARGETS[_block] = ['sulfur_cube']
|
|
SWALLOWED = ['tnt', 'stone', 'slime_block', 'magma_block', 'white_wool', 'iron_block']
|
|
|
|
|
|
def item_choice(rng, kind):
|
|
item = rng.choice(ITEMS[kind])
|
|
if item == 'BOAT':
|
|
return plain(rng.choice(WOODS) + '_boat')
|
|
if item == 'CHEST_BOAT':
|
|
return plain(rng.choice(WOODS) + '_chest_boat')
|
|
return item
|
|
|
|
|
|
class States:
|
|
def __init__(self, facts):
|
|
self.facts = facts
|
|
|
|
def get(self, name, **props):
|
|
name = name if ':' in name else 'minecraft:' + name
|
|
best = self.facts.defaults[name]
|
|
if not props:
|
|
return best
|
|
for index in self.facts.by_block[name]:
|
|
have = self.facts.states[index][4].get('properties', {})
|
|
if all(have.get(k) == v for k, v in props.items()):
|
|
return index
|
|
raise KeyError((name, props))
|
|
|
|
def any(self, rng, name):
|
|
name = name if ':' in name else 'minecraft:' + name
|
|
return rng.choice(self.facts.by_block[name])
|
|
|
|
|
|
def build_scene(cells, states, rng, scene, front, facing):
|
|
"""Put the rest of a structure the behaviour's block completes around the front."""
|
|
x, y, z = front
|
|
across = rng.choice([(1, 0, 0), (0, 0, 1)])
|
|
|
|
def put(at, name, **props):
|
|
if at != (0, 0, 0) and at != front:
|
|
h.put(cells, at, states.get(name, **props))
|
|
|
|
def side(at, k):
|
|
return (at[0] + across[0] * k, at[1], at[2] + across[2] * k)
|
|
if scene == 'snow_golem':
|
|
put((x, y - 1, z), 'snow_block')
|
|
put((x, y - 2, z), 'snow_block')
|
|
elif scene == 'copper_golem':
|
|
put((x, y - 1, z), rng.choice(COPPER_BLOCKS))
|
|
elif scene == 'iron_golem':
|
|
put((x, y - 1, z), 'iron_block')
|
|
put((x, y - 2, z), 'iron_block')
|
|
put(side((x, y - 1, z), -1), 'iron_block')
|
|
put(side((x, y - 1, z), 1), 'iron_block')
|
|
elif scene == 'wither':
|
|
put((x, y - 1, z), 'soul_sand')
|
|
put((x, y - 2, z), 'soul_sand')
|
|
put(side((x, y - 1, z), -1), rng.choice(['soul_sand', 'soul_soil']))
|
|
put(side((x, y - 1, z), 1), 'soul_sand')
|
|
put(side((x, y, z), -1), 'wither_skeleton_skull')
|
|
put(side((x, y, z), 1), rng.choice(['wither_skeleton_skull', 'wither_skeleton_skull', 'skeleton_skull']))
|
|
elif scene == 'portal':
|
|
# An obsidian frame around a 2x3 inside that holds the front block.
|
|
width, height = rng.choice([(2, 3), (2, 3), (3, 4)])
|
|
left, low = rng.randrange(width), rng.randrange(height)
|
|
for i in range(-1, width + 1):
|
|
for j in range(-1, height + 1):
|
|
at = side((x, y + j - low, z), i - left)
|
|
edge = i in (-1, width) or j in (-1, height)
|
|
if edge and not (i in (-1, width) and j in (-1, height)):
|
|
put(at, 'obsidian')
|
|
elif not edge:
|
|
if at not in ((0, 0, 0), front):
|
|
h.put(cells, at, states.get('air'))
|
|
|
|
|
|
def halves(states, name):
|
|
"""Whether a block comes in an upper and a lower half (tall plants, dripleaves, pitchers)."""
|
|
name = name if ':' in name else 'minecraft:' + name
|
|
return any(states.facts.states[i][4].get('properties', {}).get('half') == 'upper' for i in states.facts.by_block[name])
|
|
|
|
|
|
def front_state(states, rng, name):
|
|
"""Mostly the default state, sometimes a random one (charges, honey, ages, lit...)."""
|
|
if halves(states, name):
|
|
# A lone half is not a world vanilla makes; the pair is placed by put_front.
|
|
return states.get(name)
|
|
if name in ('respawn_anchor', 'beehive', 'bee_nest', 'campfire', 'candle', 'wheat', 'sweet_berry_bush', 'rail', 'powered_rail',
|
|
'candle_cake', 'soul_campfire') or rng.random() < 0.3:
|
|
return states.any(rng, name)
|
|
return states.get(name)
|
|
|
|
|
|
def cases(facts, count, seed, only):
|
|
rng = random.Random(seed)
|
|
states = States(facts)
|
|
kinds = list(ITEMS)
|
|
for n in range(count):
|
|
kind = kinds[n % len(kinds)]
|
|
item = item_choice(rng, kind)
|
|
if only and only not in item:
|
|
continue
|
|
bare = item.split('"')[1].split(':')[1]
|
|
facing = rng.choice(DIRECTIONS)
|
|
block = 'dropper' if rng.random() < 0.12 else 'dispenser'
|
|
cells = [[0, 0, 0, states.get(block, facing=facing, triggered='false')]]
|
|
dx, dy, dz = STEP[facing]
|
|
front = (dx, dy, dz)
|
|
below = (dx, dy - 1, dz)
|
|
palette = FRONT_FOR[bare] if bare in FRONT_FOR and rng.random() < 0.8 else FRONT[kind]
|
|
front_name = rng.choice(palette)
|
|
if facing == 'up' and front_name in ROOTED:
|
|
# Nothing grows out of a dispenser: vanilla never lets these stand on one.
|
|
front_name = 'air'
|
|
above = (dx, dy + 1, dz)
|
|
if halves(states, front_name) and front_name != 'pitcher_crop':
|
|
if above == (0, 0, 0):
|
|
front_name = 'air'
|
|
else:
|
|
# The upper half matches the lower one in everything but its half.
|
|
lower = states.facts.states[states.get(front_name)][4].get('properties', {})
|
|
h.put(cells, above, states.get(front_name, **{**lower, 'half': 'upper'}))
|
|
if front_name != 'air':
|
|
h.put(cells, front, front_state(states, rng, front_name))
|
|
if below != (0, 0, 0) and rng.random() < 0.8:
|
|
name = rng.choice(BELOW)
|
|
if name != 'air':
|
|
h.put(cells, below, front_state(states, rng, name))
|
|
scene = rng.choice(SCENES[bare]) if bare in SCENES and rng.random() < 0.45 else None
|
|
# A few random blocks around (portal frames, fire supports, neighbours).
|
|
for _ in range(rng.randrange(0, 6)):
|
|
at = (rng.randrange(-2, 3), rng.randrange(-2, 3), rng.randrange(-2, 3))
|
|
if at in ((0, 0, 0), front):
|
|
continue
|
|
h.put(cells, at, states.get(rng.choice(['stone', 'obsidian', 'dirt', 'water', 'soul_sand', 'grass_block', 'oak_planks'])))
|
|
if scene:
|
|
build_scene(cells, states, rng, scene, front, facing)
|
|
items = []
|
|
slots = rng.sample(range(9), rng.choice([1, 1, 1, 2, 3, 9]))
|
|
for i, slot in enumerate(slots):
|
|
items.append([0, 0, 0, slot, item if i == 0 or rng.random() < 0.6 else plain('cobblestone', rng.randrange(1, 65))])
|
|
case = {**h.environment(rng), 'mode': 'dispense', 'pos': [0, 0, 0], 'cells': cells, 'items': items}
|
|
case.pop('raining', None)
|
|
case.pop('thundering', None)
|
|
if rng.random() < 0.08:
|
|
case['rules'] = {'tnt_explodes': False}
|
|
if kind in ('equipment', 'swallow') or (bare in TARGETS and rng.random() < 0.5):
|
|
entities = []
|
|
for _ in range(rng.choice([1, 1, 1, 2])):
|
|
target = {'type': 'minecraft:' + rng.choice(TARGETS.get(bare, ['zombie'])),
|
|
'pos': [dx + 0.5, dy + rng.choice([0.0, 0.0, 0.25]), dz + 0.5], 'yaw': rng.choice([0.0, 90.0, 180.0])}
|
|
for flag, odds in (('baby', 0.2), ('tame', 0.6), ('sheared', 0.25), ('chest', 0.15), ('loot', 0.5), ('brown', 0.3)):
|
|
if rng.random() < odds:
|
|
target[flag] = True
|
|
if target['type'] == 'minecraft:sulfur_cube' and rng.random() < 0.5:
|
|
target['equip'] = {'body': plain(rng.choice(SWALLOWED))}
|
|
if target['type'] == 'minecraft:copper_golem' and rng.random() < 0.7:
|
|
# The poppy an iron golem offered sits in the antenna (saddle) slot.
|
|
target['equip'] = {'saddle': plain(rng.choice(['poppy', 'poppy', 'dandelion']))}
|
|
# Leads: tied to an earlier target, or that target holds this one's.
|
|
holders = [i for i, e in enumerate(entities) if e['type'] != 'minecraft:armor_stand']
|
|
if holders and rng.random() < 0.3:
|
|
target['leash'] = rng.choice(holders)
|
|
entities.append(target)
|
|
if bare == 'shears' and len(entities) == 1 and rng.random() < 0.3:
|
|
# A holder out of the dispenser's reach whose lead runs to the target.
|
|
entities.append({'type': 'minecraft:' + rng.choice(['cow', 'wolf']), 'pos': [dx + 3.5, dy, dz + 3.5], 'yaw': 0.0})
|
|
entities[0]['leash'] = 1
|
|
case['entities'] = entities
|
|
if block == 'dropper' and rng.random() < 0.5:
|
|
h.put(cells, front, states.get(rng.choice(['chest', 'hopper', 'barrel', 'dispenser'])))
|
|
if rng.random() < 0.5:
|
|
items.append([dx, dy, dz, 0, plain(rng.choice(['stone', 'diamond', 'stick']), rng.randrange(1, 65))])
|
|
yield case
|
|
|
|
|
|
# Items whose behaviour runs Mob.finalizeSpawn (spawn eggs, mob buckets). The
|
|
# finalization draws belong to the entity spawning port; for these cases the
|
|
# level random's end state is not compared, everything else is.
|
|
FINALIZED = ('_spawn_egg', 'salmon_bucket', 'cod_bucket', 'pufferfish_bucket', 'tropical_fish_bucket', 'axolotl_bucket',
|
|
'tadpole_bucket', 'sulfur_cube_bucket')
|
|
|
|
|
|
def finalizes(case):
|
|
return any(any(tag in i[4] for tag in FINALIZED) for i in case['items'] if i[:3] == [0, 0, 0])
|
|
|
|
def spawned_type(case):
|
|
item = next(i[4] for i in case['items'] if i[:3] == [0, 0, 0]).split('"')[1].split(':')[1]
|
|
return 'minecraft:' + item.removesuffix('_spawn_egg').removesuffix('_bucket')
|
|
|
|
|
|
def rider(line, spawned):
|
|
parts = line.split(' ')
|
|
return parts[0] == 'N' and parts[1] not in (spawned, 'minecraft:item')
|
|
|
|
|
|
def voice(line):
|
|
parts = line.split(' ')
|
|
if parts[0] == 'S' and len(parts) == 3 and parts[2].lstrip('~').startswith('minecraft:entity.'):
|
|
parts[2] = 'entity-voice'
|
|
return ' '.join(parts)
|
|
|
|
|
|
def run(command, **kwargs):
|
|
runner = run_game if str(command[0]).endswith('/horizon-client') else subprocess.run
|
|
done = runner(command, capture_output=True, text=True, **kwargs)
|
|
if done.returncode:
|
|
print((done.stdout + done.stderr)[-4000:])
|
|
raise SystemExit(f'{command[0]} failed')
|
|
return done
|
|
|
|
|
|
def numbers_close(java, rust):
|
|
"""Token-wise equality; a "~" token is an unseeded entity draw and only needs to fall in its spread."""
|
|
a, b = java.split(' '), rust.split(' ')
|
|
if len(a) != len(b):
|
|
return False
|
|
for x, y in zip(a, b):
|
|
if x.startswith('~'):
|
|
try:
|
|
# An item's own motion spans 0.2 per axis, and a shearing
|
|
# sheep pushes each wool with its own random by up to 0.1 more.
|
|
if abs(float(x[1:]) - float(y)) > 0.4:
|
|
return False
|
|
except ValueError:
|
|
return False
|
|
elif x != y:
|
|
return False
|
|
return True
|
|
|
|
|
|
def same(java, rust):
|
|
java = sorted(java)
|
|
rust = sorted(rust)
|
|
if len(java) != len(rust):
|
|
return False
|
|
pending = list(rust)
|
|
for line in java:
|
|
if line in pending:
|
|
pending.remove(line)
|
|
continue
|
|
if '~' not in line:
|
|
return False
|
|
match = next((r for r in pending if numbers_close(line, r)), None)
|
|
if match is None:
|
|
return False
|
|
pending.remove(match)
|
|
return True
|
|
|
|
|
|
def main():
|
|
parser = h.argument_parser(__doc__)
|
|
args = parser.parse_args()
|
|
if args.count == 40000:
|
|
args.count = 6000
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
facts = h.load_facts()
|
|
resources = lab.LOCAL / 'horizon-client' / lock['minecraft']['version']
|
|
work = lab.LOCAL / 'dispenser-parity'
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
all_cases = list(cases(facts, args.count, args.seed, args.only))
|
|
(work / 'cases.jsonl').write_text(''.join(json.dumps(case) + '\n' for case in all_cases))
|
|
classes = work / 'classes'
|
|
classes.mkdir(exist_ok=True)
|
|
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
|
java_root = lab.ROOT / 'mods/horizon/src/test/java'
|
|
run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-sourcepath', str(java_root), '-nowarn',
|
|
'-cp', dependencies, '-d', str(classes), str(java_root / 'dev/horizonlab/parity/VanillaDispenserOracle.java')])
|
|
run(['java', '-Xmx3G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaDispenserOracle',
|
|
str(resources), str(work / 'cases.jsonl'), str(work / 'native.txt')])
|
|
run(['cargo', 'build', '-q', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
|
|
env={**os.environ, 'CARGO_BUILD_JOBS': os.environ.get('CARGO_BUILD_JOBS', '3')})
|
|
run([str(lab.cargo_target() / 'fast/horizon-client'), '--resources', str(resources)], cwd=work,
|
|
env=lab.game_env(HORIZON_DISPENSE_FIXTURE=f'{work / "cases.jsonl"},{work / "rust.txt"}'))
|
|
# Loot table names (D) are vanilla's bookkeeping; the drops themselves are compared.
|
|
ignore = tuple(prefix + ' ' for prefix in args.ignore.split(',') if prefix) + ('D ',)
|
|
native = h.split_cases((work / 'native.txt').read_text(), ignore)
|
|
rust = h.split_cases((work / 'rust.txt').read_text(), ignore)
|
|
assert len(native) == len(rust) == len(all_cases), (len(native), len(rust), len(all_cases))
|
|
groups, errors = {}, {}
|
|
unseeded = 0
|
|
for case, a, b in zip(all_cases, native, rust):
|
|
if a and a[0].startswith('error'):
|
|
errors.setdefault(a[0], []).append(case)
|
|
continue
|
|
if finalizes(case):
|
|
unseeded += 1
|
|
# Riders finalizeSpawn adds (a baby zombie's chicken, a spider's
|
|
# skeleton) depend on whether the mob came out a baby and on the
|
|
# jockey odds, both finalization draws: only the egg's own mob is compared.
|
|
spawned = spawned_type(case)
|
|
a = [line for line in a if not rider(line, spawned)]
|
|
b = [line for line in b if not rider(line, spawned)]
|
|
# EntityType.spawn plays the new mob's ambient sound: which one (a
|
|
# wolf pants or barks, in its sound variant) comes from the mob's
|
|
# own unseeded random, so only that a voice plays, and where, is compared.
|
|
a = [voice(line) for line in a if not line.startswith('rng ')]
|
|
b = [voice(line) for line in b if not line.startswith('rng ')]
|
|
if not same(a, b):
|
|
item = next(i[4] for i in case['items'] if i[:3] == [0, 0, 0])
|
|
block = facts.states[case['cells'][0][3]][0].split(':')[1]
|
|
groups.setdefault(f'{block} {item.split(chr(34))[1]}', []).append((case, a, b))
|
|
for kind, entries in sorted(errors.items(), key=lambda e: -len(e[1])):
|
|
print(f'oracle error x{len(entries)}: {kind}')
|
|
for name, entries in sorted(groups.items(), key=lambda e: -len(e[1])):
|
|
print(f'{name}: {len(entries)} differing')
|
|
for case, a, b in entries[:args.show]:
|
|
print(' case ' + describe(case, facts))
|
|
print(' native ' + ' | '.join(sorted(a)))
|
|
print(' rust ' + ' | '.join(sorted(b)))
|
|
total = sum(len(v) for v in groups.values())
|
|
skipped = sum(len(v) for v in errors.values())
|
|
print(f'{len(all_cases) - total - skipped}/{len(all_cases) - skipped} cases match ({skipped} oracle errors; '
|
|
f'{unseeded} spawning cases compared without the final level random)')
|
|
(work / 'differences.json').write_text(json.dumps({k: [[c, a, b] for c, a, b in v[:5]] for k, v in groups.items()}))
|
|
h.finish(total)
|
|
|
|
|
|
def describe(case, facts):
|
|
cells = ' '.join(f'{c[0]},{c[1]},{c[2]}={facts.describe(c[3])}' for c in case['cells'])
|
|
extra = ''
|
|
if case.get('entities'):
|
|
extra += ' entities=' + json.dumps(case['entities'])
|
|
if case.get('rules'):
|
|
extra += ' rules=' + json.dumps(case['rules'])
|
|
return f'{cells} items={json.dumps(case["items"])}{extra} seed={case["seed"]}'
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|