diff --git a/README.md b/README.md index c07e3e0..0b2ac6f 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,66 @@ # NoDespawn-OG -![Icon](https://raw.githubusercontent.com/NotAlexNoyle/NoDespawn-OG/master/assets/nodespawn-og-logo-small.png) +![Icon](https://raw.githubusercontent.com/true-og/NoDespawn-OG/master/assets/nodespawn-og-logo-small.png) -A more sophisticated fork of NoDespawn made for the [TrueOG](https://true-og.net/) Network designed to replace the proprietary ClearLag plugin and its extension SaveDeathDrops. +A sophisticated fork of [NoDespawn](https://github.com/rydklein/NoDespawn) made for [TrueOG Network](https://true-og.net/) designed to replace closed-source ClearLag plugins and SaveDeathDrops. -Current Target: Purpur 1.18.2 +# Target -Authors: BadPylot, NotAlexNoyle. +Purpur 1.19.4 -To build: +# Authors -`./gradlew build` +BadPylot, NotAlexNoyle. + +# Features + +- Change the amount of time it takes for dropped items to despawn. + +- Chat warnings for when dropped items are soon to despawn. + +- Stop player death piles from despawning, or change the amount of time it takes for them to despawn. + +- Anti-abuse measures to stop players from using death piles to create lag machines. + +- Clear dropped items with an in-game command (/clearentities). + +- See how much time is left until entity cleanup with an in-game command (/cleanupin). + +# Permissions + +Clear dropped entities with a command (/clearentities): + +nodespawnog.clearentities + +See how much time is left until entity cleanup (/cleanupin): + +nodespawnog.cleanupin + +# Building + +`./gradlew build clean eclipse` The resulting .jar file will be in build/libs/ -*Version 3.0 Changelog:* +Running the plugin on your server requires [Utilities-OG](https://github.com/true-og/Utilities-OG) to be installed alongside it. + +# Changelog + +*Version 4.0:* + +- Migrated from NBT-API to a PersistentDataContainer implementation that works reliably on 1.19.4. + +- Added per-player-per-chunk death pile limiting to prevent death piles from being used as a lag weapon. + +- Added /clearentities to instantly clear non-death-pile dropped items in all loaded chunks. + +- Added automatic "entity cleanup" for loaded chunks every 30 minutes with in-game countdown warnings. + +- Improved how item despawn timing is tracked. + +- Performance optimizations. + +*Version 3.0:* - Setting the age now applies to all items. @@ -22,7 +68,7 @@ The resulting .jar file will be in build/libs/ - Added support for human readable time format. -*Version 2.1 Changelog:* +*Version 2.1:* - Fixed NBT-API import in gradle. @@ -30,14 +76,12 @@ The resulting .jar file will be in build/libs/ - Fixed and enhanced despawn time settings. -*Version: 2.0 Changelog:* +*Version: 2.0:* - Adopted GPLv3. - General refactor. -- Documented the code line-by-line. - - Migrated from maven to gradle. - Updated NBT-API. @@ -46,16 +90,4 @@ The resulting .jar file will be in build/libs/ - More efficient entity check. -*Features:* - -- Prevent player death piles from despawning. - -- Change the amount of time it takes for items to despawn. - -- Set in-game warnings for when items are about to despawn. (Coming in 3.1) - -- Clear items with an in-game command. (Coming in 3.1) - -Uses [NBTAPI](https://github.com/tr7zw/Item-NBT-API). - **Licensed under the GPLv3.** diff --git a/build.gradle.kts b/build.gradle.kts index 5efce8b..db3e7b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,10 @@ plugins { kotlin("jvm") version "2.1.21" // Import Kotlin JVM plugin. } +extra["kotlinAttribute"] = Attribute.of("kotlin-tag", Boolean::class.javaObjectType) + +val kotlinAttribute: Attribute by rootProject.extra + /* --------------------------- JDK / Kotlin ---------------------------- */ java { sourceCompatibility = JavaVersion.VERSION_17 // Compile with JDK 17 compatibility. @@ -23,7 +27,7 @@ kotlin { jvmToolchain(17) } /* ----------------------------- Metadata ------------------------------ */ group = "net.trueog.nodespawn-og" // Declare bundle identifier. -version = "3.0" // Declare plugin version (will be in .jar). +version = "4.0" // Declare plugin version (will be in .jar). val apiVersion = "1.19" // Declare minecraft server target version. @@ -40,17 +44,16 @@ repositories { mavenCentral() // Import the Maven Central Maven Repository. gradlePluginPortal() // Import the Gradle Plugin Portal Maven Repository. maven { url = uri("https://repo.purpurmc.org/snapshots") } // Import the PurpurMC Maven Repository. - maven { url = uri("https://repo.codemc.org/repository/maven-public/") } // Import the CodeMC Maven Repository. } /* ---------------------- Java project deps ---------------------------- */ dependencies { compileOnly("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT") // Declare Purpur API version to be packaged. - compileOnly("io.github.miniplaceholders:miniplaceholders-api:2.2.3") // Import MiniPlaceholders API. compileOnlyApi(project(":libs:Utilities-OG")) // Import TrueOG Network Utilities-OG Java API (from source). - implementation("de.tr7zw:item-nbt-api:2.11.1") // Declare NBT-API version to be packaged. } +apply(from = "eclipse.gradle.kts") // Import eclipse classpath support script. + /* ---------------------- Reproducible jars ---------------------------- */ tasks.withType().configureEach { // Ensure reproducible .jars isPreserveFileTimestamps = false @@ -60,10 +63,8 @@ tasks.withType().configureEach { // Ensure reproducible .ja /* ----------------------------- Shadow -------------------------------- */ tasks.shadowJar { exclude("io.github.miniplaceholders.*") // Exclude the MiniPlaceholders package from being shadowed. - relocate( - "de.tr7zw", - "net.trueog.nodespawnog.item-nbt-api", - ) // Prevent conflicts with other NBT-API utilizing plugins + isEnableRelocation = true + relocationPrefix = "${project.group}.shadow" archiveClassifier.set("") // Use empty string instead of null. minimize() } @@ -107,3 +108,13 @@ tasks.named("compileJava") { tasks.named("spotlessCheck") { dependsOn("spotlessApply") // Run spotless before checking if spotless ran. } + +/* ------------------------------ Eclipse SHIM ------------------------- */ + +// This can't be put in eclipse.gradle.kts because Gradle is weird. +subprojects { + apply(plugin = "java-library") + apply(plugin = "eclipse") + eclipse.project.name = "${project.name}-${rootProject.name}" + tasks.withType().configureEach { archiveBaseName.set("${project.name}-${rootProject.name}") } +} diff --git a/eclipse.gradle.kts b/eclipse.gradle.kts new file mode 100644 index 0000000..ee5eb09 --- /dev/null +++ b/eclipse.gradle.kts @@ -0,0 +1,151 @@ +/* This is free and unencumbered software released into the public domain */ + +import org.gradle.plugins.ide.eclipse.model.EclipseModel +import java.nio.file.Files +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +val kotlinAttribute: Attribute by rootProject.extra + +val kotlinPluginProjects = configurations +.getByName("compileClasspath") +.allDependencies + .filterIsInstance() + .filter { it -> it.attributes.getAttribute(kotlinAttribute) != null } + .map { it.path } +kotlinPluginProjects.forEach { evaluationDependsOn(it) } + +val ideLibs: Configuration by +configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false +} + +configure { + project { + name = "NoDespawn-OG-Plugin" + } + classpath { + plusConfigurations.add(ideLibs) + } +} + +val ideLibDir = layout.buildDirectory.dir("ide-libs") +val hashRegex = Regex("-[0-9a-fA-F]{10}(?=\\.jar$)") // Black magic. + +val copyTasks = mutableListOf>() + +kotlinPluginProjects.forEach { path -> + val sub = project(path) + val shadowJarProv = sub.tasks.named("shadowJar") + val copyTask = + tasks.register("ideCopy${sub.name.replaceFirstChar(Char::titlecase)}") { + dependsOn(shadowJarProv) + from(shadowJarProv) + into(ideLibDir) + rename { it.replace(hashRegex, "") } // Remove git commit hash from jarfile. + } + copyTasks += copyTask +} + +/* Ensure the jars exist before .classpath is generated */ +tasks.named("eclipseClasspath").configure { dependsOn(copyTasks) } + +/* Supply those jars to the ideLibs configuration (after evaluation) */ +afterEvaluate { dependencies { add("ideLibs", fileTree(ideLibDir) { include("*.jar") }) } } + +val injectIdeLibs = + tasks.register("injectIdeLibs") { + dependsOn("eclipse") // run after all eclipse files are generated + doLast { + val cpFile = file(".classpath") + if (!cpFile.exists()) return@doLast + + val jars = fileTree(ideLibDir.get()) { include("*.jar") }.files.sortedBy { it.name } + if (jars.isEmpty()) return@doLast + + // Parse DOM + val dbf = DocumentBuilderFactory.newInstance() + val doc = dbf.newDocumentBuilder().parse(cpFile) + val root = doc.documentElement + + // Helper to see if an entry already exists + fun exists(path: String): Boolean = + root.getElementsByTagName("classpathentry").let { list -> + (0 until list.length).any { i -> + val n = list.item(i) + val kind = n.attributes?.getNamedItem("kind")?.nodeValue + val p = n.attributes?.getNamedItem("path")?.nodeValue + kind == "lib" && p == path + } + } + + // Remove any entry that points to ide-libs dir (folder or jars) to avoid dupes + val toRemove = mutableListOf() + val list = root.getElementsByTagName("classpathentry") + val dirPath = ideLibDir.get().asFile.absolutePath + for (i in 0 until list.length) { + val n = list.item(i) + val kind = n.attributes?.getNamedItem("kind")?.nodeValue + val p = n.attributes?.getNamedItem("path")?.nodeValue ?: "" + if (kind == "lib" && (p == dirPath || p.startsWith("$dirPath/"))) { + toRemove += n + } + } + toRemove.forEach { root.removeChild(it) } + + // Append our jar entries LAST + jars.forEach { f -> + val abs = f.absolutePath + if (!exists(abs)) { + val entry = doc.createElement("classpathentry") + entry.setAttribute("kind", "lib") + entry.setAttribute("path", abs) + root.appendChild(entry) + } + } + + // Write back pretty + val tf = + TransformerFactory.newInstance().newTransformer().apply { + setOutputProperty(OutputKeys.INDENT, "yes") + setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "1") + setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no") + setOutputProperty(OutputKeys.ENCODING, "UTF-8") + } + Files.newBufferedWriter(cpFile.toPath()).use { w -> tf.transform(DOMSource(doc), StreamResult(w)) } + } + } + +tasks.named("eclipse").configure { finalizedBy(injectIdeLibs) } + +val eclipseModel = extensions.getByType() + +fun Project.addResolvableEclipseConfigs() { + val jarAttr = + objects.named(org.gradle.api.attributes.LibraryElements::class, org.gradle.api.attributes.LibraryElements.JAR) + val apiAttr = objects.named(org.gradle.api.attributes.Usage::class, org.gradle.api.attributes.Usage.JAVA_API) + + val compileOnlyRes = + configurations.create("eclipseCompileOnly") { + extendsFrom(configurations.getByName("compileOnly")) + isCanBeResolved = true + isCanBeConsumed = false + attributes.attribute(org.gradle.api.attributes.Usage.USAGE_ATTRIBUTE, apiAttr) + attributes.attribute(org.gradle.api.attributes.LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, jarAttr) + } + val compileOnlyApiRes = + configurations.create("eclipseCompileOnlyApi") { + extendsFrom(configurations.getByName("compileOnlyApi")) + isCanBeResolved = true + isCanBeConsumed = false + attributes.attribute(org.gradle.api.attributes.Usage.USAGE_ATTRIBUTE, apiAttr) + attributes.attribute(org.gradle.api.attributes.LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, jarAttr) + } + eclipseModel.classpath.plusConfigurations.addAll(listOf(compileOnlyRes, compileOnlyApiRes)) +} + +addResolvableEclipseConfigs() diff --git a/src/main/java/net/trueog/nodespawnog/CleanupInCommand.java b/src/main/java/net/trueog/nodespawnog/CleanupInCommand.java new file mode 100644 index 0000000..8eca8cf --- /dev/null +++ b/src/main/java/net/trueog/nodespawnog/CleanupInCommand.java @@ -0,0 +1,79 @@ +package net.trueog.nodespawnog; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import net.trueog.utilitiesog.UtilitiesOG; + +public class CleanupInCommand implements CommandExecutor { + + private final EntityCleanupScheduler entityCleanupScheduler; + + public CleanupInCommand(EntityCleanupScheduler cleanerUpper) { + + entityCleanupScheduler = cleanerUpper; + + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, + @NotNull String[] args) + { + + if (!(sender instanceof Player player)) { + + return true; + + } + + if (!sender.hasPermission("nodespawnog.cleanupin") && !sender.isOp()) { + + UtilitiesOG.trueogMessage(player, "&cERROR: You do not have permission to use that command."); + + return true; + + } + + final long seconds = entityCleanupScheduler.getSecondsRemaining(); + + UtilitiesOG.trueogMessage(player, + NoDespawnOG.getPrefix() + "&6Entity cleanup in " + formatTime(seconds) + "&6."); + + return true; + + } + + private static String unit(long v, String singular, String plural) { + + return (v == 1L) ? singular : plural; + + } + + private static String formatTime(long totalSeconds) { + + totalSeconds = Math.max(0L, totalSeconds); + + final long minutes = totalSeconds / 60L; + final long seconds = totalSeconds % 60L; + + if (minutes <= 0L) { + + return "&e" + seconds + " &6" + unit(seconds, "second", "seconds"); + + } + + if (seconds <= 0L) { + + return "&e" + minutes + " &6" + unit(minutes, "minute", "minutes"); + + } + + return "&e" + minutes + " &6" + unit(minutes, "minute", "minutes") + " &6and &e" + seconds + " &6" + + unit(seconds, "second", "seconds"); + + } + +} \ No newline at end of file diff --git a/src/main/java/net/trueog/nodespawnog/ClearEntitiesCommand.java b/src/main/java/net/trueog/nodespawnog/ClearEntitiesCommand.java new file mode 100644 index 0000000..29edfc5 --- /dev/null +++ b/src/main/java/net/trueog/nodespawnog/ClearEntitiesCommand.java @@ -0,0 +1,55 @@ +package net.trueog.nodespawnog; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import net.trueog.utilitiesog.UtilitiesOG; + +public class ClearEntitiesCommand implements CommandExecutor { + + EntityCleanupScheduler entityCleanupScheduler; + + public ClearEntitiesCommand(EntityCleanupScheduler cleanerUpper) { + + entityCleanupScheduler = cleanerUpper; + + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, + @NotNull String[] args) + { + + if (!sender.hasPermission("nodespawnog.clearentities") && !sender.isOp()) { + + if (sender instanceof Player player) { + + UtilitiesOG.trueogMessage(player, "&cERROR: You do not have permission to use that command."); + + } + + return true; + + } + + final int removed = entityCleanupScheduler.clearNonDeathPileDroppedItemsInLoadedChunks(); + if (sender instanceof Player player) { + + UtilitiesOG.trueogMessage(player, + NoDespawnOG.getPrefix() + "&aEntity cleanup complete. Cleared &e" + removed + "&a entities."); + + } else { + + UtilitiesOG.logToConsole(NoDespawnOG.getPrefix(), + "&aEntity cleanup complete. Cleared &e" + removed + "&a entities."); + + } + + return true; + + } + +} \ No newline at end of file diff --git a/src/main/java/net/trueog/nodespawnog/EntityCleanupScheduler.java b/src/main/java/net/trueog/nodespawnog/EntityCleanupScheduler.java new file mode 100644 index 0000000..977fa96 --- /dev/null +++ b/src/main/java/net/trueog/nodespawnog/EntityCleanupScheduler.java @@ -0,0 +1,312 @@ +package net.trueog.nodespawnog; + +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Item; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.scheduler.BukkitRunnable; + +public class EntityCleanupScheduler extends BukkitRunnable { + + private static final int DEFAULT_INTERVAL_TICKS = 30 * 60 * 20; + private static final int STEP_TICKS = 20; + private static final int TICKS_PER_SECOND = 20; + private static final int WARN_5M_TICKS = 5 * 60 * TICKS_PER_SECOND; // 6000 + private static final int WARN_2M_TICKS = 2 * 60 * TICKS_PER_SECOND; // 2400 + private static final int WARN_1M_TICKS = 1 * 60 * TICKS_PER_SECOND; // 1200 + private static final int WARN_30S_TICKS = 30 * TICKS_PER_SECOND; // 600 + private static final int WARN_10S_TICKS = 10 * TICKS_PER_SECOND; // 200 + private static final int WARN_3S_TICKS = 3 * TICKS_PER_SECOND; // 60 + private static final int WARN_2S_TICKS = 2 * TICKS_PER_SECOND; // 40 + private static final int WARN_1S_TICKS = 1 * TICKS_PER_SECOND; // 20 + private final int intervalTicks; + private int ticksRemaining; + private final Set warningTicks = new HashSet<>(); + private final NamespacedKey keyDeath = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_death"); + + public EntityCleanupScheduler() { + + int raw = Math.max(STEP_TICKS, readIntervalTicks()); + raw = alignToStepCeiling(raw); + + this.intervalTicks = raw; + this.ticksRemaining = intervalTicks; + + initWarningTicks(); + + } + + private int readIntervalTicks() { + + final String raw = NoDespawnOG.getPlugin().getConfig().getString("entity-cleanup-interval", "30m"); + + return parseDurationToTicks(raw, DEFAULT_INTERVAL_TICKS); + + } + + // Parses time units "30m", "10s", "1h", "6000" into ticks. + private static int parseDurationToTicks(String raw, int fallbackTicks) { + + if (raw == null) { + + return fallbackTicks; + + } + + final String s = StringUtils.trim(raw); + if (StringUtils.isEmpty(s)) { + + return fallbackTicks; + + } + + try { + + final String unit = StringUtils.lowerCase(s.replaceAll("[\\d\\s]", ""), Locale.ROOT); + final String digits = s.replaceAll("[^0-9]", ""); + if (StringUtils.isEmpty(digits)) { + + return fallbackTicks; + + } + + final long value = Long.parseLong(digits); + + final long ticks = switch (unit) { + + case "s" -> value * 20L; + case "m" -> value * 20L * 60L; + case "h" -> value * 20L * 60L * 60L; + default -> value; + + }; + + if (ticks <= 0L) { + + return fallbackTicks; + + } + + if (ticks > Integer.MAX_VALUE) { + + return Integer.MAX_VALUE; + + } + + return (int) ticks; + + } catch (Exception error) { + + return fallbackTicks; + + } + + } + + private static int alignToStepCeiling(int ticks) { + + final int q = (ticks + STEP_TICKS - 1) / STEP_TICKS; + final long aligned = (long) q * (long) STEP_TICKS; + + return aligned > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) aligned; + + } + + private static int alignToStepNearest(int ticks) { + + final int q = (ticks + (STEP_TICKS / 2)) / STEP_TICKS; + + return q * STEP_TICKS; + + } + + // Dynamic warnings. + private void initWarningTicks() { + + warningTicks.clear(); + + addWarning(WARN_5M_TICKS); + addWarning(WARN_2M_TICKS); + addWarning(WARN_1M_TICKS); + addWarning(WARN_30S_TICKS); + addWarning(WARN_10S_TICKS); + addWarning(WARN_3S_TICKS); + addWarning(WARN_2S_TICKS); + addWarning(WARN_1S_TICKS); + + // Scaling: if interval is under 5 minutes, add relative warnings at 50% and 25% + if (intervalTicks >= WARN_5M_TICKS) { + + return; + + } + + // 50% + addRelativeWarning(intervalTicks / 2); + + // 25% + addRelativeWarning(intervalTicks / 4); + + } + + private void addWarning(int ticks) { + + // After reset, first checked value is (intervalTicks - STEP_TICKS). + if (ticks < STEP_TICKS) { + + return; + + } + + if (ticks > intervalTicks - STEP_TICKS) { + + return; + + } + + warningTicks.add(ticks); + + } + + private void addRelativeWarning(int rawTicks) { + + final int t = alignToStepNearest(rawTicks); + if (t <= WARN_10S_TICKS) { + + return; + + } + + addWarning(t); + + } + + @Override + public void run() { + + ticksRemaining -= STEP_TICKS; + if (ticksRemaining > 0 && warningTicks.contains(ticksRemaining)) { + + // 3... 2... ...1 Countdown. + if (ticksRemaining == WARN_3S_TICKS || ticksRemaining == WARN_2S_TICKS || ticksRemaining == WARN_1S_TICKS) { + + final long sec = ticksRemaining / 20L; + + NoDespawnOG.broadcastCleanupMessage(NoDespawnOG.getPrefix() + "&6Entity cleanup in &e" + sec + "&6..."); + + return; + + } + + // Dynamic regular entity cleanup broadcast. + NoDespawnOG.broadcastCleanupMessage( + NoDespawnOG.getPrefix() + "&6Entity cleanup in " + formatDurationTicks(ticksRemaining) + "&6."); + + return; + + } + + if (ticksRemaining > 0) { + + return; + + } + + final int removed = clearNonDeathPileDroppedItemsInLoadedChunks(); + + // Success broadcast. + NoDespawnOG.broadcastCleanupMessage( + NoDespawnOG.getPrefix() + "&aEntity cleanup complete. Cleared &e" + removed + "&a entities."); + + ticksRemaining = intervalTicks; + + } + + // Parses ticks into a human-readable time string. + private static String formatDurationTicks(int ticks) { + + final long totalSeconds = ticks / 20L; + final long minutes = totalSeconds / 60L; + final long seconds = totalSeconds % 60L; + if (minutes <= 0L) { + + return "&e" + seconds + " &6" + (seconds == 1L ? "second" : "seconds"); + + } + + if (seconds == 0L) { + + return "&e" + minutes + " &6" + (minutes == 1L ? "minute" : "minutes"); + + } + + return "&e" + minutes + " &6" + (minutes == 1L ? "minute" : "minutes") + " &e" + seconds + " &6" + + (seconds == 1L ? "second" : "seconds"); + + } + + int clearNonDeathPileDroppedItemsInLoadedChunks() { + + int removed = 0; + final List worlds = Bukkit.getWorlds(); + for (World world : worlds) { + + for (Chunk chunk : world.getLoadedChunks()) { + + for (Entity entity : chunk.getEntities()) { + + if (!(entity instanceof Item item) || !item.isValid()) { + + continue; + + } + + final Byte deathFlag = item.getPersistentDataContainer().get(keyDeath, PersistentDataType.BYTE); + + if (deathFlag == null || deathFlag.byteValue() != (byte) 1) { + + item.remove(); + + removed++; + + } + + } + + } + + } + + return removed; + + } + + int getIntervalTicks() { + + return intervalTicks; + + } + + int getTicksRemaining() { + + return Math.max(0, ticksRemaining); + + } + + long getSecondsRemaining() { + + // Round up so /cleanupin doesn't show "0s". + return (getTicksRemaining() + 19L) / 20L; + + } + +} \ No newline at end of file diff --git a/src/main/java/net/trueog/nodespawnog/EventListener.java b/src/main/java/net/trueog/nodespawnog/EventListener.java index a78e35a..4098f5c 100644 --- a/src/main/java/net/trueog/nodespawnog/EventListener.java +++ b/src/main/java/net/trueog/nodespawnog/EventListener.java @@ -1,9 +1,17 @@ package net.trueog.nodespawnog; -import de.tr7zw.changeme.nbtapi.NBTItem; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.apache.commons.lang3.StringUtils; +import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.NamespacedKey; -import org.bukkit.World; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.entity.Entity; import org.bukkit.entity.Item; @@ -12,208 +20,604 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.entity.ItemDespawnEvent; +import org.bukkit.event.entity.ItemSpawnEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.event.world.WorldLoadEvent; -import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataContainer; import org.bukkit.persistence.PersistentDataType; -// Class to watch for in-game events. public class EventListener implements Listener { - // Enable the conversion of text from config.yml to objects. - public FileConfiguration config = NoDespawnOG.getPlugin().getConfig(); + private final FileConfiguration config = NoDespawnOG.getPlugin().getConfig(); - // If despawn time is set to this, it never changes. - public short minimumValue = -32768; + private final NamespacedKey keyDeath = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_death"); + private final NamespacedKey keyOwner = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_owner"); + private final NamespacedKey keyPile = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_pile"); + private final NamespacedKey keyCreated = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_created"); + private final NamespacedKey keyExpireAt = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_expire_at"); - // Determine the despawn time using the number taken from the config file. - private short calculateDespawnTime() { + private static final int VANILLA_DESPAWN_TICKS = 6000; + private static final long TICK_MS = 50L; - // Get the user-defined despawn time from the config file. - String timeString = config.getString("time-to-death-despawns"); + @EventHandler(priority = EventPriority.LOWEST) + public void onItemSpawn(ItemSpawnEvent event) { - // If the config value is empty, do this... - if (timeString == null || timeString.isEmpty()) { - - // Default to 5 minutes (using ticks). - return Short.valueOf(String.valueOf(-1 * 6000)); - - } - - // Deconstruct and interpret the user-specified time string. - try { - - // Declare a variable to hold the time. - long time; - - // Extract the unit (s, m, h). - String unit = timeString.replaceAll("\\d", ""); - - // Extract the numeric value. - int value = Integer.parseInt(timeString.replaceAll("\\D", "")); - - switch (unit.toLowerCase()) { - - case "s": - // Convert seconds to ticks. - time = value * 20L; - break; - case "m": - // Convert minutes to ticks. - time = value * 20L * 60L; - break; - case "h": - // Convert hours to ticks. - time = value * 20L * 60L * 60L; - break; - default: - // Default to ticks if no unit is provided. - time = value; - - } - - // Start the countdown to despawn. - return Short.valueOf(String.valueOf(-1 * time)); - - } - // If the user-specified time string is in an invalid format, do this... - catch (NumberFormatException error) { - - // Default to 5 minutes (using ticks). - return Short.valueOf(String.valueOf(-1 * 6000)); - - } + ensureExpireData(event.getEntity()); } - // Prioritize this event listener over all other plugins. @EventHandler(priority = EventPriority.HIGHEST) - // Runs whenever an entity dies. - public void entityDeathEvent(EntityDeathEvent event) { + public void onItemDespawn(ItemDespawnEvent event) { - // Check if the entity is a player. - if (!(event.getEntity() instanceof Player)) { + final Item item = event.getEntity(); + final long expireAt = getExpireAt(item); + if (expireAt == 0L) { - // If the entity is not a player, skip this event. return; } - // If the player's inventory was not empty... - if (!event.getDrops().isEmpty()) { + final long now = System.currentTimeMillis(); - // Loop through every item in the death pile. - event.getDrops().forEach(itemStack -> { + if (expireAt == Long.MAX_VALUE) { - // Get each item from the dropped entities. - Item droppedItem = event.getEntity().getWorld().dropItem(event.getEntity().getLocation(), itemStack); - NBTItem nbtItem = new NBTItem(itemStack); + event.setCancelled(true); - // Set the Age tag based on the value set in the config file. - nbtItem.setShort("Age", - config.getBoolean("disable-death-despawns") ? minimumValue : calculateDespawnTime()); + item.setTicksLived(1); - // Save the modified NBT data. - PersistentDataContainer pdc = droppedItem.getPersistentDataContainer(); - NamespacedKey key = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_age"); - pdc.set(key, PersistentDataType.INTEGER, (int) nbtItem.getShort("Age")); - - // Apply the modified NBTItem back to the ItemStack and update the dropped Item. - itemStack = nbtItem.getItem(); - droppedItem.setItemStack(itemStack); - - }); - - // Clear the original drops. - event.getDrops().clear(); + return; } + // Allow despawning if its time. + if (now >= expireAt) { + + return; + + } + + event.setCancelled(true); + + scheduleVanillaDespawnCheck(item, now, expireAt); + + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void entityDeathEvent(EntityDeathEvent event) { + + if (!(event.getEntity() instanceof Player player)) { + + return; + + } + + if (event.getDrops().isEmpty()) { + + return; + + } + + enforcePlayerPerChunkLimits(player, player.getLocation().getChunk(), event.getDrops().size(), 1, getMaxPiles(), + getMaxItems()); + + final boolean disableDeathDespawns = config.getBoolean("disable-death-despawns"); + final int deathLifetimeTicks = calculateDespawnTime("time-to-death-despawns"); + + final String ownerStr = player.getUniqueId().toString(); + final String pileId = UUID.randomUUID().toString(); + final long created = System.currentTimeMillis(); + final long expireAt = disableDeathDespawns ? Long.MAX_VALUE : (created + ticksToMs(deathLifetimeTicks)); + + event.getDrops().stream().map(itemStack -> player.getWorld().dropItem(player.getLocation(), itemStack)) + .forEach(droppedItem -> + { + + final PersistentDataContainer pdc = droppedItem.getPersistentDataContainer(); + + pdc.set(keyDeath, PersistentDataType.BYTE, (byte) 1); + pdc.set(keyOwner, PersistentDataType.STRING, ownerStr); + pdc.set(keyPile, PersistentDataType.STRING, pileId); + pdc.set(keyCreated, PersistentDataType.LONG, created); + pdc.set(keyExpireAt, PersistentDataType.LONG, expireAt); + + scheduleVanillaDespawnCheck(droppedItem, created, expireAt); + + }); + + event.getDrops().clear(); + } - // Runs every time a chunk is loaded. @EventHandler public void onChunkLoad(ChunkLoadEvent event) { - // Get the chunk that was loaded. - Chunk chunk = event.getChunk(); - // Loop through every entity in the chunk. - for (Entity entity : chunk.getEntities()) { - - // If the entity is an Item, do this... - if (entity instanceof Item) { - - // Cast the entity to an Item since it is one. - Item item = (Item) entity; - - // Declare a container to hold item data. - PersistentDataContainer pdc = item.getPersistentDataContainer(); - NamespacedKey key = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_age"); - // Check for item age. - if (pdc.has(key, PersistentDataType.INTEGER)) { - - // Derive the item age. - short savedAge = pdc.get(key, PersistentDataType.INTEGER).shortValue(); - - // Get the ItemStack from the Item. - ItemStack itemStack = item.getItemStack(); - - // Apply the saved age using NBTItem. - NBTItem nbtItem = new NBTItem(itemStack); - nbtItem.setShort("Age", savedAge); - itemStack = nbtItem.getItem(); - - // Update the Item with the modified ItemStack. - item.setItemStack(itemStack); - - } - - } - - } + handleEntitiesInChunk(event.getChunk()); } - // Runs every time a world is loaded. @EventHandler public void onWorldLoad(WorldLoadEvent event) { - // Get the world that was loaded. - World world = event.getWorld(); - // Loop through all loaded chunks in the world. - for (Chunk chunk : world.getLoadedChunks()) { + for (Chunk chunk : event.getWorld().getLoadedChunks()) { - // Loop through every entity in the chunk. - for (Entity entity : chunk.getEntities()) { + handleEntitiesInChunk(chunk); - // If the entity is an Item, do this... - if (entity instanceof Item) { + } - // Cast the entity to an Item since it is one. - Item item = (Item) entity; + } - // Declare a container to hold item data. - PersistentDataContainer pdc = item.getPersistentDataContainer(); - NamespacedKey key = new NamespacedKey(NoDespawnOG.getPlugin(), "nodespawn_age"); - // Check for item age. - if (pdc.has(key, PersistentDataType.INTEGER)) { + private static long ticksToMs(long ticks) { - // Derive the item age. - short savedAge = pdc.get(key, PersistentDataType.INTEGER).shortValue(); + if (ticks <= 0) { - // Get the ItemStack from the Item. - ItemStack itemStack = item.getItemStack(); + return 0L; - // Apply the saved age using NBTItem. - NBTItem nbtItem = new NBTItem(itemStack); - nbtItem.setShort("Age", savedAge); - itemStack = nbtItem.getItem(); + } - // Update the Item with the modified ItemStack. - item.setItemStack(itemStack); + final long ms = ticks * TICK_MS; + return ms < 0 ? Long.MAX_VALUE : ms; - } + } + + private boolean protectionEnabled() { + + return config.getBoolean("death-pile-protection.enabled", true); + + } + + private boolean isDeathPileItem(Item item) { + + final Byte flag = item.getPersistentDataContainer().get(keyDeath, PersistentDataType.BYTE); + + return Byte.valueOf((byte) 1).equals(flag); + + } + + private long getExpireAt(Item item) { + + final Long v = item.getPersistentDataContainer().get(keyExpireAt, PersistentDataType.LONG); + + return v == null ? 0L : v.longValue(); + + } + + private void setExpireAt(Item item, long expireAt) { + + item.getPersistentDataContainer().set(keyExpireAt, PersistentDataType.LONG, expireAt); + + } + + private long ensureCreatedMillis(Item item, long now) { + + final PersistentDataContainer pdc = item.getPersistentDataContainer(); + final Long created = pdc.get(keyCreated, PersistentDataType.LONG); + if (created != null && created.longValue() > 0L) { + + return created.longValue(); + + } + + // Infer created time from current ticks lived (always >= 0 on Bukkit side). + final int lived = Math.max(0, item.getTicksLived()); + final long inferred = now - (long) lived * TICK_MS; + + pdc.set(keyCreated, PersistentDataType.LONG, inferred); + + return inferred; + + } + + /** + * Set ticksLived safely (>= 1) so vanilla despawn checks happen when we want. + */ + private void scheduleVanillaDespawnCheck(Item item, long now, long expireAt) { + + if (!item.isValid()) { + + return; + + } + + if (expireAt == Long.MAX_VALUE) { + + item.setTicksLived(1); + + return; + + } + + final long remainingMs = expireAt - now; + if (remainingMs <= 0L) { + + item.remove(); + + return; + + } + + final long remainingTicks = (remainingMs + (TICK_MS - 1)) / TICK_MS; + + if (remainingTicks >= VANILLA_DESPAWN_TICKS) { + + // Too far away to represent (would require negative); schedule another check + // 6000 ticks later. + item.setTicksLived(1); + + return; + + } + + int age = (int) (VANILLA_DESPAWN_TICKS - remainingTicks); + + age = Math.max(1, age); + + item.setTicksLived(age); + + } + + /** + * Ensure expireAt exists for any item we manage. + */ + private void ensureExpireData(Item item) { + + final long now = System.currentTimeMillis(); + long expireAt = getExpireAt(item); + if (expireAt != 0L) { + + scheduleVanillaDespawnCheck(item, now, expireAt); + + return; + + } + + final long created = ensureCreatedMillis(item, now); + final boolean disableDeathDespawns = config.getBoolean("disable-death-despawns"); + if (isDeathPileItem(item)) { + + if (disableDeathDespawns) { + + expireAt = Long.MAX_VALUE; + setExpireAt(item, expireAt); + + scheduleVanillaDespawnCheck(item, now, expireAt); + + return; + + } + + final int deathLifetimeTicks = calculateDespawnTime("time-to-death-despawns"); + + expireAt = created + ticksToMs(deathLifetimeTicks); + setExpireAt(item, expireAt); + + scheduleVanillaDespawnCheck(item, now, expireAt); + + return; + + } + + final int defaultLifetimeTicks = calculateDespawnTime("time-to-despawn"); + + expireAt = created + ticksToMs(defaultLifetimeTicks); + setExpireAt(item, expireAt); + + scheduleVanillaDespawnCheck(item, now, expireAt); + + } + + // Convert machine time to human time. + private int calculateDespawnTime(String configKey) { + + final String raw = config.getString(configKey); + if (raw == null) { + + return VANILLA_DESPAWN_TICKS; + + } + + final String timeString = StringUtils.trim(raw); + if (StringUtils.isEmpty(timeString)) { + + return VANILLA_DESPAWN_TICKS; + + } + + try { + + final long time; + + // Strips digits and whitespace to leave only unit characters. + final String unit = timeString.replaceAll("[\\d\\s]", ""); + + // Strips digits and whitespace to leave only digit characters. + final String digits = timeString.replaceAll("[^0-9]", ""); + + if (StringUtils.isEmpty(digits)) { + + return VANILLA_DESPAWN_TICKS; + + } + + final int value = Integer.parseInt(digits); + + time = switch (StringUtils.lowerCase(unit)) { + + case "s" -> value * 20L; + case "m" -> value * 20L * 60L; + case "h" -> value * 20L * 60L * 60L; + default -> value; + + }; + + if (time <= 0) { + + return VANILLA_DESPAWN_TICKS; + + } + + if (time > Integer.MAX_VALUE) { + + return Integer.MAX_VALUE; + + } + + return (int) time; + + } catch (NumberFormatException error) { + + return VANILLA_DESPAWN_TICKS; + + } + + } + + private boolean isProtectedDeathPileItem(Item item) { + + return isDeathPileItem(item) && getExpireAt(item) == Long.MAX_VALUE; + + } + + private String getOwnerString(Item item) { + + return item.getPersistentDataContainer().get(keyOwner, PersistentDataType.STRING); + + } + + private String getPileId(Item item) { + + final String pile = item.getPersistentDataContainer().get(keyPile, PersistentDataType.STRING); + + return (pile == null) ? item.getUniqueId().toString() : pile; + + } + + private long getCreated(Item item) { + + final Long created = item.getPersistentDataContainer().get(keyCreated, PersistentDataType.LONG); + + return created == null ? 0L : created.longValue(); + + } + + private void timeoutPile(PileGroup group, int overflowLifetimeTicks) { + + if (group == null) { + + return; + + } + + final long now = System.currentTimeMillis(); + final long expireAt = now + ticksToMs(overflowLifetimeTicks); + for (Iterator it = group.items.iterator(); it.hasNext();) { + + final Item item = it.next(); + if (!item.isValid()) { + + it.remove(); + + continue; + + } + + setExpireAt(item, expireAt); + scheduleVanillaDespawnCheck(item, now, expireAt); + + } + + group.protectedPile = false; + group.protectedItemCount = 0; + + } + + private void enforcePlayerPerChunkLimits(Player player, Chunk chunk, int reserveItems, int reservePiles, + int maxPiles, int maxItems) + { + + if (!protectionEnabled()) { + + return; + + } + + if (!config.getBoolean("disable-death-despawns")) { + + return; + + } + + if (maxPiles == 0 && maxItems == 0) { + + return; + + } + + final int overflowLifetimeTicks = calculateDespawnTime("death-pile-protection.overflow-despawn"); + final String owner = player.getUniqueId().toString(); + + final List ownedDeathItems = new ArrayList<>(); + for (Entity entity : chunk.getEntities()) { + + if (!(entity instanceof Item item)) { + + continue; + + } + + if (!isDeathPileItem(item)) { + + continue; + + } + + final String o = getOwnerString(item); + if (o != null && o.equals(owner)) { + + ownedDeathItems.add(item); + + } + + } + + enforceOnOwnerChunkItems(ownedDeathItems, maxPiles, maxItems, reservePiles, reserveItems, + overflowLifetimeTicks); + + } + + private void enforceOnOwnerChunkItems(List items, int maxPiles, int maxItems, int reservePiles, + int reserveItems, int overflowLifetimeTicks) + { + + if (items.isEmpty()) { + + return; + + } + + final int targetPiles = maxPiles > 0 ? Math.max(0, maxPiles - reservePiles) : Integer.MAX_VALUE; + final int targetItems = maxItems > 0 ? Math.max(0, maxItems - reserveItems) : Integer.MAX_VALUE; + + final Map groups = new HashMap<>(); + + int protectedPiles = 0; + int protectedItems = 0; + + for (Item item : items) { + + if (!item.isValid()) { + + continue; + + } + + // Never sets ticksLived < 1 or else bukkit freaks out. + ensureExpireData(item); + + final String pileId = getPileId(item); + PileGroup group = groups.get(pileId); + if (group == null) { + + group = new PileGroup(); + group.created = getCreated(item); + groups.put(pileId, group); + + } + + group.items.add(item); + group.created = Math.min(group.created, getCreated(item)); + + if (isProtectedDeathPileItem(item)) { + + group.protectedItemCount++; + group.protectedPile = true; + + } + + } + + for (PileGroup group : groups.values()) { + + if (group.protectedPile) { + + protectedPiles++; + protectedItems += group.protectedItemCount; + + } + + } + + if (protectedPiles <= targetPiles && protectedItems <= targetItems) { + + return; + + } + + final List ordered = new ArrayList<>(groups.values()); + ordered.sort(Comparator.comparingLong(g -> g.created)); + + int i = 0; + while ((protectedPiles > targetPiles || protectedItems > targetItems) && i < ordered.size()) { + + final PileGroup g = ordered.get(i++); + if (!g.protectedPile) { + + continue; + + } + + protectedPiles--; + protectedItems -= g.protectedItemCount; + + timeoutPile(g, overflowLifetimeTicks); + + } + + } + + private static final class PileGroup { + + private long created; + private boolean protectedPile; + private int protectedItemCount; + private final List items = new ArrayList<>(); + + private PileGroup() { + + this.created = Long.MAX_VALUE; + + } + + } + + private void handleEntitiesInChunk(Chunk chunk) { + + final int maxPiles = getMaxPiles(); + final int maxItems = getMaxItems(); + final int overflowLifetimeTicks = calculateDespawnTime("death-pile-protection.overflow-despawn"); + + final Map> ownedDeathItems = new HashMap<>(); + + for (Entity entity : chunk.getEntities()) { + + if (!(entity instanceof Item item)) { + + continue; + + } + + // Never sets ticksLived < 1 or else bukkit freaks out. + ensureExpireData(item); + + final boolean condition = protectionEnabled() && config.getBoolean("disable-death-despawns") + && (maxPiles > 0 || maxItems > 0) && isDeathPileItem(item); + + if (condition) { + + final String owner = getOwnerString(item); + if (owner != null) { + + ownedDeathItems.computeIfAbsent(owner, k -> new ArrayList<>()).add(item); } @@ -221,6 +625,39 @@ public class EventListener implements Listener { } + if (protectionEnabled() && config.getBoolean("disable-death-despawns") && (maxPiles > 0 || maxItems > 0)) { + + ownedDeathItems.values() + .forEach(list -> enforceOnOwnerChunkItems(list, maxPiles, maxItems, 0, 0, overflowLifetimeTicks)); + + } + } -} + final int getMaxPiles() { + + return config.getInt("death-pile-protection.max-piles-per-player-per-chunk", 12); + + } + + final int getMaxItems() { + + return config.getInt("death-pile-protection.max-items-per-player-per-chunk", 1000); + + } + + public void scanLoadedChunks() { + + Bukkit.getWorlds().forEach(world -> { + + for (Chunk chunk : world.getLoadedChunks()) { + + handleEntitiesInChunk(chunk); + + } + + }); + + } + +} \ No newline at end of file diff --git a/src/main/java/net/trueog/nodespawnog/NoDespawnOG.java b/src/main/java/net/trueog/nodespawnog/NoDespawnOG.java index 774e309..ea5296a 100644 --- a/src/main/java/net/trueog/nodespawnog/NoDespawnOG.java +++ b/src/main/java/net/trueog/nodespawnog/NoDespawnOG.java @@ -1,13 +1,22 @@ package net.trueog.nodespawnog; -import org.bukkit.plugin.Plugin; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; -// init main. +import net.trueog.utilitiesog.UtilitiesOG; + +// Init main. public class NoDespawnOG extends JavaPlugin { - // Declare plugin instance. + // Declare fields. private static NoDespawnOG plugin; + private EntityCleanupScheduler cleanupScheduler; + private EventListener eventListener; + public static List serverWorlds; // This gets run when the plugin starts. @Override @@ -16,14 +25,38 @@ public class NoDespawnOG extends JavaPlugin { // Set plugin instance. plugin = this; - // Register events so that they get run by the server. - getServer().getPluginManager().registerEvents(new EventListener(), (Plugin) this); - // Create config file if it doesn't exist. saveDefaultConfig(); + // Start the entity cleanup scheduler. + cleanupScheduler = new EntityCleanupScheduler(); + cleanupScheduler.runTaskTimer(this, 20L, 20L); + + // Register events so that they get run by the server and its functions are + // callable by this class. + eventListener = new EventListener(); + getServer().getPluginManager().registerEvents(eventListener, this); + + // Scan chunks that were loaded before the plugin got enabled for entities to + // track. + Bukkit.getScheduler().runTask(this, eventListener::scanLoadedChunks); + + // Initialize the /clearentities command. + if (getCommand("clearentities") != null) { + + getCommand("clearentities").setExecutor(new ClearEntitiesCommand(cleanupScheduler)); + + } + + // Initialize the /cleanupin command. + if (getCommand("cleanupin") != null) { + + getCommand("cleanupin").setExecutor(new CleanupInCommand(cleanupScheduler)); + + } + // Log valid plugin launch to console. - getLogger().info("NoDespawn 2.2 Loaded."); + getLogger().info("NoDespawn-OG 4.0 Loaded."); } @@ -31,18 +64,45 @@ public class NoDespawnOG extends JavaPlugin { @Override public void onDisable() { + if (cleanupScheduler != null) { + + cleanupScheduler.cancel(); + cleanupScheduler = null; + + } + + eventListener = null; + // Log valid plugin shut-down to console. - getLogger().info("NoDespawn 2.2 Shut Down without errors."); + UtilitiesOG.logToConsole(getPrefix(), "Shut down without errors."); } - // Accessor constructor so that the main class (this) can be referenced from - // other classes. + // Getter for the plugin instance running on the server. public static NoDespawnOG getPlugin() { - // Pass instance of main. return plugin; } -} + public static String getPrefix() { + + return "&7[&eNoDespawn&f-&4OG&7] &r"; + + } + + public static List getServerWorlds() { + + serverWorlds = Bukkit.getWorlds(); + + return serverWorlds; + + } + + public static void broadcastCleanupMessage(String message) { + + Bukkit.getOnlinePlayers().forEach((Player player) -> UtilitiesOG.trueogMessage(player, message)); + + } + +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 6c71d54..ae4a7a4 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,14 +1,34 @@ -# NoDespawn-OG 3.0 Config. +# NoDespawn-OG 4.0 Configuration File. +# +# Time format: +# - ticks (6000 = 5m): 6000 +# - seconds: 10s +# - minutes: 30m +# - hours: 1h # Should item despawning be completely disabled on player death? +# If true, death pile items won't despawn unless limited by death-pile-protection. disable-death-despawns: true -# How long should all other entities take to despawn? (in ticks, seconds (s), minutes (m), or hours (h)) -# The vanilla behavior is 300 seconds, or 5 Minutes (roughly 6000 ticks). -# If the format of this option is invalid, NoDespawn-OG will default to 5 minutes. -time-to-despawn: 5m +# How long should non-death-pile dropped items take to despawn? +# Vanilla is 6000 ticks. +time-to-despawn: 30m -# If disable despawns is set to false, how long (in ticks, seconds (s), minutes (m), or hours (h)) -# should items dropped on death last until despawn? -# If higher than the max or lower than the min, defaults to the value of time-to-despawn above. -time-to-death-despawns: 20m \ No newline at end of file +# If disable-death-despawns is false, how long should death pile items last before despawning? +# If disable-death-despawns is true, this setting is ignored unless overflow-despawn is run. +time-to-death-despawns: 30m + +# How often to run the automatic dropped-entity cleanup across all loaded chunks. +entity-cleanup-interval: 30m + +death-pile-protection: + # Enables per-player-per-chunk caps to prevent death piles being used for lag. + enabled: true + + # A player can only have X protected death piles/items in a given chunk. 0 means unlimited for that cap. + max-piles-per-player-per-chunk: 12 + max-items-per-player-per-chunk: 1000 + + # When the cap is exceeded, the oldest protected piles in that chunk start despawning normally. + # This duration controls how long overflowed piles last before being eligible to despawn. + overflow-despawn: 10s \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 9bb8f10..3305910 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,7 +1,26 @@ main: net.trueog.nodespawnog.NoDespawnOG name: NoDespawn-OG authors: [BadPylot, NotAlexNoyle] -description: A Spigot plugin that allows you to easily determine when exactly you or others first joined the server! -website: https://github.com/NotAlexNoyle/NoDespawn-OG +description: A Spigot/Paper/Purpur plugin to control item despawn behavior. Crafted by TrueOG Network. +website: https://github.com/true-og/NoDespawn-OG version: '${version}' -api-version: 1.19 \ No newline at end of file +api-version: '${apiVersion}' +depend: [Utilities-OG] + +commands: + clearentities: + description: Clears non-death-pile dropped items in all loaded chunks. + usage: /clearentities + permission: nodespawnog.clearentities + cleanupin: + description: Informs players how long it is until entity cleanup on-demand. + usage: /cleanupin + permission: nodespawnog.cleanupin + +permissions: + nodespawnog.clearentities: + description: Allows running /clearentities. + default: op + nodespawnog.cleanupin: + description: Allow running /cleanupin. + default: op