End: no exit portal while nobody is in the fight

With force-loaded levels ticking without players (#1525), Horizon placed
the exit portal as soon as the End's main island was loaded. Vanilla
only places it from scanState and the dragon's death, both with players
in the fight, so a pre-generated End has no portal until someone
arrives.

test_forced_end_parity force-loads the End's arena on vanilla's and
Horizon's dedicated servers with nobody online and compares what is
around 0, 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FusselTV
2026-10-26 13:41:57 +01:00
parent 68db8847e7
commit 1eb7a04a4e
3 changed files with 95 additions and 2 deletions
+3 -1
View File
@@ -2598,7 +2598,9 @@ impl Game<'_> {
}
pub(super) fn fight_ready(&mut self) -> bool {
self.load_fight();
self.fight.loaded
// spawnExitPortal runs from scanState and the dragon's death, both
// only with players in the fight.
self.fight.loaded && self.fight.players
}
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
self.fight.exit_portal
+1 -1
View File
@@ -46,7 +46,7 @@ file selects ~103.
| 29 | Title, worlds, startup (49) | `L/title.rs` + `L/title/`, `G/startup_menu.rs`, `L/title_edition.rs` | world_options; `cargo test -p horizon-client --lib title` |
| 30 | Dedicated server, horizon-server (48) | `G/dedicated.rs`, `G/dedicated/status.rs`, `crates/horizon-server/`, `crates/horizon-server-files/` | dedicated_server, server_files, user_cache, online_mode, server_ops_feedback |
| 31 | Particles and client effects (46) | `L/particles.rs`, `G/render_tick.rs`, `G/level_particles.rs`, `L/animate_tick.rs` | particle_providers, block_particles, entity_particles, emitter_particles, marker_particles |
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, end_podium, end_gateway |
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, forced_end, end_podium, end_gateway |
| 33 | Protocol and packets (44) | `crates/horizon-protocol/src/` (`packets/play.rs`, `entity_data.rs`, `connection.rs`) | dedicated_server, online_mode, secure_chat; `cargo test -p horizon-protocol` |
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs`, `L/friends_screen.rs`, `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""The End of a dedicated server nobody has entered, force-loaded from the console, against vanilla's.
A vanilla dedicated server and Horizon's each open a copy of one world vanilla generated (its End not made yet).
With no player online, the console force-loads the End's main island and its arena. A FORCED ticket keeps its level active
(`ServerChunkCache.hasActiveTickets`, `TicketType.FORCED`), so the chunks are generated, loaded and ticked
without a player in the dimension. With nobody in the fight, `EnderDragonFight.tick` neither scans the state nor
places the exit portal: no bedrock and no torches around 0, 0. Both servers must answer the same.
"""
import os
import re
import subprocess
import sys
import time
try:
from . import lab, test_rust_parity as base
from .test_dedicated_server_parity import Server, wait_ticks
from .test_locator_bar_parity import generate_world, server_dir
except ImportError:
import lab
import test_rust_parity as base
from test_dedicated_server_parity import Server, wait_ticks
from test_locator_bar_parity import generate_world, server_dir
NAME = 'forced-end-parity'
END = 'execute in minecraft:the_end'
QUERIES = [
f'{END} run fill -3 0 -3 3 255 3 minecraft:air replace minecraft:bedrock',
f'{END} run fill -3 0 -3 3 255 3 minecraft:air replace minecraft:wall_torch',
]
def answer(server, command, n):
start = len(server.lines)
server.console(command)
server.console(f'say done {n}')
server.wait_for(rf'\[Server\] done {n}$', timeout=120)
replies = [l.split(']: ', 1)[-1] for l in server.lines[start:] if 'done ' not in l]
return ' / '.join(r for r in replies if r) or '(no reply)'
def run_side(label, command, build, world):
directory = build / label
server_dir(directory, world)
server = Server(command, directory, label)
try:
server.wait_for(r'Done \(')
# The main island's arena (EnderDragonFight.isArenaLoaded: chunks -8..8), in pieces of at most 256 chunks.
for area in ('-128 -128 143 -1', '-128 0 143 143'):
server.console(f'{END} run forceload add {area}')
deadline = time.time() + 600
for poll in range(1_000_000):
reply = answer(server, f'{END} if loaded 0 0 0', f'loaded {poll}')
if 'passed' in reply:
break
if time.time() > deadline:
raise AssertionError(f'{label}: the End centre never loaded')
time.sleep(2)
wait_ticks(server, 200)
return {q: answer(server, q, i) for i, q in enumerate(QUERIES)}
finally:
server.stop()
def main():
_, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / NAME
build.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
check=True, capture_output=True)
binary = lab.cargo_target() / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
world = generate_world(vanilla_server, build)
vanilla = run_side('vanilla', vanilla_server, build, world)
horizon = run_side('horizon', horizon_server, build, world)
normal = lambda s: re.sub(r'\s+', ' ', s).strip()
problems = [f'{q}\n vanilla: {vanilla[q]}\n horizon: {horizon[q]}'
for q in QUERIES if normal(vanilla[q]) != normal(horizon[q])]
if problems:
raise AssertionError('the forced End differs:\n' + '\n'.join(problems))
print(f'forced End: loaded and ticked with nobody in it, no exit portal, as vanilla ({vanilla[QUERIES[0]]})')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')