From 0481dbdb81e49134f42dccafb58474504466a440 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:10:56 +0100 Subject: [PATCH 1/3] Make hardcore players spectators when they respawn A hardcore death offered another survival life. Vanilla's death screen says Spectate in hardcore, and the server makes the respawned player a spectator (ServerGamePacketListenerImpl.handleClientCommand). Do the same on the server and in the in-frame world, and add a harness that compares a hardcore respawn on a dedicated server with vanilla's. --- crates/horizon-client/src/game/agent.rs | 8 +- crates/horizon-client/src/game/lan_actions.rs | 5 + crates/horizon-client/src/game/screens.rs | 27 +++- .../horizonlab/parity/LocatorBarClient.java | 6 +- tools/test_hardcore_respawn_parity.py | 139 ++++++++++++++++++ 5 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 tools/test_hardcore_respawn_parity.py diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index f6b68aa74b..b85710146d 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -1598,8 +1598,12 @@ impl Game<'_> { if self.screen != Screen::Dead { return Err("respawn needs the death screen".into()); } - let label = - horizon_client::text::Text::translate("deathScreen.respawn", &[]).plain(); + let key = if self.level.hardcore { + "deathScreen.spectate" + } else { + "deathScreen.respawn" + }; + let label = horizon_client::text::Text::translate(key, &[]).plain(); let w = widgets::find(&widgets::last(), &label)?; if !w.enabled { // DeathScreen enables its buttons after 20 ticks. diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 2c7019c73c..2942bea7d0 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1649,6 +1649,11 @@ impl Game<'_> { border: &self.border, }; host.hub.respawn(i, spawn, &ctx); + // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's + // respawned player only watches. + if self.level.hardcore { + host.hub.set_game_mode(conn, SPECTATOR); + } if let Some(config) = elsewhere { self.respawn_trip(conn, config); } diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index cf1f3269fe..5fa0406657 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -2,6 +2,7 @@ use super::{Game, Screen}; use horizon_client::{ Result, + command::args::GameMode, hud::{HudBatch, rgba}, text::{self, Text}, ui, @@ -81,9 +82,15 @@ fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] { (x0 + 72.0, y, "gui/sprites/pause_menu/player_reporting"), ] } -fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] { +/// DeathScreen.init: a hardcore player can only spectate. +fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] { + let respawn = if hardcore { + "deathScreen.spectate" + } else { + "deathScreen.respawn" + }; [ - (w / 2.0 - 100.0, h / 4.0 + 72.0, "deathScreen.respawn", 0), + (w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0), ( w / 2.0 - 100.0, h / 4.0 + 96.0, @@ -223,7 +230,12 @@ impl Game<'_> { ), ); } - let title = Text::translate("deathScreen.title", &[]); + let title = if self.level.hardcore { + "deathScreen.title.hardcore" + } else { + "deathScreen.title" + }; + let title = Text::translate(title, &[]); let tw = atlas.font.width_text(&title); ui::text_scaled( batch, @@ -258,7 +270,7 @@ impl Game<'_> { .ui .died_at .is_none_or(|t| t.elapsed().as_millis() >= 1000); - for (x, y, key, _) in death_buttons(w, h) { + for (x, y, key, _) in death_buttons(w, h, self.level.hardcore) { let hover = active && ui::hovered(self.cursor, x, y, 200.0, 20.0); ui::button( batch, @@ -286,7 +298,7 @@ impl Game<'_> { .as_ref() .map_or([1, 1], |r| [r.config.width, r.config.height]); let b = HudBatch::new(size); - let hit = death_buttons(b.width, b.height) + let hit = death_buttons(b.width, b.height, self.level.hardcore) .into_iter() .find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0)) .map(|d| d.3); @@ -330,6 +342,11 @@ impl Game<'_> { Some(1) => self.close_world()?, _ => {} } + // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's + // respawned player only watches. + if hit == Some(0) && self.remote.is_none() && self.level.hardcore { + self.set_mode(GameMode::Spectator); + } if hit.is_some() { self.audio.play_button_click(); } diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java index 76a9518941..3bed808f68 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -84,7 +84,9 @@ import net.minecraft.world.phys.Vec3; * still), {@code sync NAME} (runs {@code time query gametime}, whose reply is * not recorded, and prints {@code synced NAME} once it is back: the server has * handled everything this player sent before, and everything the server sent - * this player before the reply has been recorded), {@code quit NAME}, {@code + * this player before the reply has been recorded), {@code respawn NAME} (the + * death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN), + * {@code quit NAME}, {@code * players on|off} (whether each player also records the other players added * to and removed from its level, and its own respawns into a dimension), * {@code chat on|off} (whether the system chat each player receives is @@ -130,6 +132,8 @@ public final class LocatorBarClient { players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities)); } case "still" -> players.get(w[1]).still(); + case "respawn" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket( + net.minecraft.network.protocol.game.ServerboundClientCommandPacket.Action.PERFORM_RESPAWN)); case "sync" -> { try { players.get(w[1]).sync(); diff --git a/tools/test_hardcore_respawn_parity.py b/tools/test_hardcore_respawn_parity.py new file mode 100644 index 0000000000..6e0669534e --- /dev/null +++ b/tools/test_hardcore_respawn_parity.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Respawning in a hardcore world on a dedicated server, against vanilla's, seen by a vanilla client. + +A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one hardcore +flat world vanilla generated first, with Alice an operator in survival. LocatorBarClient (a player made of +vanilla's protocol code) joins, runs /kill on itself and presses the death screen's button +(ServerboundClientCommandPacket PERFORM_RESPAWN). Vanilla's ServerGamePacketListenerImpl.handleClientCommand +respawns the player, then makes it a spectator because the server is hardcore. The game mode changes and respawns +Alice is sent in the death and respawn steps are compared exactly. +""" +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time + +try: + from . import lab, test_rust_parity as base, test_locator_bar_parity as locator + from .test_dedicated_server_parity import Server +except ImportError: + import lab + import test_rust_parity as base + import test_locator_bar_parity as locator + from test_dedicated_server_parity import Server + +NAME = 'hardcore-respawn-parity' +# server_dir and generate_world write these server.properties. +locator.PROPERTIES = dict(locator.PROPERTIES, hardcore='true', gamemode='survival') +SCRIPT = [ + ('death', ['cmd Alice kill @s']), + ('respawn', ['respawn Alice']), +] +STEP_SECONDS = 4.0 +SHOWN = ('gamemode', 'respawn') + + +def run_side(label, server_command, client_command, build, world): + directory = build / label + port = locator.server_dir(directory, world) + out = build / f'{label}.txt' + out.unlink(missing_ok=True) + server = Server(server_command, directory, label) + try: + server.wait_for(r'Done \(') + with (build / f'{label}-client.log').open('w') as log: + client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)], + cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, + text=True) + + def tell(line): + client.stdin.write(line + '\n') + client.stdin.flush() + try: + tell('players on') + tell('state on') + tell('step joins') + tell('join Alice') + server.wait_for(r'Alice joined the game', timeout=120) + time.sleep(STEP_SECONDS) + for step, actions in SCRIPT: + tell(f'step {step}') + for action in actions: + tell(action) + time.sleep(STEP_SECONDS) + tell('end') + client.wait(timeout=60) + finally: + if client.poll() is None: + client.kill() + finally: + server.stop() + if not out.exists(): + raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') + return out.read_text(encoding='utf-8').splitlines() + + +def shown(lines): + """Alice's game mode changes and respawns in the death and respawn steps.""" + keep = [] + for line in lines: + parts = line.split(' ', 3) + if len(parts) >= 3 and parts[1] in ('step=death', 'step=respawn') and parts[2] in SHOWN: + keep.append(line) + return keep + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / NAME + build.mkdir(parents=True, exist_ok=True) + report = build / 'report.json' + report.unlink(missing_ok=True) + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' + classes = build / 'classes' + shutil.rmtree(classes, ignore_errors=True) + classes.mkdir() + dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) + subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath', + lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle], + cwd=lab.ROOT, check=True, capture_output=True) + subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, + check=True, capture_output=True) + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], + cwd=lab.ROOT, text=True)) + binary = Path(metadata['target_directory']) / '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'] + client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] + + world = locator.generate_world(vanilla_server, build) + vanilla_all = run_side('vanilla', vanilla_server, client, build, world) + horizon_all = run_side('horizon', horizon_server, client, build, world) + errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] + vanilla, horizon = shown(vanilla_all), shown(horizon_all) + if errors or vanilla != horizon: + import difflib + diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) + raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' + + '\n'.join(errors[:10]) + '\n' + diff[:6000]) + if 'Alice step=respawn gamemode 3' not in vanilla: + raise AssertionError(f'vanilla did not make the respawned player a spectator: the script did not run ' + f'(see {build})') + lab.write_json(report, dict( + schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), + java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), + scope='A player dying and respawning in a hardcore world on a dedicated server: the respawn and the game ' + 'mode it is sent (spectator).')) + print(f'hardcore respawn: {len(vanilla)} lines identical') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From 10efca9e6cd922ff7b9a4a3cc3f5a467e97f52f2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:40:29 +0100 Subject: [PATCH 2/3] Discard a dead player's ender pearl when the rule says so With ender_pearls_vanish_on_death on, a pearl thrown just before death kept flying and pulled the respawned player back to it. Vanilla's ThrownEnderpearl.tick discards the pearl instead of ticking it while its owner is dead. Add a harness that throws a pearl, dies and respawns on a dedicated server and compares the teleports with vanilla's. --- crates/horizon-client/src/game/creatures.rs | 19 +++ .../horizonlab/parity/LocatorBarClient.java | 15 +- tools/test_pearl_death_parity.py | 142 ++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tools/test_pearl_death_parity.py diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 0bb7bbe850..ff07e90112 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1694,7 +1694,26 @@ impl Game<'_> { targets: targets_guests, }; let guest_entities = self.lan_guest_entities(); + // ThrownEnderpearl.tick: a dead player's pearl is discarded in + // place of its tick while ender_pearls_vanish_on_death is on. + let vanish = self.ui.rules.bool("ender_pearls_vanish_on_death"); + let host_dead = self.player_view().is_some_and(|p| !p.alive); + let dead_guests: Vec = self.lan.as_ref().map_or_else(Vec::new, |h| { + h.hub + .guests + .iter() + .filter(|g| g.dead) + .map(|g| g.conn) + .collect() + }); projectiles.retain_mut(|p| { + if vanish + && matches!(p.kind, ProjectileKind::EnderPearl) + && p.owner == Some(Who::Player) + && p.guest.map_or(host_dead, |c| dead_guests.contains(&c)) + { + return false; + } let mob_shot = !matches!(p.owner, Some(Who::Player)); // A player's arrows, snowballs and eggs meet every player; a // guest's flies from that guest (Projectile.getOwner), not diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java index 76a9518941..8187463369 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -84,7 +84,10 @@ import net.minecraft.world.phys.Vec3; * still), {@code sync NAME} (runs {@code time query gametime}, whose reply is * not recorded, and prints {@code synced NAME} once it is back: the server has * handled everything this player sent before, and everything the server sent - * this player before the reply has been recorded), {@code quit NAME}, {@code + * this player before the reply has been recorded), {@code respawn NAME} (the + * death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN), + * {@code use NAME PITCH} (the main hand's item used looking that way, + * ServerboundUseItemPacket), {@code quit NAME}, {@code * players on|off} (whether each player also records the other players added * to and removed from its level, and its own respawns into a dimension), * {@code chat on|off} (whether the system chat each player receives is @@ -130,6 +133,9 @@ public final class LocatorBarClient { players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities)); } case "still" -> players.get(w[1]).still(); + case "respawn" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket( + net.minecraft.network.protocol.game.ServerboundClientCommandPacket.Action.PERFORM_RESPAWN)); + case "use" -> players.get(w[1]).use(Float.parseFloat(w[2])); case "sync" -> { try { players.get(w[1]).sync(); @@ -250,6 +256,13 @@ public final class LocatorBarClient { } } + private int sequence; + + void use(float pitch) throws Exception { + send(new net.minecraft.network.protocol.game.ServerboundUseItemPacket( + net.minecraft.world.InteractionHand.MAIN_HAND, ++sequence, position.yRot(), pitch)); + } + void still() throws Exception { PositionMoveRotation at = position; // A tick of its own: the server takes one position per client tick (receivedPositionThisTick). diff --git a/tools/test_pearl_death_parity.py b/tools/test_pearl_death_parity.py new file mode 100644 index 0000000000..a6795c7494 --- /dev/null +++ b/tools/test_pearl_death_parity.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""An ender pearl in flight when its thrower dies, on a dedicated server, against vanilla's, seen by a vanilla client. + +A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world +vanilla generated first, with Alice an operator in survival. LocatorBarClient (a player made of vanilla's protocol +code) throws an ender pearl straight up (ServerboundUseItemPacket), runs /kill on itself while the pearl flies, +presses the death screen's button (PERFORM_RESPAWN) and waits for the pearl to come down, with the rule +ender_pearls_vanish_on_death true: ThrownEnderpearl.tick discards a dead owner's pearl, so the respawned player is +never teleported. The teleports, game mode changes and respawns Alice is sent from the throw on are compared +exactly. +""" +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time + +try: + from . import lab, test_rust_parity as base, test_locator_bar_parity as locator + from .test_dedicated_server_parity import Server +except ImportError: + import lab + import test_rust_parity as base + import test_locator_bar_parity as locator + from test_dedicated_server_parity import Server + +NAME = 'pearl-death-parity' +# server_dir and generate_world write these server.properties. +locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival') +STEP_SECONDS = 4.0 +SHOWN = ('teleport', 'gamemode', 'respawn') + + +SCRIPT = [ + ('setup', ['cmd Alice gamerule ender_pearls_vanish_on_death true', 'cmd Alice give @s minecraft:ender_pearl']), + # Straight up: it comes down about four seconds later, near where it was thrown. + ('throw', ['use Alice -90', 'cmd Alice kill @s']), + ('respawn', ['respawn Alice']), + ('landed', []), +] + + +def run_side(label, server_command, client_command, build, world): + directory = build / label + port = locator.server_dir(directory, world) + out = build / f'{label}.txt' + out.unlink(missing_ok=True) + server = Server(server_command, directory, label) + try: + server.wait_for(r'Done \(') + with (build / f'{label}-client.log').open('w') as log: + client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)], + cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, + text=True) + + def tell(line): + client.stdin.write(line + '\n') + client.stdin.flush() + try: + tell('players on') + tell('state on') + tell('step joins') + tell('join Alice') + server.wait_for(r'Alice joined the game', timeout=120) + time.sleep(STEP_SECONDS) + for step, actions in SCRIPT: + tell(f'step {step}') + for action in actions: + tell(action) + time.sleep(STEP_SECONDS) + tell('end') + client.wait(timeout=60) + finally: + if client.poll() is None: + client.kill() + finally: + server.stop() + if not out.exists(): + raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') + return out.read_text(encoding='utf-8').splitlines() + + +def shown(lines): + """Alice's teleports, game mode changes and respawns from the throws on.""" + steps = {f'step={step}' for step, _ in SCRIPT if step != 'setup'} + return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 and parts[1] in steps + and parts[2] in SHOWN] + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / NAME + build.mkdir(parents=True, exist_ok=True) + report = build / 'report.json' + report.unlink(missing_ok=True) + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' + classes = build / 'classes' + shutil.rmtree(classes, ignore_errors=True) + classes.mkdir() + dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) + subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath', + lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle], + cwd=lab.ROOT, check=True, capture_output=True) + subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, + check=True, capture_output=True) + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], + cwd=lab.ROOT, text=True)) + binary = Path(metadata['target_directory']) / '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'] + client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] + + world = locator.generate_world(vanilla_server, build) + vanilla_all = run_side('vanilla', vanilla_server, client, build, world) + horizon_all = run_side('horizon', horizon_server, client, build, world) + errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] + vanilla, horizon = shown(vanilla_all), shown(horizon_all) + if errors or vanilla != horizon: + import difflib + diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) + raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' + + '\n'.join(errors[:10]) + '\n' + diff[:6000]) + killed = any(l.startswith('Alice step=throw killed') for l in vanilla_all) + if not killed or 'Alice step=respawn respawn minecraft:overworld' not in vanilla: + raise AssertionError(f'vanilla did not kill and respawn Alice: the script did not run (see {build})') + lab.write_json(report, dict( + schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), + java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), + scope='An ender pearl in flight when its thrower dies and respawns on a dedicated server, with ' + 'ender_pearls_vanish_on_death true: the pearl vanishes and never teleports the player.')) + print(f'pearl death: {len(vanilla)} lines identical') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From 50727eb5c3d389628549c9237bbcca76029c3b79 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:26:43 +0100 Subject: [PATCH 3/3] Harnesses: one scaffold for one-player dedicated server scripts test_hardcore_respawn_parity.py copied test_locator_bar_parity.py's build and run code; both now share locator.sides, and one-player scripts run through locator.one_player. Co-Authored-By: Claude Opus 5.5 --- tools/test_hardcore_respawn_parity.py | 114 +++----------------------- tools/test_locator_bar_parity.py | 76 ++++++++++++++++- 2 files changed, 84 insertions(+), 106 deletions(-) diff --git a/tools/test_hardcore_respawn_parity.py b/tools/test_hardcore_respawn_parity.py index 6e0669534e..8c7a5394d1 100644 --- a/tools/test_hardcore_respawn_parity.py +++ b/tools/test_hardcore_respawn_parity.py @@ -8,23 +8,12 @@ vanilla's protocol code) joins, runs /kill on itself and presses the death scree respawns the player, then makes it a spectator because the server is hardcore. The game mode changes and respawns Alice is sent in the death and respawn steps are compared exactly. """ -import json -import os -from pathlib import Path -import platform -import shutil -import subprocess import sys -import time try: - from . import lab, test_rust_parity as base, test_locator_bar_parity as locator - from .test_dedicated_server_parity import Server + from . import test_locator_bar_parity as locator except ImportError: - import lab - import test_rust_parity as base import test_locator_bar_parity as locator - from test_dedicated_server_parity import Server NAME = 'hardcore-respawn-parity' # server_dir and generate_world write these server.properties. @@ -33,103 +22,24 @@ SCRIPT = [ ('death', ['cmd Alice kill @s']), ('respawn', ['respawn Alice']), ] -STEP_SECONDS = 4.0 -SHOWN = ('gamemode', 'respawn') - - -def run_side(label, server_command, client_command, build, world): - directory = build / label - port = locator.server_dir(directory, world) - out = build / f'{label}.txt' - out.unlink(missing_ok=True) - server = Server(server_command, directory, label) - try: - server.wait_for(r'Done \(') - with (build / f'{label}-client.log').open('w') as log: - client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)], - cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, - text=True) - - def tell(line): - client.stdin.write(line + '\n') - client.stdin.flush() - try: - tell('players on') - tell('state on') - tell('step joins') - tell('join Alice') - server.wait_for(r'Alice joined the game', timeout=120) - time.sleep(STEP_SECONDS) - for step, actions in SCRIPT: - tell(f'step {step}') - for action in actions: - tell(action) - time.sleep(STEP_SECONDS) - tell('end') - client.wait(timeout=60) - finally: - if client.poll() is None: - client.kill() - finally: - server.stop() - if not out.exists(): - raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') - return out.read_text(encoding='utf-8').splitlines() def shown(lines): """Alice's game mode changes and respawns in the death and respawn steps.""" - keep = [] - for line in lines: - parts = line.split(' ', 3) - if len(parts) >= 3 and parts[1] in ('step=death', 'step=respawn') and parts[2] in SHOWN: - keep.append(line) - return keep + return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 + and parts[1] in ('step=death', 'step=respawn') and parts[2] in ('gamemode', 'respawn')] + + +def check(vanilla, _): + if 'Alice step=respawn gamemode 3' not in vanilla: + return 'vanilla did not make the respawned player a spectator' + return None def main(): - lock, jars, libraries = base.verify_artifacts() - build = lab.LOCAL / NAME - build.mkdir(parents=True, exist_ok=True) - report = build / 'report.json' - report.unlink(missing_ok=True) - oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' - classes = build / 'classes' - shutil.rmtree(classes, ignore_errors=True) - classes.mkdir() - dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) - subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath', - lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle], - cwd=lab.ROOT, check=True, capture_output=True) - subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, - check=True, capture_output=True) - metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], - cwd=lab.ROOT, text=True)) - binary = Path(metadata['target_directory']) / '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'] - client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] - - world = locator.generate_world(vanilla_server, build) - vanilla_all = run_side('vanilla', vanilla_server, client, build, world) - horizon_all = run_side('horizon', horizon_server, client, build, world) - errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] - vanilla, horizon = shown(vanilla_all), shown(horizon_all) - if errors or vanilla != horizon: - import difflib - diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) - raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' - + '\n'.join(errors[:10]) + '\n' + diff[:6000]) - if 'Alice step=respawn gamemode 3' not in vanilla: - raise AssertionError(f'vanilla did not make the respawned player a spectator: the script did not run ' - f'(see {build})') - lab.write_json(report, dict( - schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), - java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], - lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), - scope='A player dying and respawning in a hardcore world on a dedicated server: the respawn and the game ' - 'mode it is sent (spectator).')) - print(f'hardcore respawn: {len(vanilla)} lines identical') + lines = locator.one_player(NAME, SCRIPT, shown, check, 'A player dying and respawning in a hardcore world on a ' + 'dedicated server: the respawn and the game mode it is sent (spectator).') + print(f'hardcore respawn: {len(lines)} lines identical') if __name__ == '__main__': diff --git a/tools/test_locator_bar_parity.py b/tools/test_locator_bar_parity.py index bc7d217503..985b87a35d 100644 --- a/tools/test_locator_bar_parity.py +++ b/tools/test_locator_bar_parity.py @@ -205,12 +205,13 @@ def run_side(label, server_command, client_command, build, world): return out.read_text(encoding='utf-8').splitlines() -def main(): +def sides(name): + """The vanilla and Horizon dedicated servers and the LocatorBarClient that joins them, built for harness + `name`: (lock, build directory, vanilla server, Horizon server, client) commands.""" lock, jars, libraries = base.verify_artifacts() - build = lab.LOCAL / NAME + build = lab.LOCAL / name build.mkdir(parents=True, exist_ok=True) - report = build / 'report.json' - report.unlink(missing_ok=True) + (build / 'report.json').unlink(missing_ok=True) oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' classes = build / 'classes' shutil.rmtree(classes, ignore_errors=True) @@ -228,7 +229,74 @@ def main(): 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'] client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] + return lock, build, vanilla_server, horizon_server, client + +def one_player(name, script, shown, check, scope, step_seconds=4.0): + """A harness of one player, Alice: on each server she joins, then runs `script`, a list of (step, client + actions) `step_seconds` apart. The lines `shown(transcript)` keeps must match vanilla's exactly, and + `check(vanilla's shown lines, vanilla's transcript)` returns why vanilla's run is no proof (the script did + not run), or None.""" + lock, build, vanilla_server, horizon_server, client = sides(name) + + def run(label, server_command, world): + directory = build / label + port = server_dir(directory, world) + out = build / f'{label}.txt' + out.unlink(missing_ok=True) + server = Server(server_command, directory, label) + try: + server.wait_for(r'Done \(') + with (build / f'{label}-client.log').open('w') as log: + process = subprocess.Popen([*map(str, client), '127.0.0.1', str(port), str(out)], cwd=lab.ROOT, + stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, text=True) + + def tell(line): + process.stdin.write(line + '\n') + process.stdin.flush() + try: + for line in ['players on', 'state on', 'step joins', 'join Alice']: + tell(line) + server.wait_for(r'Alice joined the game', timeout=120) + time.sleep(step_seconds) + for step, actions in script: + tell(f'step {step}') + for action in actions: + tell(action) + time.sleep(step_seconds) + tell('end') + process.wait(timeout=60) + finally: + if process.poll() is None: + process.kill() + finally: + server.stop() + if not out.exists(): + raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') + return out.read_text(encoding='utf-8').splitlines() + + world = generate_world(vanilla_server, build) + vanilla_all = run('vanilla', vanilla_server, world) + horizon_all = run('horizon', horizon_server, world) + errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] + vanilla, horizon = shown(vanilla_all), shown(horizon_all) + if errors or vanilla != horizon: + import difflib + diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) + raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' + + '\n'.join(errors[:10]) + '\n' + diff[:6000]) + if (why := check(vanilla, vanilla_all)) is not None: + raise AssertionError(f'{why}: the script did not run (see {build})') + lab.write_json(build / 'report.json', dict( + schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), + java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), scope=scope)) + return vanilla + + +def main(): + lock, build, vanilla_server, horizon_server, client = sides(NAME) + report = build / 'report.json' world = generate_world(vanilla_server, build) vanilla = run_side('vanilla', vanilla_server, client, build, world) horizon = run_side('horizon', horizon_server, client, build, world)