From fb9edcd3948da6065fa740fbb767638aa90854af Mon Sep 17 00:00:00 2001 From: Nowko Date: Wed, 21 Oct 2026 23:14:12 +0200 Subject: [PATCH] fix: pets take no damage from their owner Pets following their owner were teleported next to them while an area skill was still active. Warrior Dash pulses every tick for 2.5 blocks and deals 10 damage, which kills a 10 HP cat outright, and the death deleted the pet. Damage is now cancelled at LOWEST when the responsible player is the pet's owner. That covers melee, the owner's projectiles and skill damage that names the owner as its causing entity. Other players and other owners' pets are unaffected. Co-Authored-By: Claude Opus 5.5 --- .../companionpets/listen/PetListener.java | 14 ++++++++ .../listen/PetListenerCoverageTest.java | 35 +++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/companionpets/listen/PetListener.java b/src/main/java/net/tfminecraft/companionpets/listen/PetListener.java index da7571b..9815358 100644 --- a/src/main/java/net/tfminecraft/companionpets/listen/PetListener.java +++ b/src/main/java/net/tfminecraft/companionpets/listen/PetListener.java @@ -215,6 +215,20 @@ public void onEnvironmentalDamage(EntityDamageEvent event) { && runtime.byEntity(event.getEntity()) != null) event.setCancelled(true); } + /** Owners cannot hurt their own pets: following pets end up inside area skills such as dashes. */ + @EventHandler(priority = EventPriority.LOWEST) + public void onOwnerDamage(EntityDamageByEntityEvent event) { + Pet pet = runtime.byEntity(event.getEntity()); + if (pet != null && pet.ownerId().equals(responsiblePlayer(event))) event.setCancelled(true); + } + + private static UUID responsiblePlayer(EntityDamageByEntityEvent event) { + Entity cause = event.getDamageSource().getCausingEntity(); + if (cause == null) cause = event.getDamager(); + if (cause instanceof Projectile projectile && projectile.getShooter() instanceof Entity shooter) cause = shooter; + return cause instanceof Player player ? player.getUniqueId() : null; + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onVisualDamage(EntityDamageEvent event) { if (event.getFinalDamage() <= 0) return; diff --git a/src/test/java/net/tfminecraft/companionpets/listen/PetListenerCoverageTest.java b/src/test/java/net/tfminecraft/companionpets/listen/PetListenerCoverageTest.java index 71bebaa..1361c22 100644 --- a/src/test/java/net/tfminecraft/companionpets/listen/PetListenerCoverageTest.java +++ b/src/test/java/net/tfminecraft/companionpets/listen/PetListenerCoverageTest.java @@ -253,7 +253,7 @@ private EntityDamageByEntityEvent attack(Entity attacker, Entity victim, double @Test void highPriorityDamageCancellationPreservesMoodAndAnimations() { atHigh(EntityDamageByEntityEvent.class, event -> event.setCancelled(true)); - var event = fire(attack(owner, body, 2)); + var event = fire(attack(server.addPlayer(), body, 2)); assertTrue(event.isCancelled()); assertEquals(80, pet.need(Need.MOOD)); assertTrue(animations.isEmpty()); @@ -261,7 +261,7 @@ private EntityDamageByEntityEvent attack(Entity attacker, Entity victim, double } @Test void zeroFinalDamageDoesNotCountAsBeingStruck() { - fire(attack(owner, body, 0)); + fire(attack(server.addPlayer(), body, 0)); assertEquals(80, pet.need(Need.MOOD)); assertTrue(animations.isEmpty()); } @@ -354,6 +354,37 @@ void drowningAndSuffocationAreCancelledBeforeOtherPluginsAndVisualDamage(EntityD assertFalse(ordinary.isCancelled()); assertFalse(observed.getLast()); } + @Test void ownerDamageIsCancelledBeforeOtherPluginsWhetherMeleeProjectileOrSkill() { + var observed = new ArrayList(); + server.getPluginManager().registerEvent(EntityDamageByEntityEvent.class, new Listener() { }, EventPriority.LOW, + (unused, event) -> observed.add(((EntityDamageEvent) event).isCancelled()), runtime.plugin()); + Snowball shot = world.spawn(owner.getEyeLocation(), Snowball.class); + shot.setShooter(owner); + // Skill effects may hit through another entity while naming the caster as the cause. + Snowball effect = world.spawn(owner.getEyeLocation(), Snowball.class); + var skill = DamageSource.builder(DamageType.MAGIC).withCausingEntity(owner).withDirectEntity(effect).build(); + for (var event : List.of(attack(owner, body, 10), attack(shot, body, 10), + new EntityDamageByEntityEvent(effect, body, EntityDamageEvent.DamageCause.MAGIC, skill, 10))) { + assertTrue(fire(event).isCancelled()); + } + assertEquals(List.of(true, true, true), observed); + assertEquals(80, pet.need(Need.MOOD)); + assertTrue(animations.isEmpty()); + assertTrue(cancelledAnimations.isEmpty()); + } + + @Test void ownerProtectionCoversOnlyTheirOwnPets() { + var stranger = server.addPlayer(); + assertFalse(fire(attack(stranger, body, 2)).isCancelled()); + assertEquals(80 - runtime.config().care().struckMoodPenalty(), pet.need(Need.MOOD)); + var otherBody = wolf(owner.getLocation().add(2, 0, 0)); + Pet other = net.tfminecraft.companionpets.testutil.OwnershipFixture.pet(UUID.randomUUID(), stranger.getUniqueId(), "wolf", "Luna", PetSex.FEMALE); + runtime.store().add(other); + runtime.remember(other, otherBody); + assertFalse(fire(attack(owner, otherBody, 2)).isCancelled()); + assertFalse(fire(attack(owner, wolf(owner.getLocation()), 2)).isCancelled()); + } + @Test void toyProjectileDamageIsCancelledBeforeMoodOrVisualEffects() { Snowball toy = world.spawn(owner.getEyeLocation(), Snowball.class); toy.getPersistentDataContainer().set(runtime.toyKey(), PersistentDataType.STRING, UUID.randomUUID().toString());