feat(combat): opt out attacking players

This commit is contained in:
dmg
2026-08-10 18:24:02 -04:00
parent 1b622211cb
commit d1427fbe38
12 changed files with 293 additions and 15 deletions
+7
View File
@@ -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`.
@@ -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
@@ -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 {
@@ -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<UUID> 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);
}
}
}
@@ -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) {
@@ -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.")
);
@@ -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);
}
}
@@ -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<UUID> responsiblePlayer(EntityDamageEvent event) {
DamageSource source = event.getDamageSource();
Optional<UUID> causing = source == null
? Optional.empty()
: fromEntity(source.getCausingEntity(), 0);
if (causing.isPresent()) {
return causing;
}
Optional<UUID> 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<UUID> 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<UUID> fromSource(ProjectileSource source, int depth) {
if (source instanceof Entity entity) {
return fromEntity(entity, depth);
}
return Optional.empty();
}
}
+1 -1
View File
@@ -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."
@@ -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());
}
}
@@ -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();
@@ -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;
}
}