diff --git a/design/log.md b/design/log.md index a7789d1..5961ff5 100644 --- a/design/log.md +++ b/design/log.md @@ -42,3 +42,10 @@ - Name cleanup restores only values Leaf installed; third-party changes are preserved. - Documented scoreboard, custom chat, tab-list, and per-viewer scoreboard limitations. - US-003 remains in progress until combat and global administration paths are verified. + +### US-002 combat opt-out completed + +- Added modern Spigot damage-source attribution for direct attacks, projectiles, thrown potions, area clouds, tamed animals, TNT, Thorns, and other reliably attributed sources. +- Ambiguous, cancelled, zero-damage, environmental, and self damage do not opt out a player. +- Combat opt-out bypasses administrative locks, immediately reconciles effect and prefix state, persists once, and sends one explanatory message. +- Verified attribution, victim safety, lock bypass, duplicate suppression, and the full build with `./gradlew clean check jar`. diff --git a/design/user-stories/us-002-relinquish-protection-when-attacking.md b/design/user-stories/us-002-relinquish-protection-when-attacking.md index d9f08fd..581a8ea 100644 --- a/design/user-stories/us-002-relinquish-protection-when-attacking.md +++ b/design/user-stories/us-002-relinquish-protection-when-attacking.md @@ -2,7 +2,7 @@ type: User Story title: "US-002: Relinquish protection when attacking" description: Remove Leaf protection when an opted-in player attacks another player. -status: backlog +status: done --- # US-002: Relinquish protection when attacking @@ -11,17 +11,17 @@ As a **player facing PvP**, I want Leaf protection to belong only to non-aggress ## Acceptance criteria -- [ ] When an opted-in player damages another player, Leaf automatically changes the attacker's saved choice to opted out. -- [ ] Automatic opt-out immediately removes Leaf-managed Resistance and the attacker's leaf prefix. -- [ ] The attacker receives a clear chat message explaining that attacking another player disabled Leaf and that they may opt in again when permitted. -- [ ] Receiving player-caused damage without retaliating does not change the protected player's choice or Resistance. -- [ ] Retaliatory damage counts as attacking, including Thorns damage attributable to the protected player. -- [ ] Direct melee attacks and player-fired projectiles are attributed to the attacking player. -- [ ] Harmful splash or lingering potion damage is attributed to the player who threw the potion. -- [ ] Damage caused by a tamed animal is attributed to its player owner when Spigot exposes that ownership. -- [ ] TNT, fire, and other indirect damage trigger opt-out only when Spigot exposes a reliable responsible player; ambiguous environmental damage does not opt out an innocent player. -- [ ] PvP-triggered opt-out applies even when an administrator has locked the player's preference. -- [ ] A single attack produces no duplicate state changes or duplicate notifications. +- [x] When an opted-in player damages another player, Leaf automatically changes the attacker's saved choice to opted out. +- [x] Automatic opt-out immediately removes Leaf-managed Resistance and the attacker's leaf prefix. +- [x] The attacker receives a clear chat message explaining that attacking another player disabled Leaf and that they may opt in again when permitted. +- [x] Receiving player-caused damage without retaliating does not change the protected player's choice or Resistance. +- [x] Retaliatory damage counts as attacking, including Thorns damage attributable to the protected player. +- [x] Direct melee attacks and player-fired projectiles are attributed to the attacking player. +- [x] Harmful splash or lingering potion damage is attributed to the player who threw the potion. +- [x] Damage caused by a tamed animal is attributed to its player owner when Spigot exposes that ownership. +- [x] TNT, fire, and other indirect damage trigger opt-out only when Spigot exposes a reliable responsible player; ambiguous environmental damage does not opt out an innocent player. +- [x] PvP-triggered opt-out applies even when an administrator has locked the player's preference. +- [x] A single attack produces no duplicate state changes or duplicate notifications. ## Related diff --git a/src/main/java/games/dmg/leaf/LeafCommand.java b/src/main/java/games/dmg/leaf/LeafCommand.java index d8a2a7d..968eabd 100644 --- a/src/main/java/games/dmg/leaf/LeafCommand.java +++ b/src/main/java/games/dmg/leaf/LeafCommand.java @@ -67,7 +67,7 @@ public final class LeafCommand implements TabExecutor { private void reportChoice(Player player, boolean enabled, LeafRuntime.Change change) { if (change == LeafRuntime.Change.LOCKED) { - player.sendMessage(runtime.settings().lockedMessage()); + player.sendMessage(LeafText.color(runtime.settings().lockedMessage())); } else if (change == LeafRuntime.Change.UNCHANGED) { player.sendMessage("Leaf was already " + (enabled ? "enabled" : "disabled") + "."); } else { diff --git a/src/main/java/games/dmg/leaf/LeafListener.java b/src/main/java/games/dmg/leaf/LeafListener.java index 14df438..f4b773e 100644 --- a/src/main/java/games/dmg/leaf/LeafListener.java +++ b/src/main/java/games/dmg/leaf/LeafListener.java @@ -2,10 +2,15 @@ package games.dmg.leaf; import java.io.IOException; import java.time.Clock; +import java.util.Optional; +import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; +import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.player.PlayerJoinEvent; /** Handles player lifecycle events that affect Leaf protection. */ @@ -13,11 +18,13 @@ public final class LeafListener implements Listener { private final LeafRuntime runtime; private final Clock clock; private final Logger logger; + private final PlayerDamageAttributor damageAttributor; public LeafListener(LeafRuntime runtime, Clock clock, Logger logger) { this.runtime = runtime; this.clock = clock; this.logger = logger; + this.damageAttributor = new PlayerDamageAttributor(); } @EventHandler @@ -30,4 +37,21 @@ public final class LeafListener implements Listener { event.getPlayer().sendMessage("Leaf protection is unavailable because state could not be saved."); } } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDamage(EntityDamageEvent event) { + if (event.isCancelled() || event.getFinalDamage() <= 0.0 + || !(event.getEntity() instanceof Player victim)) { + return; + } + Optional responsible = damageAttributor.responsiblePlayer(event); + if (responsible.isEmpty() || responsible.get().equals(victim.getUniqueId())) { + return; + } + try { + runtime.combatOptOut(responsible.get()); + } catch (IOException exception) { + logger.log(Level.SEVERE, "Could not persist Leaf combat opt-out", exception); + } + } } diff --git a/src/main/java/games/dmg/leaf/LeafRuntime.java b/src/main/java/games/dmg/leaf/LeafRuntime.java index 08eb486..0444a51 100644 --- a/src/main/java/games/dmg/leaf/LeafRuntime.java +++ b/src/main/java/games/dmg/leaf/LeafRuntime.java @@ -76,6 +76,19 @@ public final class LeafRuntime { return Change.CHANGED; } + public Change combatOptOut(UUID playerId) throws IOException { + PlayerLeafState state = stateManager.find(playerId).orElse(null); + if (state == null || !state.optedIn()) { + return Change.UNCHANGED; + } + Change changed = setChoice(playerId, false); + Player player = server.getPlayer(playerId); + if (changed == Change.CHANGED && player != null) { + player.sendMessage(LeafText.color(settingsProvider.current().combatDisabledMessage())); + } + return changed; + } + public Change setLocked(UUID playerId, boolean locked) throws IOException { PlayerLeafState state = requiredState(playerId); if (state.locked() == locked) { diff --git a/src/main/java/games/dmg/leaf/LeafSettings.java b/src/main/java/games/dmg/leaf/LeafSettings.java index 3ed6fef..a829280 100644 --- a/src/main/java/games/dmg/leaf/LeafSettings.java +++ b/src/main/java/games/dmg/leaf/LeafSettings.java @@ -41,7 +41,8 @@ public record LeafSettings( string( values, "combat-disabled-message", - "&cLeaf protection was disabled because you attacked another player." + "&cLeaf protection was disabled because you attacked another player. " + + "You may use /leaf on again when permitted." ), string(values, "locked-message", "&cAn administrator locked your Leaf setting.") ); diff --git a/src/main/java/games/dmg/leaf/LeafText.java b/src/main/java/games/dmg/leaf/LeafText.java new file mode 100644 index 0000000..4af77a6 --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafText.java @@ -0,0 +1,11 @@ +package games.dmg.leaf; + +import org.bukkit.ChatColor; + +final class LeafText { + private LeafText() { } + + static String color(String value) { + return ChatColor.translateAlternateColorCodes('&', value); + } +} diff --git a/src/main/java/games/dmg/leaf/PlayerDamageAttributor.java b/src/main/java/games/dmg/leaf/PlayerDamageAttributor.java new file mode 100644 index 0000000..69a68b3 --- /dev/null +++ b/src/main/java/games/dmg/leaf/PlayerDamageAttributor.java @@ -0,0 +1,66 @@ +package games.dmg.leaf; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.damage.DamageSource; +import org.bukkit.entity.AreaEffectCloud; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.entity.TNTPrimed; +import org.bukkit.entity.Tameable; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.projectiles.ProjectileSource; + +/** Resolves only player attribution that Spigot exposes reliably. */ +public final class PlayerDamageAttributor { + public Optional responsiblePlayer(EntityDamageEvent event) { + DamageSource source = event.getDamageSource(); + Optional causing = source == null + ? Optional.empty() + : fromEntity(source.getCausingEntity(), 0); + if (causing.isPresent()) { + return causing; + } + Optional direct = source == null + ? Optional.empty() + : fromEntity(source.getDirectEntity(), 0); + if (direct.isPresent()) { + return direct; + } + if (event instanceof EntityDamageByEntityEvent byEntity) { + return fromEntity(byEntity.getDamager(), 0); + } + return Optional.empty(); + } + + private Optional fromEntity(Entity entity, int depth) { + if (entity == null || depth > 4) { + return Optional.empty(); + } + if (entity instanceof Player player) { + return Optional.of(player.getUniqueId()); + } + if (entity instanceof Tameable tameable && tameable.getOwner() != null) { + return Optional.of(tameable.getOwner().getUniqueId()); + } + if (entity instanceof TNTPrimed tnt) { + return fromEntity(tnt.getSource(), depth + 1); + } + if (entity instanceof Projectile projectile) { + return fromSource(projectile.getShooter(), depth + 1); + } + if (entity instanceof AreaEffectCloud cloud) { + return fromSource(cloud.getSource(), depth + 1); + } + return Optional.empty(); + } + + private Optional fromSource(ProjectileSource source, int depth) { + if (source instanceof Entity entity) { + return fromEntity(entity, depth); + } + return Optional.empty(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 34f27d9..cd06e47 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -11,5 +11,5 @@ prefix: "&a🍃 " onboarding-days: 7 welcome-message: "&aLeaf protection is available: /leaf on, /leaf off, or /leaf status. Attacking another player opts you out." -combat-disabled-message: "&cLeaf protection was disabled because you attacked another player." +combat-disabled-message: "&cLeaf protection was disabled because you attacked another player. You may use /leaf on again when permitted." locked-message: "&cAn administrator locked your Leaf setting." diff --git a/src/test/java/games/dmg/leaf/LeafListenerCombatTest.java b/src/test/java/games/dmg/leaf/LeafListenerCombatTest.java new file mode 100644 index 0000000..d59c1eb --- /dev/null +++ b/src/test/java/games/dmg/leaf/LeafListenerCombatTest.java @@ -0,0 +1,53 @@ +package games.dmg.leaf; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.damage.DamageSource; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityDamageEvent; +import org.junit.jupiter.api.Test; + +final class LeafListenerCombatTest { + @Test + void optsOutTheAttackerButNotTheVictim() throws Exception { + UUID attackerId = UUID.randomUUID(); + UUID victimId = UUID.randomUUID(); + Player attacker = mock(Player.class); + Player victim = mock(Player.class); + when(attacker.getUniqueId()).thenReturn(attackerId); + when(victim.getUniqueId()).thenReturn(victimId); + DamageSource source = mock(DamageSource.class); + when(source.getCausingEntity()).thenReturn(attacker); + EntityDamageEvent event = mock(EntityDamageEvent.class); + when(event.getEntity()).thenReturn(victim); + when(event.getFinalDamage()).thenReturn(2.0); + when(event.getDamageSource()).thenReturn(source); + LeafRuntime runtime = mock(LeafRuntime.class); + + new LeafListener(runtime, Clock.systemUTC(), Logger.getAnonymousLogger()).onDamage(event); + + verify(runtime).combatOptOut(attackerId); + verify(runtime, never()).combatOptOut(victimId); + } + + @Test + void ignoresCancelledAndUnattributedDamage() throws Exception { + Player victim = mock(Player.class); + when(victim.getUniqueId()).thenReturn(UUID.randomUUID()); + EntityDamageEvent event = mock(EntityDamageEvent.class); + when(event.getEntity()).thenReturn(victim); + when(event.getFinalDamage()).thenReturn(2.0); + when(event.isCancelled()).thenReturn(true); + LeafRuntime runtime = mock(LeafRuntime.class); + + new LeafListener(runtime, Clock.systemUTC(), Logger.getAnonymousLogger()).onDamage(event); + + verify(runtime, never()).combatOptOut(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java index 6b6b56b..9089486 100644 --- a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java +++ b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java @@ -3,6 +3,7 @@ package games.dmg.leaf; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -62,6 +63,28 @@ final class LeafRuntimeTest { verify(protection, never()).apply(player, 1); } + @Test + void combatOptOutBypassesLockAndNotifiesExactlyOnce() throws Exception { + UUID playerId = UUID.randomUUID(); + Player player = player(playerId, "Alex"); + Server server = mock(Server.class); + when(server.getPlayer(playerId)).thenReturn(player); + LeafRuntime runtime = runtime( + server, + mock(LeafProtection.class), + temporaryDirectory.resolve("combat.yml") + ); + runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z")); + runtime.setChoice(playerId, true); + runtime.setLocked(playerId, true); + + assertEquals(LeafRuntime.Change.CHANGED, runtime.combatOptOut(playerId)); + assertEquals(LeafRuntime.Change.UNCHANGED, runtime.combatOptOut(playerId)); + + assertFalse(runtime.status(playerId).savedChoice()); + verify(player).sendMessage(contains("attacked another player")); + } + @Test void optingOutRemovesLeafProtectionAndRepeatingIsANoOp() throws Exception { UUID playerId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/leaf/PlayerDamageAttributorTest.java b/src/test/java/games/dmg/leaf/PlayerDamageAttributorTest.java new file mode 100644 index 0000000..2a827cc --- /dev/null +++ b/src/test/java/games/dmg/leaf/PlayerDamageAttributorTest.java @@ -0,0 +1,80 @@ +package games.dmg.leaf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.OfflinePlayer; +import org.bukkit.damage.DamageSource; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.entity.Tameable; +import org.bukkit.event.entity.EntityDamageEvent; +import org.junit.jupiter.api.Test; + +final class PlayerDamageAttributorTest { + @Test + void attributesDirectAndThornsDamageToTheCausingPlayer() { + Player attacker = player(); + EntityDamageEvent event = eventWith(attacker, null); + + assertEquals( + Optional.of(attacker.getUniqueId()), + new PlayerDamageAttributor().responsiblePlayer(event) + ); + } + + @Test + void attributesProjectileDamageToItsPlayerShooter() { + Player attacker = player(); + Projectile projectile = mock(Projectile.class); + when(projectile.getShooter()).thenReturn(attacker); + + assertEquals( + Optional.of(attacker.getUniqueId()), + new PlayerDamageAttributor().responsiblePlayer(eventWith(null, projectile)) + ); + } + + @Test + void attributesTamedAnimalDamageToItsOwner() { + OfflinePlayer owner = mock(OfflinePlayer.class); + UUID ownerId = UUID.randomUUID(); + when(owner.getUniqueId()).thenReturn(ownerId); + Tameable animal = mock(Tameable.class); + when(animal.getOwner()).thenReturn(owner); + + assertEquals( + Optional.of(ownerId), + new PlayerDamageAttributor().responsiblePlayer(eventWith(animal, animal)) + ); + } + + @Test + void leavesAmbiguousEnvironmentalDamageUnattributed() { + assertTrue( + new PlayerDamageAttributor().responsiblePlayer(eventWith(null, null)).isEmpty() + ); + } + + private static EntityDamageEvent eventWith( + org.bukkit.entity.Entity causing, + org.bukkit.entity.Entity direct + ) { + DamageSource source = mock(DamageSource.class); + when(source.getCausingEntity()).thenReturn(causing); + when(source.getDirectEntity()).thenReturn(direct); + EntityDamageEvent event = mock(EntityDamageEvent.class); + when(event.getDamageSource()).thenReturn(source); + return event; + } + + private static Player player() { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + return player; + } +}