From 693f157bf7bfb0a58b363e1f1b27d6c4d8cd8c4d Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 20 Jul 2026 17:48:44 +0300 Subject: [PATCH 1/2] Updated project dependencies and configurations for compatibility with Java 21 and Spigot API 26.2. Added new services for display tracking and plugin settings management. Enhanced action bar functionality and improved command handling. Introduced unit tests for new features and refactored existing code for better performance and maintainability. --- pom.xml | 40 ++++- .../damagedisplay/Commands/ReloadCommand.java | 1 + .../damagedisplay/Commands/ToggleCommand.java | 6 +- .../danlegt/damagedisplay/DamageDisplay.java | 16 +- .../Event/EventListeners/OnDamageHandler.java | 142 +++++++++------- .../damagedisplay/Services/ActionBar.java | 27 ++- .../Services/DisplayTracker.java | 105 ++++++++++++ .../Services/PluginSettings.java | 80 +++++++++ src/main/resources/config.yml | 7 +- src/main/resources/plugin.yml | 2 +- .../damagedisplay/Services/ActionBarTest.java | 34 ++++ .../Services/DisplayTrackerTest.java | 154 ++++++++++++++++++ .../Services/PluginSettingsTest.java | 113 +++++++++++++ 13 files changed, 651 insertions(+), 76 deletions(-) create mode 100644 src/main/java/com/danlegt/damagedisplay/Services/DisplayTracker.java create mode 100644 src/main/java/com/danlegt/damagedisplay/Services/PluginSettings.java create mode 100644 src/test/java/com/danlegt/damagedisplay/Services/ActionBarTest.java create mode 100644 src/test/java/com/danlegt/damagedisplay/Services/DisplayTrackerTest.java create mode 100644 src/test/java/com/danlegt/damagedisplay/Services/PluginSettingsTest.java 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() { + } + }); + } + } +} From 89924ef03e7f71dadf7a0d6c802eb9230ac68b35 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 20 Jul 2026 18:01:55 +0300 Subject: [PATCH 2/2] Add CI and Release workflows for automated testing and deployment - Introduced a CI workflow to run tests on pull requests and manual triggers. - Added a Release workflow to handle versioning, building, and creating GitHub releases upon tagging. - Both workflows utilize JDK 21 and Maven for building and testing the project. --- .github/workflows/ci.yml | 24 ++++++++++++++ .github/workflows/release.yml | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml 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 }}