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
@@ -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."