Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 e749b17104 mob_attacks_mob and the hit catch-up move to combat.rs: creatures.rs may not grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:44:46 +01:00
Horizon agentandClaude Opus 5.5 add5b203c6 A hoglin hits from its fight activity, 41 ticks apart
HoglinAi.updateActivity runs after the brain, so a hoglin given an attack
target enters the fight activity at the end of that tick and its
MeleeAttack hits from the next one: the goal now acts only in the fight
activity (brain_step runs after the goals). MeleeAttack.create(40) sets
ATTACK_COOLING_DOWN for 40 brain ticks and MemorySlot.tick erases it on
the 41st, so hits come 41 ticks apart (16 for a baby).

A hit one mob's tick deals another is handled after the mob loop, after
the target's own tick; vanilla deals it inside the attacker's tick, so a
target later in the list counts HurtTime and its damage cooldown down
once more that tick. The pig hit by a hoglin read HurtTime 10 on the tick
of the hit; vanilla's reads 9.

hoglin_repeat_attack reports its rand- rows instead of comparing them:
each hit is attack / 2 + level.getRandom().nextInt(attack), and
Level.random is RandomSource.create(); two vanilla runs disagree on the
pigs' Health.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:05:50 +01:00
6 changed files with 137 additions and 18 deletions

No files matched your search

