56aae0e902
A mace hit after falling more than 1.5 blocks is now a smash attack (MaceItem): the fall-based damage bonus plus Density, mace_smash damage, the stopped fall with its damage waived, the ground/air smash sounds, the dust pillars (level event 2013) and the knockback of the creatures around the target, then the fall reset and landing particles. Breach thins the target's armor (CombatRules.getDamageAfterAbsorb), Wind Burst bursts at the player's feet (ExplodeEffect, trigger), and weapons wear by their weapon component and award the used statistic. Vanilla details matched on the way: the hurt knockback is 0.4F and reads the live knockback-resistance attribute, explosion exposure uses the exact COLLIDER clip, a burst posts the explode game event and ends the impulse grace unless it is a wind charge's, visible armor stands feel bursts that affect block-like entities, attack sounds play at the player. tools/test_mace_parity.py compares the hit with VanillaDispenserOracle's real MaceItem / hurtServer / post-attack effects: 3000/3000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
10 KiB
Python
205 lines
10 KiB
Python
"""Compare a player's melee hit with a mace (and other weapons) with vanilla's.
|
|
|
|
Every case has a stone floor, a target mob standing on it or hanging in the
|
|
air (health raised so no hit kills it, sometimes in armor), other mobs and
|
|
armor stands around it, and a player near the target holding a mace with
|
|
Density, Breach, Wind Burst and Unbreaking in any mix, or a sword, an axe, a
|
|
stick or nothing. The player has fallen some distance (sometimes gliding or
|
|
flying), moves, and may still have the impact point of an earlier impulse.
|
|
|
|
Vanilla runs, over the headless level of the dispenser harness
|
|
(`VanillaDispenserOracle.attack`), the steps of `Player.attack` after the
|
|
attack strength scaled the base damage: `Item.getAttackDamageBonus`
|
|
(`MaceItem`'s smash bonus with Density), the item's damage source
|
|
(`mace_smash` or `player_attack`), the target's real `hurtServer` (armor
|
|
thinned by Breach in `CombatRules.getDamageAfterAbsorb`), then
|
|
`itemAttackInteraction`: `ItemStack.hurtEnemy` (`MaceItem.hurtEnemy`: the
|
|
stopped fall, the waived fall damage, the smash sounds, the dust-pillar level
|
|
event and the knockback around the target), the post-attack enchantments
|
|
(Wind Burst's `ExplodeEffect` on the player) and `postHurtEnemy` (the fall
|
|
reset and the weapon's wear with Unbreaking). Horizon runs the same steps
|
|
through the helpers `Game::attack` uses (`smash_damage_bonus`,
|
|
`hurt_by_player`, `weapon_hit`) in a headless game
|
|
(`HORIZON_DISPENSE_FIXTURE`). Both print every target's health and
|
|
velocity, the player's velocity, fall distance, impulse impact and grace
|
|
time and its landing-particle flag, sounds, level and game events, the used
|
|
statistic, the held stack and the level random afterwards.
|
|
"""
|
|
import json
|
|
import math
|
|
import os
|
|
import random
|
|
try:
|
|
from . import block_update_harness as h, lab, test_dispenser_parity as d, test_rust_parity as base
|
|
except ImportError:
|
|
import block_update_harness as h
|
|
import lab
|
|
import test_dispenser_parity as d
|
|
import test_rust_parity as base
|
|
|
|
TARGETS = ['pig', 'cow', 'sheep', 'zombie', 'husk', 'skeleton', 'zombie', 'pig']
|
|
AROUND = ['pig', 'cow', 'zombie', 'sheep', 'armor_stand']
|
|
ARMOR = ['iron', 'diamond', 'golden', 'chainmail', 'leather']
|
|
FALLS = [0.0, 0.5, 1.0, 1.5, 1.5000001, 2.0, 2.75, 3.0, 3.25, 5.0, 5.0001, 6.5, 8.0, 8.5, 12.0, 25.3, 60.0]
|
|
|
|
|
|
def stack(item, enchantments=None, damage=0):
|
|
components = []
|
|
if enchantments:
|
|
levels = ','.join(f'"minecraft:{e}":{lvl}' for e, lvl in enchantments.items())
|
|
components.append('"minecraft:enchantments":{%s}' % levels)
|
|
if damage:
|
|
components.append(f'"minecraft:damage":{damage}')
|
|
extra = ',components:{%s}' % ','.join(components) if components else ''
|
|
return '{id:"minecraft:%s",count:1%s}' % (item, extra)
|
|
|
|
|
|
def weapon(rng):
|
|
roll = rng.random()
|
|
if roll < 0.75:
|
|
enchantments = {}
|
|
for name, top in [('density', 5), ('breach', 4), ('wind_burst', 3), ('unbreaking', 3)]:
|
|
if rng.random() < 0.35:
|
|
enchantments[name] = rng.randint(1, top)
|
|
return stack('mace', enchantments, rng.choice([0, 0, 0, 7, 300]))
|
|
if roll < 0.85:
|
|
return stack(rng.choice(['diamond_sword', 'iron_axe', 'stone_pickaxe']),
|
|
{'breach': 2} if rng.random() < 0.2 else None, rng.choice([0, 0, 10]))
|
|
return rng.choice([stack('stick'), '-'])
|
|
|
|
|
|
def cases(facts, count, seed):
|
|
rng = random.Random(seed)
|
|
states = d.States(facts)
|
|
floors = [states.get(b) for b in ['stone', 'grass_block', 'dirt', 'sand', 'oak_planks']]
|
|
for n in range(count):
|
|
case = {**h.environment(rng), 'pos': [0, 0, 0]}
|
|
case.pop('raining', None)
|
|
case.pop('thundering', None)
|
|
floor = rng.choice(floors)
|
|
case['cells'] = [[x, -1, z, floor] for x in range(-6, 7) for z in range(-6, 7)]
|
|
if rng.random() < 0.2:
|
|
# A wall the burst and the dust have to get past.
|
|
wall = states.get(rng.choice(['stone', 'glass', 'oak_planks']))
|
|
x = rng.choice([-3, -2, 2, 3])
|
|
case['cells'] += [[x, y, z, wall] for y in range(0, 3) for z in range(-2, 3)]
|
|
item = weapon(rng)
|
|
wind = 'wind_burst' in item
|
|
grounded = rng.random() < 0.75
|
|
tx, tz = rng.uniform(-0.5, 0.5), rng.uniform(-0.5, 0.5)
|
|
ty = 0.0 if grounded else rng.choice([0.5, 1.25, 2.0])
|
|
target = {'type': 'minecraft:' + rng.choice(TARGETS), 'pos': [tx, ty, tz], 'yaw': rng.uniform(-180, 180),
|
|
'health': 1000.0}
|
|
if grounded:
|
|
target['ground'] = True
|
|
if target['type'] in ('minecraft:zombie', 'minecraft:husk', 'minecraft:skeleton') and rng.random() < 0.6:
|
|
material = rng.choice(ARMOR)
|
|
target['equip'] = {'chest': d.plain(f'{material}_chestplate')}
|
|
if rng.random() < 0.5:
|
|
target['equip']['head'] = d.plain(f'{material}_helmet')
|
|
if rng.random() < 0.15:
|
|
target['knockback_resistance'] = rng.choice([0.25, 0.5, 1.0])
|
|
entities = [target]
|
|
for _ in range(rng.choice([0, 1, 2, 3, 5])):
|
|
kind = rng.choice(AROUND)
|
|
near = rng.random() < 0.8
|
|
spread = 3.2 if near else 5.0
|
|
e = {'type': 'minecraft:' + kind, 'pos': [tx + rng.uniform(-spread, spread), rng.choice([0.0, 0.0, 0.5, 1.0]),
|
|
tz + rng.uniform(-spread, spread)], 'yaw': rng.uniform(-180, 180)}
|
|
if kind == 'armor_stand':
|
|
# A marker is spared by the smash; Wind Burst's explosion would
|
|
# need its zero-size box.
|
|
if not wind and rng.random() < 0.4:
|
|
e['marker'] = True
|
|
else:
|
|
e['health'] = 1000.0
|
|
if rng.random() < 0.2:
|
|
e['knockback_resistance'] = rng.choice([0.4, 1.0])
|
|
if e['pos'][1] == 0.0 and rng.random() < 0.8:
|
|
e['ground'] = True
|
|
entities.append(e)
|
|
case['entities'] = entities
|
|
angle = rng.uniform(0, 6.283)
|
|
reach = rng.uniform(0.6, 2.8)
|
|
px, pz = tx + math.cos(angle) * reach, tz + math.sin(angle) * reach
|
|
py = ty + rng.choice([0.0, 0.3, 1.0, 1.7, 2.5])
|
|
fall = rng.choice(FALLS) if rng.random() < 0.7 else round(rng.uniform(0, 30), 3)
|
|
attack = {'item': item, 'creative': rng.random() < 0.15, 'player': [px, py, pz],
|
|
'motion': [rng.uniform(-0.3, 0.3), -rng.uniform(0, 2.5), rng.uniform(-0.3, 0.3)],
|
|
'fall': fall, 'fall_flying': rng.random() < 0.08, 'flying': rng.random() < 0.04,
|
|
'base': round(rng.uniform(1.2, 7.0), 3)}
|
|
if rng.random() < 0.3:
|
|
attack['impact'] = [px + rng.uniform(-1, 1), py + rng.choice([-3.0, -0.5, 0.0, 2.0]), pz + rng.uniform(-1, 1)]
|
|
case['attack'] = attack
|
|
case['items'] = []
|
|
case['mode'] = 'dispense'
|
|
yield case
|
|
|
|
|
|
def kind(case):
|
|
item = case['attack']['item']
|
|
name = item.split('"')[1].split(':')[1] if item != '-' else 'empty hand'
|
|
smash = case['attack']['fall'] > 1.5 and not case['attack']['fall_flying']
|
|
if name == 'mace':
|
|
name += ' smash' if smash else ' hit'
|
|
if 'wind_burst' in item:
|
|
name += ' with wind burst'
|
|
return name
|
|
|
|
|
|
def main():
|
|
parser = h.argument_parser(__doc__)
|
|
args = parser.parse_args()
|
|
if args.count == 40000:
|
|
args.count = 3000
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
facts = h.load_facts()
|
|
resources = lab.LOCAL / 'horizon-client' / lock['minecraft']['version']
|
|
work = lab.LOCAL / 'mace-parity'
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
all_cases = list(cases(facts, args.count, args.seed))
|
|
(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'
|
|
d.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')])
|
|
d.run(['java', '-Xmx3G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaDispenserOracle',
|
|
str(resources), str(work / 'cases.jsonl'), str(work / 'native.txt')])
|
|
d.run(['cargo', 'build', '-q', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
|
|
env={**os.environ, 'CARGO_BUILD_JOBS': os.environ.get('CARGO_BUILD_JOBS', '3')})
|
|
d.run([str(lab.cargo_target() / 'release/horizon-client'), '--resources', str(resources)], cwd=work,
|
|
env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}', 'HORIZON_NO_AUDIO': '1'})
|
|
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 = {}, {}
|
|
smashed = burst = 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
|
|
smashed += any('source=minecraft:mace_smash hurt=true' in line for line in a)
|
|
burst += any(line.endswith('minecraft:entity.wind_charge.wind_burst') for line in a)
|
|
if not d.same(a, b):
|
|
groups.setdefault(kind(case), []).append((case, a, b))
|
|
for name, entries in sorted(errors.items(), key=lambda e: -len(e[1])):
|
|
print(f'oracle error x{len(entries)}: {name}')
|
|
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 entities=' + json.dumps(case['entities']) + ' attack=' + json.dumps(case['attack']) + f' seed={case["seed"]}')
|
|
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'vanilla smashed in {smashed} and burst in {burst}')
|
|
h.finish(total + skipped)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|