Feat: now a fully fledged alernative to clearlagg

This commit is contained in:
NotAlexNoyle committed 2025-12-16 22:18:09 -05:00
1 parent 9ba19bf52c
commit 577cb7aa7a
10 files changed
+1388 -212

No files matched your search

+55 -23
View File
@@ -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.**
+19 -8
View File
@@ -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<Boolean> 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<AbstractArchiveTask>().configureEach { // Ensure reproducible .jars
isPreserveFileTimestamps = false
@@ -60,10 +63,8 @@ tasks.withType<AbstractArchiveTask>().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<Jar>().configureEach { archiveBaseName.set("${project.name}-${rootProject.name}") }
}
+151
View File
@@ -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<Boolean> by rootProject.extra
val kotlinPluginProjects = configurations
.getByName("compileClasspath")
.allDependencies
.filterIsInstance<ProjectDependency>()
.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<EclipseModel> {
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<TaskProvider<Copy>>()
kotlinPluginProjects.forEach { path ->
val sub = project(path)
val shadowJarProv = sub.tasks.named("shadowJar")
val copyTask =
tasks.register<Copy>("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<org.w3c.dom.Node>()
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<EclipseModel>()
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()
@@ -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");
}
}
@@ -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;
}
}
@@ -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<Integer> 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<World> 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;
}
}
@@ -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<Item> 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<Item> 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<Item> 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<String, PileGroup> 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<PileGroup> 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<Item> 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<String, List<Item>> 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);
}
});
}
}
@@ -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<World> 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<World> getServerWorlds() {
serverWorlds = Bukkit.getWorlds();
return serverWorlds;
}
public static void broadcastCleanupMessage(String message) {
Bukkit.getOnlinePlayers().forEach((Player player) -> UtilitiesOG.trueogMessage(player, message));
}
}
+29 -9
View File
@@ -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
# 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
+22 -3
View File
@@ -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
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