mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
EnchantmentHelper.doPostAttackEffectsWithItemSource runs the victim's equipment before the attacker's weapon: the player's melee hit and sweep, arrow, trident and wind charge now meet the mob's own post_attack effects. Thorns rolls from the level's random, hurts the player from the wearer by 1-5 and wears the piece by 2 (Unbreaking, breaking with its entity event); the player's own Thorns doesn't answer a thorns hit, and a thorns hit that lands whole plays enchant.thorns.hit. The mace harness gets armored targets with Thorns and Unbreaking; new lan_e2e check mob-thorns. Part of #9954 Co-Authored-By: Claude Opus 5.5 <[email protected]>
291 lines
15 KiB
Python
291 lines
15 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: Unbreaking, pieces
|
|
worn to their last points, Thorns on one of them) or an armor stand
|
|
(a living target whose own `hurtServer` wobbles on a first punch and breaks on
|
|
a second within five ticks; its hit still runs `itemAttackInteraction`, so a
|
|
smash knocks the creatures around it away and a Wind Burst launches the
|
|
attacker), 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
|
|
(the target's Thorns first: the player's hurt, its knockback and sounds, the
|
|
piece's wear, then 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 first target's worn pieces, the player's health, velocity,
|
|
fall distance, impulse impact and grace time and its landing-particle flag,
|
|
sounds, level and game events, the used and damage_taken statistics, the held
|
|
stack and the level random afterwards. Thorns' damage comes from the player's
|
|
own unseeded random, so only its range is compared (`own_random`).
|
|
"""
|
|
import json
|
|
import math
|
|
import os
|
|
import random
|
|
import re
|
|
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']
|
|
PIECES = [('feet', 'boots'), ('legs', 'leggings'), ('chest', 'chestplate'), ('head', 'helmet')]
|
|
BASE_DURABILITY = {'feet': 13, 'legs': 15, 'chest': 16, 'head': 11}
|
|
MULTIPLIER = {'iron': 15, 'diamond': 33, 'golden': 7, 'chainmail': 15, 'leather': 5}
|
|
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 armor(rng):
|
|
"""A target's armor: a chestplate and sometimes other pieces, with Unbreaking,
|
|
some worn down to their last points, and Thorns on one of them at most (the
|
|
player's second Thorns hurt would land inside its hurt cooldown by an amount
|
|
from its own unseeded random, which the runs can't compare)."""
|
|
material = rng.choice(ARMOR)
|
|
worn = [slot for slot, _ in PIECES if slot == 'chest' or rng.random() < 0.4]
|
|
thorny = rng.choice(worn) if rng.random() < 0.7 else None
|
|
equip = {}
|
|
for slot, name in PIECES:
|
|
if slot in worn:
|
|
# ArmorMaterial durability: the piece's base times the material's multiplier.
|
|
most = BASE_DURABILITY[slot] * MULTIPLIER[material]
|
|
enchantments = {'thorns': rng.randint(1, 3)} if slot == thorny else {}
|
|
if rng.random() < 0.3:
|
|
enchantments['unbreaking'] = rng.randint(1, 3)
|
|
damage = rng.choice([0, 0, 0, 7, most - 3, most - 2, most - 1])
|
|
equip[slot] = stack(f'{material}_{name}', enchantments, damage)
|
|
return equip
|
|
|
|
|
|
def own_random(lines):
|
|
"""Thorns' damage to the player comes from the player's own unseeded random
|
|
(vanilla's runs mark it "~"): its health and damage_taken only need to show
|
|
one hurt of 1 to 5 (normal difficulty, no armor)."""
|
|
out = []
|
|
for line in lines:
|
|
value = line.rsplit(' ', 1)[-1].lstrip('~')
|
|
if line.startswith('H ') and 15.0 < float(value) <= 19.0:
|
|
line = 'H thorned'
|
|
elif line.startswith('stat minecraft:damage_taken minecraft:custom ') and 10 <= int(value) <= 50:
|
|
line = 'stat minecraft:damage_taken minecraft:custom thorned'
|
|
out.append(line)
|
|
return out
|
|
|
|
|
|
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])
|
|
stand = rng.random() < 0.3
|
|
target = {'type': 'minecraft:armor_stand' if stand else 'minecraft:' + rng.choice(TARGETS),
|
|
'pos': [tx, ty, tz], 'yaw': rng.uniform(-180, 180)}
|
|
if stand:
|
|
# ArmorStand.hurtServer wobbles on a first punch and breaks on a
|
|
# second one within five ticks of the last (lastHit).
|
|
target['last_hit'] = case['time'] - rng.choice([1, 3, 5, 6, 10, 1000])
|
|
else:
|
|
target['health'] = 1000.0
|
|
if grounded:
|
|
target['ground'] = True
|
|
if target['type'] in ('minecraft:zombie', 'minecraft:husk', 'minecraft:skeleton') and rng.random() < 0.6:
|
|
target['equip'] = armor(rng)
|
|
if not stand and 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'
|
|
if case['entities'][0]['type'] == 'minecraft:armor_stand':
|
|
name += ' on an armor stand'
|
|
if 'thorns' in json.dumps(case['entities'][0].get('equip')):
|
|
name += ' on thorns'
|
|
return name
|
|
|
|
|
|
def weapon_part(lines):
|
|
"""A hit armor stand's lines without what its own `hurtServer` adds.
|
|
|
|
The weapon's part is what this harness compares: the smash, Wind Burst
|
|
and the wear. The stand's wobble and break have their own effects, which
|
|
Horizon does not copy yet: the `entity_damage` / `entity_die` game events,
|
|
the hit sound (vanilla's client plays it for entity event 32), the stand
|
|
item the break pops (place, motion and random draws) and so the random
|
|
stream after a break, which Unbreaking's roll for the weapon's wear
|
|
reads: the held stack's damage is left out then.
|
|
"""
|
|
broke = any(line.startswith('T 0 ') and line.endswith(' removed') for line in lines)
|
|
|
|
def own(line):
|
|
return ((line.startswith('G ') and line.endswith(('minecraft:entity_damage', 'minecraft:entity_die')))
|
|
or (line.startswith('N minecraft:item ') and '"minecraft:armor_stand"' in line)
|
|
or (line.startswith('S ') and line.endswith('minecraft:entity.armor_stand.hit'))
|
|
or (broke and line.startswith('rng ')))
|
|
|
|
def held(line):
|
|
if broke and line.startswith('held '):
|
|
line = re.sub(r'minecraft:damage:\d+,?', '', line).replace('components:{},', '')
|
|
return line
|
|
return [held(line) for line in lines if not own(line)]
|
|
|
|
|
|
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', '--profile', 'fast', '-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() / 'fast/horizon-client'), '--resources', str(resources)], cwd=work,
|
|
env=lab.game_env(HORIZON_DISPENSE_FIXTURE=f'{work / "cases.jsonl"},{work / "rust.txt"}'))
|
|
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 = thorned = 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)
|
|
thorned += any(line.endswith('minecraft:enchant.thorns.hit') for line in a)
|
|
if case['entities'][0]['type'] == 'minecraft:armor_stand':
|
|
a, b = weapon_part(a), weapon_part(b)
|
|
a, b = own_random(a), own_random(b)
|
|
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}, burst in {burst} and thorns hurt the player in {thorned}')
|
|
h.finish(total + skipped)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|