diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4ee3da1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [master]
+ workflow_dispatch:
+
+jobs:
+ test:
+ name: test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run tests
+ run: mvn -B test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..7757313
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,60 @@
+name: Release
+
+on:
+ push:
+ tags: ["v*"]
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Verify tag is on master
+ run: |
+ git fetch origin master
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
+ echo "Tagged commit $GITHUB_SHA is not on origin/master"
+ exit 1
+ fi
+
+ - name: Derive version from tag
+ id: version
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ VERSION="${TAG#v}"
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "Using Maven version: ${VERSION}"
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+
+ - name: Run tests
+ run: mvn -B test
+
+ - name: Set project version from tag
+ run: mvn -B versions:set -DnewVersion="${{ steps.version.outputs.version }}" -DgenerateBackupPoms=false
+
+ - name: Build plugin jar
+ run: mvn -B package -DskipTests
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ name: ${{ github.ref_name }}
+ generate_release_notes: true
+ files: target/DamageDisplay-${{ steps.version.outputs.version }}.jar
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/pom.xml b/pom.xml
index a7722ad..52eda0e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,8 +13,10 @@
A simple and efficient plugin for displaying damage events
- 1.8
+ 21
UTF-8
+ 5.11.4
+ 5.20.0
https://danlegt.com
@@ -23,16 +25,16 @@
org.apache.maven.plugins
maven-compiler-plugin
- 3.8.1
+ 3.13.0
- 16
- 16
+ ${java.version}
+ ${java.version}
org.apache.maven.plugins
maven-shade-plugin
- 3.3.0
+ 3.6.0
package
@@ -51,6 +53,14 @@
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.2
+
+ -Dnet.bytebuddy.experimental=true
+
+
@@ -75,7 +85,7 @@
org.spigotmc
spigot-api
- 1.21.11-R0.1-SNAPSHOT
+ 26.2-R0.1-SNAPSHOT
provided
@@ -84,5 +94,23 @@
3.0.2
compile
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+ org.mockito
+ mockito-core
+ ${mockito.version}
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ ${mockito.version}
+ test
+
diff --git a/src/main/java/com/danlegt/damagedisplay/Commands/ReloadCommand.java b/src/main/java/com/danlegt/damagedisplay/Commands/ReloadCommand.java
index f95d206..bd1f824 100644
--- a/src/main/java/com/danlegt/damagedisplay/Commands/ReloadCommand.java
+++ b/src/main/java/com/danlegt/damagedisplay/Commands/ReloadCommand.java
@@ -19,6 +19,7 @@ public boolean handleCommand(CommandSender sender, Command command, String label
}
DamageDisplay.me.reloadConfig();
+ DamageDisplay.me.getSettings().reload();
ToggleCommand.loadToggleCache();
sender.sendMessage(ChatColor.GREEN + "Configuration reloaded.");
diff --git a/src/main/java/com/danlegt/damagedisplay/Commands/ToggleCommand.java b/src/main/java/com/danlegt/damagedisplay/Commands/ToggleCommand.java
index f749b8c..5b8d9aa 100644
--- a/src/main/java/com/danlegt/damagedisplay/Commands/ToggleCommand.java
+++ b/src/main/java/com/danlegt/damagedisplay/Commands/ToggleCommand.java
@@ -8,13 +8,15 @@
import java.io.*;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
public class ToggleCommand implements DDCommand {
- public static List disabledPlayers = new ArrayList<>();
+ public static Set disabledPlayers = new HashSet<>();
// The file that we store all the player toggle command statuses to
private static final String CACHE_FILE = "cache.dat";
@@ -74,7 +76,7 @@ public static void loadToggleCache() {
File file = new File(DamageDisplay.me.getDataFolder(), CACHE_FILE);
if (file.exists()) {
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {
- disabledPlayers = new ArrayList<>((List) ois.readObject());
+ disabledPlayers = new HashSet<>((List) ois.readObject());
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
DamageDisplay.me.getLogger().log(Level.WARNING, "Could not load player toggle command cache: " + e.getMessage());
diff --git a/src/main/java/com/danlegt/damagedisplay/DamageDisplay.java b/src/main/java/com/danlegt/damagedisplay/DamageDisplay.java
index 1616429..7a7d4e4 100644
--- a/src/main/java/com/danlegt/damagedisplay/DamageDisplay.java
+++ b/src/main/java/com/danlegt/damagedisplay/DamageDisplay.java
@@ -3,6 +3,8 @@
import com.danlegt.damagedisplay.Commands.CommandManager;
import com.danlegt.damagedisplay.Commands.ToggleCommand;
import com.danlegt.damagedisplay.Event.EventManager;
+import com.danlegt.damagedisplay.Services.DisplayTracker;
+import com.danlegt.damagedisplay.Services.PluginSettings;
import org.bstats.bukkit.Metrics;
import org.bukkit.plugin.java.JavaPlugin;
@@ -11,6 +13,9 @@ public final class DamageDisplay extends JavaPlugin {
public static DamageDisplay me;
public static final int bStatsID = 20685;
+ private final PluginSettings settings = new PluginSettings();
+ private final DisplayTracker displayTracker = new DisplayTracker();
+
@Override
public void onEnable() {
// Singleton for Services
@@ -24,6 +29,7 @@ public void onEnable() {
// Save the default settings if they are not saved
this.saveDefaultConfig();
+ this.settings.reload();
this.getCommand("dd").setExecutor(new CommandManager());
// Load the toggle command cache
@@ -32,7 +38,15 @@ public void onEnable() {
@Override
public void onDisable() {
- // Plugin shutdown logic
+ displayTracker.removeAll();
+ }
+
+ public PluginSettings getSettings() {
+ return settings;
+ }
+
+ public DisplayTracker getDisplayTracker() {
+ return displayTracker;
}
private void RegisterMetrics() {
diff --git a/src/main/java/com/danlegt/damagedisplay/Event/EventListeners/OnDamageHandler.java b/src/main/java/com/danlegt/damagedisplay/Event/EventListeners/OnDamageHandler.java
index b2216a8..c99bdad 100644
--- a/src/main/java/com/danlegt/damagedisplay/Event/EventListeners/OnDamageHandler.java
+++ b/src/main/java/com/danlegt/damagedisplay/Event/EventListeners/OnDamageHandler.java
@@ -3,6 +3,8 @@
import com.danlegt.damagedisplay.Commands.ToggleCommand;
import com.danlegt.damagedisplay.DamageDisplay;
import com.danlegt.damagedisplay.Services.ActionBar;
+import com.danlegt.damagedisplay.Services.DisplayTracker;
+import com.danlegt.damagedisplay.Services.PluginSettings;
import net.md_5.bungee.api.ChatColor;
import org.bukkit.Location;
import org.bukkit.entity.*;
@@ -12,49 +14,40 @@
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.potion.PotionEffectType;
-import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.util.Vector;
-import java.util.Random;
-import java.util.logging.Level;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.ThreadLocalRandom;
public class OnDamageHandler implements Listener {
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerDamage(EntityDamageByEntityEvent e) {
- // Check if the event has already been cancelled
- if ( e.isCancelled() ) return;
- // Check if this feature is enabled in the config
- if ( !DamageDisplay.me.getConfig().getBoolean("actionbar.damageDealt.enabled") ) return;
-
- // Check if the player has dealt the damage
- if ( e.getDamager().getType().equals(EntityType.PLAYER) ) {
- // Yoink a ref to the player
+ if (e.isCancelled()) return;
+
+ PluginSettings settings = DamageDisplay.me.getSettings();
+ if (!settings.isActionBarDamageDealtEnabled()) return;
+
+ if (e.getDamager().getType() == EntityType.PLAYER) {
Player p = (Player) e.getDamager();
- // Toggle Setting check
if (ToggleCommand.disabledPlayers.contains(p.getUniqueId())) return;
- // Permission check
- if ( !p.hasPermission("damagedisplay.enabled") )
+ if (!p.hasPermission("damagedisplay.enabled"))
return;
- // Send out a Damage indicator to the player
ActionBar.sendToPlayer(p, "★ Damage Dealt: " + ChatColor.YELLOW + Math.round(e.getDamage() * 100.0) / 100.0);
}
- // Check if this is a projectile send out by the player
- if ( e.getDamager() instanceof Projectile a) {
- // Check if the player has dealt the damage
- if ( a.getShooter() instanceof Player p ) {
- // Permission check
- if ( !p.hasPermission("damagedisplay.enabled") )
+ if (e.getDamager() instanceof Projectile a) {
+ if (a.getShooter() instanceof Player p) {
+ if (!p.hasPermission("damagedisplay.enabled"))
return;
- // Toggle Setting check
if (ToggleCommand.disabledPlayers.contains(p.getUniqueId())) return;
- // Send out a Damage indicator to the player
ActionBar.sendToPlayer(p, "★ Damage Dealt: " + ChatColor.YELLOW + Math.round(e.getDamage() * 100.0) / 100.0);
}
}
@@ -62,60 +55,93 @@ public void onPlayerDamage(EntityDamageByEntityEvent e) {
@EventHandler(priority = EventPriority.MONITOR)
public void onEnvironmentDamage(EntityDamageEvent e) {
- // Check if the event has already been cancelled
- if ( e.isCancelled() ) return;
+ if (e.isCancelled()) return;
+ PluginSettings settings = DamageDisplay.me.getSettings();
var receivedDamage = (Math.round(e.getDamage() * 100.0) / 100.0);
- // Check if the player has received the damage
- if ( e.getEntity().getType().equals(EntityType.PLAYER) && DamageDisplay.me.getConfig().getBoolean("actionbar.damageReceived.enabled") ) {
+ if (e.getEntity().getType() == EntityType.PLAYER && settings.isActionBarDamageReceivedEnabled()) {
Player p = (Player) e.getEntity();
- // Permission check
- if ( !p.hasPermission("damagedisplay.enabled") )
+ if (!p.hasPermission("damagedisplay.enabled"))
return;
- // Toggle Setting check
if (ToggleCommand.disabledPlayers.contains(p.getUniqueId())) return;
- // Send out the Damage indicator to the player
- ActionBar.sendToPlayer(p, "☠ Damage Taken: " + ChatColor.RED + receivedDamage + ChatColor.GRAY + " | " + ActionBar.parseStringToPretty(e.getCause().toString()) );
+ ActionBar.sendToPlayer(p, "☠ Damage Taken: " + ChatColor.RED + receivedDamage + ChatColor.GRAY + " | " + ActionBar.parseStringToPretty(e.getCause()));
+ }
+
+ if (!settings.isDisplayTextEnabled()) {
+ return;
+ }
+
+ Location entityLoc = e.getEntity().getLocation();
+ DisplayTracker tracker = DamageDisplay.me.getDisplayTracker();
+ if (!tracker.canSpawn(entityLoc.getChunk(), settings)) {
+ return;
}
- if ( DamageDisplay.me.getConfig().getBoolean("displayText.enabled") ) {
- var rand = new Random();
- var lingertime = DamageDisplay.me.getConfig().getInt("displayText.lingertime");
- // Do not allow the linger time be less than 1
- if ( lingertime <= 0 ) {
- DamageDisplay.me.getServer().getLogger().log(Level.WARNING, "The configured linger time ahs been set to be equal or lower than 0, please add at least 1 tick to the linger time. If your intention was to disable the displayText please use displayText.enabled = false");
- lingertime = 1;
+ if (settings.isOnlyFromPlayers() && !isPlayerCaused(e)) {
+ return;
+ }
+
+ double nearbyRange = settings.getNearbyRange();
+ Collection nearbyPlayers = Collections.emptyList();
+ if (nearbyRange > 0) {
+ nearbyPlayers = getNearbyPlayers(entityLoc, nearbyRange);
+ if (nearbyPlayers.isEmpty()) {
+ return;
}
+ }
- String damagePrefix = ChatColor.RED.toString();
+ ThreadLocalRandom rand = ThreadLocalRandom.current();
+ String damagePrefix = ChatColor.RED.toString();
- if (e instanceof EntityDamageByEntityEvent damageByEntityEvent ) {
- if (damageByEntityEvent.getDamager() instanceof Player) {
- Player damager = (Player) damageByEntityEvent.getDamager();
- // Check if the damage is a critical hit
- if (damager.getFallDistance() > 0.0f && !damager.isOnGround() && !damager.isInWater() && !damager.isInsideVehicle() && !damager.hasPotionEffect(PotionEffectType.BLINDNESS)) {
- damagePrefix = ChatColor.GOLD + "✴";
- }
+ if (e instanceof EntityDamageByEntityEvent damageByEntityEvent) {
+ if (damageByEntityEvent.getDamager() instanceof Player damager) {
+ if (damager.getFallDistance() > 0.0f && !damager.isOnGround() && !damager.isInWater() && !damager.isInsideVehicle() && !damager.hasPotionEffect(PotionEffectType.BLINDNESS)) {
+ damagePrefix = ChatColor.GOLD + "✴";
}
}
+ }
- Location loc = e.getEntity().getLocation().clone();
- loc.add(new Vector(.05 * (rand.nextBoolean() ? -1 : 0), 1.5f, .05 * (rand.nextBoolean() ? -1 : 0)));
+ Location loc = entityLoc.clone();
+ loc.add(new Vector(.05 * (rand.nextBoolean() ? -1 : 0), 1.5f, .05 * (rand.nextBoolean() ? -1 : 0)));
- TextDisplay textDisplay = e.getEntity().getWorld().spawn(loc, TextDisplay.class);
- textDisplay.setText(damagePrefix + receivedDamage);
- textDisplay.setBillboard(Display.Billboard.VERTICAL);
+ TextDisplay textDisplay = e.getEntity().getWorld().spawn(loc, TextDisplay.class);
+ textDisplay.setText(damagePrefix + receivedDamage);
+ textDisplay.setBillboard(Display.Billboard.VERTICAL);
- new BukkitRunnable() {
- @Override
- public void run() {
- textDisplay.remove();
- }
- }.runTaskLater(DamageDisplay.me, lingertime);
+ if (settings.isViewerScoped()) {
+ textDisplay.setVisibleByDefault(false);
+ Collection viewers = nearbyRange > 0
+ ? nearbyPlayers
+ : getNearbyPlayers(loc, 16);
+ for (Player viewer : viewers) {
+ viewer.showEntity(DamageDisplay.me, textDisplay);
+ }
+ }
+
+ tracker.trackAndScheduleRemoval(textDisplay, settings.getLingerTime());
+ }
+
+ private static Collection getNearbyPlayers(Location location, double range) {
+ Collection nearby = location.getWorld().getNearbyEntities(location, range, range, range, entity -> entity instanceof Player);
+ Collection players = new ArrayList<>(nearby.size());
+ for (Entity entity : nearby) {
+ players.add((Player) entity);
}
+ return players;
}
+ private static boolean isPlayerCaused(EntityDamageEvent e) {
+ if (!(e instanceof EntityDamageByEntityEvent damageByEntityEvent)) {
+ return false;
+ }
+
+ Entity damager = damageByEntityEvent.getDamager();
+ if (damager instanceof Player) {
+ return true;
+ }
+ return damager instanceof Projectile projectile && projectile.getShooter() instanceof Player;
+ }
}
diff --git a/src/main/java/com/danlegt/damagedisplay/Services/ActionBar.java b/src/main/java/com/danlegt/damagedisplay/Services/ActionBar.java
index ec6b38e..506ff9a 100644
--- a/src/main/java/com/danlegt/damagedisplay/Services/ActionBar.java
+++ b/src/main/java/com/danlegt/damagedisplay/Services/ActionBar.java
@@ -1,25 +1,31 @@
package com.danlegt.damagedisplay.Services;
-import com.danlegt.damagedisplay.DamageDisplay;
import net.md_5.bungee.api.ChatMessageType;
import net.md_5.bungee.api.chat.TextComponent;
-import org.bukkit.Server;
import org.bukkit.entity.Player;
+import org.bukkit.event.entity.EntityDamageEvent;
+
+import java.util.EnumMap;
+import java.util.Map;
public class ActionBar {
+ private static final Map PRETTY_CAUSES = buildPrettyCauses();
+
public static void sendToPlayer(Player p, String msg) {
- // Get the server
- Server server = DamageDisplay.me.getServer();
- // Use the lower API to send the message
p.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(msg));
}
- public static String parseStringToPretty( String input ) {
+ public static String parseStringToPretty(EntityDamageEvent.DamageCause cause) {
+ String pretty = PRETTY_CAUSES.get(cause);
+ return pretty != null ? pretty : parseStringToPretty(cause.toString());
+ }
+
+ public static String parseStringToPretty(String input) {
StringBuilder output = new StringBuilder();
String[] words = input.split("_");
- for ( int i = 0; i < words.length; i++ ) {
+ for (int i = 0; i < words.length; i++) {
words[i] = words[i].toLowerCase();
words[i] = words[i].substring(0, 1).toUpperCase() + words[i].substring(1);
@@ -29,4 +35,11 @@ public static String parseStringToPretty( String input ) {
return output.toString();
}
+ private static Map buildPrettyCauses() {
+ Map causes = new EnumMap<>(EntityDamageEvent.DamageCause.class);
+ for (EntityDamageEvent.DamageCause cause : EntityDamageEvent.DamageCause.values()) {
+ causes.put(cause, parseStringToPretty(cause.toString()));
+ }
+ return causes;
+ }
}
diff --git a/src/main/java/com/danlegt/damagedisplay/Services/DisplayTracker.java b/src/main/java/com/danlegt/damagedisplay/Services/DisplayTracker.java
new file mode 100644
index 0000000..0799e76
--- /dev/null
+++ b/src/main/java/com/danlegt/damagedisplay/Services/DisplayTracker.java
@@ -0,0 +1,105 @@
+package com.danlegt.damagedisplay.Services;
+
+import com.danlegt.damagedisplay.DamageDisplay;
+import org.bukkit.Chunk;
+import org.bukkit.entity.TextDisplay;
+import org.bukkit.scheduler.BukkitRunnable;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+
+public class DisplayTracker {
+
+ private final Map activeDisplays = new HashMap<>();
+ private final Map perChunkCounts = new HashMap<>();
+
+ public boolean canSpawn(Chunk chunk, PluginSettings settings) {
+ if (settings.getMaxTotal() > 0 && activeDisplays.size() >= settings.getMaxTotal()) {
+ return false;
+ }
+ if (settings.getMaxPerChunk() > 0) {
+ int count = perChunkCounts.getOrDefault(ChunkKey.of(chunk), 0);
+ if (count >= settings.getMaxPerChunk()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public int getActiveCount() {
+ return activeDisplays.size();
+ }
+
+ public void track(TextDisplay display) {
+ ChunkKey key = ChunkKey.of(display.getLocation().getChunk());
+ activeDisplays.put(display, key);
+ perChunkCounts.merge(key, 1, Integer::sum);
+ }
+
+ public void trackAndScheduleRemoval(TextDisplay display, int lingerTime) {
+ track(display);
+
+ new BukkitRunnable() {
+ @Override
+ public void run() {
+ remove(display);
+ }
+ }.runTaskLater(DamageDisplay.me, lingerTime);
+ }
+
+ public void remove(TextDisplay display) {
+ ChunkKey key = activeDisplays.remove(display);
+ if (key == null) {
+ return;
+ }
+
+ perChunkCounts.computeIfPresent(key, (k, count) -> count <= 1 ? null : count - 1);
+
+ if (display.isValid()) {
+ display.remove();
+ }
+ }
+
+ public void removeAll() {
+ Iterator> iterator = activeDisplays.entrySet().iterator();
+ while (iterator.hasNext()) {
+ TextDisplay display = iterator.next().getKey();
+ iterator.remove();
+ if (display.isValid()) {
+ display.remove();
+ }
+ }
+ perChunkCounts.clear();
+ }
+
+ private static final class ChunkKey {
+ private final UUID worldId;
+ private final int x;
+ private final int z;
+
+ private ChunkKey(UUID worldId, int x, int z) {
+ this.worldId = worldId;
+ this.x = x;
+ this.z = z;
+ }
+
+ static ChunkKey of(Chunk chunk) {
+ return new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ());
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ChunkKey chunkKey)) return false;
+ return x == chunkKey.x && z == chunkKey.z && Objects.equals(worldId, chunkKey.worldId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(worldId, x, z);
+ }
+ }
+}
diff --git a/src/main/java/com/danlegt/damagedisplay/Services/PluginSettings.java b/src/main/java/com/danlegt/damagedisplay/Services/PluginSettings.java
new file mode 100644
index 0000000..ea143e6
--- /dev/null
+++ b/src/main/java/com/danlegt/damagedisplay/Services/PluginSettings.java
@@ -0,0 +1,80 @@
+package com.danlegt.damagedisplay.Services;
+
+import com.danlegt.damagedisplay.DamageDisplay;
+import org.bukkit.configuration.file.FileConfiguration;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public class PluginSettings {
+
+ private boolean actionBarDamageDealtEnabled = true;
+ private boolean actionBarDamageReceivedEnabled = true;
+ private boolean displayTextEnabled = true;
+ private int lingerTime = 10;
+ private double nearbyRange = 16;
+ private boolean onlyFromPlayers = false;
+ private int maxTotal = 64;
+ private int maxPerChunk = 8;
+ private boolean viewerScoped = true;
+
+ public void reload() {
+ reload(DamageDisplay.me.getConfig(), DamageDisplay.me.getLogger());
+ }
+
+ public void reload(FileConfiguration config, Logger logger) {
+ actionBarDamageDealtEnabled = config.getBoolean("actionbar.damageDealt.enabled", true);
+ actionBarDamageReceivedEnabled = config.getBoolean("actionbar.damageReceived.enabled", true);
+ displayTextEnabled = config.getBoolean("displayText.enabled", true);
+ lingerTime = config.getInt("displayText.lingertime", 10);
+ nearbyRange = config.getDouble("displayText.nearbyRange", 16);
+ onlyFromPlayers = config.getBoolean("displayText.onlyFromPlayers", false);
+ maxTotal = Math.max(0, config.getInt("displayText.maxTotal", 64));
+ maxPerChunk = Math.max(0, config.getInt("displayText.maxPerChunk", 8));
+ viewerScoped = config.getBoolean("displayText.viewerScoped", true);
+
+ if (lingerTime <= 0) {
+ if (logger != null) {
+ logger.log(Level.WARNING,
+ "The configured linger time has been set to be equal or lower than 0, please add at least 1 tick to the linger time. If your intention was to disable the displayText please use displayText.enabled = false");
+ }
+ lingerTime = 1;
+ }
+ }
+
+ public boolean isActionBarDamageDealtEnabled() {
+ return actionBarDamageDealtEnabled;
+ }
+
+ public boolean isActionBarDamageReceivedEnabled() {
+ return actionBarDamageReceivedEnabled;
+ }
+
+ public boolean isDisplayTextEnabled() {
+ return displayTextEnabled;
+ }
+
+ public int getLingerTime() {
+ return lingerTime;
+ }
+
+ public double getNearbyRange() {
+ return nearbyRange;
+ }
+
+ public boolean isOnlyFromPlayers() {
+ return onlyFromPlayers;
+ }
+
+ public int getMaxTotal() {
+ return maxTotal;
+ }
+
+ public int getMaxPerChunk() {
+ return maxPerChunk;
+ }
+
+ public boolean isViewerScoped() {
+ return viewerScoped;
+ }
+}
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
index 8cbb868..8fad8b8 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -8,4 +8,9 @@ actionbar:
# The text that spawns whenever an entity is hit
displayText:
lingertime: 10 # The amount of time in ticks that the text should stay alive for, 20 = 1 second
- enabled: true # Whether to display the floating DisplayText instances or not
\ No newline at end of file
+ enabled: true # Whether to display the floating DisplayText instances or not
+ nearbyRange: 16 # Skip spawn if no player is within this distance; 0 disables the nearby-player gate
+ onlyFromPlayers: false # Only spawn when the damager (or projectile shooter) is a Player
+ maxTotal: 64 # Maximum concurrent TextDisplay entities globally
+ maxPerChunk: 8 # Maximum concurrent TextDisplay entities per chunk
+ viewerScoped: true # Hide displays by default and only show them to nearby players
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index bf741e2..bdf8809 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -1,7 +1,7 @@
name: DamageDisplay
version: '${project.version}'
main: com.danlegt.damagedisplay.DamageDisplay
-api-version: '1.20'
+api-version: '26.2'
prefix: DamageDisplay
authors: [Daniel Legt ]
description: "A simple and efficient plugin for displaying damage events"
diff --git a/src/test/java/com/danlegt/damagedisplay/Services/ActionBarTest.java b/src/test/java/com/danlegt/damagedisplay/Services/ActionBarTest.java
new file mode 100644
index 0000000..5854376
--- /dev/null
+++ b/src/test/java/com/danlegt/damagedisplay/Services/ActionBarTest.java
@@ -0,0 +1,34 @@
+package com.danlegt.damagedisplay.Services;
+
+import org.bukkit.event.entity.EntityDamageEvent;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ActionBarTest {
+
+ @Test
+ void parseStringToPrettyFormatsUnderscoredEnumNames() {
+ assertEquals("Entity Attack ", ActionBar.parseStringToPretty("ENTITY_ATTACK"));
+ assertEquals("Fall ", ActionBar.parseStringToPretty("FALL"));
+ assertEquals("Projectile ", ActionBar.parseStringToPretty("PROJECTILE"));
+ }
+
+ @Test
+ void parseDamageCauseUsesPrecomputedMap() {
+ assertEquals("Entity Attack ", ActionBar.parseStringToPretty(EntityDamageEvent.DamageCause.ENTITY_ATTACK));
+ assertEquals("Fall ", ActionBar.parseStringToPretty(EntityDamageEvent.DamageCause.FALL));
+ assertEquals("Fire Tick ", ActionBar.parseStringToPretty(EntityDamageEvent.DamageCause.FIRE_TICK));
+ }
+
+ @Test
+ void everyDamageCauseHasAPrettyLabel() {
+ for (EntityDamageEvent.DamageCause cause : EntityDamageEvent.DamageCause.values()) {
+ String pretty = ActionBar.parseStringToPretty(cause);
+ assertFalse(pretty.isBlank(), "Expected pretty label for " + cause);
+ assertTrue(Character.isUpperCase(pretty.charAt(0)), "Expected capitalized label for " + cause);
+ }
+ }
+}
diff --git a/src/test/java/com/danlegt/damagedisplay/Services/DisplayTrackerTest.java b/src/test/java/com/danlegt/damagedisplay/Services/DisplayTrackerTest.java
new file mode 100644
index 0000000..6bfd0ff
--- /dev/null
+++ b/src/test/java/com/danlegt/damagedisplay/Services/DisplayTrackerTest.java
@@ -0,0 +1,154 @@
+package com.danlegt.damagedisplay.Services;
+
+import org.bukkit.Chunk;
+import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.entity.TextDisplay;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.UUID;
+
+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.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DisplayTrackerTest {
+
+ private static final UUID WORLD_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
+
+ @Mock
+ private World world;
+
+ private PluginSettings settings;
+ private DisplayTracker tracker;
+
+ @BeforeEach
+ void setUp() {
+ when(world.getUID()).thenReturn(WORLD_ID);
+
+ settings = new PluginSettings();
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("displayText.maxTotal", 2);
+ config.set("displayText.maxPerChunk", 1);
+ settings.reload(config, null);
+
+ tracker = new DisplayTracker();
+ }
+
+ @Test
+ void canSpawnAllowsFirstDisplayInChunk() {
+ Chunk chunk = mockChunk(0, 0);
+ assertTrue(tracker.canSpawn(chunk, settings));
+ }
+
+ @Test
+ void canSpawnBlocksWhenPerChunkCapReached() {
+ Chunk chunk = mockChunk(0, 0);
+ TextDisplay display = mockDisplay(chunk);
+
+ tracker.track(display);
+
+ assertFalse(tracker.canSpawn(chunk, settings));
+ assertTrue(tracker.canSpawn(mockChunk(1, 0), settings));
+ }
+
+ @Test
+ void canSpawnBlocksWhenTotalCapReached() {
+ TextDisplay first = mockDisplay(mockChunk(0, 0));
+ TextDisplay second = mockDisplay(mockChunk(1, 0));
+
+ tracker.track(first);
+ tracker.track(second);
+
+ assertEquals(2, tracker.getActiveCount());
+ assertFalse(tracker.canSpawn(mockChunk(2, 0), settings));
+ }
+
+ @Test
+ void removeFreesCapSlotsAndRemovesValidEntity() {
+ Chunk chunk = mockChunk(0, 0);
+ TextDisplay display = mockDisplay(chunk);
+ when(display.isValid()).thenReturn(true);
+
+ tracker.track(display);
+ assertFalse(tracker.canSpawn(chunk, settings));
+
+ tracker.remove(display);
+
+ assertEquals(0, tracker.getActiveCount());
+ assertTrue(tracker.canSpawn(chunk, settings));
+ verify(display).remove();
+ }
+
+ @Test
+ void removeDoesNotCallRemoveOnInvalidEntity() {
+ TextDisplay display = mockDisplay(mockChunk(0, 0));
+ when(display.isValid()).thenReturn(false);
+
+ tracker.track(display);
+ tracker.remove(display);
+
+ verify(display, never()).remove();
+ assertEquals(0, tracker.getActiveCount());
+ }
+
+ @Test
+ void removeAllClearsEveryTrackedDisplay() {
+ TextDisplay first = mockDisplay(mockChunk(0, 0));
+ TextDisplay second = mockDisplay(mockChunk(1, 0));
+ when(first.isValid()).thenReturn(true);
+ when(second.isValid()).thenReturn(true);
+
+ tracker.track(first);
+ tracker.track(second);
+
+ tracker.removeAll();
+
+ assertEquals(0, tracker.getActiveCount());
+ verify(first).remove();
+ verify(second).remove();
+ assertTrue(tracker.canSpawn(mockChunk(0, 0), settings));
+ }
+
+ @Test
+ void zeroCapsDisableLimiting() {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("displayText.maxTotal", 0);
+ config.set("displayText.maxPerChunk", 0);
+ settings.reload(config, null);
+
+ Chunk chunk = mockChunk(0, 0);
+ tracker.track(mockDisplay(chunk));
+ tracker.track(mockDisplay(chunk));
+ tracker.track(mockDisplay(chunk));
+
+ assertTrue(tracker.canSpawn(chunk, settings));
+ }
+
+ private Chunk mockChunk(int x, int z) {
+ Chunk chunk = mock(Chunk.class);
+ lenient().when(chunk.getWorld()).thenReturn(world);
+ lenient().when(chunk.getX()).thenReturn(x);
+ lenient().when(chunk.getZ()).thenReturn(z);
+ return chunk;
+ }
+
+ private TextDisplay mockDisplay(Chunk chunk) {
+ TextDisplay display = mock(TextDisplay.class);
+ Location location = mock(Location.class);
+ lenient().when(display.getLocation()).thenReturn(location);
+ lenient().when(location.getChunk()).thenReturn(chunk);
+ return display;
+ }
+}
diff --git a/src/test/java/com/danlegt/damagedisplay/Services/PluginSettingsTest.java b/src/test/java/com/danlegt/damagedisplay/Services/PluginSettingsTest.java
new file mode 100644
index 0000000..4bb4127
--- /dev/null
+++ b/src/test/java/com/danlegt/damagedisplay/Services/PluginSettingsTest.java
@@ -0,0 +1,113 @@
+package com.danlegt.damagedisplay.Services;
+
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PluginSettingsTest {
+
+ @Test
+ void reloadAppliesConfiguredValues() {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("actionbar.damageDealt.enabled", false);
+ config.set("actionbar.damageReceived.enabled", false);
+ config.set("displayText.enabled", false);
+ config.set("displayText.lingertime", 25);
+ config.set("displayText.nearbyRange", 32.5);
+ config.set("displayText.onlyFromPlayers", true);
+ config.set("displayText.maxTotal", 12);
+ config.set("displayText.maxPerChunk", 3);
+ config.set("displayText.viewerScoped", false);
+
+ PluginSettings settings = new PluginSettings();
+ settings.reload(config, null);
+
+ assertFalse(settings.isActionBarDamageDealtEnabled());
+ assertFalse(settings.isActionBarDamageReceivedEnabled());
+ assertFalse(settings.isDisplayTextEnabled());
+ assertEquals(25, settings.getLingerTime());
+ assertEquals(32.5, settings.getNearbyRange());
+ assertTrue(settings.isOnlyFromPlayers());
+ assertEquals(12, settings.getMaxTotal());
+ assertEquals(3, settings.getMaxPerChunk());
+ assertFalse(settings.isViewerScoped());
+ }
+
+ @Test
+ void reloadUsesDefaultsWhenKeysAreMissing() {
+ PluginSettings settings = new PluginSettings();
+ settings.reload(new YamlConfiguration(), null);
+
+ assertTrue(settings.isActionBarDamageDealtEnabled());
+ assertTrue(settings.isActionBarDamageReceivedEnabled());
+ assertTrue(settings.isDisplayTextEnabled());
+ assertEquals(10, settings.getLingerTime());
+ assertEquals(16.0, settings.getNearbyRange());
+ assertFalse(settings.isOnlyFromPlayers());
+ assertEquals(64, settings.getMaxTotal());
+ assertEquals(8, settings.getMaxPerChunk());
+ assertTrue(settings.isViewerScoped());
+ }
+
+ @Test
+ void reloadClampsInvalidLingerTimeAndWarnsOnce() {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("displayText.lingertime", 0);
+
+ CapturingLogger logger = new CapturingLogger();
+ PluginSettings settings = new PluginSettings();
+ settings.reload(config, logger);
+
+ assertEquals(1, settings.getLingerTime());
+ assertEquals(1, logger.warnings.size());
+ assertTrue(logger.warnings.getFirst().contains("linger time"));
+ }
+
+ @Test
+ void reloadClampsNegativeCapsToZero() {
+ YamlConfiguration config = new YamlConfiguration();
+ config.set("displayText.maxTotal", -5);
+ config.set("displayText.maxPerChunk", -2);
+
+ PluginSettings settings = new PluginSettings();
+ settings.reload(config, null);
+
+ assertEquals(0, settings.getMaxTotal());
+ assertEquals(0, settings.getMaxPerChunk());
+ }
+
+ private static final class CapturingLogger extends Logger {
+ private final List warnings = new ArrayList<>();
+
+ private CapturingLogger() {
+ super("plugin-settings-test", null);
+ setUseParentHandlers(false);
+ addHandler(new Handler() {
+ @Override
+ public void publish(LogRecord record) {
+ if (record.getLevel() == Level.WARNING) {
+ warnings.add(record.getMessage());
+ }
+ }
+
+ @Override
+ public void flush() {
+ }
+
+ @Override
+ public void close() {
+ }
+ });
+ }
+ }
+}