diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 830c181ec7..f7046a1091 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -725,6 +725,15 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6 .map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2])); let _ = write!(out, "\tcg {} {held} {opened}", m.extra.variant3); } + if m.species() == Species::Wolf { + // NeutralMob anger left (the angry texture), -1 when calm. + let anger = if m.extra.anger > 0 { + i64::from(m.extra.anger) + } else { + -1 + }; + let _ = write!(out, "\twolf {anger}"); + } if let Some(b) = horizon_client::mobs::bee::get(m) { let anger = if b.anger_end > 0 { b.anger_end - now diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index c5119c01df..2a71378685 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -547,7 +547,13 @@ impl Game<'_> { -1 }; self.mobs[i].extra.angry_at = if anger > 0 { - self.mobs[i].ai.target + // A wolf keeps its anger's target itself, also once the target + // is gone (`tame::update_persistent_anger`). + if self.mobs[i].species() == horizon_client::mobs::Species::Wolf { + self.mobs[i].extra.angry_at + } else { + self.mobs[i].ai.target + } } else { None }; diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index 7af00b55fb..4e9b08452a 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -149,8 +149,9 @@ fn wolf_prey(s: &super::Seen) -> bool { fn baby_turtle_on_land(s: &super::Seen) -> bool { s.species == Species::Turtle && s.baby } +/// `NeutralMob.isAngryAt(player)`: the player is the anger's target. fn angry_at_player(m: &Mob, _: &MobWorld) -> bool { - m.extra.anger > 0 + m.extra.angry_at == Some(Who::Player) } /// `Wolf.isAngry`. pub fn angry(m: &Mob) -> bool { @@ -574,14 +575,66 @@ impl Goal for Beg { self.ticks -= 1; } } -/// `Wolf.tick` / `aiStep`: anger countdown, begging tilt and shaking off water. -pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { - if m.extra.anger > 0 { - m.extra.anger -= 1; - if m.extra.anger == 0 && m.ai.target == Some(Who::Player) { - m.ai.target = None; - } +/// `NeutralMob.isValidPlayerTarget`: a player in survival or adventure, off +/// peaceful. +fn valid_player_target(w: &MobWorld) -> bool { + w.player + .as_ref() + .is_some_and(|p| !p.creative && !p.spectator) + && w.difficulty.id != 0 +} +/// `NeutralMob.stopBeingAngry`. +fn stop_being_angry(m: &mut Mob) { + // setLastHurtByMob(null) also stamps the time. + m.ai.last_hurt_by = None; + m.ai.last_hurt_by_time = m.age as i32; + m.ai.target = None; + m.extra.angry_at = None; + m.extra.anger = 0; +} +/// `NeutralMob.updatePersistentAnger(level, true)` from `Wolf.aiStep`: any +/// target (the attacker, a pack-mate's attacker the `HurtByTargetGoal` alert +/// gave it, prey, the owner's enemy) is the anger's target, and the 20 to 39 +/// seconds of `PERSISTENT_ANGER_TIME` start over every tick it is held. +/// Without a target the anger runs out (`extra.anger` counts down to the end +/// time), and anger at a mob ends when that mob dies. +fn update_persistent_anger(m: &mut Mob, w: &MobWorld) { + let angry_at = m.extra.angry_at; + if let Some(target @ Who::Mob(id)) = m.ai.target + && w.seen(id).is_none_or(|s| !s.alive) + && angry_at == Some(target) + { + stop_being_angry(m); + return; } + // Mob.getTarget: a player in creative or spectator mode is no target. + let target = m.ai.target.filter(|t| { + *t != Who::Player + || w.player + .as_ref() + .is_some_and(|p| !p.creative && !p.spectator) + }); + if let Some(target) = target { + m.extra.angry_at = Some(target); + // Wolf.startPersistentAngerTimer: TimeUtil.rangeOfSeconds(20, 39). + m.extra.anger = 400 + m.random.next_int_bound(381).unwrap_or(0); + } else if m.extra.anger > 0 { + m.extra.anger -= 1; + } + if angry_at.is_some() + && m.extra.anger == 0 + && !(target == Some(Who::Player) && valid_player_target(w)) + { + stop_being_angry(m); + } + // The anger's target as it was when the tick began. + if angry_at == Some(Who::Player) && !valid_player_target(w) { + stop_being_angry(m); + } +} +/// `Wolf.tick` / `aiStep`: anger, begging tilt and shaking off water. +pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + update_persistent_anger(m, w); if !m.alive() { return; } @@ -628,13 +681,10 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.anim.vo[SHAKE] = 0.0; } } -/// `Wolf.hurtServer` and `NeutralMob` anger: stop sitting; untamed wolves -/// hit by the player stay angry for 20 to 39 seconds. -pub fn after_hurt(m: &mut Mob, source: &Damage) { +/// `Wolf.hurtServer`: stop sitting. The anger comes with the target +/// (`HurtByTargetGoal`, then `updatePersistentAnger`). +pub fn after_hurt(m: &mut Mob, _source: &Damage) { m.extra.sitting = false; - if source.attacker == Some(Who::Player) && !m.extra.tame { - m.extra.anger = 400 + m.random.next_int_bound(380).unwrap_or(0); - } } /// `Wolf.getTailAngle`. fn tail_angle(m: &Mob) -> f32 { diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java index 256333d464..5f0c655513 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java @@ -1072,6 +1072,11 @@ public final class VanillaMobAiOracle { sb.append("\tcg ").append(golem.getState().ordinal()).append(' ').append(stack(golem.getMainHandItem())).append(' ') .append(opened == null ? "-" : pos((BlockPos) opened)); } + if (m instanceof net.minecraft.world.entity.animal.wolf.Wolf wolf) { + // NeutralMob anger left (the angry texture), -1 when calm. + long end = wolf.getPersistentAngerEndTime(); + sb.append(" wolf ").append(end > 0 ? end - wolf.level().getGameTime() : -1); + } if (m instanceof net.minecraft.world.entity.animal.bee.Bee bee) { // Flags (roll 2, stung 4, nectar 8), anger left, ticks without nectar, flower cooldown, flower. Object acc = field(net.minecraft.world.entity.animal.bee.Bee.class, "DATA_FLAGS_ID").get(null); diff --git a/tools/test_mob_ai_parity.py b/tools/test_mob_ai_parity.py index 9d88ca18de..de3aa86c51 100644 --- a/tools/test_mob_ai_parity.py +++ b/tools/test_mob_ai_parity.py @@ -272,6 +272,10 @@ def scenarios(): add('bee_hive_rain', 300, [HIVE, 'mob minecraft:bee 0.5 -58 0.5 0 162 persistent'], 'rain') add('bee_hive_morning', 1000, [HIVE, 'mob minecraft:bee 0.5 -58 0.5 0 163 persistent'], 'time=22900 daycycle') add('bee_hive_honey', 2600, [HIVE, 'mob minecraft:bee 0.5 -58 0.5 0 164 persistent nectar']) + # A wolf the player hits (#3185, #2820): any wolf with a target is angry (the angry texture): + # NeutralMob.updatePersistentAnger in Wolf.aiStep restarts the 20-39 s timer, one random draw, + # every tick it has one, not once when hurt. + add('wolf_hurt', 200, ['player 5.5 -60 0.5', 'mob minecraft:wolf 0.5 -60 0.5 0 181 persistent', 'hurt 20 0']) # Mobs dropped into deep water (#542): FloatGoal users float at the surface (their navigation # can float, so Mob.jumpInLiquid adds 0.04 a tick), zombies sink. for species, seed in [('pig', 171), ('cow', 172), ('wolf', 173), ('cat', 174), ('horse', 175),