From 9409d2c19b2ab10ad1e00697ab3e0aa7f2a0de2d Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:22:29 +0000 Subject: [PATCH] Multiplayer: allow punching End crystals Include End crystals in attack-only object hit-testing using their network entity IDs. Add a real-mouse LAN regression for survival and creative, reach, block occlusion, passing item use, and explosion damage. Co-Authored-By: OpenCode GPT-6.1 --- crates/horizon-client/src/game/remote.rs | 6 +- .../src/game/remote_vehicles.rs | 24 ++++- tools/lan_e2e.py | 92 ++++++++++++++++++- 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 23135f16f..e70160372 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3883,7 +3883,7 @@ impl Game<'_> { let sneaking = self.keys.contains(&KeyCode::ShiftLeft); // A boat, minecart, item frame, armor stand or painting nearer than // any mob: the server uses it (AbstractBoat.interact boards it). - if let Some(hit) = self.remote_object_hit() + if let Some(hit) = self.remote_object_hit(false) && self.mob_hit().is_none_or(|(_, d)| hit.distance < d) && self.before_block(hit.distance) { @@ -4360,12 +4360,12 @@ impl Game<'_> { id: self.creatures.projectiles[i].id as i32, })); self.reset_attack_strength(); - } else if let Some(hit) = self.remote_object_hit() + } else if let Some(hit) = self.remote_object_hit(true) && self.mob_hit().is_none_or(|(_, d)| hit.distance < d) && self.before_block(hit.distance) { // VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer, - // a painting broken: the server decides. + // a painting broken or an EndCrystal hurtServer: the server decides. self.send(S::Attack(play::Attack { id: hit.id })); self.reset_attack_strength(); } else if let Some((i, distance)) = self.mob_hit() diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index cf1b2d683..620aff97c 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -9,8 +9,8 @@ //! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`, //! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on //! the server with the rider's input, and the rider sits in it. -use super::{Game, Keys, view_vector}; -use horizon_core::geometry::Vec3; +use super::{Game, Keys, ray_box, view_vector}; +use horizon_core::geometry::{Aabb, Vec3}; use horizon_protocol::packets::play::{self, Serverbound as S}; /// An object under the crosshair: its entity id, how far along the view it @@ -24,8 +24,8 @@ pub(super) struct ObjectHit { impl Game<'_> { /// The nearest boat, minecart, item frame, armor stand or painting the - /// view hits within reach. - pub(super) fn remote_object_hit(&self) -> Option { + /// view hits within reach; also End crystals when attacking. + pub(super) fn remote_object_hit(&self, attacking: bool) -> Option { let eye = self.eye_position()?; let dir = view_vector(self.yaw, self.pitch); let at = |d: f64| { @@ -64,6 +64,22 @@ impl Game<'_> { let p = self.carts[i].position; hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z])); } + if attacking { + // EndCrystal.isPickable: its 2 x 2 box, with the server's entity + // id, not special_targets' local projectile target id. + // Entity.interact passes on a crystal, so right clicks use the item. + let reach = if self.creative { 5.0 } else { 3.0 }; + for c in self.crystals.iter().filter(|c| c.id != 0) { + let p = c.position; + let bounds = Aabb::new( + Vec3::new(p.x - 1.0, p.y, p.z - 1.0), + Vec3::new(p.x + 1.0, p.y + 2.0, p.z + 1.0), + ); + if let Some(d) = ray_box(eye, dir, reach, bounds) { + hits.push((c.id as i32, d, [p.x, p.y, p.z])); + } + } + } let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?; let point = at(distance); Some(ObjectHit { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 80cd434c8..c87e35989 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -109,7 +109,9 @@ address. The script then checks, from both sides, what a guest's game used to ge - objects: boats, minecarts, armor stands, item frames and paintings the host summons reach the guest as the host has them (kind, place, rotation, what they hold or wear, the variant). - projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where - the host has them; a falling block lands on both sides. + the host has them; a falling block lands on both sides. +- crystals: the host and the guest punch placed End crystals in survival and creative; the blast + kills the survival player, and attacks respect reach and blocks in front. - thrown: a splash potion and an eye of ender the host and the guest each throw fly on both sides (#1363, #1391); - sparks: level events' particles on both sides: the host's and the guest's bone meal on wheat @@ -5539,5 +5541,93 @@ CHECKS += ['falling'] CHECK_FNS.update(falling=check_falling) +def check_crystals(host, guest, out): + """#1532: a real left click detonates a placed End crystal in survival and creative, + on the host and the guest; reach, block occlusion and passing right clicks still apply.""" + spots = apart(host, guest) + seen = {} + + def crystals(game, at): + return [p for p in game('status')['projectiles'] if p['kind'] == 'minecraft:end_crystal' + and math.dist(p['position'], at) < 0.01] + + for label, player, other in (('host', host, guest), ('guest', guest, host)): + x, y, z = spots[label] + y += 32 # Above the terrain and its entities: EndCrystalItem requires an empty box. + at = [x + 2.5, y, z + 0.5] + other('command', command='gamemode creative') + for mode in ('creative', 'survival'): + case = f'{label}-{mode}' + try: + host('command', command=f'fill {x - 5} {y - 1} {z - 3} {x + 5} {y - 1} {z + 3} minecraft:obsidian') + host('command', command=f'fill {x - 5} {y} {z - 3} {x + 5} {y + 3} {z + 3} minecraft:air') + player('command', command=f'gamemode {mode}') + player('command', command='clear @s') + player('command', command='effect clear @s') + player('command', command='effect give @s minecraft:instant_health 1 5 true') + player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}') + player('command', command='give @s minecraft:end_crystal 1') + player.raw('select', 1) + player.wait(10) + player.raw('look-at', at[0], y - 0.01, at[2]) + player.raw('mouse', 'right', 'click') + player.wait(10) + placed = crystals(player, at) + assert len(placed) == 1, f'{case}: placing the crystal: {placed}' + crystal_id = placed[0]['id'] + tracked = crystals(other, at) + assert crystal_id != 0 and [p['id'] for p in tracked] == [crystal_id], f'{case}: crystal not tracked on both sides' + + # The nearest face is six blocks away, beyond both modes' attack reach. + player('command', command=f'tp @s {x - 4.5} {y} {z + 0.5}') + player.wait(5) + player.raw('look-at', at[0], y + 1, at[2]) + player.raw('mouse', 'left', 'click') + player.wait(10) + assert crystals(player, at), f'{case}: attacked a crystal out of reach' + + player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}') + host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:obsidian') + player.wait(5) + player.raw('look-at', at[0], y + 1, at[2]) + player.raw('mouse', 'left', 'click') + player.wait(10) + assert crystals(player, at), f'{case}: attacked a crystal through a block' + host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:air') + player('command', command='item replace entity @s weapon.mainhand with minecraft:shield') + player.wait(10) + player.raw('look-at', at[0], y + 1, at[2]) + player.raw('mouse', 'right', 'down') + player.wait(10) + assert player('state').get('using') and server_using(host, player), f'{case}: crystal consumed right click' + player.raw('mouse', 'right', 'up') + player('command', command='clear @s') + player.wait(5) + player.shot(out, f'crystals-{mode}-before') + + player.raw('mouse', 'left', 'click') + player.wait(40) + remaining = {g.name: len(crystals(g, at)) for g in (player, other)} + health = server_player(host, player)['health'] + screen = player('state').get('screen') + seen[case] = {'id': crystal_id, 'remaining': remaining, 'health': health, 'screen': screen} + player.shot(out, f'crystals-{mode}-after') + assert not any(remaining.values()), f'{case}: left click left the crystal intact: {seen[case]}' + assert health == (20 if mode == 'creative' else 0), f'{case}: explosion damage: {seen[case]}' + assert screen == ('playing' if mode == 'creative' else 'dead'), f'{case}: explosion death: {seen[case]}' + finally: + if player('state').get('screen') == 'dead': + respawn_when_ready(player) + player.wait(30) + else: + player.raw('mouse', 'right', 'up') + player('command', command='gamemode creative') + return seen + + +CHECKS += ['crystals'] +CHECK_FNS.update(crystals=check_crystals) + + if __name__ == '__main__': main()