874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
532 lines
30 KiB
Python
532 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare AbstractContainerMenu.clicked with vanilla's menu classes.
|
|
|
|
The Java oracle (VanillaMenuOracle) runs each case's clicks on the real menu
|
|
class with a stand-in player and level; the Rust example (menu_fixture) runs
|
|
horizon_client::menu. Cases are randomized slot contents with random click
|
|
sequences covering every ContainerInput (pickup, quick move, swap, clone,
|
|
throw, the three quick craft drags, pickup all, and the screens' Shift double
|
|
click: QUICK_MOVE on every slot of the clicked slot's container holding the
|
|
item the first click moved) over the player inventory,
|
|
crafting table, chests, hopper, dispenser, shulker box, the furnaces, the
|
|
brewing stand, the crafter (the client's menu, and the server's over the block
|
|
entity with slot state packets), the loom (with its pattern buttons), the
|
|
stonecutter (with its recipe buttons), the grindstone (enchanted, damaged and
|
|
cursed tools and books), the anvil (repairs, enchantment merges and renames,
|
|
with the player's levels), the smithing table (upgrades and trims), the
|
|
enchanting table (its button checks against the costs, lapis and levels), the
|
|
beacon (its payment slot and ServerboundSetBeaconPacket against the pyramid's
|
|
levels), the
|
|
cartography table (over a level with locked, scaled and missing maps: its
|
|
results, and the scaled and locked copies a take makes), the lectern (over the
|
|
block entity: its page buttons, clamped to the book's pages, and Take Book,
|
|
with the inventory compared too) and the
|
|
server's furnace menus over their block entities (`*_be`: a new input restarts
|
|
cooking, other clicks keep its progress). Bundles (empty, partly and fully
|
|
loaded, nested) are among the stacks and often in hand, for BundleItem's click
|
|
overrides. After every click the slots, carried stack, data slots, drops and
|
|
the player's sounds are compared.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import random
|
|
try:
|
|
from . import interaction_lab as lib, lab
|
|
except ImportError:
|
|
import interaction_lab as lib
|
|
import lab
|
|
|
|
MENUS = {'inventory': 46, 'crafting': 46, 'chest1': 45, 'chest2': 54, 'chest3': 63, 'chest4': 72, 'chest5': 81,
|
|
'chest6': 90, 'hopper': 41, 'dispenser': 45, 'shulker': 63, 'furnace': 39, 'blast_furnace': 39, 'smoker': 39, 'brewing': 41, 'crafter': 46,
|
|
'crafter_be': 46, 'loom': 40, 'stonecutter': 38, 'grindstone': 39, 'anvil': 39, 'smithing': 40, 'enchanting': 38, 'beacon': 37, 'cartography': 39, 'lectern': 1, 'furnace_be': 39, 'blast_furnace_be': 39, 'smoker_be': 39}
|
|
# Where a carried bundle is common: the containers that hold anything.
|
|
BUNDLE_MENUS = {'inventory', 'chest1', 'chest3', 'shulker', 'hopper', 'dispenser'}
|
|
DATA = {'furnace': 4, 'blast_furnace': 4, 'smoker': 4, 'brewing': 4, 'crafter': 10, 'crafter_be': 10, 'loom': 1,
|
|
'stonecutter': 1, 'enchanting': 10, 'beacon': 3, 'lectern': 1,
|
|
'furnace_be': 4, 'blast_furnace_be': 4, 'smoker_be': 4}
|
|
DYES = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple',
|
|
'blue', 'brown', 'green', 'red', 'black']
|
|
PATTERNS = ['minecraft:' + p for p in ('base', 'square_bottom_left', 'stripe_bottom', 'cross', 'border', 'gradient',
|
|
'creeper', 'flower', 'globe', 'rhombus', 'small_stripes', 'half_horizontal')]
|
|
COMMON = ['stone', 'dirt', 'cobblestone', 'oak_planks', 'stick', 'coal', 'iron_ingot', 'diamond', 'apple', 'bread',
|
|
'oak_log', 'sand', 'bucket', 'water_bucket', 'lava_bucket', 'ender_pearl', 'snowball', 'egg', 'white_wool',
|
|
'iron_helmet', 'diamond_chestplate', 'leather_leggings', 'iron_boots', 'shield', 'carved_pumpkin',
|
|
'elytra', 'turtle_helmet', 'iron_sword', 'diamond_pickaxe', 'bow', 'shulker_box', 'red_shulker_box',
|
|
'raw_iron', 'potato', 'beef', 'chicken', 'sticky_piston', 'blaze_rod', 'lava_bucket', 'oak_sapling',
|
|
'white_dye', 'red_dye', 'torch', 'crafting_table', 'furnace', 'chest', 'glass_bottle', 'nether_wart', 'blaze_powder',
|
|
'redstone', 'gunpowder', 'sugar', 'potion', 'splash_potion', 'lingering_potion', 'fermented_spider_eye', 'dragon_breath']
|
|
# Stonecutter inputs (several recipes each), and a block it doesn't cut.
|
|
CUTTABLE = ['stone', 'cobblestone', 'andesite', 'granite', 'copper_block', 'sandstone', 'quartz_block', 'deepslate',
|
|
'stone_bricks', 'oak_planks']
|
|
# Grindstone inputs: tools and armor, books, and their enchantments (curses too).
|
|
GRINDABLE = ['iron_sword', 'diamond_pickaxe', 'bow', 'iron_helmet', 'elytra', 'shield', 'fishing_rod', 'trident']
|
|
# Beacon powers (BEACON_EFFECTS by level, and one no level gives) and payments.
|
|
POWERS = ['speed', 'haste', 'resistance', 'jump_boost', 'strength', 'regeneration', 'night_vision']
|
|
PAYMENTS = ['iron_ingot', 'gold_ingot', 'emerald', 'diamond', 'netherite_ingot']
|
|
ENCHANTS = ['sharpness', 'unbreaking', 'mending', 'efficiency', 'protection', 'power', 'looting', 'fortune',
|
|
'binding_curse', 'vanishing_curse']
|
|
|
|
|
|
# Anvil renames (ServerboundRenameItemPacket): blank, too long, a section sign, an item's own name.
|
|
NAMES = ['', ' ', 'Blade', 'Iron Sword', 'Diamond Pickaxe', 'Enchanted Book', '\u00a7cRed', 'x' * 50, 'x' * 51]
|
|
# Smithing inputs: templates, bases and additions (trim materials and netherite).
|
|
TEMPLATES = ['netherite_upgrade_smithing_template', 'coast_armor_trim_smithing_template',
|
|
'sentry_armor_trim_smithing_template', 'bolt_armor_trim_smithing_template']
|
|
BASES = ['diamond_sword', 'diamond_chestplate', 'diamond_pickaxe', 'iron_helmet', 'iron_chestplate', 'netherite_boots',
|
|
'leather_leggings', 'golden_helmet', 'turtle_helmet', 'diamond_hoe']
|
|
ADDITIONS = ['netherite_ingot', 'iron_ingot', 'gold_ingot', 'emerald', 'redstone', 'quartz', 'amethyst_shard',
|
|
'lapis_lazuli', 'copper_ingot', 'diamond']
|
|
|
|
|
|
def quote(text):
|
|
return '"' + text + '"'
|
|
|
|
|
|
def snbt(item, count, damage=0, curse=False, potion=None):
|
|
comps = []
|
|
if potion:
|
|
comps.append('"minecraft:potion_contents":{potion:"%s"}' % potion)
|
|
if damage:
|
|
comps.append('"minecraft:damage":%d' % damage)
|
|
if curse:
|
|
comps.append('"minecraft:enchantments":{"minecraft:binding_curse":1}')
|
|
body = ',components:{%s}' % ','.join(comps) if comps else ''
|
|
return '{id:%s,count:%d%s}' % (quote(item), count, body)
|
|
|
|
|
|
class Gen:
|
|
def __init__(self, rng, items, bundles=None):
|
|
self.rng = rng
|
|
# Bundles come from their own generator, so the other stacks stay as they were.
|
|
self.bundles = bundles or random.Random(0)
|
|
self.items = items
|
|
self.pool = ['minecraft:' + c for c in COMMON if 'minecraft:' + c in items]
|
|
self.all = [k for k, v in items.items() if k != 'minecraft:air' and 'bundle' not in k
|
|
and not k.endswith('_spawn_egg')]
|
|
|
|
def stack(self):
|
|
rng = self.rng
|
|
item = rng.choice(self.pool if rng.random() < 0.85 else self.all)
|
|
info = self.items[item]
|
|
top = info.get('minecraft:max_stack_size', 64)
|
|
damage = 0
|
|
if info.get('minecraft:max_damage') and rng.random() < 0.3:
|
|
damage = rng.randrange(1, info['minecraft:max_damage'])
|
|
count = 1 if top == 1 else rng.choice([1, 1, 2, top, top, top - 1, rng.randrange(1, top + 1)])
|
|
potion = rng.choice(['minecraft:water', 'minecraft:awkward', 'minecraft:healing', 'minecraft:thick']) if item.endswith('potion') and rng.random() < 0.8 else None
|
|
stack = snbt(item, max(1, count), damage, curse=rng.random() < 0.15 and 'equippable' in json.dumps(info), potion=potion)
|
|
return self.bundle() if self.bundles.random() < 0.06 else stack
|
|
|
|
def bundle(self, room=64):
|
|
"""A bundle, empty or holding up to its weight (in 64ths: 1 per item of a
|
|
64-stack, 4 of a 16-stack, 64 of a tool), now and then another bundle."""
|
|
return self.loaded_bundle(room)[0]
|
|
|
|
def loaded_bundle(self, room):
|
|
rng = self.bundles
|
|
color = rng.choice(['', '', 'red_', 'light_blue_'])
|
|
inside, used = [], 0
|
|
for _ in range(rng.choice([0, 0, 1, 1, 2, 3, 5])):
|
|
if room == 64 and rng.random() < 0.1:
|
|
# A nested bundle weighs its contents plus 1/16.
|
|
if room - used > 4:
|
|
nested, weight = self.loaded_bundle(min(room - used - 4, 16))
|
|
inside.append(nested)
|
|
used += weight + 4
|
|
continue
|
|
item = rng.choice(self.pool)
|
|
top = self.items[item].get('minecraft:max_stack_size', 64)
|
|
unit = 64 // top
|
|
if unit > room - used or 'shulker_box' in item:
|
|
continue
|
|
most = min(top, (room - used) // unit)
|
|
count = min(most, rng.choice([1, 1, 2, rng.randrange(1, most + 1)]))
|
|
used += count * unit
|
|
inside.append(snbt(item, count))
|
|
body = ',components:{"minecraft:bundle_contents":[%s]}' % ','.join(inside) if inside else ''
|
|
return '{id:"minecraft:%sbundle",count:1%s}' % (color, body), used
|
|
|
|
def slots(self, size, density):
|
|
return [self.stack() if self.rng.random() < density else '-' for _ in range(size)]
|
|
|
|
def loom_stack(self, roll=None):
|
|
"""A banner (with up to seven layers), a dye, a pattern item or anything."""
|
|
rng = self.rng
|
|
roll = rng.random() if roll is None else roll
|
|
if roll < 0.3:
|
|
layers = ','.join('{pattern:"%s",color:"%s"}' % (rng.choice(PATTERNS), rng.choice(DYES))
|
|
for _ in range(rng.choice([0, 0, 1, 2, 5, 6, 6, 7])))
|
|
extra = ',components:{"minecraft:banner_patterns":[%s]}' % layers if layers else ''
|
|
return '{id:"minecraft:%s_banner",count:%d%s}' % (rng.choice(DYES), rng.choice([1, 1, 2, 16]), extra)
|
|
if roll < 0.55:
|
|
return snbt('minecraft:%s_dye' % rng.choice(DYES), rng.choice([1, 1, 2, 3, 64]))
|
|
if roll < 0.75:
|
|
pool = [k for k in self.items if k.endswith('_banner_pattern')]
|
|
return snbt(rng.choice(pool), 1)
|
|
return self.stack()
|
|
|
|
def cut_stack(self):
|
|
"""Mostly something the stonecutter cuts, sometimes anything."""
|
|
rng = self.rng
|
|
if rng.random() < 0.75:
|
|
return snbt('minecraft:' + rng.choice(CUTTABLE), rng.choice([1, 1, 2, 3, 10, 63, 64]))
|
|
return self.stack()
|
|
|
|
def grind_stack(self):
|
|
"""Mostly an enchanted or damaged tool or an enchanted book, sometimes anything."""
|
|
rng = self.rng
|
|
roll = rng.random()
|
|
if roll > 0.8:
|
|
return self.stack()
|
|
levels = ','.join('"minecraft:%s":%d' % (e, rng.randrange(1, 4))
|
|
for e in rng.sample(ENCHANTS, rng.choice([0, 1, 1, 2, 3])))
|
|
comps = []
|
|
if roll < 0.25:
|
|
item = 'minecraft:' + rng.choice(['enchanted_book', 'enchanted_book', 'book'])
|
|
if levels:
|
|
comps.append('"minecraft:stored_enchantments":{%s}' % levels)
|
|
count = 1 if rng.random() < 0.85 else 2
|
|
else:
|
|
item = 'minecraft:' + rng.choice(GRINDABLE)
|
|
info = self.items.get(item, {})
|
|
if levels:
|
|
comps.append('"minecraft:enchantments":{%s}' % levels)
|
|
if info.get('minecraft:max_damage') and rng.random() < 0.6:
|
|
comps.append('"minecraft:damage":%d' % rng.randrange(0, info['minecraft:max_damage']))
|
|
count = 1
|
|
if rng.random() < 0.2:
|
|
comps.append('"minecraft:repair_cost":%d' % rng.choice([1, 3, 7]))
|
|
body = ',components:{%s}' % ','.join(comps) if comps else ''
|
|
return '{id:%s,count:%d%s}' % (quote(item), count, body)
|
|
|
|
def anvil_stack(self):
|
|
"""A tool to repair or enchant, an enchanted book, a repair material, a named item or anything."""
|
|
rng = self.rng
|
|
roll = rng.random()
|
|
if roll < 0.45:
|
|
stack = self.grind_stack()
|
|
elif roll < 0.65:
|
|
item = rng.choice(['iron_ingot', 'diamond', 'leather', 'oak_planks', 'phantom_membrane', 'string'])
|
|
stack = snbt('minecraft:' + item, rng.choice([1, 1, 2, 3, 5, 64]))
|
|
else:
|
|
stack = self.stack()
|
|
if rng.random() < 0.15:
|
|
name = '"minecraft:custom_name":"%s"' % rng.choice(['Blade', 'Iron Sword', 'Old'])
|
|
stack = stack.replace(',components:{', ',components:{%s,' % name) if ',components:{' in stack \
|
|
else stack[:-1] + ',components:{%s}}' % name
|
|
return stack
|
|
|
|
def smith_stack(self, slot=None):
|
|
"""A template, a base or an addition (for `slot`, else any), sometimes anything."""
|
|
rng = self.rng
|
|
if rng.random() < 0.15:
|
|
return self.stack()
|
|
slot = rng.randrange(3) if slot is None else slot
|
|
pool = [TEMPLATES, BASES, ADDITIONS][slot]
|
|
item = 'minecraft:' + rng.choice(pool)
|
|
count = 1 if slot == 1 else rng.choice([1, 1, 2, 5, 64])
|
|
if slot == 1 and rng.random() < 0.3:
|
|
return '{id:%s,count:1,components:{"minecraft:enchantments":{"minecraft:unbreaking":2}}}' % quote(item)
|
|
return snbt(item, count)
|
|
|
|
def carto_stack(self):
|
|
"""A filled map (some ids without data), paper, an empty map, a glass pane or anything."""
|
|
rng = self.rng
|
|
roll = rng.random()
|
|
if roll < 0.4:
|
|
return '{id:"minecraft:filled_map",count:%d,components:{"minecraft:map_id":%d}}' % (
|
|
rng.choice([1, 1, 2, 3, 64]), rng.randrange(0, 7))
|
|
if roll < 0.75:
|
|
return snbt('minecraft:' + rng.choice(['paper', 'map', 'glass_pane']), rng.choice([1, 1, 2, 5, 64]))
|
|
if roll < 0.78:
|
|
# Something else with a map id: the map slot takes it.
|
|
return '{id:"minecraft:paper",count:1,components:{"minecraft:map_id":%d}}' % rng.randrange(0, 7)
|
|
return self.stack()
|
|
|
|
def book(self):
|
|
"""A written or writable book with a few pages, or something else."""
|
|
rng = self.rng
|
|
pages = ','.join('"p%d"' % i for i in range(rng.choice([0, 1, 2, 3, 5])))
|
|
roll = rng.random()
|
|
if roll < 0.45:
|
|
return ('{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":'
|
|
'{title:"T",author:"A",pages:[%s]}}}' % pages)
|
|
if roll < 0.85:
|
|
return '{id:"minecraft:writable_book",count:1,components:{"minecraft:writable_book_content":{pages:[%s]}}}' % pages
|
|
return self.stack()
|
|
|
|
def recipe(self, slots, width, start=1):
|
|
"""Fills the crafting grid (slots `start`..) with a small known recipe."""
|
|
rng = self.rng
|
|
amount = lambda: rng.choice([1, 1, 2, 5, 64])
|
|
choices = [[('oak_log',)], [('oak_planks',), ('oak_planks',)], [('oak_planks', 'oak_planks'), ('oak_planks', 'oak_planks')],
|
|
[('iron_ingot',) * 3] * 3, [('milk_bucket',) * 3, ('sugar', 'egg', 'sugar'), ('wheat',) * 3],
|
|
[('wheat',) * 3], [('white_wool',) * 2, ('white_wool',) * 2], [('coal',), ('stick',)]]
|
|
pattern = rng.choice(choices)
|
|
if len(pattern) > width or any(len(row) > width for row in pattern):
|
|
pattern = [('oak_planks',), ('oak_planks',)]
|
|
for y, row in enumerate(pattern):
|
|
for x, name in enumerate(row):
|
|
slots[start + x + y * width] = snbt('minecraft:' + name, amount() if name != 'milk_bucket' else 1)
|
|
|
|
def ops(self, size, menu, slots):
|
|
rng = self.rng
|
|
out = []
|
|
for _ in range(rng.randrange(1, 9)):
|
|
if menu == 'loom' and rng.random() < 0.25:
|
|
# A pattern button (clickMenuButton), sometimes out of range.
|
|
out.append([rng.choice([0, 0, 1, 2, 3, 5, 10, 20, 39, 40, -1]), 0, 'BUTTON'])
|
|
continue
|
|
if menu == 'loom' and rng.random() < 0.15:
|
|
# The result: take it, shift it out, throw it.
|
|
out.append([3, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'THROW', 'SWAP'])])
|
|
continue
|
|
if menu == 'stonecutter' and rng.random() < 0.25:
|
|
# A recipe button (clickMenuButton), sometimes out of range or the selected one.
|
|
out.append([rng.choice([0, 0, 1, 2, 3, 5, 10, 30, -1]), 0, 'BUTTON'])
|
|
continue
|
|
if menu == 'stonecutter' and rng.random() < 0.2:
|
|
out.append([1, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'QUICK_MOVE', 'THROW', 'SWAP'])])
|
|
continue
|
|
if menu == 'anvil' and rng.random() < 0.15:
|
|
out.append([0, 0, 'RENAME', rng.choice(NAMES)])
|
|
continue
|
|
if menu in ('anvil', 'smithing') and rng.random() < 0.2:
|
|
result = 2 if menu == 'anvil' else 3
|
|
out.append([result, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'QUICK_MOVE', 'THROW', 'SWAP'])])
|
|
continue
|
|
if menu == 'enchanting' and rng.random() < 0.25:
|
|
# An offer button (clickMenuButton), sometimes out of range.
|
|
out.append([rng.choice([0, 1, 2, 2, 3, -1]), 0, 'BUTTON'])
|
|
continue
|
|
if menu == 'beacon' and rng.random() < 0.3:
|
|
# ServerboundSetBeaconPacket: powers, valid or not, or none.
|
|
pick = lambda: 'minecraft:' + rng.choice(POWERS) if rng.random() < 0.8 else None
|
|
primary, secondary = pick(), pick()
|
|
if primary is None and secondary not in (None, 'minecraft:regeneration', 'minecraft:night_vision'):
|
|
# validateEffects compares a null primary (Holder.equals: a NullPointerException).
|
|
primary = secondary
|
|
out.append([0, 0, 'SET_BEACON', primary, secondary])
|
|
continue
|
|
if menu == 'grindstone' and rng.random() < 0.2:
|
|
out.append([2, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'QUICK_MOVE', 'THROW', 'SWAP'])])
|
|
continue
|
|
if menu == 'cartography' and rng.random() < 0.3:
|
|
# The result: take it, shift it out, throw it, swap it, clone it.
|
|
out.append([2, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'QUICK_MOVE', 'THROW', 'SWAP', 'CLONE'])])
|
|
continue
|
|
if menu == 'lectern':
|
|
# The page buttons, a page jump, Take Book, and buttons it doesn't have; now and
|
|
# then a click on the book's slot. Not SWAP or the drags: vanilla's bookAccess
|
|
# hands out the block entity's own stack, which the swapped slot then shares.
|
|
if rng.random() < 0.7:
|
|
out.append([rng.choice([1, 1, 2, 2, 2, 3, 100, 101, 102, 104, 0, 4, 99, -1]), 0, 'BUTTON'])
|
|
else:
|
|
out.append([rng.choice([0, 0, -999]), rng.randrange(0, 2),
|
|
rng.choice(['PICKUP', 'THROW', 'QUICK_MOVE', 'CLONE'])])
|
|
continue
|
|
if menu == 'crafter_be' and rng.random() < 0.2:
|
|
out.append([rng.choice([0, 1, 4, 8, 8, 9, -1]), rng.randrange(0, 2), 'SLOT_STATE'])
|
|
continue
|
|
kind = rng.choices(['PICKUP', 'QUICK_MOVE', 'SWAP', 'CLONE', 'THROW', 'DRAG', 'PICKUP_ALL', 'JUNK',
|
|
'QUICK_MOVE_ALL'], [30, 20, 8, 3, 6, 14, 6, 2, 0 if menu.endswith('_be') else 6])[0]
|
|
slot = 0 if rng.random() < 0.2 and size in (46,) else rng.randrange(size)
|
|
if kind == 'QUICK_MOVE_ALL':
|
|
# A Shift double click: lastQuickMoved is mostly what a slot of the menu holds.
|
|
held = [s for s in slots if s != '-']
|
|
moved = rng.choice(held) if held and rng.random() < 0.85 else self.stack()
|
|
out.append([slot, 0, kind, moved])
|
|
continue
|
|
if kind == 'PICKUP':
|
|
out.append([rng.choice([slot, slot, slot, -999, -1]), rng.randrange(0, 3), kind])
|
|
elif kind == 'QUICK_MOVE':
|
|
out.append([rng.choice([slot, slot, slot, -999, -1]), rng.randrange(0, 3), kind])
|
|
elif kind == 'SWAP':
|
|
out.append([slot, rng.choice([0, 1, 2, 3, 4, 5, 6, 7, 8, 40, 9, -1]), kind])
|
|
elif kind in ('CLONE', 'THROW', 'PICKUP_ALL'):
|
|
out.append([slot, rng.randrange(0, 2), kind])
|
|
elif kind == 'DRAG':
|
|
ty = rng.randrange(0, 4)
|
|
out.append([-999, 0 | ty << 2, 'QUICK_CRAFT'])
|
|
for _ in range(rng.randrange(0, 6)):
|
|
out.append([rng.randrange(size), 1 | ty << 2, 'QUICK_CRAFT'])
|
|
out.append([-999, 2 | ty << 2, 'QUICK_CRAFT'])
|
|
else:
|
|
out.append([rng.choice([slot, 999, -5]), rng.randrange(-1, 5), rng.choice(
|
|
['PICKUP', 'QUICK_MOVE', 'QUICK_CRAFT', 'SWAP', 'THROW', 'PICKUP_ALL'])])
|
|
return out
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--seed', type=int, default=7)
|
|
parser.add_argument('--cases', type=int, default=8000)
|
|
parser.add_argument('--limit', type=int, default=3)
|
|
parser.add_argument('--menus', help='comma-separated menus to run (default: all)')
|
|
args = parser.parse_args()
|
|
work = lib.work_dir('menu-click-parity')
|
|
vanilla = lib.Vanilla(work)
|
|
vanilla.compile('ParityData', 'VanillaMenuOracle')
|
|
items = json.loads((lib.RESOURCES / 'items.json').read_text())
|
|
rng = random.Random(args.seed)
|
|
g = Gen(rng, items, random.Random(args.seed + 1))
|
|
cases = []
|
|
chosen = args.menus.split(',') if args.menus else list(MENUS)
|
|
for n in range(args.cases):
|
|
menu = chosen[n % len(chosen)]
|
|
size = MENUS[menu]
|
|
slots = g.slots(size, rng.choice([0.1, 0.4, 0.8]))
|
|
if menu == 'loom':
|
|
for i in rng.sample(range(4, size), 8):
|
|
slots[i] = g.loom_stack()
|
|
# Mostly a banner, a dye and sometimes a pattern item in their slots.
|
|
slots[0] = g.loom_stack(0.0) if rng.random() < 0.75 else rng.choice(['-', g.loom_stack()])
|
|
slots[1] = g.loom_stack(0.4) if rng.random() < 0.75 else rng.choice(['-', g.loom_stack()])
|
|
slots[2] = g.loom_stack(0.6) if rng.random() < 0.4 else rng.choice(['-', '-', g.loom_stack()])
|
|
# The result slot follows the inputs; a fixture stack there is rare.
|
|
slots[3] = '-' if rng.random() < 0.9 else g.loom_stack()
|
|
if menu == 'stonecutter':
|
|
for i in rng.sample(range(2, size), 8):
|
|
slots[i] = g.cut_stack()
|
|
slots[0] = g.cut_stack() if rng.random() < 0.8 else '-'
|
|
# The result follows the input and the selected recipe; a fixture stack there is rare.
|
|
slots[1] = '-' if rng.random() < 0.9 else g.cut_stack()
|
|
if menu == 'grindstone':
|
|
for i in rng.sample(range(3, size), 10):
|
|
slots[i] = g.grind_stack()
|
|
slots[0] = g.grind_stack() if rng.random() < 0.7 else '-'
|
|
slots[1] = g.grind_stack() if rng.random() < 0.6 else '-'
|
|
# Two of the same tool, so mergeItems runs.
|
|
if slots[0] != '-' and rng.random() < 0.3:
|
|
slots[1] = slots[0] if rng.random() < 0.4 else g.grind_stack()
|
|
# The result follows the inputs; a fixture stack there is rare.
|
|
slots[2] = '-' if rng.random() < 0.9 else g.grind_stack()
|
|
if menu == 'anvil':
|
|
for i in rng.sample(range(3, size), 10):
|
|
slots[i] = g.anvil_stack()
|
|
slots[0] = g.anvil_stack() if rng.random() < 0.8 else '-'
|
|
slots[1] = g.anvil_stack() if rng.random() < 0.6 else '-'
|
|
if slots[0] != '-' and rng.random() < 0.25:
|
|
slots[1] = slots[0]
|
|
slots[2] = '-' if rng.random() < 0.9 else g.anvil_stack()
|
|
if menu == 'smithing':
|
|
for i in rng.sample(range(4, size), 12):
|
|
slots[i] = g.smith_stack()
|
|
for i in range(3):
|
|
slots[i] = g.smith_stack(i) if rng.random() < 0.75 else '-'
|
|
slots[3] = '-' if rng.random() < 0.9 else g.smith_stack()
|
|
if menu == 'enchanting':
|
|
lapis = lambda: snbt('minecraft:lapis_lazuli', rng.choice([1, 2, 3, 5, 64]))
|
|
enchantable = lambda: snbt('minecraft:' + rng.choice(['iron_sword', 'book', 'diamond_pickaxe', 'bow',
|
|
'golden_helmet', 'fishing_rod']), 1)
|
|
for i in rng.sample(range(2, size), 10):
|
|
slots[i] = rng.choice([lapis, enchantable, g.grind_stack])()
|
|
slots[0] = rng.choice([enchantable, enchantable, g.grind_stack, g.stack])() if rng.random() < 0.8 else '-'
|
|
slots[1] = lapis() if rng.random() < 0.7 else rng.choice(['-', g.stack()])
|
|
if menu == 'beacon':
|
|
pay = lambda: snbt('minecraft:' + rng.choice(PAYMENTS), rng.choice([1, 1, 1, 2, 64]))
|
|
for i in rng.sample(range(1, size), 8):
|
|
slots[i] = pay()
|
|
slots[0] = pay() if rng.random() < 0.7 else rng.choice(['-', g.stack()])
|
|
if menu == 'cartography':
|
|
for i in rng.sample(range(3, size), 12):
|
|
slots[i] = g.carto_stack()
|
|
slots[0] = g.carto_stack() if rng.random() < 0.8 else '-'
|
|
slots[1] = g.carto_stack() if rng.random() < 0.75 else '-'
|
|
# The result follows the inputs; a fixture stack there is rare.
|
|
slots[2] = '-' if rng.random() < 0.9 else g.carto_stack()
|
|
if menu == 'lectern':
|
|
slots[0] = g.book() if rng.random() < 0.85 else '-'
|
|
if menu == 'crafter_be':
|
|
slots[45] = '-'
|
|
if rng.random() < 0.6:
|
|
g.recipe(slots, 3, start=0)
|
|
if menu in ('inventory', 'crafting'):
|
|
slots[0] = '-'
|
|
if menu in ('inventory', 'crafting') and rng.random() < 0.6:
|
|
g.recipe(slots, 2 if menu == 'inventory' else 3)
|
|
if menu.endswith('_be') and menu != 'crafter_be' and rng.random() < 0.7:
|
|
# Mostly something cooking: a smeltable input, the same in the
|
|
# inventory and in hand, so merges and partial takes happen.
|
|
food = snbt('minecraft:' + rng.choice(['raw_iron', 'beef', 'potato', 'sand', 'cobblestone', 'oak_log']),
|
|
rng.choice([1, 2, 5, 32, 64]))
|
|
slots[0] = food
|
|
for i in rng.sample(range(3, size), 4):
|
|
slots[i] = food if rng.random() < 0.6 else g.stack()
|
|
carried_food = food if rng.random() < 0.5 else None
|
|
else:
|
|
carried_food = None
|
|
def datum(i):
|
|
if menu.endswith('_be') and menu != 'crafter_be':
|
|
# litTimeRemaining, litTotalTime, cookingTimer, cookingTotalTime
|
|
return [rng.choice([0, 37, 1600]), 1600, rng.choice([0, 1, 57, 199]), rng.choice([200, 100])][i]
|
|
if menu in ('crafter', 'crafter_be'):
|
|
return rng.choice([0, 0, 1])
|
|
if menu in ('loom', 'stonecutter'):
|
|
return rng.choice([-1, -1, 0, 0, 1, 2, 3, 30])
|
|
if menu == 'lectern':
|
|
return rng.choice([-1, 0, 0, 1, 2, 4, 9])
|
|
if menu == 'beacon':
|
|
# the levels, then the powers (encodeEffect: id + 1)
|
|
return rng.choice([0, 1, 2, 3, 4, 4]) if i == 0 else rng.choice([0, 0, 2, 4, 11])
|
|
if menu == 'enchanting':
|
|
# costs, the seed, then the clues
|
|
return [rng.choice([0, 1, 2, 5, 15, 30]) for _ in range(3)][0] if i < 3 else \
|
|
rng.randrange(-2**15, 2**15) if i == 3 else rng.choice([-1, -1, 0, 3, 12])
|
|
return 0
|
|
carried = {'loom': g.loom_stack, 'stonecutter': g.cut_stack, 'grindstone': g.grind_stack,
|
|
'anvil': g.anvil_stack, 'smithing': g.smith_stack}.get(menu, g.stack)() if rng.random() < 0.5 else '-'
|
|
if carried_food is not None:
|
|
carried = carried_food
|
|
elif menu in BUNDLE_MENUS and g.bundles.random() < 0.3:
|
|
# A bundle in hand, emptied into slots and filled from them.
|
|
carried = g.bundle()
|
|
if menu == 'lectern':
|
|
carried = '-'
|
|
extra = {}
|
|
if menu == 'cartography':
|
|
# Maps 0-4 exist (5 and 6 don't): [id, scale, locked].
|
|
extra['maps'] = [[i, rng.choice([0, 0, 1, 3, 4]), rng.random() < 0.25] for i in range(5)]
|
|
if rng.random() < 0.5:
|
|
carried = g.carto_stack()
|
|
if menu == 'lectern':
|
|
# The inventory Take Book adds to (full, so the book drops, now and then).
|
|
density = rng.choice([0.0, 0.3, 1.0])
|
|
extra['inventory'] = [g.stack() if rng.random() < density else '-' for _ in range(36)]
|
|
cases.append({'menu': menu, 'creative': rng.random() < 0.25, 'slots': slots, **extra,
|
|
'levels': rng.choice([0, 1, 3, 8, 30, 39, 40]),
|
|
'carried': carried, 'data': [datum(i) for i in range(DATA.get(menu, 0))],
|
|
'ops': g.ops(size, menu, slots)})
|
|
(work / 'cases.jsonl').write_text(''.join(json.dumps(c) + '\n' for c in cases))
|
|
vanilla.run('VanillaMenuOracle', work / 'cases.jsonl', work / 'native.txt')
|
|
lib.cargo_example('horizon-client', 'menu_fixture', lib.RESOURCES, work / 'cases.jsonl', work / 'rust.txt')
|
|
native = (work / 'native.txt').read_text().split('\n')
|
|
rust = (work / 'rust.txt').read_text().split('\n')
|
|
|
|
def by_case(lines):
|
|
out, cur = [], []
|
|
for line in lines:
|
|
cur.append(line)
|
|
if line == 'end' or line.startswith('error-case'):
|
|
out.append(cur)
|
|
cur = []
|
|
return out
|
|
native, rust = by_case(native), by_case(rust)
|
|
bad = {}
|
|
for case, a, b in zip(cases, native, rust):
|
|
if a != b:
|
|
step = next(i for i, (x, y) in enumerate(zip(a, b)) if x != y) if len(a) == len(b) else 0
|
|
op = case['ops'][step - 1] if 0 < step <= len(case['ops']) else None
|
|
bad.setdefault((case['menu'], op[2] if op else 'init'), []).append((case, step, a, b))
|
|
total = sum(len(v) for v in bad.values())
|
|
for key, entries in sorted(bad.items(), key=lambda e: -len(e[1])):
|
|
print(f'{key}: {len(entries)} differing')
|
|
for case, step, a, b in entries[:args.limit]:
|
|
print(f' ops {case["ops"][:step]}\n start {a[0]}\n native {a[step]}\n rust {b[step]}')
|
|
print(f'{len(cases) - total}/{len(cases)} cases match')
|
|
raise SystemExit(1 if total else 0)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|