ca76075cc9
mobs::trial_spawner: TrialSpawner and its state data, the registry configs (data/*/trial_spawner), the six states, player detection with the visual line of sight, mob spawns and reward ejection, read and saved as vanilla does (a field that fails to decode takes its default alone). tools/test_trial_spawner_parity.py ticks 2,500 random trial spawners in VanillaNaturalSpawnOracle and natural_spawn_fixture and compares states, spawns, events and ejected items line by line. Also: copper grates have no visual shape; a slime's or magma cube's obstruction box scales with the Size its spawn data sets; LootData::roll_split for an empty-context getRandomItems. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
13 KiB
Python
239 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare trial spawners (TrialSpawner.tickServer and TrialSpawnerState) with vanilla.
|
|
|
|
Java (VanillaNaturalSpawnOracle, mode=trial) loads a TrialSpawnerBlockEntity
|
|
from each case's NBT on the oracle's stand-in ServerLevel, on a trial spawner
|
|
block in the case's state, and runs `TrialSpawner.tickServer` tick by tick
|
|
with the game time advancing. Rust (natural_spawn_fixture, mode=trial) loads
|
|
`mobs::trial_spawner::TrialSpawner` from the same NBT and ticks it on the same
|
|
world. Both log, tick by tick, the block's state changes, the player
|
|
detection events, every spawn (type, exact position and yaw bits) with its
|
|
level and game events, the next spawn data updates, the shutter sounds, each
|
|
ejected item (type, count, position and velocity bits), and at the end the
|
|
saved state data and the next draw of the level random, which pins every draw
|
|
in between.
|
|
|
|
Rooms are tuff halls around the spawner with pillars, glass and bars between
|
|
it and the players (line of sight), players inside and outside the required
|
|
range in every game mode, configs from the trial chamber registry and inline
|
|
ones from the edge values up, saved state data (registered players, tracked
|
|
mobs that are gone, cooldowns, spawn data, a loot table being ejected), every
|
|
state of the block, every difficulty and both game rules. Every so many ticks
|
|
the case kills the mobs the spawner added, so waves finish, rewards are
|
|
ejected and cooldowns run out.
|
|
|
|
finalizeSpawn runs with a fixed random in the oracle; its own outcomes are not
|
|
compared. Ominous spawners (which also drop ominous item spawners) are not in
|
|
the corpus yet.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import random
|
|
import subprocess
|
|
try:
|
|
from . import lab, test_rust_parity as base, test_natural_spawn_parity as natural
|
|
except ImportError:
|
|
import lab
|
|
import test_rust_parity as base
|
|
import test_natural_spawn_parity as natural
|
|
|
|
box = natural.box
|
|
REGISTRY = ['breeze', 'melee/husk', 'melee/spider', 'melee/zombie', 'ranged/poison_skeleton', 'ranged/skeleton',
|
|
'ranged/stray', 'slow_ranged/poison_skeleton', 'slow_ranged/skeleton', 'slow_ranged/stray',
|
|
'small_melee/baby_zombie', 'small_melee/cave_spider', 'small_melee/silverfish', 'small_melee/slime']
|
|
MOBS = ['zombie', 'husk', 'spider', 'cave_spider', 'skeleton', 'stray', 'bogged', 'breeze', 'slime', 'silverfish',
|
|
'creeper', 'witch', 'pig', 'blaze']
|
|
STATES = ['inactive', 'waiting_for_players', 'active', 'waiting_for_reward_ejection', 'ejecting_reward', 'cooldown']
|
|
TABLES = ['minecraft:spawners/trial_chamber/consumables', 'minecraft:spawners/trial_chamber/key',
|
|
'minecraft:chests/trial_chambers/reward', 'minecraft:spawners/ominous/trial_chamber/key']
|
|
|
|
|
|
def uuid(rng):
|
|
return '[I;' + ','.join(str(rng.randrange(-2**31, 2**31)) for _ in range(4)) + ']'
|
|
|
|
|
|
def inline_config(rng):
|
|
fields = []
|
|
if rng.random() < 0.6:
|
|
fields.append(f'spawn_range:{rng.choice([1, 2, 4, 4, 6, 10])}')
|
|
for key, values in [('total_mobs', [0.0, 1.0, 2.0, 3.5, 6.0]), ('simultaneous_mobs', [0.0, 1.0, 2.0, 3.0]),
|
|
('total_mobs_added_per_player', [0.0, 0.5, 1.0, 2.0]),
|
|
('simultaneous_mobs_added_per_player', [0.0, 0.5, 1.0])]:
|
|
if rng.random() < 0.6:
|
|
fields.append(f'{key}:{rng.choice(values)}f')
|
|
if rng.random() < 0.6:
|
|
fields.append(f'ticks_between_spawn:{rng.choice([0, 1, 5, 20, 40, 160])}')
|
|
if rng.random() < 0.9:
|
|
entries = []
|
|
for _ in range(rng.choice([1, 1, 2, 3])):
|
|
mob = rng.choice(MOBS)
|
|
entity = rng.choice([f'{{id:"minecraft:{mob}"}}', f'{{id:"{mob}"}}', f'{{id:"minecraft:{mob}",PersistenceRequired:1b}}',
|
|
'{id:"minecraft:not_an_entity"}', '{}'] if rng.random() < 0.2 else [f'{{id:"minecraft:{mob}"}}'])
|
|
entries.append(f'{{data:{{entity:{entity}}},weight:{rng.choice([1, 1, 2, 5, 0])}}}')
|
|
fields.append('spawn_potentials:[' + ','.join(entries) + ']')
|
|
if rng.random() < 0.4:
|
|
entries = [f'{{data:"{rng.choice(TABLES)}",weight:{rng.choice([1, 1, 3, 0])}}}' for _ in range(rng.choice([1, 2, 3]))]
|
|
fields.append('loot_tables_to_eject:[' + ','.join(entries) + ']')
|
|
return '{' + ','.join(fields) + '}'
|
|
|
|
|
|
def trial_snbt(rng, now):
|
|
fields = []
|
|
r = rng.random()
|
|
if r < 0.6:
|
|
name = rng.choice(REGISTRY)
|
|
fields.append(f'normal_config:"minecraft:trial_chamber/{name}/normal"')
|
|
fields.append(f'ominous_config:"minecraft:trial_chamber/{name}/ominous"')
|
|
elif r < 0.9:
|
|
fields.append(f'normal_config:{inline_config(rng)}')
|
|
elif r < 0.95:
|
|
fields.append('normal_config:"minecraft:trial_chamber/not_a_config"')
|
|
if rng.random() < 0.5:
|
|
fields.append(f'target_cooldown_length:{rng.choice([0, 1, 20, 45, 100, 200, 36000])}')
|
|
if rng.random() < 0.4:
|
|
fields.append(f'required_player_range:{rng.choice([1, 4, 8, 14, 20, 0, 200])}')
|
|
if rng.random() < 0.15:
|
|
fields.append('registered_players:[' + ','.join(rng.choice(['[I;0,0,0,1]', uuid(rng)]) for _ in range(rng.randrange(1, 3))) + ']')
|
|
if rng.random() < 0.15:
|
|
fields.append('current_mobs:[' + ','.join(uuid(rng) for _ in range(rng.randrange(1, 3))) + ']')
|
|
if rng.random() < 0.2:
|
|
fields.append(f'cooldown_ends_at:{now + rng.choice([-50, 0, 30, 100, 300])}L')
|
|
if rng.random() < 0.2:
|
|
fields.append(f'next_mob_spawns_at:{now + rng.choice([-5, 0, 10, 60])}L')
|
|
if rng.random() < 0.15:
|
|
fields.append(f'total_mobs_spawned:{rng.choice([0, 1, 3, 6, 20])}')
|
|
if rng.random() < 0.15:
|
|
fields.append(f'spawn_data:{{entity:{{id:"minecraft:{rng.choice(MOBS)}"}}}}')
|
|
if rng.random() < 0.08:
|
|
fields.append(f'ejecting_loot_table:"{rng.choice(TABLES)}"')
|
|
return '{' + ','.join(fields) + '}'
|
|
|
|
|
|
def room(rng):
|
|
"""A trial chamber hall around the spawner; returns (world, spawner position)."""
|
|
w = natural.World(rng)
|
|
w.dim = 'minecraft:overworld'
|
|
w.kv['dim'] = w.dim
|
|
w.kv['biome'] = rng.choice(['minecraft:plains', 'minecraft:dripstone_caves', 'minecraft:deep_dark', 'minecraft:lush_caves'])
|
|
sx, sy, sz = rng.randrange(-8, 9), rng.randrange(-40, 20), rng.randrange(-8, 9)
|
|
w.surface = sy
|
|
half, height = rng.choice([4, 5, 6, 8]), rng.choice([4, 5, 7])
|
|
w.block(box(sx - 24, sy - 6, sz - 24, sx + 24, sy + 12, sz + 24), rng.choice(['minecraft:tuff_bricks', 'minecraft:stone']))
|
|
w.block(box(sx - half, sy - 1, sz - half, sx + half, sy - 1, sz + half), rng.choice(['minecraft:chiseled_tuff', 'minecraft:waxed_copper_block']))
|
|
w.block(box(sx - half, sy, sz - half, sx + half, sy + height - 1, sz + half), 'minecraft:air')
|
|
if rng.random() < 0.3:
|
|
# A corridor out of the hall, past the range.
|
|
w.block(box(sx + half, sy, sz - 1, sx + 22, sy + 2, sz + 1), 'minecraft:air')
|
|
for _ in range(rng.choice([0, 0, 1, 3, 6])):
|
|
px, pz = sx + rng.randrange(-half, half + 1), sz + rng.randrange(-half, half + 1)
|
|
if (px, pz) != (sx, sz):
|
|
w.block(box(px, sy, pz, px, sy + rng.randrange(0, height), pz),
|
|
rng.choice(['minecraft:tuff_bricks', 'minecraft:glass', 'minecraft:iron_bars', 'minecraft:oak_slab', 'minecraft:copper_grate']))
|
|
w.light(box(sx - 24, sy - 2, sz - 24, sx + 24, sy + 12, sz + 24), 0, rng.choice([0, 0, 4, 8, 15]))
|
|
return w, (sx, sy, sz), half, height
|
|
|
|
|
|
def players(w, rng, spos, half, height):
|
|
for _ in range(rng.choice([0, 1, 1, 1, 2, 3])):
|
|
r = rng.random()
|
|
if r < 0.75:
|
|
x, y, z = spos[0] + rng.uniform(-half, half + 1), spos[1] + rng.choice([0, 0, 0, 1, height - 2]), spos[2] + rng.uniform(-half, half + 1)
|
|
else:
|
|
x, y, z = spos[0] + rng.uniform(-20, 21), spos[1] + rng.choice([0, 0, -8, 10]), spos[2] + rng.uniform(-20, 21)
|
|
mode = rng.choice(['survival'] * 7 + ['creative', 'adventure', 'spectator'])
|
|
w.players.append(f'{x:.3f},{y:.3f},{z:.3f}/{mode}')
|
|
|
|
|
|
def corpus():
|
|
rng = random.Random(0x54524941)
|
|
cases = []
|
|
for k in range(2500):
|
|
w, spos, half, height = room(rng)
|
|
players(w, rng, spos, half, height)
|
|
natural.common(w, rng, rng.randrange(-2**47, 2**47))
|
|
w.kv.pop('unloaded', None)
|
|
w.kv.pop('respawn', None)
|
|
if rng.random() < 0.05:
|
|
w.kv['spawners'] = 0
|
|
if rng.random() < 0.05:
|
|
w.kv['rules'] = 0
|
|
t0 = rng.choice([0, 1, 19, 1000, rng.randrange(0, 10**6), rng.randrange(10**9, 10**12)])
|
|
w.kv['spos'] = f'{spos[0]},{spos[1]},{spos[2]}'
|
|
w.kv['state'] = rng.choices(STATES, [2, 6, 4, 1, 1, 1])[0]
|
|
w.kv['snbt'] = base64.b64encode(trial_snbt(rng, t0).encode()).decode()
|
|
w.kv['t0'] = t0
|
|
w.kv['ticks'] = rng.choice([1, 2, 20, 41, 100, 200, 400, 600])
|
|
w.kv['kill'] = rng.choice([0, 1, 7, 20, 60, 60])
|
|
cases.append(f'trial/{k} mode=trial {w.tokens()}')
|
|
return cases
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--prepare', action='store_true')
|
|
args = parser.parse_args()
|
|
build = lab.LOCAL / 'rust-trial-spawner-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'
|
|
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java'
|
|
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/natural_spawn_fixture.rs',
|
|
lab.ROOT / 'crates/horizon-client/src/mobs/trial_spawner.rs',
|
|
lab.ROOT / 'crates/horizon-client/src/mobs/spawner.rs',
|
|
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
|
|
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
|
|
tested = base.source_hashes(sources)
|
|
inputs = build / 'inputs.txt'
|
|
cases = corpus()
|
|
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', '-Xmx3G', '-cp', os.pathsep.join([str(classes), str(jars['server']), deps]),
|
|
'dev.horizonlab.parity.VanillaNaturalSpawnOracle', inputs, build / 'server.txt'], 'server')
|
|
run(['cargo', 'build', '--release', '-p', 'horizon-client', '--example', 'natural_spawn_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/natural_spawn_fixture'
|
|
run([binary, resources, inputs, build / 'rust.txt'], 'rust')
|
|
vanilla = (build / 'server.txt').read_text().splitlines()
|
|
errors = [l for l in vanilla if ' error ' in l]
|
|
if errors:
|
|
raise SystemExit(f'oracle errors ({len(errors)}): {errors[:5]}')
|
|
rust = (build / 'rust.txt').read_text().splitlines()
|
|
bad = [(a, b) for a, b in zip(vanilla, rust) if a != b]
|
|
if bad or len(vanilla) != len(rust):
|
|
report = '\n'.join(f' vanilla {a[:2000]}\n rust {b[:2000]}' for a, b in bad[:12])
|
|
raise SystemExit(f'{len(bad)} of {len(vanilla)} cases differ (rust {len(rust)} lines):\n{report}')
|
|
base.ensure_sources_unchanged(sources, tested)
|
|
count = lambda text: sum(line.count(text) for line in vanilla)
|
|
stats = {'spawns': count(' spawn minecraft:'), 'items': count(' item minecraft:'), 'states': count(' state '),
|
|
'detections': count(' levent 3013 '), 'rewards': count(' levent 3014 '), 'kills': count(' kill ')}
|
|
for state in STATES:
|
|
stats[f'reached_{state}'] = sum(f' state {state} ' in line for line in vanilla)
|
|
lab.write_json(build / 'report.json', {
|
|
'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(), 'cases': len(cases),
|
|
**stats, 'sources_sha256': tested,
|
|
'scope': 'TrialSpawner: load (state data, FullConfig with registry and inline configs), tickServer (mob '
|
|
'tracking), TrialSpawnerState.tickAndGetNext for every state (display entity, canSpawnInLevel, '
|
|
'tryDetectPlayers with line of sight, waves and their targets per player, spawnMob with its '
|
|
'collision, line of sight, spawn rules, yaw and obstruction checks, next spawn data, reward '
|
|
'ejection with the loot tables and spawnItem, shutter sounds, cooldown) with every level random '
|
|
'draw. finalizeSpawn runs with a fixed random in the oracle; its own outcomes are not compared.'})
|
|
print(f'{len(cases):,} trial spawner cases match vanilla (' + ', '.join(f'{v:,} {k}' for k, v in stats.items()) + ')')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|