+46
View File
@@ -731,6 +731,52 @@ fn damage_taken_award(amount: f32) -> Option<i32> {
(amount < 3.402_823_5e37).then(|| horizon_core::jmath::round_f32(amount * 10.0))
}
/// Hits one mob's tick deals another, handled after the mob loop.
impl Game<'_> {
/// A melee hit on another mob, then `postAttack`; whether it landed.
pub(super) fn mob_attacks_mob(
&mut self,
target: u32,
amount: f32,
source: horizon_client::mobs::Damage,
) -> bool {
let hurt = self.mob_hits_mob(target, amount, source);
if hurt {
self.mob_post_attack(
target,
source.attacker,
None,
Some(super::creatures::damage_type(source.kind, false)),
);
}
hurt
}
/// Whether a hit from a mob's tick on `target`, handled after the mob
/// loop, comes after a tick of the target that vanilla runs after the hit:
/// `ServerLevel.tick` ticks entities in list order and the attacker deals
/// the hit in its own tick, so a target later in the list counts its
/// `HurtTime` and invulnerability down once more that tick. PARTIAL: the
/// rest of that tick (its movement, its own goals) still ran before the hit.
pub(super) fn catch_up_countdowns(
&self,
attacker: Option<horizon_client::mobs::Who>,
target: u32,
) -> bool {
let Some(horizon_client::mobs::Who::Mob(attacker)) = attacker else {
return false;
};
let ticked = &self.creatures.ticked;
if !self.creatures.deferred_hits || !ticked.contains(&attacker) || !ticked.contains(&target)
{
return false;
}
// The mobs added during the tick come first in `self.mobs`; the
// ticked ones keep their tick order after them.
let at = |id: u32| self.mobs.iter().position(|m| m.id == id);
matches!((at(attacker), at(target)), (Some(a), Some(t)) if a < t)
}
}
#[cfg(test)]
mod damage_taken_tests {
use super::damage_taken_award;
+10 -12
View File
@@ -119,6 +119,8 @@ pub(super) struct Creatures {
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
/// (pickups, item searches) is for these alone.
pub ticked: horizon_client::fast_hash::FxHashSet<u32>,
/// While the mob loop's own events are handled (`catch_up_countdowns`).
pub deferred_hits: bool,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
@@ -1693,7 +1695,9 @@ impl Game<'_> {
let bounds = m.bounds();
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
}
self.creatures.deferred_hits = true;
let event_result = self.handle_mob_events_reusable(&mut events);
self.creatures.deferred_hits = false;
self.creatures.event_buf = events;
event_result?;
self.lan_guest_mob_events(guest_events);
@@ -2207,18 +2211,6 @@ impl Game<'_> {
}
}
/// `Mob.doHurtTarget` and the other melee callers of post-attack effects.
fn mob_attacks_mob(&mut self, target: u32, amount: f32, source: Damage) -> bool {
let hurt = self.mob_hits_mob(target, amount, source);
if hurt {
self.mob_post_attack(
target,
source.attacker,
None,
Some(damage_type(source.kind, false)),
);
}
hurt
}
/// Melee or ranged damage to another mob; whether the hit landed.
pub(super) fn mob_hits_mob(&mut self, target: u32, amount: f32, mut source: Damage) -> bool {
if source.attacker_species.is_none()
@@ -2229,12 +2221,18 @@ impl Game<'_> {
let mut killed = false;
let mut hurt = false;
let mut infested = Vec::new();
let catch_up = self.catch_up_countdowns(source.attacker, target);
let aim = self.attacker_aim(source.attacker);
// So `living_knockback_v2` sees who hit and what they hold.
let _attacker = self.knockback_scope_for(source.attacker);
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage_aimed(amount, source, aim);
hurt = hit;
if hit && catch_up {
// `LivingEntity.baseTick`'s countdowns, run after the hit in vanilla.
t.hurt_time = (t.hurt_time - 1).max(0);
t.invulnerable = (t.invulnerable - 1).max(0);
}
if hit {
killed = !t.alive();
// The hurt or death sound the mob raised.
@@ -133,6 +133,31 @@ fn a_hoglin_keeps_attacking_its_attacker_beside_another_hit_hoglin() {
);
}
fn hurt_time(game: &mut Game<'_>, uuid: &str) -> i32 {
let id = mob_id(game, uuid);
game.mobs.iter().find(|m| m.id == id).unwrap().hurt_time
}
/// Vanilla 26.3's hit pen: the hoglin enters the fight activity at the end of
/// the tick after the hit (`HoglinAi.updateActivity` follows the brain), hits
/// in the next one, and again 41 ticks later. The pig ticks after the hoglin,
/// so it reads `HurtTime` 9 on the tick of each hit.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_hoglin_hits_from_its_second_fighting_tick_every_41_ticks() {
let mut game = game();
step(&mut game, 40);
assert!(game.run_command(&format!("damage {HOGLIN} 1 minecraft:mob_attack by {PIG}")));
let mut hits = Vec::new();
for tick in 1..=90 {
step(&mut game, 1);
if hurt_time(&mut game, PIG) == 9 {
hits.push(tick);
}
}
assert_eq!(hits, [2, 43, 84]);
}
/// Added controls (after the frozen baseline above).
fn mob_id(game: &mut Game<'_>, uuid: &str) -> u32 {
match game.target_with_uuid(horizon_client::save::uuid_from_text(uuid).unwrap()) {
+44 -2
View File
@@ -1159,6 +1159,46 @@ impl<G: Goal + Clone + 'static> Goal for NotRetreating<G> {
}
}
/// A goal of the fight activity: `HoglinAi.updateActivity` runs after the
/// brain, so a hoglin given a target fights from the next tick on (here
/// `brain_step` runs after the goals). The goal may start a tick early, as
/// the goal selector picks new goals every other tick only and the brain
/// every tick, but it acts only once the activity is fight.
#[derive(Clone, Debug)]
struct InFight<G>(G);
impl<G: Goal + Clone + 'static> Goal for InFight<G> {
goal_clone!();
fn flags(&self) -> u8 {
self.0.flags()
}
fn name(&self) -> &'static str {
self.0.name()
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.0.can_use(m, cx)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.0.can_continue(m, cx)
}
fn interruptable(&self) -> bool {
self.0.interruptable()
}
fn every_tick(&self) -> bool {
self.0.every_tick()
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.start(m, cx)
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.stop(m, cx)
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
if m.extra.piglin.activity == Activity::Fight {
self.0.tick(m, cx)
}
}
}
/// A goal of the idle and avoid activities' movement: the ride activity has
/// none of it (`PiglinAi.initRideHoglinActivity`), so it waits while the
/// brain is in the ride activity.
@@ -1457,8 +1497,10 @@ pub fn register_hoglin(m: &mut Mob) {
g.add(0, Float);
// `HoglinAi.initRetreatActivity`: `SetWalkTargetAwayFrom.entity(AVOID_TARGET, 1.3, 15, false)`.
g.add(1, WalkAway::retreat(1.3, 15.0, false));
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies.
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies, in the
// fight activity. Its `ATTACK_COOLING_DOWN` lives 40 brain ticks and
// `MemorySlot.tick` erases it on the 41st, so hits come 41 (16) apart.
g.add(2, InFight(MeleeAttack::new(1.0, true).intervals(41, 16)));
g.add(3, NotRetreating(super::goals::Breed::new(1.0)));
g.add(3, HoglinAvoidRepellent);
// `HoglinAi.initIdleActivity`: an adult keeps away from the nearest adult
+1 -1
View File
@@ -17,7 +17,7 @@ The accepted-hit fact is the pig's own: `HurtTime` is 10 on the tick after a hit
read too, but each hit's amount is the hoglin's damage roll (`attack / 2 + nextInt(attack)`).
Label classes, declared before any native run: `target-` rows are the hit pen's pig `HurtTime` every
tick and the pig at the end; `rand-` rows are recorded and compared, but hold a random amount (the hit
tick and the pig at the end; `rand-` rows are recorded and reported, not compared: they hold a random amount (the hit
pen's and the near pen's pig Health); `diag-` rows are recorded diagnostics (positions, raw bodies, clock); every other row
is a setup admission or a control (the still pen, the hits, the near pen's HurtTime and end).
"""
+11 -3
View File
@@ -40,9 +40,17 @@ if __name__ == '__main__':
for op, a, b in zip(recorded, expected, native):
if op['admission'] and not full:
assert a == b, ('actual actor/state/control admission', op, a, b)
differences = [{'label': op['label'], 'expected': a, 'actual': b}
for op, a, b in zip(recorded, expected, native) if a != b]
report = {'records': len(native), 'passed': len(native) - len(differences),
# `rand-` rows are reported, not compared: each hit is `attack / 2 +
# level.getRandom().nextInt(attack)`, and `Level.random` is
# `RandomSource.create()`, seeded from the clock. Two vanilla runs of
# the same script disagree on them.
random = [op['label'] for op, a, b in zip(recorded, expected, native)
if op['label'].startswith('rand-') and a != b]
compared = [(op, a, b) for op, a, b in zip(recorded, expected, native)
if not op['label'].startswith('rand-')]
differences = [{'label': op['label'], 'expected': a, 'actual': b} for op, a, b in compared if a != b]
report = {'records': len(compared), 'passed': len(compared) - len(differences),
'random_rows': len(native) - len(compared), 'random_rows_differing': random,
'comparison': 'complete raw records, no masks/tolerances',
'reference_sha256': hashlib.sha256(source.read_bytes()).hexdigest(),
'fresh_java_sha256': hashlib.sha256((build / 'vanilla.jsonl').read_bytes()).hexdigest(),