The dragon fight takes only a living dragon, and scans legacy worlds as vanilla

findOrCreateDragon passes over a dying dragon (ServerLevel.getDragons
takes living ones). scanState makes a living dragon the fight's, and
removes it for a new one when there is no active exit portal. The
harness now runs with a player too: arriving in the End beside a dying
dragon, and after the fight is won.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Evrim CicekandClaude Opus 5.5 committed 2026-11-05 00:41:10 +03:00
1 parent 31f4897c3e
commit bc8eb368b0
2 files changed
+125 -41

No files matched your search

+29 -7
View File
@@ -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
+96 -34
View File
@@ -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__':