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 <noreply@openai.com>
This commit is contained in:
rdbtCVS
2026-10-25 05:22:29 +00:00
parent 4a675206b1
commit 9409d2c19b
3 changed files with 114 additions and 8 deletions
+3 -3
View File
@@ -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()
@@ -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<ObjectHit> {
/// view hits within reach; also End crystals when attacking.
pub(super) fn remote_object_hit(&self, attacking: bool) -> Option<ObjectHit> {
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 {
+91 -1
View File
@@ -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()