mobs: a powered wither refuses a player's wind charge

WitherBoss.hurtServer refuses a direct AbstractArrow or WindCharge once
isPowered. A wind charge hurts mobs as DamageKind::Thrown, which
wither::accepts let through, so every charge took a point off the armor.

The check is on the projectile, not the damage kind. A breeze's
BreezeWindCharge deals the same damage but is not a WindCharge (both only
extend AbstractWindCharge), so it still hurts, unlike what the issue asks.
/damage with the wind_charge type has no direct charge and goes through too.

tools/test_wither_wind_charge_parity.py compares the three cases against a
vanilla dedicated server.

Fixes #8779

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Mayonesu0andClaude Opus 5.5 committed 2026-11-04 14:54:22 +01:00
1 parent 757ce1bd14
commit 2f6d9c7dc4
3 files changed
+129 -2

No files matched your search

+5
View File
@@ -162,6 +162,11 @@ impl Game<'_> {
if owner_breeze && m.species() == Species::Breeze {
return;
}
// Refused before the hurt, so the kill is not credited
// either; the burst of `wind_charge_hit` still goes off.
if horizon_client::mobs::wither::armor_refuses(m, &p.kind) {
return;
}
m.damage(1.0, source);
}
if p.owner == Some(Who::Player) {
+44 -2
View File
@@ -1,7 +1,7 @@
//! The wither (`WitherBoss`): eleven seconds of invulnerable charging that
//! end in a blast, then flying after its target while three heads fire
//! skulls, breaking blocks around it when hurt, immune to arrows below half
//! health.
//! skulls, breaking blocks around it when hurt, immune to arrows and a
//! player's wind charges below half health.
use super::{
DamageKind, Mob, MobEvent, MobWorld, Species, Who,
anim::{Poses, State},
@@ -449,6 +449,14 @@ pub fn accepts(m: &mut Mob, kind: DamageKind, attacker: Option<Who>, others_unde
m.extra.ints[5] += 3;
true
}
/// `WitherBoss.hurtServer`: a powered wither's armor refuses a direct
/// `WindCharge`. A breeze's `BreezeWindCharge` is not one (both only extend
/// `AbstractWindCharge`), so it still hurts. Checked on the projectile, not on
/// `DamageKind`: the `wind_charge` damage type alone has no direct charge
/// (`/damage @s minecraft:wind_charge`) and goes through.
pub fn armor_refuses(m: &Mob, direct: &ProjectileKind) -> bool {
m.species() == Species::Wither && *direct == ProjectileKind::WindCharge && powered(m)
}
/// `WitherBossModel.setupAnim`.
pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
let body =
@@ -518,6 +526,40 @@ mod tests {
assert!(!shielded_from(Species::Wither, Species::Creeper));
assert!(!shielded_from(Species::Zombie, Species::Wither));
}
/// `WitherBoss.hurtServer` tests `instanceof WindCharge`: a powered wither
/// took a point of damage from every charge (#8779), and a breeze's charge,
/// a sibling class, has to keep hurting it.
#[test]
fn only_a_powered_withers_armor_refuses_a_wind_charge() {
use crate::mobs::Damage;
use crate::mobs::sensor_scan_tests::kind;
use std::sync::Arc;
let mob = |species, health: f32| {
let mut kind = kind();
Arc::make_mut(&mut kind).species = species;
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
m.attrs.max_health = 300.0;
m.health = health;
m
};
let (charge, breeze) = (ProjectileKind::WindCharge, ProjectileKind::BreezeWindCharge);
// `isPowered` is `health <= max / 2`: the armor starts at exactly half.
let half = mob(Species::Wither, 150.0);
assert!(armor_refuses(&half, &charge));
assert!(armor_refuses(&mob(Species::Wither, 1.0), &charge));
assert!(!armor_refuses(&half, &breeze));
assert!(!armor_refuses(&mob(Species::Wither, 150.5), &charge));
assert!(!armor_refuses(&mob(Species::Zombie, 150.0), &charge));
// What `wind_charge_damage` lets through still lands on the armor...
let mut m = mob(Species::Wither, 150.0);
assert!(m.damage(1.0, Damage::of(DamageKind::Thrown)));
assert!(m.health < 150.0);
// ...and arrows stay refused by the damage filter.
let mut m = mob(Species::Wither, 150.0);
assert!(!m.damage(1.0, Damage::of(DamageKind::Arrow)));
assert_eq!(m.health, 150.0);
}
/// A spawning wither rolled the three particles of `aiStep`'s
/// `getInvulnerableTicks() > 0` block and dropped them on the floor: the
/// potion-coloured (`0.7, 0.7, 0.9`) cloud around it never appeared (#7486).
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Wind charges meeting a wither, 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. Three summoned withers (NoAI, so none heals, moves or
shoots) each take one charge from above:
- at half health (`isPowered`), a `wind_charge`: `WitherBoss.hurtServer` refuses a direct `WindCharge`, and the charge
is spent all the same;
- at half health, a `breeze_wind_charge`: not a `WindCharge` (both only extend `AbstractWindCharge`), so it hurts;
- above half health, a `wind_charge`: it hurts, which shows the charge does reach a wither.
Whether each wither kept its health is compared (vanilla's answer is the oracle). The health a hurt wither is left
with is not asked: only that it changed.
"""
import sys
try:
from . import test_locator_bar_parity as locator
except ImportError:
import test_locator_bar_parity as locator
NAME = 'wither-wind-charge-parity'
# server_dir and generate_world write these server.properties.
locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival')
def wither(tag, x, z, health):
return f'cmd Alice summon minecraft:wither {x} -50 {z} {{NoAI:1b,Health:{health}f,Tags:["{tag}"]}}'
def charge(kind, x, z):
# Six blocks above the wither's feet, flying straight down: it hits within a few ticks.
return f'cmd Alice summon minecraft:{kind} {x} -44 {z} {{Motion:[0.0,-1.0,0.0]}}'
SCRIPT = [
('setup', ['chat on', wither('armored', 12.5, 0.5, 100), wither('breeze', -11.5, 0.5, 100),
wither('unarmored', 0.5, 12.5, 200)]),
('hit', [charge('wind_charge', 12.5, 0.5), charge('breeze_wind_charge', -11.5, 0.5),
charge('wind_charge', 0.5, 12.5)]),
('asked', ['cmd Alice execute unless entity @e[type=minecraft:wind_charge]',
'cmd Alice execute unless entity @e[type=minecraft:breeze_wind_charge]',
'cmd Alice execute if entity @e[tag=armored,nbt={Health:100.0f}]',
'cmd Alice execute if entity @e[tag=breeze]',
'cmd Alice execute unless entity @e[tag=breeze,nbt={Health:100.0f}]',
'cmd Alice execute if entity @e[tag=unarmored]',
'cmd Alice execute unless entity @e[tag=unarmored,nbt={Health:200.0f}]']),
]
ANSWERS = 7
def shown(lines):
"""The answers about the charges and the withers."""
out = []
for line in lines:
parts = line.split(' ', 3)
if len(parts) >= 3 and parts[1] == 'step=asked' and parts[2] == 'chat' and 'conditional' in line:
out.append(f'{parts[1]} {parts[3]}')
return out
def check(vanilla, vanilla_all):
if sum('conditional.pass' in line for line in vanilla) != ANSWERS:
return 'a charge did not hit its wither, or a wither did not take it as WitherBoss.hurtServer says'
return None
def main():
lines = locator.one_player(NAME, SCRIPT, shown, check, 'Wind charges flying into summoned withers on a dedicated '
'server: a powered wither refuses a wind_charge, and takes a breeze_wind_charge as '
'one above half health takes a wind_charge.', step_seconds=1.0)
print(f'wither wind charge: {len(lines)} lines identical')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')