Merge #1843 into #1847: share LocatorBarClient's respawn command and the harness scaffold

Both add the same respawn command to LocatorBarClient; this branch now
builds on #1843's, and test_pearl_death_parity.py runs through
locator.one_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-25 17:27:00 +01:00
commit 94bc569a7f
6 files changed
+168 -111

No files matched your search

+6 -2
View File
@@ -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.
@@ -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);
}
+22 -5
View File
@@ -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();
}
+49
View File
@@ -0,0 +1,49 @@
#!/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 sys
try:
from . import test_locator_bar_parity as locator
except ImportError:
import test_locator_bar_parity as locator
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']),
]
def shown(lines):
"""Alice's game mode changes and respawns in the death and respawn steps."""
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():
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__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')
+72 -4
View File
@@ -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)
+14 -100
View File
@@ -9,31 +9,16 @@ ender_pearls_vanish_on_death true: ThrownEnderpearl.tick discards a dead owner's
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
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 = '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.
@@ -43,96 +28,25 @@ SCRIPT = [
]
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."""
"""Alice's teleports, game mode changes and respawns from the throw 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]
and parts[2] in ('teleport', 'gamemode', 'respawn')]
def check(vanilla, vanilla_all):
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:
return 'vanilla did not kill and respawn Alice'
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])
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')
lines = locator.one_player(NAME, SCRIPT, shown, check, '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(lines)} lines identical')
if __name__ == '__main__':