mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
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:
1 parent
31f4897c3e
commit
bc8eb368b0
2 files changed
+125
-41
No files matched your search
@@ -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
|
||||
|
||||
@@ -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__':
|
||||
|
||||
Reference in new issue
Block a user