Compare commits

...
24 Commits
Author SHA1 Message Date
NotAlexNoyle 1e037fac29 Update canonical Gitea link 2026-10-30 14:15:45 -04:00
NotAlexNoyle 28fa577a58 Exclude CODEOWNERS from language statistics 2026-10-29 16:18:51 -04:00
NotAlexNoyle 5b8b992769 Add CODEOWNERS (one staff approval) 2026-10-29 16:18:50 -04:00
NotAlexNoyle 4505fe3d88 Reset timer with /afk to exit afk mode 2026-07-09 13:03:39 -04:00
NotAlexNoyle 56410af86e Fix duplicate AFK messages 2026-06-25 17:16:19 -04:00
NotAlexNoyle 72501a60c6 Fix luckperms prefix formatting 2026-06-25 17:02:53 -04:00
NotAlexNoyle 51300c87fd Luckperms prefix support 2026-06-25 16:45:22 -04:00
NotAlexNoyle 0de1738130 Add AFK broadcasts for main worlds 2026-06-24 17:16:50 -04:00
NotAlexNoyle 9e62db8b12 Improve anti autoclicker 2026-06-24 16:46:06 -04:00
NotAlexNoyle 244b426c96 Fix placeholder 2026-06-24 11:52:11 -04:00
NotAlexNoyle 50fd7c4a49 Advanced anti AFK for clicking (except afk pools, fishing) 2026-06-22 09:13:58 -04:00
NotAlexNoyle 868623c7d5 Clickable store message in kick 2026-05-20 08:22:36 -04:00
NotAlexNoyle 1e6aa29415 Add shop link 2026-05-18 08:36:48 -04:00
NotAlexNoyle 7d4f6a70f2 Fix AFK persisting after quit 2026-05-15 10:51:43 -04:00
NotAlexNoyle 727bc657cb Update prefix 2026-05-11 11:40:12 -04:00
NotAlexNoyle 5866f9ad74 Bring in line with 1.18 afk plugin features 2026-05-11 07:11:17 -04:00
NotAlexNoyle d73200d175 Refactor for TrueOG Network 2026-05-07 14:11:00 -04:00
NotAlexNoyle 93277277df Use live TrueOG Network config file 2026-04-29 14:15:00 -04:00
NotAlexNoyle 2a4efda81e Fix old AFK states 2026-04-21 15:02:16 -04:00
NotAlexNoyle 3fd0ee7ef1 Fix player invulnerability 2026-04-21 14:30:11 -04:00
NotAlexNoyle a3f2a4f807 Un-AFK upon player activity 2026-04-21 14:17:44 -04:00
NotAlexNoyle f887d56434 Remove update checker 2026-04-13 07:24:22 -04:00
NotAlexNoyle 35b48eaa4e Update README.md 2026-03-30 13:07:31 -04:00
NotAlexNoyle 3cee1beff4 Use Kotlin DSL with gradle 8.14.3, add %afkog_status% placeholder 2026-03-30 13:05:52 -04:00
24 changed files with 1624 additions and 445 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
.gitea/CODEOWNERS linguist-detectable=false
+3
View File
@@ -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
View File
@@ -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
+39 -12
View File
@@ -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/`.
---
-64
View File
@@ -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
}
}
+84
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
version=1.0.0-beta
+1 -1
View File
@@ -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
Vendored Regular → Executable
View File
File mode changed.
-1
View File
@@ -1 +0,0 @@
rootProject.name = 'SmartAfk'
+1
View File
@@ -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;
}
}
+63 -3
View File
@@ -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
+11 -6
View File
@@ -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