Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e037fac29
|
||
|
|
28fa577a58
|
||
|
|
5b8b992769
|
||
|
|
4505fe3d88 | ||
|
|
56410af86e | ||
|
|
72501a60c6 | ||
|
|
51300c87fd | ||
|
|
0de1738130 | ||
|
|
9e62db8b12 | ||
|
|
244b426c96 | ||
|
|
50fd7c4a49 | ||
|
|
868623c7d5 | ||
|
|
1e6aa29415 | ||
|
|
7d4f6a70f2 | ||
|
|
727bc657cb | ||
|
|
5866f9ad74 | ||
|
|
d73200d175 | ||
|
|
93277277df | ||
|
|
2a4efda81e | ||
|
|
3fd0ee7ef1 | ||
|
|
a3f2a4f807 | ||
|
|
f887d56434 | ||
|
|
35b48eaa4e | ||
|
|
3cee1beff4 |
No files matched your search
@@ -0,0 +1 @@
|
||||
.gitea/CODEOWNERS linguist-detectable=false
|
||||
@@ -0,0 +1,3 @@
|
||||
# Pull requests request review from the eligible staff team(s).
|
||||
# One approval from either an Admin or Developer satisfies branch protection.
|
||||
* @trueog/Admins @trueog/Developers
|
||||
+7
-1
@@ -13,12 +13,18 @@ out/
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Compiled class file
|
||||
# Compiled class files
|
||||
*.class
|
||||
bin/
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# Eclipse files
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# Smart Afk
|
||||
# AFK-OG
|
||||
|
||||
**SmartAfk** is a lightweight and configurable Minecraft plugin that detects player inactivity.
|
||||
Players can manually toggle their AFK status, and server admins can configure behaviors like freezing movement or granting invulnerability.
|
||||
**AFK-OG** is a lightweight and configurable Minecraft plugin for Purpur-compatible 1.19.4 servers that detects player inactivity.
|
||||
Players can manually toggle their AFK status, and server admins can configure behaviors like freezing movement, cancelling AFK on jump, or granting invulnerability while a player is AFK.
|
||||
|
||||
**Download:** [https://modrinth.com/project/smart-afk](https://modrinth.com/project/smart-afk)
|
||||
**Requirements:** Java 17, Purpur/Paper-compatible 1.19.4 server
|
||||
|
||||
**Optional dependency:** [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/)
|
||||
|
||||
**Source:** [https://github.com/IschdeFelin/mc-smart-afk](https://github.com/IschdeFelin/mc-smart-afk)
|
||||
|
||||
---
|
||||
|
||||
@@ -14,8 +18,9 @@ Players can manually toggle their AFK status, and server admins can configure be
|
||||
- Optionally make AFK players invulnerable
|
||||
- Optionally cancel AFK mode when jumping
|
||||
- Prevents AFK players from interacting with the world
|
||||
- Notifies players when they go AFK or return
|
||||
- Adds an `[AFK]` prefix to the player's display name and player list name
|
||||
- Update checker to inform admins about new releases
|
||||
- PlaceholderAPI expansion for AFK status output
|
||||
|
||||
---
|
||||
|
||||
@@ -25,22 +30,25 @@ Toggle your AFK status manually.
|
||||
|
||||
Useful if you want to go AFK immediately without waiting for the timeout.
|
||||
|
||||
This command is player-only.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1) Download the `.jar` file from Modrinth.
|
||||
2) Place it in your server's `/plugins` folder.
|
||||
3) Restart the server.
|
||||
1. Build or download the plugin `.jar`.
|
||||
2. Place it in your server's `/plugins` folder.
|
||||
3. Install PlaceholderAPI as well if you want placeholder support.
|
||||
4. Start or restart the server.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit the config.yml to adjust AFK behavior to your server’s needs.
|
||||
Edit `config.yml` to adjust AFK behavior to your server’s needs.
|
||||
Changes require a reload or restart.
|
||||
|
||||
Located at: `/plugins/SmartAfk/config.yml`
|
||||
Located at: `/plugins/AFK-OG/config.yml`
|
||||
|
||||
### Options
|
||||
|
||||
@@ -53,5 +61,24 @@ Located at: `/plugins/SmartAfk/config.yml`
|
||||
|
||||
---
|
||||
|
||||
Have suggestions or found a bug?
|
||||
Visit my [GitHub repository](https://github.com/IschdeFelin/mc-smart-afk) to open an issue or contribute via pull request!
|
||||
## PlaceholderAPI
|
||||
|
||||
If PlaceholderAPI is installed, AFK-OG registers the following placeholder:
|
||||
|
||||
| Placeholder | Description |
|
||||
|----------------|-----------------------------------------------|
|
||||
| `%afkog_status%` | Returns `AFK` while the player is AFK, otherwise an empty string |
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
Build the plugin locally with:
|
||||
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
The shaded plugin jar is produced in `build/libs/`.
|
||||
|
||||
---
|
||||
@@ -1,64 +0,0 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id("xyz.jpenilla.run-paper") version "2.3.1"
|
||||
}
|
||||
|
||||
group = 'de.codecrafter'
|
||||
version = '1.0.0-beta'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
// maven {
|
||||
// name = "papermc-repo"
|
||||
// url = "https://repo.papermc.io/repository/maven-public/"
|
||||
// }
|
||||
maven {
|
||||
name = "spigotmc-repo"
|
||||
url = "https://hub.spigotmc.org/nexus/content/repositories/snapshots/"
|
||||
}
|
||||
maven {
|
||||
name = "sonatype"
|
||||
url = "https://oss.sonatype.org/content/groups/public/"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// compileOnly("io.papermc.paper:paper-api:1.21.5-R0.1-SNAPSHOT")
|
||||
compileOnly("org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT")
|
||||
}
|
||||
|
||||
tasks {
|
||||
runServer {
|
||||
// Configure the Minecraft version for our task.
|
||||
// This is the only required configuration besides applying the plugin.
|
||||
// Your plugin's jar (or shadowJar if present) will be used automatically.
|
||||
minecraftVersion("1.21")
|
||||
}
|
||||
}
|
||||
|
||||
def targetJavaVersion = 21
|
||||
java {
|
||||
def javaVersion = JavaVersion.toVersion(targetJavaVersion)
|
||||
sourceCompatibility = javaVersion
|
||||
targetCompatibility = javaVersion
|
||||
if (JavaVersion.current() < javaVersion) {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
|
||||
if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) {
|
||||
options.release.set(targetJavaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
processResources {
|
||||
def props = [version: version]
|
||||
inputs.properties props
|
||||
filteringCharset 'UTF-8'
|
||||
filesMatching('plugin.yml') {
|
||||
expand props
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
plugins {
|
||||
id("java")
|
||||
id("java-library")
|
||||
id("com.gradleup.shadow") version "8.3.9"
|
||||
eclipse
|
||||
}
|
||||
|
||||
group = "de.codecrafter"
|
||||
version = providers.gradleProperty("version").getOrElse("1.0.0")
|
||||
|
||||
val pluginName = "AFK-OG"
|
||||
val apiVersion = "1.19"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
vendor.set(JvmVendorSpec.GRAAL_VM)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
maven { url = uri("https://repo.purpurmc.org/snapshots") }
|
||||
maven { url = uri("https://repo.extendedclip.com/content/repositories/placeholderapi/") }
|
||||
maven { url = uri("file://${System.getProperty("user.home")}/.m2/repository") }
|
||||
System.getProperty("SELF_MAVEN_LOCAL_REPO")?.let {
|
||||
val dir = file(it)
|
||||
if (dir.isDirectory) {
|
||||
maven { url = uri("file://${dir.absolutePath}") }
|
||||
} else {
|
||||
mavenLocal()
|
||||
}
|
||||
} ?: mavenLocal()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT")
|
||||
compileOnly("me.clip:placeholderapi:2.12.2")
|
||||
compileOnly("net.luckperms:api:5.5")
|
||||
implementation("com.google.code.gson:gson:2.13.2")
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
val props = mapOf("version" to version, "apiVersion" to apiVersion, "pluginName" to pluginName)
|
||||
inputs.properties(props)
|
||||
filteringCharset = "UTF-8"
|
||||
filesMatching("plugin.yml") { expand(props) }
|
||||
from("LICENSE") { into("/") }
|
||||
}
|
||||
|
||||
tasks.withType<AbstractArchiveTask>().configureEach {
|
||||
isPreserveFileTimestamps = false
|
||||
isReproducibleFileOrder = true
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
options.isFork = true
|
||||
options.release.set(17)
|
||||
options.compilerArgs.add("-parameters")
|
||||
options.compilerArgs.add("-Xlint:deprecation")
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
archiveBaseName.set(pluginName)
|
||||
archiveClassifier.set("part")
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
archiveBaseName.set(pluginName)
|
||||
archiveClassifier.set("")
|
||||
archiveVersion.set(project.version.toString())
|
||||
archiveFileName.set("$pluginName-${project.version}.jar")
|
||||
isEnableRelocation = true
|
||||
relocationPrefix = "${project.group}.shadow"
|
||||
minimize()
|
||||
}
|
||||
|
||||
tasks.build {
|
||||
dependsOn(tasks.shadowJar)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
version=1.0.0-beta
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
rootProject.name = 'SmartAfk'
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "AFK-OG"
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* This file is part of the Minecraft Smart Afk project.
|
||||
* Licensed under the MIT License. See the LICENSE file in the project root for full license information.
|
||||
*/
|
||||
|
||||
package de.codecrafter.smartAfk;
|
||||
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import de.codecrafter.smartAfk.commands.AfkCommand;
|
||||
import de.codecrafter.smartAfk.integrations.LuckPermsHook;
|
||||
import de.codecrafter.smartAfk.integrations.NoCheatPlusHook;
|
||||
import de.codecrafter.smartAfk.listeners.AfkListener;
|
||||
import de.codecrafter.smartAfk.listeners.PlayerJoinListener;
|
||||
import de.codecrafter.smartAfk.placeholders.AfkPlaceholderExpansion;
|
||||
import de.codecrafter.smartAfk.utils.AfkConfig;
|
||||
import de.codecrafter.smartAfk.utils.AfkManager;
|
||||
|
||||
public final class AFKOG extends JavaPlugin {
|
||||
private static AFKOG plugin;
|
||||
private AfkConfig afkConfig;
|
||||
private AfkManager afkManager;
|
||||
private static String prefix = "&7[&cAFK&f-&4OG&7] ";
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
|
||||
plugin = this;
|
||||
|
||||
saveDefaultConfig();
|
||||
|
||||
afkConfig = new AfkConfig(this);
|
||||
afkManager = new AfkManager();
|
||||
|
||||
// commands
|
||||
getCommand("afk").setExecutor(new AfkCommand());
|
||||
|
||||
// listeners
|
||||
getServer().getPluginManager().registerEvents(new PlayerJoinListener(), this);
|
||||
getServer().getPluginManager().registerEvents(new AfkListener(), this);
|
||||
|
||||
// optional NoCheatPlus backstop for combat auto-click detection.
|
||||
NoCheatPlusHook.register(this, afkManager);
|
||||
|
||||
// optional LuckPerms hook for AFK broadcast prefixes.
|
||||
LuckPermsHook.initialize(this);
|
||||
|
||||
if (getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) {
|
||||
|
||||
new AfkPlaceholderExpansion(this).register();
|
||||
|
||||
}
|
||||
|
||||
getServer().getOnlinePlayers().forEach(afkManager::clearLegacyInvulnerability);
|
||||
|
||||
// start the afk check task if either auto-afk or auto-kick is enabled.
|
||||
if (afkConfig.getAfkTimeoutSeconds() > 0 || afkConfig.getAfkKickSeconds() > 0) {
|
||||
|
||||
afkManager.startAfkCheckTask(this);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
|
||||
NoCheatPlusHook.unregister(this);
|
||||
|
||||
getServer().getOnlinePlayers().stream().filter(afkManager::isAfk).forEach(afkManager::unsetAfk);
|
||||
|
||||
LuckPermsHook.shutdown();
|
||||
|
||||
}
|
||||
|
||||
public static AFKOG getPlugin() {
|
||||
|
||||
return plugin;
|
||||
|
||||
}
|
||||
|
||||
public AfkManager getAfkManager() {
|
||||
|
||||
return afkManager;
|
||||
|
||||
}
|
||||
|
||||
public AfkConfig getAfkConfig() {
|
||||
|
||||
return afkConfig;
|
||||
|
||||
}
|
||||
|
||||
public static String getPrefix() {
|
||||
|
||||
return prefix;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Minecraft Smart Afk project.
|
||||
* Licensed under the MIT License. See the LICENSE file in the project root for full license information.
|
||||
*/
|
||||
|
||||
package de.codecrafter.smartAfk;
|
||||
|
||||
import de.codecrafter.smartAfk.commands.AfkCommand;
|
||||
import de.codecrafter.smartAfk.listeners.AfkListener;
|
||||
import de.codecrafter.smartAfk.listeners.PlayerJoinListener;
|
||||
import de.codecrafter.smartAfk.utils.AfkConfig;
|
||||
import de.codecrafter.smartAfk.utils.AfkManager;
|
||||
import de.codecrafter.smartAfk.utils.UpdateChecker;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
|
||||
public final class SmartAfk extends JavaPlugin {
|
||||
private static SmartAfk plugin;
|
||||
private AfkConfig afkConfig;
|
||||
private AfkManager afkManager;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
plugin = this;
|
||||
saveDefaultConfig();
|
||||
afkConfig = new AfkConfig(this);
|
||||
afkManager = new AfkManager();
|
||||
|
||||
// config
|
||||
|
||||
// update checker
|
||||
BukkitScheduler scheduler = getServer().getScheduler();
|
||||
UpdateChecker updateChecker = new UpdateChecker(this);
|
||||
scheduler.runTaskTimerAsynchronously(this, updateChecker::check, 0, 72000);
|
||||
|
||||
// commands
|
||||
getCommand("afk").setExecutor(new AfkCommand());
|
||||
|
||||
// listeners
|
||||
getServer().getPluginManager().registerEvents(new PlayerJoinListener(updateChecker), this);
|
||||
getServer().getPluginManager().registerEvents(new AfkListener(), this);
|
||||
|
||||
// start the afk check task if in config enabled
|
||||
if (afkConfig.getAfkTimeoutSeconds() > 0) afkManager.startAfkCheckTask(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
getServer().getOnlinePlayers().forEach(player -> {
|
||||
if (afkManager.isAfk(player)) afkManager.unsetAfk(player);
|
||||
});
|
||||
}
|
||||
|
||||
public static SmartAfk getPlugin() {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
public AfkManager getAfkManager() {
|
||||
return afkManager;
|
||||
}
|
||||
|
||||
public AfkConfig getAfkConfig() {
|
||||
return afkConfig;
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,43 @@
|
||||
|
||||
package de.codecrafter.smartAfk.commands;
|
||||
|
||||
import de.codecrafter.smartAfk.SmartAfk;
|
||||
import de.codecrafter.smartAfk.utils.AfkManager;
|
||||
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 de.codecrafter.smartAfk.AFKOG;
|
||||
import de.codecrafter.smartAfk.utils.AfkManager;
|
||||
|
||||
public class AfkCommand implements CommandExecutor {
|
||||
@Override
|
||||
public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] strings) {
|
||||
if (!(commandSender instanceof Player player)) {
|
||||
commandSender.sendMessage("This command can only be executed by players.");
|
||||
return true;
|
||||
}
|
||||
|
||||
AfkManager afkManager = SmartAfk.getPlugin().getAfkManager();
|
||||
@Override
|
||||
public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] strings) {
|
||||
|
||||
if (afkManager.isAfk(player)) {
|
||||
afkManager.unsetAfk(player);
|
||||
} else {
|
||||
afkManager.setAfk(player);
|
||||
}
|
||||
if (!(commandSender instanceof Player player)) {
|
||||
|
||||
commandSender.sendMessage("This command can only be executed by players.");
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
final AfkManager afkManager = AFKOG.getPlugin().getAfkManager();
|
||||
if (afkManager.isAfk(player)) {
|
||||
|
||||
afkManager.updateActivity(player);
|
||||
afkManager.unsetAfk(player);
|
||||
|
||||
}
|
||||
else {
|
||||
|
||||
afkManager.setAfk(player);
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* This file is part of the Minecraft Smart Afk project.
|
||||
* Licensed under the MIT License. See the LICENSE file in the project root for full license information.
|
||||
*/
|
||||
|
||||
package de.codecrafter.smartAfk.integrations;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import de.codecrafter.smartAfk.AFKOG;
|
||||
import net.luckperms.api.LuckPerms;
|
||||
import net.luckperms.api.LuckPermsProvider;
|
||||
import net.luckperms.api.cacheddata.CachedMetaData;
|
||||
|
||||
/**
|
||||
* Optional bridge to LuckPerms. When LuckPerms is installed, resolves a player's
|
||||
* chat prefix (in their current context) for use in AFK broadcast messages.
|
||||
*
|
||||
* LuckPerms is a true soft dependency: every method is a no-op returning an empty
|
||||
* string when LuckPerms is absent, so the plugin runs identically without it. The
|
||||
* LuckPerms API classes are only touched after the presence check, so the JVM
|
||||
* never needs them on the classpath when LuckPerms is not installed.
|
||||
*/
|
||||
public final class LuckPermsHook {
|
||||
|
||||
private static LuckPerms luckPerms;
|
||||
|
||||
private LuckPermsHook() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches the LuckPerms API handle if LuckPerms is installed. A no-op otherwise.
|
||||
*/
|
||||
public static void initialize(AFKOG plugin) {
|
||||
|
||||
if (Bukkit.getPluginManager().getPlugin("LuckPerms") == null) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
luckPerms = LuckPermsProvider.get();
|
||||
plugin.getLogger().info("Hooked into LuckPerms for AFK broadcast prefixes.");
|
||||
|
||||
} catch (final IllegalStateException exception) {
|
||||
|
||||
luckPerms = null;
|
||||
plugin.getLogger().warning("LuckPerms is present but its API was unavailable; AFK broadcasts will omit prefixes.");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached API handle so a plugin disable/reload does not retain the
|
||||
* LuckPerms classloader.
|
||||
*/
|
||||
public static void shutdown() {
|
||||
|
||||
luckPerms = null;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the player's prefix in their current context, or an empty string when
|
||||
* LuckPerms is absent or the player has no prefix. The returned value may carry
|
||||
* legacy '&'/section color codes set by LuckPerms.
|
||||
*
|
||||
* @param player The player whose prefix to resolve.
|
||||
*/
|
||||
public static String getPrefix(Player player) {
|
||||
|
||||
if (luckPerms == null) {
|
||||
|
||||
return "";
|
||||
|
||||
}
|
||||
|
||||
final CachedMetaData metaData = luckPerms.getPlayerAdapter(Player.class).getMetaData(player);
|
||||
final String prefix = metaData.getPrefix();
|
||||
|
||||
return prefix == null ? "" : prefix;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* This file is part of the Minecraft Smart Afk project.
|
||||
* Licensed under the MIT License. See the LICENSE file in the project root for full license information.
|
||||
*/
|
||||
|
||||
package de.codecrafter.smartAfk.integrations;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import de.codecrafter.smartAfk.AFKOG;
|
||||
import de.codecrafter.smartAfk.utils.AfkManager;
|
||||
|
||||
/**
|
||||
* Optional, reflection-based bridge to NoCheatPlus. When NoCheatPlus is present,
|
||||
* registers a passive hook on the NET_ATTACKFREQUENCY check that records when a
|
||||
* player is flagged for excessive attack frequency. The hook is observe-only: it
|
||||
* never cancels or alters NoCheatPlus' own violation processing.
|
||||
*
|
||||
* Reflection (rather than a compile-time dependency) keeps NoCheatPlus a true
|
||||
* soft dependency — the plugin compiles and runs identically whether or not
|
||||
* NoCheatPlus is installed.
|
||||
*/
|
||||
public final class NoCheatPlusHook {
|
||||
|
||||
private static Integer registeredHookId;
|
||||
|
||||
private NoCheatPlusHook() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the attack-frequency observer if NoCheatPlus is installed. A no-op
|
||||
* (with a single warning) if NoCheatPlus is absent or its hook API is
|
||||
* incompatible.
|
||||
*/
|
||||
public static void register(AFKOG plugin, AfkManager afkManager) {
|
||||
|
||||
final Plugin ncp = Bukkit.getPluginManager().getPlugin("NoCheatPlus");
|
||||
if (ncp == null) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
final ClassLoader loader = ncp.getClass().getClassLoader();
|
||||
final Class<?> hookManagerClass = Class.forName("fr.neatmonster.nocheatplus.hooks.NCPHookManager", true, loader);
|
||||
final Class<?> hookClass = Class.forName("fr.neatmonster.nocheatplus.hooks.NCPHook", true, loader);
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
final Class<? extends Enum> checkTypeClass = (Class<? extends Enum>) Class.forName("fr.neatmonster.nocheatplus.checks.CheckType", true, loader);
|
||||
@SuppressWarnings("unchecked")
|
||||
final Object attackFrequency = Enum.valueOf(checkTypeClass, "NET_ATTACKFREQUENCY");
|
||||
|
||||
final String version = plugin.getPluginMeta().getVersion();
|
||||
final Object hookProxy = Proxy.newProxyInstance(loader, new Class<?>[] { hookClass }, new InvocationHandler() {
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) {
|
||||
|
||||
switch (method.getName()) {
|
||||
|
||||
case "getHookName":
|
||||
return "AFK-OG";
|
||||
case "getHookVersion":
|
||||
return version;
|
||||
case "onCheckFailure":
|
||||
if (args != null && args.length >= 2 && args[1] instanceof Player player) {
|
||||
|
||||
afkManager.flagAttackFrequency(player);
|
||||
|
||||
}
|
||||
// false = do not cancel NoCheatPlus' failure processing.
|
||||
return Boolean.FALSE;
|
||||
case "equals":
|
||||
return proxy == args[0];
|
||||
case "hashCode":
|
||||
return System.identityHashCode(proxy);
|
||||
case "toString":
|
||||
return "AFK-OG NCPHook";
|
||||
default:
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
final Method addHook = hookManagerClass.getMethod("addHook", checkTypeClass, hookClass);
|
||||
registeredHookId = (Integer) addHook.invoke(null, attackFrequency, hookProxy);
|
||||
|
||||
plugin.getLogger().info("Hooked into NoCheatPlus (NET_ATTACKFREQUENCY) for combat auto-click detection.");
|
||||
|
||||
} catch (final ReflectiveOperationException | RuntimeException exception) {
|
||||
|
||||
plugin.getLogger().warning("NoCheatPlus is present but its hook API was unavailable; combat auto-click will rely on cadence only (" + exception.getClass().getSimpleName() + ").");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the hook registered by {@link #register} so that a plugin
|
||||
* disable/reload does not leave a stale proxy (and the old plugin
|
||||
* classloader) retained inside NoCheatPlus. No-op if nothing was registered.
|
||||
*/
|
||||
public static void unregister(AFKOG plugin) {
|
||||
|
||||
if (registeredHookId == null) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
final Plugin ncp = Bukkit.getPluginManager().getPlugin("NoCheatPlus");
|
||||
if (ncp == null) {
|
||||
|
||||
registeredHookId = null;
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
final ClassLoader loader = ncp.getClass().getClassLoader();
|
||||
final Class<?> hookManagerClass = Class.forName("fr.neatmonster.nocheatplus.hooks.NCPHookManager", true, loader);
|
||||
final Method removeHook = hookManagerClass.getMethod("removeHook", Integer.class);
|
||||
removeHook.invoke(null, registeredHookId);
|
||||
|
||||
} catch (final ReflectiveOperationException | RuntimeException exception) {
|
||||
|
||||
plugin.getLogger().warning("Failed to unregister NoCheatPlus hook (" + exception.getClass().getSimpleName() + ").");
|
||||
|
||||
} finally {
|
||||
|
||||
registeredHookId = null;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,78 +5,310 @@
|
||||
|
||||
package de.codecrafter.smartAfk.listeners;
|
||||
|
||||
import de.codecrafter.smartAfk.SmartAfk;
|
||||
import de.codecrafter.smartAfk.AFKOG;
|
||||
import de.codecrafter.smartAfk.utils.AfkConfig;
|
||||
import de.codecrafter.smartAfk.utils.AfkManager;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.entity.Mob;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityTargetLivingEntityEvent;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
|
||||
public class AfkListener implements Listener {
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onPlayerMove(PlayerMoveEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
SmartAfk smartAfk = SmartAfk.getPlugin();
|
||||
AfkManager afkManager = smartAfk.getAfkManager();
|
||||
AfkConfig afkConfig = smartAfk.getAfkConfig();
|
||||
afkManager.updateActivity(player);
|
||||
|
||||
if (event.getFrom().getBlockX() == event.getTo().getBlockX() && event.getFrom().getBlockZ() == event.getTo().getBlockZ() && event.getFrom().getBlockY() == event.getTo().getBlockY()) {
|
||||
return;
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onPlayerMove(PlayerMoveEvent event) {
|
||||
|
||||
final Player player = event.getPlayer();
|
||||
final AFKOG smartAfk = AFKOG.getPlugin();
|
||||
final AfkManager afkManager = smartAfk.getAfkManager();
|
||||
final AfkConfig afkConfig = smartAfk.getAfkConfig();
|
||||
|
||||
if (afkManager.isAfk(player)) {
|
||||
if (afkConfig.isFreezeAfkPlayers()) {
|
||||
Location from = event.getFrom();
|
||||
Location origTo = event.getTo();
|
||||
if (afkConfig.isCancelAfkOnJump() && origTo.getY() >= from.getBlockY() + 1) {
|
||||
afkManager.unsetAfk(player);
|
||||
return;
|
||||
}
|
||||
final Location from = event.getFrom();
|
||||
final Location to = event.getTo();
|
||||
|
||||
event.setTo(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
// only real positional (block-to-block) movement counts as activity here.
|
||||
// look-only changes (mouse jiggle) are ignored, so a mouse-jiggle macro
|
||||
// alone cannot defeat the idle timer; walking and water-pushed AFK pools
|
||||
// keep players active. interaction-based activity is gated separately by
|
||||
// cadence (see creditInteraction).
|
||||
if (from.getBlockX() == to.getBlockX() && from.getBlockZ() == to.getBlockZ() && from.getBlockY() == to.getBlockY()) {
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerInteract(PlayerInteractEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
AfkManager afkManager = SmartAfk.getPlugin().getAfkManager();
|
||||
afkManager.updateActivity(player);
|
||||
if (afkManager.isAfk(player)) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerInteractEntity(PlayerInteractEntityEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
AfkManager afkManager = SmartAfk.getPlugin().getAfkManager();
|
||||
afkManager.updateActivity(player);
|
||||
if (afkManager.isAfk(player)) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onEntityDamageByEntity(EntityDamageByEntityEvent event) {
|
||||
if (!(event.getDamager() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
if (!afkManager.isAfk(player)) {
|
||||
|
||||
AfkManager afkManager = SmartAfk.getPlugin().getAfkManager();
|
||||
afkManager.updateActivity(player);
|
||||
afkManager.updateActivity(player);
|
||||
|
||||
if (afkManager.isAfk(player)) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (!afkConfig.isFreezeAfkPlayers()) {
|
||||
|
||||
afkManager.updateActivity(player);
|
||||
afkManager.unsetAfk(player);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (afkConfig.isCancelAfkOnJump() && to.getY() >= from.getBlockY() + 1) {
|
||||
|
||||
afkManager.updateActivity(player);
|
||||
afkManager.unsetAfk(player);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
// frozen AFK player: revert the move and do not credit activity.
|
||||
event.setTo(from);
|
||||
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerInteract(PlayerInteractEvent event) {
|
||||
|
||||
// count the main hand only; the off-hand fires a second event per click.
|
||||
if (event.getHand() != EquipmentSlot.HAND) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
// physical triggers (pressure plates, tripwires) require movement to fire
|
||||
// repeatedly, which is already credited as activity; ignore them here.
|
||||
// fishing is treated as an ordinary interaction here; auto-fishing is out
|
||||
// of scope and handled by a dedicated companion plugin.
|
||||
if (event.getAction() == Action.PHYSICAL) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
creditInteraction(event.getPlayer(), blockKey(event.getClickedBlock()), true);
|
||||
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerInteractEntity(PlayerInteractEntityEvent event) {
|
||||
|
||||
if (event.getHand() != EquipmentSlot.HAND) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
creditInteraction(event.getPlayer(), "e:" + event.getRightClicked().getUniqueId(), true);
|
||||
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onInventoryClick(InventoryClickEvent event) {
|
||||
|
||||
if (event.getWhoClicked() instanceof Player player) {
|
||||
|
||||
// menu use is gated by target (slot) only, not cadence: a real player
|
||||
// sorting/trading moves across slots (varied target, always credited),
|
||||
// while spamming one slot repeats the target and is withheld.
|
||||
creditInteraction(player, "s:" + event.getRawSlot(), false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
public void onBlockPlace(BlockPlaceEvent event) {
|
||||
|
||||
creditInteraction(event.getPlayer(), blockKey(event.getBlock()), true);
|
||||
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
public void onBlockBreak(BlockBreakEvent event) {
|
||||
|
||||
creditInteraction(event.getPlayer(), blockKey(event.getBlock()), true);
|
||||
|
||||
}
|
||||
|
||||
private static String blockKey(org.bukkit.block.Block block) {
|
||||
|
||||
if (block == null) {
|
||||
|
||||
return "air";
|
||||
|
||||
}
|
||||
|
||||
return "b:" + block.getWorld().getName() + ":" + block.getX() + ":" + block.getY() + ":" + block.getZ();
|
||||
|
||||
}
|
||||
|
||||
// credit activity unless automated: one target hit repeatedly, few distinct
|
||||
// targets over the idle window, or (when cadence applies) machine-regular timing.
|
||||
private void creditInteraction(Player player, String targetKey, boolean useCadence) {
|
||||
|
||||
final AfkManager afkManager = AFKOG.getPlugin().getAfkManager();
|
||||
|
||||
afkManager.recordTarget(player, targetKey);
|
||||
|
||||
boolean automated = afkManager.isRepeatedTarget(player, targetKey)
|
||||
|| afkManager.isLowDiversityAutomation(player);
|
||||
|
||||
if (useCadence) {
|
||||
|
||||
afkManager.recordInteraction(player);
|
||||
automated = automated || afkManager.isAutomatedCadence(player);
|
||||
|
||||
}
|
||||
|
||||
if (automated) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
afkManager.updateActivity(player);
|
||||
|
||||
if (afkManager.isAfk(player)) {
|
||||
|
||||
afkManager.unsetAfk(player);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
public void onEntityDamageByEntity(EntityDamageByEntityEvent event) {
|
||||
|
||||
final AfkManager afkManager = AFKOG.getPlugin().getAfkManager();
|
||||
if (event.getEntity() instanceof Player attackedPlayer) {
|
||||
|
||||
afkManager.updateActivity(attackedPlayer);
|
||||
|
||||
if (afkManager.isAfk(attackedPlayer)) {
|
||||
|
||||
afkManager.unsetAfk(attackedPlayer);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Player player = getAttacker(event);
|
||||
if (player == null) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
afkManager.recordInteraction(player);
|
||||
|
||||
// withhold credit when the attack cadence looks automated (grinder
|
||||
// auto-clicker) or NoCheatPlus recently flagged the player for attack
|
||||
// frequency. being attacked (victim, above) is unaffected.
|
||||
if (afkManager.isCombatAutomated(player)) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
afkManager.updateActivity(player);
|
||||
|
||||
if (afkManager.isAfk(player)) {
|
||||
|
||||
afkManager.unsetAfk(player);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onEntityDamage(EntityDamageEvent event) {
|
||||
|
||||
if (!(event.getEntity() instanceof Player player)) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
final AFKOG plugin = AFKOG.getPlugin();
|
||||
final AfkManager afkManager = plugin.getAfkManager();
|
||||
if (!plugin.getAfkConfig().isInvulnerableDuringAfk() || !afkManager.isAfk(player)) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (event instanceof EntityDamageByEntityEvent damageByEntityEvent && getAttacker(damageByEntityEvent) != null) {
|
||||
|
||||
afkManager.updateActivity(player);
|
||||
afkManager.unsetAfk(player);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
event.setCancelled(true);
|
||||
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onEntityTarget(EntityTargetLivingEntityEvent event) {
|
||||
|
||||
if (!(event.getTarget() instanceof Player player)) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (!AFKOG.getPlugin().getAfkManager().isAfk(player)) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
event.setCancelled(true);
|
||||
event.setTarget(null);
|
||||
|
||||
if (event.getEntity() instanceof Mob mob) {
|
||||
|
||||
mob.setTarget(null);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private Player getAttacker(EntityDamageByEntityEvent event) {
|
||||
|
||||
if (event.getDamager() instanceof Player player) {
|
||||
|
||||
return player;
|
||||
|
||||
}
|
||||
|
||||
if (event.getDamager() instanceof Projectile projectile && projectile.getShooter() instanceof Player player) {
|
||||
|
||||
return player;
|
||||
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,31 +5,30 @@
|
||||
|
||||
package de.codecrafter.smartAfk.listeners;
|
||||
|
||||
import de.codecrafter.smartAfk.utils.UpdateChecker;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
|
||||
import de.codecrafter.smartAfk.AFKOG;
|
||||
import de.codecrafter.smartAfk.utils.AfkManager;
|
||||
|
||||
public class PlayerJoinListener implements Listener {
|
||||
private final UpdateChecker updateChecker;
|
||||
|
||||
public PlayerJoinListener(UpdateChecker updateChecker) {
|
||||
this.updateChecker = updateChecker;
|
||||
}
|
||||
@EventHandler
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
|
||||
final AfkManager afkManager = AFKOG.getPlugin().getAfkManager();
|
||||
afkManager.initializeSession(event.getPlayer());
|
||||
afkManager.clearLegacyInvulnerability(event.getPlayer());
|
||||
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
|
||||
AFKOG.getPlugin().getAfkManager().clearSession(event.getPlayer());
|
||||
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
if (updateChecker.isUpdateAvailable()) {
|
||||
player.sendMessage(ChatColor.YELLOW + "A new version of " +
|
||||
ChatColor.GOLD + "SmartAfk" +
|
||||
ChatColor.YELLOW + " is available: " +
|
||||
ChatColor.GREEN + "v" + updateChecker.getLatestVersion() +
|
||||
ChatColor.YELLOW + " (you have " +
|
||||
ChatColor.RED + "v" + updateChecker.getCurrentVersion() +
|
||||
ChatColor.YELLOW + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* This file is part of the Minecraft Smart Afk project.
|
||||
* Licensed under the MIT License. See the LICENSE file in the project root for full license information.
|
||||
*/
|
||||
|
||||
package de.codecrafter.smartAfk.placeholders;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import de.codecrafter.smartAfk.AFKOG;
|
||||
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
|
||||
|
||||
public class AfkPlaceholderExpansion extends PlaceholderExpansion {
|
||||
|
||||
private final AFKOG plugin;
|
||||
|
||||
public AfkPlaceholderExpansion(AFKOG plugin) {
|
||||
|
||||
this.plugin = plugin;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull String getIdentifier() {
|
||||
|
||||
return "afkog";
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull String getAuthor() {
|
||||
|
||||
return String.join(", ", plugin.getPluginMeta().getAuthors());
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull String getVersion() {
|
||||
|
||||
return plugin.getPluginMeta().getVersion();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean persist() {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable String onRequest(OfflinePlayer player, @NotNull String params) {
|
||||
|
||||
if (!StringUtils.equalsIgnoreCase(params, "status")) {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
if (player == null) {
|
||||
|
||||
return "";
|
||||
|
||||
}
|
||||
|
||||
final Player onlinePlayer = player.getPlayer();
|
||||
if (onlinePlayer == null) {
|
||||
|
||||
return "";
|
||||
|
||||
}
|
||||
|
||||
return plugin.getAfkManager().isAfk(onlinePlayer) ? "AFK" : "";
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,64 +5,195 @@
|
||||
|
||||
package de.codecrafter.smartAfk.utils;
|
||||
|
||||
import de.codecrafter.smartAfk.SmartAfk;
|
||||
import org.bukkit.ChatColor;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
|
||||
import de.codecrafter.smartAfk.AFKOG;
|
||||
|
||||
public class AfkConfig {
|
||||
private final SmartAfk plugin;
|
||||
|
||||
// config values
|
||||
private boolean freezeAfkPlayers;
|
||||
private boolean cancelAfkOnMove;
|
||||
private boolean cancelAfkOnJump;
|
||||
private boolean invulnerableDuringAfk;
|
||||
private int afkTimeoutSeconds;
|
||||
private final AFKOG plugin;
|
||||
|
||||
/**
|
||||
* Creates an instance of {@code TimerConfig} class.
|
||||
*
|
||||
* @param plugin The plugin.
|
||||
*/
|
||||
public AfkConfig(SmartAfk plugin) {
|
||||
this.plugin = plugin;
|
||||
this.load(plugin.getConfig());
|
||||
}
|
||||
// Config values.
|
||||
private boolean freezeAfkPlayers;
|
||||
private boolean cancelAfkOnJump;
|
||||
private boolean invulnerableDuringAfk;
|
||||
private int afkTimeoutSeconds;
|
||||
private int afkKickSeconds;
|
||||
private String afkKickMessage;
|
||||
private int autoclickMinClicks;
|
||||
private int autoclickWindowSeconds;
|
||||
private double autoclickCvThreshold;
|
||||
private int autoclickMaxSameTarget;
|
||||
private int ncpFlagWindowSeconds;
|
||||
// lowercased world names where going AFK is broadcast to everyone.
|
||||
private Set<String> afkBroadcastWorlds;
|
||||
private String afkBroadcastMessage;
|
||||
private String afkBroadcastReturnMessage;
|
||||
|
||||
/**
|
||||
* Reloads the config from the config file on the disk.
|
||||
*/
|
||||
public void reload() {
|
||||
plugin.reloadConfig();
|
||||
load(plugin.getConfig());
|
||||
plugin.getLogger().info(ChatColor.GREEN + "Timer config reloaded.");
|
||||
}
|
||||
/**
|
||||
* Creates an instance of {@code TimerConfig} class.
|
||||
*
|
||||
* @param plugin The plugin.
|
||||
*/
|
||||
public AfkConfig(AFKOG plugin) {
|
||||
|
||||
/**
|
||||
* Populates the attributes of the class instance with the values from the config file.
|
||||
*
|
||||
* @param config The config object.
|
||||
*/
|
||||
private void load(FileConfiguration config) {
|
||||
this.afkTimeoutSeconds = config.getInt("afk-timeout-seconds", 120);
|
||||
this.freezeAfkPlayers = config.getBoolean("freeze-afk-players", true);
|
||||
this.cancelAfkOnJump = config.getBoolean("cancel-afk-on-jump", true);
|
||||
this.invulnerableDuringAfk = config.getBoolean("invulnerable-during-afk", true);
|
||||
}
|
||||
this.plugin = plugin;
|
||||
this.load(plugin.getConfig());
|
||||
|
||||
public int getAfkTimeoutSeconds() {
|
||||
return afkTimeoutSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isFreezeAfkPlayers() {
|
||||
return freezeAfkPlayers;
|
||||
}
|
||||
/**
|
||||
* Reloads the config from the config file on the disk.
|
||||
*/
|
||||
public void reload() {
|
||||
|
||||
public boolean isCancelAfkOnJump() {
|
||||
return cancelAfkOnJump;
|
||||
}
|
||||
plugin.reloadConfig();
|
||||
load(plugin.getConfig());
|
||||
|
||||
public boolean isInvulnerableDuringAfk() {
|
||||
return invulnerableDuringAfk;
|
||||
}
|
||||
}
|
||||
plugin.getLogger().info("AFK-OG config file reloaded.");
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the attributes of the class instance with the values from the config file.
|
||||
*
|
||||
* @param config The config object.
|
||||
*/
|
||||
private void load(FileConfiguration config) {
|
||||
|
||||
this.afkTimeoutSeconds = config.getInt("afk-timeout-seconds", 120);
|
||||
this.freezeAfkPlayers = config.getBoolean("freeze-afk-players", true);
|
||||
this.cancelAfkOnJump = config.getBoolean("cancel-afk-on-jump", true);
|
||||
this.invulnerableDuringAfk = config.getBoolean("invulnerable-during-afk", true);
|
||||
this.afkKickSeconds = config.getInt("afk-kick-seconds", 1200);
|
||||
this.afkKickMessage = config.getString("afk-kick-message", "&cYou have been kicked for being AFK too long.\n&7Buy a rank at &bhttps://shop.trueog.net &7to bypass the AFK timer.");
|
||||
// clamp to sane ranges so a misconfigured value cannot silently disable
|
||||
// detection or produce degenerate math.
|
||||
this.autoclickMinClicks = Math.max(2, config.getInt("autoclick-min-clicks", 10));
|
||||
this.autoclickWindowSeconds = Math.max(1, config.getInt("autoclick-window-seconds", 4));
|
||||
|
||||
double cvThreshold = config.getDouble("autoclick-cv-threshold", 0.18D);
|
||||
if (Double.isNaN(cvThreshold) || cvThreshold <= 0.0D) {
|
||||
|
||||
plugin.getLogger().warning("Invalid autoclick-cv-threshold (" + cvThreshold + "); falling back to 0.18.");
|
||||
cvThreshold = 0.18D;
|
||||
|
||||
}
|
||||
this.autoclickCvThreshold = cvThreshold;
|
||||
|
||||
this.autoclickMaxSameTarget = Math.max(2, config.getInt("autoclick-max-same-target", 6));
|
||||
this.ncpFlagWindowSeconds = Math.max(0, config.getInt("nocheatplus-flag-window-seconds", 2));
|
||||
|
||||
final Set<String> worlds = new HashSet<>();
|
||||
final List<String> configured = config.getStringList("afk-broadcast-worlds");
|
||||
for (final String world : configured) {
|
||||
|
||||
if (world != null && !world.isBlank()) {
|
||||
|
||||
worlds.add(world.toLowerCase(Locale.ROOT));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
this.afkBroadcastWorlds = worlds;
|
||||
this.afkBroadcastMessage = config.getString("afk-broadcast-message", "%prefix%%player% &7is now AFK.");
|
||||
this.afkBroadcastReturnMessage = config.getString("afk-broadcast-return-message", "%prefix%%player% &7is no longer AFK.");
|
||||
|
||||
}
|
||||
|
||||
public int getAfkTimeoutSeconds() {
|
||||
|
||||
return afkTimeoutSeconds;
|
||||
|
||||
}
|
||||
|
||||
public boolean isFreezeAfkPlayers() {
|
||||
|
||||
return freezeAfkPlayers;
|
||||
|
||||
}
|
||||
|
||||
public boolean isCancelAfkOnJump() {
|
||||
|
||||
return cancelAfkOnJump;
|
||||
|
||||
}
|
||||
|
||||
public boolean isInvulnerableDuringAfk() {
|
||||
|
||||
return invulnerableDuringAfk;
|
||||
|
||||
}
|
||||
|
||||
public int getAfkKickSeconds() {
|
||||
|
||||
return afkKickSeconds;
|
||||
|
||||
}
|
||||
|
||||
public String getAfkKickMessage() {
|
||||
|
||||
return afkKickMessage;
|
||||
|
||||
}
|
||||
|
||||
public int getAutoclickMinClicks() {
|
||||
|
||||
return autoclickMinClicks;
|
||||
|
||||
}
|
||||
|
||||
public int getAutoclickWindowSeconds() {
|
||||
|
||||
return autoclickWindowSeconds;
|
||||
|
||||
}
|
||||
|
||||
public double getAutoclickCvThreshold() {
|
||||
|
||||
return autoclickCvThreshold;
|
||||
|
||||
}
|
||||
|
||||
public int getAutoclickMaxSameTarget() {
|
||||
|
||||
return autoclickMaxSameTarget;
|
||||
|
||||
}
|
||||
|
||||
public int getNcpFlagWindowSeconds() {
|
||||
|
||||
return ncpFlagWindowSeconds;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when going AFK in the given world should be broadcast to
|
||||
* everyone. World names are matched case-insensitively.
|
||||
*
|
||||
* @param worldName The name of the world the player is in.
|
||||
*/
|
||||
public boolean isBroadcastWorld(String worldName) {
|
||||
|
||||
return worldName != null && afkBroadcastWorlds.contains(worldName.toLowerCase(Locale.ROOT));
|
||||
|
||||
}
|
||||
|
||||
public String getAfkBroadcastMessage() {
|
||||
|
||||
return afkBroadcastMessage;
|
||||
|
||||
}
|
||||
|
||||
public String getAfkBroadcastReturnMessage() {
|
||||
|
||||
return afkBroadcastReturnMessage;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,74 +5,502 @@
|
||||
|
||||
package de.codecrafter.smartAfk.utils;
|
||||
|
||||
import de.codecrafter.smartAfk.SmartAfk;
|
||||
import org.bukkit.ChatColor;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Mob;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
|
||||
import java.util.*;
|
||||
import de.codecrafter.smartAfk.AFKOG;
|
||||
import de.codecrafter.smartAfk.integrations.LuckPermsHook;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.TextReplacementConfig;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
|
||||
public class AfkManager {
|
||||
private final Set<UUID> afkPlayers = new HashSet<>();
|
||||
private final Map<UUID, Location> afkPositions = new HashMap<>();
|
||||
private final Map<UUID, Long> lastActivities = new HashMap<>();
|
||||
private static final String AFK_PREFIX = ChatColor.RED + "[AFK] ";
|
||||
|
||||
public void setAfk(Player player) {
|
||||
afkPlayers.add(player.getUniqueId());
|
||||
afkPositions.put(player.getUniqueId(), player.getLocation());
|
||||
if (SmartAfk.getPlugin().getAfkConfig().isInvulnerableDuringAfk()) player.setInvulnerable(true);
|
||||
private final Set<UUID> afkPlayers = new HashSet<>();
|
||||
private final Map<UUID, Location> afkPositions = new HashMap<>();
|
||||
private final Map<UUID, Long> lastActivities = new HashMap<>();
|
||||
// main-thread only (Bukkit events + the AFK check task).
|
||||
private final Map<UUID, Deque<Long>> interactionTimes = new HashMap<>();
|
||||
// recent (timestamp, target) pairs for the windowed diversity check.
|
||||
private final Map<UUID, Deque<Interaction>> interactionHistory = new HashMap<>();
|
||||
private final Map<UUID, TargetState> targetStates = new HashMap<>();
|
||||
// written from NoCheatPlus' NET check, which may run off the main thread.
|
||||
private final Map<UUID, Long> lastAttackFlags = new ConcurrentHashMap<>();
|
||||
private static final int MAX_SAMPLES = 64;
|
||||
// diversity check: withhold credit at >= MIN_CLICKS interactions spanning <= MAX_DISTINCT_TARGETS targets in one idle window.
|
||||
private static final int AUTOCLICK_MAX_DISTINCT_TARGETS = 2;
|
||||
private static final int AUTOCLICK_DIVERSITY_MIN_CLICKS = 10;
|
||||
private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.legacyAmpersand();
|
||||
private static final Pattern URL_PATTERN = Pattern.compile("https?://\\S+");
|
||||
private static final TextReplacementConfig URL_REPLACEMENT = TextReplacementConfig.builder()
|
||||
.match(URL_PATTERN)
|
||||
.replacement((match, builder) -> {
|
||||
final String url = match.group();
|
||||
return Component.text(url)
|
||||
.decorate(TextDecoration.UNDERLINED)
|
||||
.clickEvent(ClickEvent.openUrl(url))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("Click to open " + url)));
|
||||
})
|
||||
.build();
|
||||
|
||||
String name = player.getName();
|
||||
player.setDisplayName(AFK_PREFIX + ChatColor.RESET + name);
|
||||
player.setPlayerListName(AFK_PREFIX + ChatColor.RESET + name);
|
||||
private static Component legacy(String text) {
|
||||
|
||||
player.sendMessage(ChatColor.GREEN + "Du bist jetzt Afk.");
|
||||
}
|
||||
return LEGACY.deserialize(text);
|
||||
|
||||
public void unsetAfk(Player player) {
|
||||
afkPlayers.remove(player.getUniqueId());
|
||||
afkPositions.remove(player.getUniqueId());
|
||||
if (SmartAfk.getPlugin().getAfkConfig().isInvulnerableDuringAfk()) player.setInvulnerable(false);
|
||||
}
|
||||
|
||||
String name = player.getName();
|
||||
player.setDisplayName(name);
|
||||
player.setPlayerListName(name);
|
||||
private static Component legacyClickable(String text) {
|
||||
|
||||
player.sendMessage(ChatColor.YELLOW + "Du bist jetzt nicht mehr Afk.");
|
||||
}
|
||||
return LEGACY.deserialize(text).replaceText(URL_REPLACEMENT);
|
||||
|
||||
public boolean isAfk(Player player) {
|
||||
return afkPlayers.contains(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
public Location getAfkPosition(Player player) {
|
||||
return afkPositions.get(player.getUniqueId());
|
||||
}
|
||||
public void setAfk(Player player) {
|
||||
|
||||
public void updateActivity(Player player) {
|
||||
lastActivities.put(player.getUniqueId(), System.currentTimeMillis());
|
||||
}
|
||||
afkPlayers.add(player.getUniqueId());
|
||||
afkPositions.put(player.getUniqueId(), player.getLocation());
|
||||
|
||||
public long getLastActivity(Player player) {
|
||||
return lastActivities.getOrDefault(player.getUniqueId(), System.currentTimeMillis());
|
||||
}
|
||||
player.sendMessage(legacy(AFKOG.getPrefix() + "&cYou are now AFK."));
|
||||
|
||||
public void startAfkCheckTask(SmartAfk plugin) {
|
||||
new BukkitRunnable() {
|
||||
final int timeoutSeconds = plugin.getAfkConfig().getAfkTimeoutSeconds();
|
||||
final AfkConfig config = AFKOG.getPlugin().getAfkConfig();
|
||||
broadcastAfk(player, config.getAfkBroadcastMessage());
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
plugin.getServer().getOnlinePlayers().forEach(player -> {
|
||||
if (isAfk(player)) return;
|
||||
clearMobTargets(player);
|
||||
|
||||
}
|
||||
|
||||
private void clearMobTargets(Player player) {
|
||||
|
||||
player.getWorld().getNearbyEntities(player.getLocation(), 64.0D, 64.0D, 64.0D).forEach(entity -> {
|
||||
|
||||
if (entity instanceof Mob mob && mob.getTarget() != null && mob.getTarget().getUniqueId().equals(player.getUniqueId())) {
|
||||
|
||||
mob.setTarget(null);
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public void unsetAfk(Player player) {
|
||||
|
||||
afkPlayers.remove(player.getUniqueId());
|
||||
afkPositions.remove(player.getUniqueId());
|
||||
|
||||
player.sendMessage(legacy(AFKOG.getPrefix() + "&aYou are no longer AFK."));
|
||||
|
||||
final AfkConfig config = AFKOG.getPlugin().getAfkConfig();
|
||||
broadcastAfk(player, config.getAfkBroadcastReturnMessage());
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts {@code template} to the whole server when the player is in a
|
||||
* configured broadcast world. {@code %prefix%} is replaced with the player's
|
||||
* LuckPerms prefix (empty when LuckPerms is absent) and {@code %player%} with
|
||||
* the player name.
|
||||
*/
|
||||
private void broadcastAfk(Player player, String template) {
|
||||
|
||||
final AfkConfig config = AFKOG.getPlugin().getAfkConfig();
|
||||
if (!config.isBroadcastWorld(player.getWorld().getName())) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
final String message = template
|
||||
.replace("%prefix%", LuckPermsHook.getPrefix(player))
|
||||
.replace("%player%", player.getName());
|
||||
final Component component = legacy(AFKOG.getPrefix() + message);
|
||||
|
||||
// the AFK player already sees their own "You are now/no longer AFK"
|
||||
// message, so exclude them from the broadcast to avoid a duplicate.
|
||||
final UUID self = player.getUniqueId();
|
||||
AFKOG.getPlugin().getServer().getOnlinePlayers().forEach(recipient -> {
|
||||
|
||||
if (!recipient.getUniqueId().equals(self)) {
|
||||
|
||||
recipient.sendMessage(component);
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public boolean isAfk(Player player) {
|
||||
|
||||
return afkPlayers.contains(player.getUniqueId());
|
||||
|
||||
}
|
||||
|
||||
public Location getAfkPosition(Player player) {
|
||||
|
||||
return afkPositions.get(player.getUniqueId());
|
||||
|
||||
}
|
||||
|
||||
public void updateActivity(Player player) {
|
||||
|
||||
lastActivities.put(player.getUniqueId(), System.currentTimeMillis());
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the timestamp of an interaction (click, attack, menu use, block
|
||||
* place/break) for use in cadence analysis. Bounded to {@link #MAX_SAMPLES}
|
||||
* per player.
|
||||
*/
|
||||
public void recordInteraction(Player player) {
|
||||
|
||||
final Deque<Long> times = interactionTimes.computeIfAbsent(player.getUniqueId(), k -> new ArrayDeque<>());
|
||||
final long now = System.currentTimeMillis();
|
||||
|
||||
// dedupe events that share a millisecond (some actions fire multiple
|
||||
// events) so duplicates cannot inflate the sample count or produce
|
||||
// zero-interval (degenerate) cadence.
|
||||
if (!times.isEmpty() && times.peekLast().longValue() == now) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
times.addLast(now);
|
||||
|
||||
while (times.size() > MAX_SAMPLES) {
|
||||
|
||||
times.removeFirst();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the player's recent interaction cadence looks automated:
|
||||
* a sustained burst (at least {@code minSamples} interactions within
|
||||
* {@code recentSpanMs}) whose timing regularity (coefficient of variation) is
|
||||
* below {@code cvThreshold}. Too few or too sparse samples are always treated
|
||||
* as human (returns false).
|
||||
*/
|
||||
public boolean isRoboticCadence(Player player, int minSamples, long recentSpanMs, double cvThreshold) {
|
||||
|
||||
final int floor = Math.max(minSamples, 2);
|
||||
final Deque<Long> times = interactionTimes.get(player.getUniqueId());
|
||||
if (times == null || times.size() < floor) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
final long cutoff = System.currentTimeMillis() - recentSpanMs;
|
||||
final List<Long> recent = new ArrayList<>();
|
||||
for (final long t : times) {
|
||||
|
||||
if (t >= cutoff) {
|
||||
|
||||
recent.add(t);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (recent.size() < floor) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
final int n = recent.size() - 1;
|
||||
double sum = 0.0D;
|
||||
final double[] intervals = new double[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
|
||||
final double interval = recent.get(i + 1) - recent.get(i);
|
||||
intervals[i] = interval;
|
||||
sum += interval;
|
||||
|
||||
}
|
||||
|
||||
final double mean = sum / n;
|
||||
if (mean <= 0.0D) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
double squared = 0.0D;
|
||||
for (final double interval : intervals) {
|
||||
|
||||
final double delta = interval - mean;
|
||||
squared += delta * delta;
|
||||
|
||||
}
|
||||
|
||||
final double cv = Math.sqrt(squared / n) / mean;
|
||||
return cv < cvThreshold;
|
||||
|
||||
}
|
||||
|
||||
private static final class TargetState {
|
||||
|
||||
private String key;
|
||||
private int count;
|
||||
private long time;
|
||||
|
||||
}
|
||||
|
||||
private static final class Interaction {
|
||||
|
||||
private final long time;
|
||||
private final String key;
|
||||
|
||||
private Interaction(long time, String key) {
|
||||
|
||||
this.time = time;
|
||||
this.key = key;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// record an interaction target for the windowed diversity check, dropping
|
||||
// entries older than one idle window and bounding history to MAX_SAMPLES.
|
||||
public void recordTarget(Player player, String targetKey) {
|
||||
|
||||
final Deque<Interaction> history = interactionHistory.computeIfAbsent(player.getUniqueId(), k -> new ArrayDeque<>());
|
||||
final long now = System.currentTimeMillis();
|
||||
history.addLast(new Interaction(now, targetKey));
|
||||
|
||||
final int timeoutSeconds = AFKOG.getPlugin().getAfkConfig().getAfkTimeoutSeconds();
|
||||
final long windowMs = (timeoutSeconds > 0 ? timeoutSeconds : 300) * 1000L;
|
||||
final long cutoff = now - windowMs;
|
||||
while (!history.isEmpty() && (history.peekFirst().time < cutoff || history.size() > MAX_SAMPLES)) {
|
||||
|
||||
history.removeFirst();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// true when recent interaction is low-variety automation: at least
|
||||
// AUTOCLICK_DIVERSITY_MIN_CLICKS interactions in one idle window across no more
|
||||
// than AUTOCLICK_MAX_DISTINCT_TARGETS distinct targets. catches auto-clickers
|
||||
// that rotate among a few buttons/levers/slots at any rate or jitter, which the
|
||||
// same-target streak and short-window cadence both miss; a roaming player
|
||||
// touches many distinct targets and is unaffected.
|
||||
public boolean isLowDiversityAutomation(Player player) {
|
||||
|
||||
final Deque<Interaction> history = interactionHistory.get(player.getUniqueId());
|
||||
if (history == null || history.size() < AUTOCLICK_DIVERSITY_MIN_CLICKS) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
final int timeoutSeconds = AFKOG.getPlugin().getAfkConfig().getAfkTimeoutSeconds();
|
||||
final long windowMs = (timeoutSeconds > 0 ? timeoutSeconds : 300) * 1000L;
|
||||
final long cutoff = System.currentTimeMillis() - windowMs;
|
||||
|
||||
final Set<String> distinct = new HashSet<>();
|
||||
int count = 0;
|
||||
for (final Interaction interaction : history) {
|
||||
|
||||
if (interaction.time < cutoff) {
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
count++;
|
||||
distinct.add(interaction.key);
|
||||
|
||||
}
|
||||
|
||||
return count >= AUTOCLICK_DIVERSITY_MIN_CLICKS && distinct.size() <= AUTOCLICK_MAX_DISTINCT_TARGETS;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an interaction against a target identity (a block position, an
|
||||
* entity, an inventory slot, or "air") and returns true once the player has
|
||||
* hit the SAME target at least {@code autoclick-max-same-target} times in a
|
||||
* row without interacting with any different target in between, within the
|
||||
* idle window. This catches auto-clicking a single button/lever/sign/slot or
|
||||
* empty air at any rate or jitter. A different target resets the streak.
|
||||
*/
|
||||
public boolean isRepeatedTarget(Player player, String targetKey) {
|
||||
|
||||
final AfkConfig config = AFKOG.getPlugin().getAfkConfig();
|
||||
final int max = config.getAutoclickMaxSameTarget();
|
||||
final int timeoutSeconds = config.getAfkTimeoutSeconds();
|
||||
// a target hit more than one idle window ago can no longer keep the
|
||||
// player non-AFK, so it starts a fresh streak.
|
||||
final long windowMs = (timeoutSeconds > 0 ? timeoutSeconds : 300) * 1000L;
|
||||
final long now = System.currentTimeMillis();
|
||||
|
||||
final TargetState state = targetStates.computeIfAbsent(player.getUniqueId(), k -> new TargetState());
|
||||
if (targetKey.equals(state.key) && now - state.time <= windowMs) {
|
||||
|
||||
state.count++;
|
||||
|
||||
} else {
|
||||
|
||||
state.key = targetKey;
|
||||
state.count = 1;
|
||||
|
||||
}
|
||||
state.time = now;
|
||||
|
||||
return state.count >= max;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper applying the configured cadence thresholds.
|
||||
*/
|
||||
public boolean isAutomatedCadence(Player player) {
|
||||
|
||||
final AfkConfig config = AFKOG.getPlugin().getAfkConfig();
|
||||
return isRoboticCadence(player, config.getAutoclickMinClicks(), config.getAutoclickWindowSeconds() * 1000L, config.getAutoclickCvThreshold());
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* True when combat activity should be withheld: either a robotic cadence or a
|
||||
* recent NoCheatPlus attack-frequency flag (when NoCheatPlus is installed).
|
||||
*/
|
||||
public boolean isCombatAutomated(Player player) {
|
||||
|
||||
final AfkConfig config = AFKOG.getPlugin().getAfkConfig();
|
||||
if (isRecentlyAttackFlagged(player, config.getNcpFlagWindowSeconds() * 1000L)) {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
return isAutomatedCadence(player);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that NoCheatPlus flagged the player for excessive attack frequency.
|
||||
*/
|
||||
public void flagAttackFrequency(Player player) {
|
||||
|
||||
lastAttackFlags.put(player.getUniqueId(), System.currentTimeMillis());
|
||||
|
||||
}
|
||||
|
||||
private boolean isRecentlyAttackFlagged(Player player, long windowMs) {
|
||||
|
||||
final Long flagged = lastAttackFlags.get(player.getUniqueId());
|
||||
return flagged != null && System.currentTimeMillis() - flagged < windowMs;
|
||||
|
||||
}
|
||||
|
||||
public void initializeSession(Player player) {
|
||||
|
||||
final UUID playerId = player.getUniqueId();
|
||||
afkPlayers.remove(playerId);
|
||||
afkPositions.remove(playerId);
|
||||
interactionTimes.remove(playerId);
|
||||
interactionHistory.remove(playerId);
|
||||
targetStates.remove(playerId);
|
||||
lastAttackFlags.remove(playerId);
|
||||
lastActivities.put(playerId, System.currentTimeMillis());
|
||||
|
||||
}
|
||||
|
||||
public void clearSession(Player player) {
|
||||
|
||||
final UUID playerId = player.getUniqueId();
|
||||
afkPlayers.remove(playerId);
|
||||
afkPositions.remove(playerId);
|
||||
interactionTimes.remove(playerId);
|
||||
interactionHistory.remove(playerId);
|
||||
targetStates.remove(playerId);
|
||||
lastAttackFlags.remove(playerId);
|
||||
lastActivities.remove(playerId);
|
||||
|
||||
}
|
||||
|
||||
public void clearLegacyInvulnerability(Player player) {
|
||||
|
||||
if (!isAfk(player) && player.isInvulnerable()) {
|
||||
|
||||
player.setInvulnerable(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public long getLastActivity(Player player) {
|
||||
|
||||
return lastActivities.getOrDefault(player.getUniqueId(), System.currentTimeMillis());
|
||||
|
||||
}
|
||||
|
||||
public void startAfkCheckTask(AFKOG plugin) {
|
||||
|
||||
new BukkitRunnable() {
|
||||
|
||||
final int timeoutSeconds = plugin.getAfkConfig().getAfkTimeoutSeconds();
|
||||
final int kickSeconds = plugin.getAfkConfig().getAfkKickSeconds();
|
||||
final String kickMessage = plugin.getAfkConfig().getAfkKickMessage();
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
|
||||
plugin.getServer().getOnlinePlayers().forEach(player -> {
|
||||
|
||||
final long idleMillis = System.currentTimeMillis() - getLastActivity(player);
|
||||
|
||||
if (kickSeconds > 0 && idleMillis > kickSeconds * 1000L && !player.hasPermission("afkog.exempt")) {
|
||||
|
||||
player.kick(legacyClickable(kickMessage));
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (isAfk(player)) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (timeoutSeconds > 0 && idleMillis > timeoutSeconds * 1000L) {
|
||||
|
||||
setAfk(player);
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Every 5 seconds.
|
||||
}.runTaskTimer(plugin, 20L, 100L);
|
||||
|
||||
}
|
||||
|
||||
if (System.currentTimeMillis() - getLastActivity(player) > timeoutSeconds * 1000L) {
|
||||
setAfk(player);
|
||||
}
|
||||
});
|
||||
}
|
||||
}.runTaskTimer(plugin, 20L, 100L); // every 5 seconds
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* This file is part of the Minecraft Simple Timer project.
|
||||
* Licensed under the MIT License. See the LICENSE file in the project root for full license information.
|
||||
*/
|
||||
|
||||
package de.codecrafter.smartAfk.utils;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import de.codecrafter.smartAfk.SmartAfk;
|
||||
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
|
||||
public class UpdateChecker {
|
||||
private static final String githubApiUrl = "https://api.github.com/repos/IschdeFelin/mc-smart-afk/releases/latest";
|
||||
private final SmartAfk plugin;
|
||||
|
||||
private boolean updateAvailable = false;
|
||||
private String latestVersion = "";
|
||||
private String currentVersion = "";
|
||||
|
||||
public UpdateChecker(SmartAfk plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
public void check() {
|
||||
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
HttpURLConnection conn = (HttpURLConnection) URI.create(githubApiUrl).toURL().openConnection();
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json");
|
||||
conn.setRequestProperty("User-Agent", "SimpleTimerUpdateChecker");
|
||||
|
||||
try (InputStreamReader reader = new InputStreamReader(conn.getInputStream())) {
|
||||
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
|
||||
String latestVersion = normalizeVersion(json.get("tag_name").getAsString());
|
||||
String currentVersion = normalizeVersion(plugin.getDescription().getVersion());
|
||||
|
||||
if (isNewerVersion(currentVersion, latestVersion)) {
|
||||
this.updateAvailable = true;
|
||||
this.latestVersion = latestVersion;
|
||||
this.currentVersion = currentVersion;
|
||||
plugin.getLogger().warning("A new version is available: " + latestVersion + " (you have " + currentVersion + ")");
|
||||
} else {
|
||||
plugin.getLogger().info("You are using the latest version (" + currentVersion + ")");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().severe("Failed to check for updates: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String normalizeVersion(String version) {
|
||||
return version.replace("v", "").replace("_", "-").trim().toLowerCase();
|
||||
}
|
||||
|
||||
private boolean isNewerVersion(String current, String latest) {
|
||||
String[] currentParts = current.split("-", 2);
|
||||
String currentBase = currentParts[0];
|
||||
boolean currentIsPreRelease = currentParts.length > 1;
|
||||
|
||||
String[] latestParts = latest.split("-", 2);
|
||||
String latestBase = latestParts[0];
|
||||
boolean latestIsPreRelease = latestParts.length > 1;
|
||||
|
||||
String[] currentNumbers = currentBase.split("\\.");
|
||||
String[] latestNumbers = latestBase.split("\\.");
|
||||
|
||||
int length = Math.max(currentNumbers.length, latestNumbers.length);
|
||||
for (int i = 0; i < length; i++) {
|
||||
int curr = i < currentNumbers.length ? parseSafe(currentNumbers[i]) : 0;
|
||||
int lat = i < latestNumbers.length ? parseSafe(latestNumbers[i]) : 0;
|
||||
if (curr < lat) return true; // A newer version exists -> return true
|
||||
if (curr > lat) return false; // Already up to date -> return false
|
||||
}
|
||||
|
||||
// If both version are equal -> check if current version is pre-release and latest not
|
||||
return currentIsPreRelease && !latestIsPreRelease;
|
||||
}
|
||||
|
||||
private int parseSafe(String number) {
|
||||
try {
|
||||
return Integer.parseInt(number);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isUpdateAvailable() {
|
||||
return updateAvailable;
|
||||
}
|
||||
|
||||
public String getLatestVersion() {
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
public String getCurrentVersion() {
|
||||
return currentVersion;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,73 @@
|
||||
# the time in seconds of inactivity before the player is marked as afk
|
||||
# use '-1' to disable this feature
|
||||
afk-timeout-seconds: 120
|
||||
afk-timeout-seconds: 300
|
||||
|
||||
# prevent afk players from moving
|
||||
freeze-afk-players: true
|
||||
freeze-afk-players: false
|
||||
|
||||
# cancel afk status if the player jumps
|
||||
# ONLY WORKS IF 'freeze-afk-players' IS TRUE
|
||||
cancel-afk-on-jump: true
|
||||
|
||||
# the player cannot take damage when he is afk
|
||||
# the player cannot take damage when they are afk
|
||||
invulnerable-during-afk: true
|
||||
|
||||
# broadcast a chat message to everyone when a player goes AFK while in one of
|
||||
# these worlds. leave the list empty to disable the broadcast entirely.
|
||||
afk-broadcast-worlds:
|
||||
- world
|
||||
- world_nether
|
||||
- world_the_end
|
||||
|
||||
# message broadcast when a player goes AFK in a whitelisted world.
|
||||
# supports legacy '&' color codes. %player% is replaced with the player name and
|
||||
# %prefix% with the player's LuckPerms prefix (empty if LuckPerms is not installed).
|
||||
# the prefix's trailing color/formatting bleeds into the name (no color is forced
|
||||
# before %player%); add a '&' code before %player% if you want to override it.
|
||||
afk-broadcast-message: "%prefix%%player% &7is now AFK."
|
||||
|
||||
# message broadcast when a player stops being AFK in a whitelisted world.
|
||||
# supports legacy '&' color codes. %player% is replaced with the player name and
|
||||
# %prefix% with the player's LuckPerms prefix (empty if LuckPerms is not installed).
|
||||
# the prefix's trailing color/formatting bleeds into the name (no color is forced
|
||||
# before %player%); add a '&' code before %player% if you want to override it.
|
||||
afk-broadcast-return-message: "%prefix%%player% &7is no longer AFK."
|
||||
|
||||
# kick players after this many seconds of true inactivity (no movement, no input).
|
||||
# set to -1 to disable.
|
||||
afk-kick-seconds: 1200
|
||||
|
||||
# kick message shown to kicked players. supports legacy '&' color codes.
|
||||
afk-kick-message: "&cYou have been kicked for being AFK too long.\n&7Buy a rank at &bhttps://shop.trueog.net &7to bypass the AFK timer."
|
||||
|
||||
# --- auto-click detection ---
|
||||
# interactions (clicks, attacks, menu use, block place/break) only count as
|
||||
# anti-AFK activity when their timing looks human. a steady, machine-regular
|
||||
# cadence is treated as automation and does NOT reset the AFK timer, so
|
||||
# auto-clickers and combat grinders still go AFK. real block-to-block movement
|
||||
# (walking, water-pushed AFK pools) is unaffected. auto-fishing is intentionally
|
||||
# NOT handled here; pair with a dedicated anti-auto-fishing plugin.
|
||||
|
||||
# minimum number of recent interactions before cadence is judged. below this,
|
||||
# input is always treated as human, so occasional clickers are never flagged.
|
||||
autoclick-min-clicks: 10
|
||||
|
||||
# only interactions within this many seconds count toward the cadence judgement.
|
||||
autoclick-window-seconds: 4
|
||||
|
||||
# timing-regularity cutoff (coefficient of variation). lower is stricter (only
|
||||
# very steady cadence is flagged); higher catches more automation but may flag
|
||||
# fast, rhythmic human clicking. 0.18 catches typical auto-clickers including
|
||||
# light jitter while leaving normal play alone.
|
||||
autoclick-cv-threshold: 0.18
|
||||
|
||||
# how many times in a row a player may interact with the SAME target (one
|
||||
# button/lever/sign, one block, one menu slot, or empty air) before those
|
||||
# interactions stop counting as activity. catches auto-clicking a single target
|
||||
# at any speed or jitter. a real player varies targets, which resets the count.
|
||||
# lower is stricter. counts within one afk-timeout-seconds window.
|
||||
autoclick-max-same-target: 6
|
||||
|
||||
# if NoCheatPlus is installed, an attack-frequency violation within this many
|
||||
# seconds suppresses combat activity credit (backstop for fast attack-clickers).
|
||||
nocheatplus-flag-window-seconds: 2
|
||||
@@ -1,10 +1,15 @@
|
||||
name: SmartAfk
|
||||
name: ${pluginName}
|
||||
version: ${version}
|
||||
main: de.codecrafter.smartAfk.SmartAfk
|
||||
api-version: '1.21'
|
||||
authors: [ Felix Heidisch ]
|
||||
website: https://github.com/IschdeFelin
|
||||
main: de.codecrafter.smartAfk.AFKOG
|
||||
api-version: ${apiVersion}
|
||||
authors: [ Felix_Heidisch, NotAlexNoyle ]
|
||||
website: https://git.trueog.net/trueog/AFK-OG
|
||||
softdepend: [PlaceholderAPI, NoCheatPlus, LuckPerms]
|
||||
commands:
|
||||
afk:
|
||||
description: Toggles the afk state.
|
||||
usage: /afk
|
||||
usage: /afk
|
||||
permissions:
|
||||
afkog.exempt:
|
||||
description: Exempts the player from being auto-kicked for AFK inactivity.
|
||||
default: false
|
||||
Reference in new issue
Block a user