feat(combat): opt out attacking players
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user