Wither: stay still while charging, and spare itself from its spawn blast

While a wither charges (Invul > 0), WitherBoss.customServerAiStep returns
before setAlternativeTarget(0, ...), so aiStep has no target to fly to.
Horizon stored the target's position first, so the charging wither flew
toward whatever it targeted. The position is now taken after the charge.

The blast that ends the charge is level.explode(this, ...): the wither is
the source and ServerExplosion's getEntities(source, box) leaves it out.
The wither now names itself as the explosion source (as a creeper does),
and the blast's mob loop skips a source named that way, so it is neither
hurt nor pushed. A source only guessed from the mobs near the centre (a
sulfur cube, already off the list) may be a bystander and is still hurt.

The command_exec corpus gets a "wither spawn" group: a wither summoned
with Invul:20 next to a pig, its position mid-charge and its health after
the blast.

Fixes #3322

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-29 15:25:46 +07:00
1 parent 8f0c178957
commit ab9ceb6a6d
5 files changed
+51 -5

No files matched your search

+4
View File
@@ -57,6 +57,10 @@ pub(super) struct CombatTracker {
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// The exploding mob named itself (`MobEvent::ExplosionSource`), so the
/// blast leaves it out (`getEntities(source, box)`). A source guessed
/// from the mobs near the centre is still hurt: it may be a bystander.
pub(super) explosion_source_named: bool,
/// The blast now going off comes from a primed TNT, creeper, wither
/// skull, wither or TNT minecart, the sources that empty a hive they
/// destroy (`BeehiveBlock.getDrops`).
@@ -1301,6 +1301,7 @@ impl Game<'_> {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
self.combat.explosion_source_named = noted;
// Only a creeper or a wither empties the hives it blows up.
// The creeper is already gone from the list, so the event
// carries its kind.
@@ -1310,6 +1311,7 @@ impl Game<'_> {
self.explode(at, radius)
};
self.combat.explosion_source = None;
self.combat.explosion_source_named = false;
result?
}
MobEvent::AttackMob {
+7 -2
View File
@@ -382,9 +382,14 @@ impl Game<'_> {
}
self.lan = Some(host);
}
// Mobs.
// Mobs, but not the one exploding (`getEntities(source, box)`): a
// wither's spawn blast neither hurts nor pushes it.
let spared = exploding.filter(|_| self.combat.explosion_source_named);
let mut mobs = std::mem::take(&mut self.mobs);
for m in mobs.iter_mut().filter(|m| m.alive()) {
for m in mobs
.iter_mut()
.filter(|m| m.alive() && spared != Some(m.id))
{
let hurtable = teams_allow(self, self.mob_team(m.id));
let feet = [m.position.x, m.position.y, m.position.z];
let dist = distance(feet) / diameter;
+16 -3
View File
@@ -241,12 +241,20 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
/// `WitherBoss.customServerAiStep`: charging, then the side heads' targets
/// and volleys, block breaking and healing.
pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.point = m
.target_info(w)
.map(|t| [t.position.x, t.position.y, t.position.z]);
if m.extra.counter > 0 {
let left = m.extra.counter - 1;
if left <= 0 {
// `level.explode(this, ...)`: the wither is the blast's source, so
// its entity query (`getEntities(source, box)`) leaves it out.
let name = match &m.custom_name {
Some(name) => crate::text::Text::literal(name.clone()),
None => crate::text::Text::translate("entity.minecraft.wither", &[]),
};
events.push(MobEvent::ExplosionSource {
mob: m.id,
charged: false,
name,
});
events.push(MobEvent::Explode(
[m.position.x, m.eye_y(), m.position.z],
7.0,
@@ -268,6 +276,11 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
}
return;
}
// `setAlternativeTarget(0, ...)` once charged: `aiStep` flies toward this
// target only after the charge, never during it.
m.extra.point = m
.target_info(w)
.map(|t| [t.position.x, t.position.y, t.position.z]);
// The side heads pick targets and fire on their own.
let age = m.age as i32;
for i in 1..3 {
+22
View File
@@ -349,6 +349,27 @@ def entities():
]
def wither_spawn():
"""A wither charging up (`Invul`) neither moves toward its target nor is hurt by
the blast that ends the charge (#3322): `WitherBoss.customServerAiStep` sets no
alternative target while invulnerable, and `ServerLevel.explode`'s entity query
skips the source."""
wither = '@e[type=minecraft:wither,limit=1]'
return [
*setup('/fill 0 209 4 9 209 20 minecraft:stone',
'/summon minecraft:pig 4.5 210 18.5 {NoAI:1b,Tags:["wither_prey"]}',
'/summon minecraft:wither 4.5 210 8.5 {Invul:20}'),
{'op': 'tick', 'n': 10},
cmd(f'/data get entity {wither} Pos'),
cmd(f'/data get entity {wither} Invul'),
{'op': 'tick', 'n': 12},
cmd(f'/data get entity {wither} Invul'),
cmd(f'/data get entity {wither} Health'),
*setup('/kill @e[type=minecraft:wither]', '/kill @e[tag=wither_prey]', '/fill 0 200 0 15 228 20 air'),
{'op': 'tick', 'n': 5},
]
def riding_teleport():
"""Player teleports remove the vehicle, leave it behind and keep the destination (#1473)."""
horse = '@e[tag=tp_horse,limit=1]'
@@ -1122,6 +1143,7 @@ def groups():
('player state', player_state()),
('blocks and border', world_blocks()),
('entities', entities()),
('wither spawn', wither_spawn()),
('riding teleport', riding_teleport()),
('mob riding', mob_riding()),
('scores, teams and bossbars', scores()),