Merge #1847 into #1849: LocatorBarClient's respawn and use commands, the harness scaffold

The three harnesses add commands to the same LocatorBarClient lines;
this branch now builds on #1847 (itself on #1843), keeps all three
commands, and test_cursor_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:31 +01:00
commit 75951a769a
9 files changed
+256 -113

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.
@@ -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<u32> = 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
@@ -1670,6 +1670,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();
}
@@ -86,8 +86,10 @@ import net.minecraft.world.phys.Vec3;
* handled everything this player sent before, and everything the server sent
* this player before the reply has been recorded), {@code respawn NAME} (the
* death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN),
* {@code pickup NAME SLOT} (a left click on that slot of the player's
* inventory menu, claiming nothing), {@code quit NAME}, {@code
* {@code use NAME PITCH} (the main hand's item used looking that way,
* ServerboundUseItemPacket), {@code pickup NAME SLOT} (a left click on that
* slot of the player's inventory menu, claiming nothing), {@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
@@ -135,6 +137,7 @@ public final class LocatorBarClient {
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 "pickup" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
0, 0, Short.parseShort(w[2]), (byte) 0, net.minecraft.world.inventory.ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
@@ -258,6 +261,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).
+15 -100
View File
@@ -10,23 +10,12 @@ inventoryMenu.removed drops the cursor's stack where it died. Alice then asks wh
site; the reply, and the inventory slots, respawns and teleports she is sent from the death 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 = 'cursor-death-parity'
# server_dir and generate_world write these server.properties.
@@ -38,102 +27,28 @@ SCRIPT = [
('pickup', ['pickup Alice 36']),
('death', ['cmd Alice kill @s']),
('respawn', ['respawn Alice']),
('check', ['chat Alice on',
'cmd Alice execute if entity @e[type=minecraft:item,x=10.5,y=-60,z=0.5,distance=..4]']),
('check', ['chat on', 'cmd Alice execute if entity @e[type=minecraft:item,x=10.5,y=-60,z=0.5,distance=..4]']),
]
STEP_SECONDS = 4.0
SHOWN = ('slot', 'inventory', 'respawn', 'teleport', 'chat')
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.replace('chat Alice', 'chat'))
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 slots, respawns, teleports and chat from the death on."""
steps = {'step=death', 'step=respawn', 'step=check'}
return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 and parts[1] in steps
and parts[2] in SHOWN]
return [line for line in lines if len(parts := line.split(' ', 3)) >= 3
and parts[1] in ('step=death', 'step=respawn', 'step=check')
and parts[2] in ('slot', 'inventory', 'respawn', 'teleport', 'chat')]
def check(vanilla, _):
if not any(l.startswith('Alice step=check chat') for l in vanilla):
return 'vanilla answered no check command'
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 not any(l.startswith('Alice step=check chat') for l in vanilla):
raise AssertionError(f'vanilla answered no check command: 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='A player dying with a stack on the inventory menu cursor (keep_inventory off) and respawning on a '
'dedicated server: the stack is dropped where it died, not kept.'))
print(f'cursor death: {len(vanilla)} lines identical')
lines = locator.one_player(NAME, SCRIPT, shown, check, 'A player dying with a stack on the inventory menu cursor '
'(keep_inventory off) and respawning on a dedicated server: the stack is dropped '
'where it died, not kept.')
print(f'cursor death: {len(lines)} lines identical')
if __name__ == '__main__':
+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)
+56
View File
@@ -0,0 +1,56 @@
#!/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 sys
try:
from . import test_locator_bar_parity as locator
except ImportError:
import test_locator_bar_parity as locator
NAME = 'pearl-death-parity'
# server_dir and generate_world write these server.properties.
locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival')
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 shown(lines):
"""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 ('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():
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__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')