diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index ba46ab3967..6dcf8c78e3 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -1034,11 +1034,7 @@ impl Game<'_> { } if self.fight.players { if self.fight.needs_scan { - // scanState: a missing exit portal means a fight in progress. - self.fight.previously_killed = self.end_portal_active(); - if !self.fight.previously_killed { - self.fight.killed = false; - } + self.scan_state(); self.fight.needs_scan = false; self.fight.dirty = true; } @@ -3911,12 +3907,38 @@ impl Game<'_> { /// `EnderDragonFight.findOrCreateDragon`: the End's dragon becomes the /// fight's, or a new one is made. fn find_or_create_dragon(&mut self) { - match self.dragon.as_ref().map(|d| d.id) { + match self.dragon.as_ref().map(|d| (d.id, d.alive())) { None => self.create_new_dragon(), - Some(id) => { + Some((id, true)) => { self.fight.dragon_uuid = Some(self.uuid_for(id)); self.fight.dirty = true; } + // `ServerLevel.getDragons` takes only living dragons, so vanilla + // makes a new one beside a dying one; with one dragon to a level, + // the new one comes once the dying one is gone. + Some((_, false)) => {} + } + } + /// `EnderDragonFight.scanState`, for a world from before the fight was + /// saved: an active exit portal means the dragon was killed before. A + /// living dragon there is the fight's, unless there is no active portal: + /// then it is removed for a new one. + fn scan_state(&mut self) { + let active = self.end_portal_active(); + self.fight.previously_killed = active; + match self.dragon.as_ref().filter(|d| d.alive()).map(|d| d.id) { + None => self.fight.killed = true, + Some(id) => { + self.fight.dragon_uuid = Some(self.uuid_for(id)); + self.fight.killed = false; + if !active { + self.dragon = None; + self.fight.dragon_uuid = None; + } + } + } + if !self.fight.previously_killed && self.fight.killed { + self.fight.killed = false; } } /// The dragon is the End fight's own (`EnderDragon.dragonFight`, set diff --git a/tools/test_dragon_not_the_fights_parity.py b/tools/test_dragon_not_the_fights_parity.py index cb536e719e..3808c4bf9c 100644 --- a/tools/test_dragon_not_the_fights_parity.py +++ b/tools/test_dragon_not_the_fights_parity.py @@ -1,14 +1,20 @@ #!/usr/bin/env python3 -"""An ender dragon that is not the End fight's own, dying in the End, against vanilla's dedicated server. +"""Ender dragons that are not the End fight's own, dying in the End, against vanilla's dedicated server. EnderDragonFight.setDragonKilled builds the exit portal, a gateway and the egg only for the fight's own dragon (dragon.getUUID().equals(dragonUUID)); EnderDragon.aiStep joins a dragon to the fight only when its UUID is the -fight's, and tickDeath gives the first kill's 12000 experience only with a fight. The fight takes a dragon of its -own (findOrCreateDragon) only while players stand in it, and never once it is won. A vanilla dedicated server and -Horizon's each serve a fresh world with nobody in the End; the console summons a dying dragon (Health 0, NoAI: -LivingEntity.baseTick runs tickDeath while isDeadOrDying) in the End and waits past its 200 death ticks. Then the -dragon egg, end portal and bedrock blocks around 0 0, the End gateways at the 20 places a fight puts them, and the -experience its orbs hold must match vanilla's (none of the blocks; 500 experience). +fight's, and tickDeath gives the first kill's 12000 experience only with a fight. The fight takes a dragon +(findOrCreateDragon) only while a player stands in it, only a living one (ServerLevel.getDragons), and never +once it is won. A vanilla dedicated server and Horizon's each serve a fresh world; a vanilla-codec client +(VanillaProtocolClient, level-events mode, only to be a player) joins in the overworld. With the ticks frozen the +console summons a dying dragon (Health 0, NoAI: LivingEntity.baseTick runs tickDeath while isDeadOrDying) in the +End and steps past its 200 death ticks, three times: +- alone: nobody is in the End, so there is no fight to join; +- arriving: the player is sent to the End with it, so the fight begins, passes over the dying dragon and makes its + own; +- won: the fight's dragon is killed (/kill: setDragonKilled, a gateway and the egg), then the dying one is summoned. +After each, the dragon egg and end portal blocks around 0 0, the End gateways at the 20 places a fight puts them, +the experience its orbs hold and the living dragons must match vanilla's. """ import argparse import atexit @@ -31,32 +37,39 @@ except ImportError: from test_dedicated_server_parity import Server, free_port, game_time from test_server_world_gen_parity import escape +PLAYER = 'Tester' MESSAGE = re.compile(r'\[Server thread/INFO\]: (?:System chat: )?(.*)$') END = 'execute in minecraft:the_end run ' # Where setDragonKilled builds the exit portal and its egg: the column at 0 0 of the End. BOX = '-4 0 -4 4 120 4' -BLOCKS = ['dragon_egg', 'end_portal', 'bedrock'] +BLOCKS = ['dragon_egg', 'end_portal'] # spawnNewGateway: gateway i of the 20 is 96 blocks out at y 75. GATEWAYS = [(math.floor(96 * math.cos(a)), math.floor(96 * math.sin(a))) for a in (2 * (-math.pi + math.pi / 20 * i) for i in range(20))] +DYING = 'summon minecraft:ender_dragon 8.5 100 3.5 {NoAI:1b,Health:0.0f}' +STEPS = 240 FILLED = re.compile(r'Successfully filled (\d+) block') VALUE = re.compile(r'has the following entity data: (-?\d+)') +COUNT = re.compile(r'Test passed, count: (\d+)|Test passed. Count: (\d+)', re.IGNORECASE) -def run_side(label, command, out): +def run_side(label, command, client_command, out): directory = out / label if directory.exists(): shutil.rmtree(directory) directory.mkdir(parents=True) - props = {'server-ip': '127.0.0.1', 'server-port': str(free_port()), 'online-mode': 'false', - 'generate-structures': 'false', 'level-seed': '42', - 'view-distance': '2', 'simulation-distance': '2', 'spawn-protection': '0', + port = free_port() + props = {'server-ip': '127.0.0.1', 'server-port': str(port), 'online-mode': 'false', 'white-list': 'false', + 'generate-structures': 'false', 'level-seed': '42', 'gamemode': 'creative', 'force-gamemode': 'true', + 'allow-flight': 'true', 'view-distance': '2', 'simulation-distance': '2', 'spawn-protection': '0', 'pause-when-empty-seconds': '-1', 'max-tick-time': '-1'} (directory / 'server.properties').write_text( ''.join(f'{k}={escape(v)}\n' for k, v in props.items()), encoding='iso-8859-1') (directory / 'eula.txt').write_text('eula=true\n') + transcript = out / f'{label}-packets.txt' server = Server(command, directory, label) atexit.register(lambda: server.process.poll() is None and server.process.kill()) + client = None serial = 0 def reply(text): @@ -73,22 +86,14 @@ def run_side(label, command, out): def passed(text): return any('Test passed' in line for line in reply(text)) - try: - server.wait_for(r'Done \(') - reply('tick freeze') - reply('gamerule minecraft:spawn_mobs false') - # The exit portal's column and every gateway's place. - reply(END + 'forceload add -100 -100 99 99') - deadline = time.monotonic() + 300 - while not all(passed(END + f'execute if loaded {x} 64 {z}') for x, z in [(-8, -8), (8, 8), *GATEWAYS]): - assert time.monotonic() < deadline, f'{label}: scene chunks did not load' - reply(END + 'kill @e[type=!minecraft:player]') - reply(END + 'summon minecraft:ender_dragon 8.5 100 3.5 {NoAI:1b,Health:0.0f}') + def advance(ticks): start = game_time(server) - reply('tick step 240') - deadline = time.monotonic() + 180 - while game_time(server) < start + 240: + reply(f'tick step {ticks}') + deadline = time.monotonic() + 300 + while game_time(server) < start + ticks: assert time.monotonic() < deadline, f'{label}: tick step stalled' + + def measure(): blocks = {} for block in BLOCKS: filled = [m for line in reply(END + f'fill {BOX} minecraft:air replace minecraft:{block}') @@ -107,9 +112,51 @@ def run_side(label, command, out): reply(END + f'kill {orb}') else: raise AssertionError(f'{label}: more than 500 experience orbs') + dragons = [m for line in reply(END + 'execute if entity @e[type=minecraft:ender_dragon]') + if (m := COUNT.search(line))] + return {'blocks': blocks, 'experience': experience, + 'living dragons': int(next(g for g in dragons[0].groups() if g)) if dragons else 0} + + results = {} + try: + server.wait_for(r'Done \(') + with (out / f'{label}-client.log').open('w') as log: + client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), PLAYER, + str(transcript), 'level-events'], cwd=lab.ROOT, + stdout=log, stderr=subprocess.STDOUT) + server.wait_for(rf'<{PLAYER}> summon the scene', timeout=180) + reply('tick freeze') + reply('gamerule minecraft:spawn_mobs false') + # The exit portal's column, every gateway's place and where the player stands. + reply(END + 'forceload add -100 -100 99 99') + deadline = time.monotonic() + 300 + while not all(passed(END + f'execute if loaded {x} 64 {z}') + for x, z in [(-8, -8), (8, 8), (0, 90), *GATEWAYS]): + assert time.monotonic() < deadline, f'{label}: scene chunks did not load' + reply(END + 'kill @e[type=!minecraft:player]') + reply(END + DYING) + advance(STEPS) + results['alone'] = measure() + # Far from the orbs (a player picks them up), within 192 blocks of 0 128 0 (the fight's players). + reply(END + DYING) + reply(f'execute in minecraft:the_end run tp {PLAYER} 0.5 80 90.5') + advance(STEPS) + results['arriving'] = measure() + reply(END + 'kill @e[type=minecraft:ender_dragon]') + advance(1) + reply(END + DYING) + advance(STEPS) + results['won'] = measure() + server.console('say level_events_done') + client.wait(timeout=180) finally: + if client is not None and client.poll() is None: + client.kill() + client.wait() server.stop() - return {'blocks': blocks, 'experience': experience} + lines = transcript.read_text().splitlines() + assert not any(line.startswith('error ') for line in lines), lines[-10:] + return results def main(): @@ -129,18 +176,33 @@ def main(): 'horizon-client.exe' if os.name == 'nt' else 'horizon-client') out = args.out.resolve() out.mkdir(parents=True, exist_ok=True) + classes = out / 'classes' + classes.mkdir(exist_ok=True) + source = lab.ROOT / 'mods/horizon/src/test/java' cp = os.pathsep.join(map(str, [jars['server'], *libraries])) + subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', + '-sourcepath', source, '-cp', cp, '-d', classes, + source / 'dev/horizonlab/parity/VanillaProtocolClient.java'], check=True) + client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + cp, + 'dev.horizonlab.parity.VanillaProtocolClient'] commands = {'vanilla': ['java', '-Xmx2g', '-cp', cp, 'net.minecraft.server.Main', 'nogui'], 'horizon': [binary, '--resources', lab.RESOURCES.resolve(), '--dedicated', '--nogui']} - results = {label: run_side(label, command, out) for label, command in commands.items()} + results = {label: run_side(label, command, client, out) for label, command in commands.items()} lab.write_json(out / 'records.json', results) for label, result in results.items(): - print(f'{label}: {result}', flush=True) - # The spec as read from setDragonKilled and tickDeath: not the fight's dragon, so nothing built and 500. - nothing = {'blocks': dict.fromkeys([*BLOCKS, 'end_gateway'], 0), 'experience': 500} - assert results['vanilla'] == nothing, results['vanilla'] - assert results['horizon'] == results['vanilla'], f"the death of a dragon not the fight's differs; see {out}" - print("dragon not the fight's: matches vanilla", flush=True) + for case, value in result.items(): + print(f'{label} {case}: {value}', flush=True) + # The spec as read from setDragonKilled, findOrCreateDragon and tickDeath: no dying dragon is the fight's, + # so each is worth 500 and builds nothing; the fight that began makes a dragon of its own, and its /kill + # builds the exit portal, the egg and one gateway. + nothing = dict.fromkeys([*BLOCKS, 'end_gateway'], 0) + vanilla = results['vanilla'] + assert vanilla['alone'] == {'blocks': nothing, 'experience': 500, 'living dragons': 0}, vanilla['alone'] + assert vanilla['arriving'] == {'blocks': nothing, 'experience': 500, 'living dragons': 1}, vanilla['arriving'] + assert vanilla['won']['blocks']['end_gateway'] == 1 and vanilla['won']['blocks']['dragon_egg'] == 1, vanilla + assert vanilla['won']['experience'] == 500 and vanilla['won']['living dragons'] == 0, vanilla['won'] + assert results['horizon'] == vanilla, f"the deaths of dragons not the fight's differ; see {out}" + print("dragons not the fight's: matches vanilla", flush=True) if __name__ == '__main__':