diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index f6b68aa74..b85710146 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/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 0bb7bbe85..ff07e9011 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/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index de6ae51dc..ede200981 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -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); } diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index cf1f3269f..5fa040665 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 e4aeef25e..ec2c08012 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -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). diff --git a/tools/test_cursor_death_parity.py b/tools/test_cursor_death_parity.py index 8da944350..f64f2d506 100644 --- a/tools/test_cursor_death_parity.py +++ b/tools/test_cursor_death_parity.py @@ -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__': diff --git a/tools/test_hardcore_respawn_parity.py b/tools/test_hardcore_respawn_parity.py new file mode 100644 index 000000000..8c7a5394d --- /dev/null +++ b/tools/test_hardcore_respawn_parity.py @@ -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}') diff --git a/tools/test_locator_bar_parity.py b/tools/test_locator_bar_parity.py index bc7d21750..985b87a35 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) diff --git a/tools/test_pearl_death_parity.py b/tools/test_pearl_death_parity.py new file mode 100644 index 000000000..81714c5d4 --- /dev/null +++ b/tools/test_pearl_death_parity.py @@ -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}')