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() { + } + }); + } + } +}