Merge pull request #6053 from HrafnaValkyrja/fix/lightning-hits-all

🤖 Agent: Lightning tests every entity's box and destroys boats, carts and orbs as vanilla; main merged in (lan_e2e).
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-01 17:07:56 +01:00
commit c9d474dd91
2 files changed
+134 -12

No files matched your search

+67 -12
View File
@@ -7,7 +7,11 @@
use super::{Game, commands::Target};
use horizon_client::advancements::EntityView;
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
pub(super) struct Bolt {
/// The entity id a server's players know it by (0 until it has one).
@@ -452,10 +456,26 @@ impl Game<'_> {
}
});
}
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
/// `thunderHit` for entities whose box meets the strike box, 3 blocks
/// around and up to 9 above (`getEntities(this, AABB)` tests boxes, not
/// positions). Returns the mobs in that box before the hit, as
/// `CHANNELED_LIGHTNING` counts them. Boats, minecarts and experience
/// orbs are hit too (`VehicleEntity.hurtServer` adds 5 x 10 > 40 and
/// destroys them; an orb has 5 health).
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
let at = b.at;
let strike = Aabb::new(
Vec3::new(at[0] - 3.0, at[1] - 3.0, at[2] - 3.0),
Vec3::new(at[0] + 3.0, at[1] + 9.0, at[2] + 3.0),
);
// Whether the box of an entity standing at `p` (half width `w`,
// height `h`) meets the strike box.
let meets = move |p: [f64; 3], w: f64, h: f64| {
strike.intersects(Aabb::new(
Vec3::new(p[0] - w, p[1], p[2] - w),
Vec3::new(p[0] + w, p[1] + h, p[2] + w),
))
};
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
&& (p[2] - at[2]).abs() <= 3.0
@@ -465,7 +485,14 @@ impl Game<'_> {
let struck: Vec<(u32, EntityView)> = self
.mobs
.iter()
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
.filter(|m| {
m.alive()
&& meets(
[m.position.x, m.position.y, m.position.z],
m.kind.width / 2.0,
m.kind.height,
)
})
.map(|m| (m.id, self.mob_entity(m)))
.collect();
b.hit.extend(struck.iter().map(|(id, _)| *id));
@@ -477,7 +504,11 @@ impl Game<'_> {
let mut events = Vec::new();
let peaceful = self.ui.difficulty == 0;
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
if !inside([m.position.x, m.position.y, m.position.z]) {
if !meets(
[m.position.x, m.position.y, m.position.z],
m.kind.width / 2.0,
m.kind.height,
) {
continue;
}
let setup = |m: &horizon_client::mobs::Mob| SpawnSetup {
@@ -554,10 +585,34 @@ impl Game<'_> {
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
}
}
// Every player in the box, the server's players too.
let strike = move |g: &mut Self| {
// Boats, then minecarts: `VehicleEntity.hurtServer` for 5 damage
// (x 10 over the 40 a vehicle takes) destroys them. Lightning is no
// fire source, so a TNT cart does not prime.
let mut i = self.boats.len();
while i > 0 {
i -= 1;
let bb = self.boats[i].bounds();
if strike.intersects(bb) {
self.hurt_boat(i, 5.0, false);
}
}
let mut i = self.carts.len();
while i > 0 {
i -= 1;
let bb = self.carts[i].bounds();
if strike.intersects(bb) {
self.hurt_cart(i, 5.0, false, false);
}
}
// `ExperienceOrb.hurtServer`: 5 damage off 5 health.
let orbs_before = self.orb_ids();
self.orbs
.retain_mut(|o| !(strike.intersects(o.bounds()) && o.hurt(5.0)));
self.native_orbs_left_since(&orbs_before);
// Every player in the box, the server's players too (0.6 x 1.8).
let strike_players = move |g: &mut Self| {
if let Some(p) = g.player.as_ref().map(|p| p.position)
&& inside([p.x, p.y, p.z])
&& meets([p.x, p.y, p.z], 0.3, 1.8)
{
if let Some(p) = g.player.as_mut() {
p.fire_ticks += 1;
@@ -570,18 +625,18 @@ impl Game<'_> {
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
};
strike(self);
self.each_guest_hit(strike);
strike_players(self);
self.each_guest_hit(strike_players);
b.hit_items.extend(
self.dropped
.iter()
.filter(|e| inside([e.position.x, e.position.y, e.position.z]))
.filter(|e| meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25))
.map(|e| e.id),
);
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
let before = self.dropped_ids();
self.dropped.retain(|e| {
!inside([e.position.x, e.position.y, e.position.z])
!meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25)
|| e.stack.item.contains("netherite")
|| (e.age < 20 && b.spared.contains(&e.id))
});
+67
View File
@@ -11156,6 +11156,73 @@ CHECKS += ['attack_over_slab']
CHECK_FNS['attack_over_slab'] = check_attack_over_slab
def check_lightning_box(host, guest, out):
"""A lightning bolt tests the box of every entity against its strike box (3 around, 9 above),
not their feet, and destroys boats, minecarts and experience orbs (LightningBolt.tick ->
Entity.thunderHit -> VehicleEntity.hurtServer 5 x 10 > 40; ExperienceOrb health 5). Real
vanilla 26.3 (same scene): boat, cart, orb and a boat whose feet stand 3.5 out are gone, a cow
whose feet stand 3.2 out drops from 10 to 5, a boat 6 out is untouched."""
x, y, z = (int(v // 1) for v in pos_of(host))
ox, oy, oz = x + 12, y, z
tag = 'dg_bolt'
spots = {'boat': ('minecraft:oak_boat', 1.5, 0), 'cart': ('minecraft:minecart', -1.5, 0),
'orb': ('minecraft:experience_orb', 0, -1.5), 'edgeboat': ('minecraft:oak_boat', 0, 3.5),
'edgecow': ('minecraft:cow', 3.2, 0), 'farboat': ('minecraft:oak_boat', 6.0, 0)}
markers = {name: (ox - 8 + i, oy, oz + 12) for i, name in enumerate(spots)}
def alive(name):
if name == 'orb': # @e does not list experience orbs; the state counts them by kind
return remote(host).get('entities', {}).get('minecraft:experience_orb', 0) > 0
# Marker blocks: a command the game runs, a block the control reads back.
mx, my, mz = markers[name]
host('command', command=f'setblock {mx} {my} {mz} minecraft:air')
host('command', command=f'execute if entity @e[tag={tag}_{name}] run setblock {mx} {my} {mz} minecraft:gold_block')
host.wait(2)
return host.raw('block', mx, my, mz).get('block') == 'minecraft:gold_block'
seen = {}
try:
host('command', command='gamemode creative')
host('command', command='difficulty normal')
host('command', command=f'fill {ox - 9} {oy - 1} {oz - 9} {ox + 9} {oy - 1} {oz + 14} minecraft:stone')
host('command', command=f'fill {ox - 9} {oy} {oz - 9} {ox + 9} {oy + 6} {oz + 14} minecraft:air')
host('command', command=f'kill @e[tag={tag}]')
for name, (kind, dx, dz) in spots.items():
extra = ',NoAI:1b,PersistenceRequired:1b' if kind == 'minecraft:cow' else ''
host('command', command=f'summon {kind} {ox + 0.5 + dx} {oy} {oz + 0.5 + dz} '
f'{{Tags:["{tag}","{tag}_{name}"]{extra}}}')
host.wait(10)
seen['before'] = {name: alive(name) for name in spots}
assert all(seen['before'].values()), f'the command cannot see every entity before the bolt: {seen}'
def cow():
near = [m for m in host.raw('entities', 20, 'cow')['entities']
if math.dist(m['pos'], (ox + 3.7, oy, oz + 0.5)) < 1.0]
return near[0]['health'] if near else 0.0
seen['cow_before'] = cow()
host('command', command=f'summon minecraft:lightning_bolt {ox + 0.5} {oy} {oz + 0.5}')
seen['cow_series'] = []
for _ in range(8):
host.wait(1)
seen['cow_series'].append(cow())
host.wait(2)
seen['after'] = {name: alive(name) for name in spots}
seen['cow_after'] = min(seen['cow_series'])
host.shot(out, 'lightning-box')
finally:
host('command', command=f'kill @e[tag={tag}]')
# Vanilla: everything but the far boat is gone; the cow is hurt.
# (The cow burns on after the strike, so only its health at the strike is compared.)
gone = {k: v for k, v in seen['after'].items() if k != 'edgecow'}
assert gone == {'boat': False, 'cart': False, 'orb': False, 'edgeboat': False, 'farboat': True}, \
f'who survived the bolt: {seen}'
assert seen['cow_series'][0] == 5.0, f'the cow with its feet 3.2 out was not struck for 5: {seen}'
return seen
CHECKS += ['lightning-box']
CHECK_FNS['lightning-box'] = check_lightning_box
def check_boat_submerged(host, guest, out):
"""AbstractBoat.tick ejects players after 60 consecutive underwater ticks on the server."""
hx, _, hz = pos_of(host)