Feat: now a fully fledged alernative to clearlagg
This commit is contained in:
1 parent
9ba19bf52c
commit
577cb7aa7a
10 files changed
+1388
-212
No files matched your search
@@ -1,20 +1,66 @@
|
||||
# NoDespawn-OG
|
||||
|
||||

|
||||

|
||||
|
||||
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
@@ -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}") }
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user