diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index fff03ef1c..a2602fb96 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 0350bd1ea..8e19046f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/tools/test_forced_end_parity.py b/tools/test_forced_end_parity.py new file mode 100755 index 000000000..81a92e4ed --- /dev/null +++ b/tools/test_forced_end_parity.py @@ -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}')