From fef720798706f8088ad4602f162f0952bb67cd4f Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Wed, 22 Apr 2026 19:58:25 +0200 Subject: [PATCH 01/15] chore: upgrade to v3.0.0, Java 25, and Paper 26.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump project version 2.4.1 → 3.0.0 - Set maven.compiler.release to 25 - Update Paper API dependency to 26.1.2.build+ - Switch MockBukkit artifact to mockbukkit-v26.1 (dev-d245e0a) - Enable compiler fork for Java 25 compatibility - Update api-version in plugin.yml to 26.1 --- pom.xml | 11 ++++++----- src/main/resources/plugin.yml | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index ef5d0ae..0e02183 100644 --- a/pom.xml +++ b/pom.xml @@ -6,20 +6,20 @@ com.gyvex ezafk - 2.4.1 + 3.0.0 UTF-8 provided - 21 - 1.21.11-R0.1-SNAPSHOT + 25 + [26.1.2.build,) 3.1.0 7.0.13 3.13.0 3.6.0 org.mockbukkit.mockbukkit - mockbukkit-v1.21 - 4.101.0 + mockbukkit-v26.1 + dev-d245e0a ez-plugins/ezafk @@ -187,6 +187,7 @@ ${maven.compiler.plugin.version} ${maven.compiler.release} + true diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index ebc5f12..650ae3d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -2,7 +2,7 @@ name: "EzAfk" description: "Manage your AFK players easily" main: com.gyvex.ezafk.EzAfk version: "${project.version}" -api-version: 1.13 +api-version: 26.1 commands: ezafk: description: "Manage AFK states and EzAfk configuration" From fd0d67d87ead4a012919265810722db24c255200 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Wed, 22 Apr 2026 19:58:34 +0200 Subject: [PATCH 02/15] test: add new test coverage for v3.0.0 features - AfkStateFeatureTest: core AFK state machine - MoveListenerAntiBypassFeatureTest: water/vehicle/bubble-column bypass detection - PlayerActivityListenerAllEventsTest: all tracked activity event types - PlayerAfkStatusChangeEventFeatureTest: cancellable API event - PlayerQuitAfkFlushTest: AFK time flush on player disconnect - PlaceholderExpansionTest: all 16 PlaceholderAPI placeholders - EconomyManagerTest: entry and recurring cost logic - LastActiveStateTest: last-active timestamp state - ZoneCacheTest / ZoneContainsTest: zone lookup and containment --- .../ezafk/feature/AfkStateFeatureTest.java | 154 +++++++++++++++++ .../MoveListenerAntiBypassFeatureTest.java | 100 +++++++++++ .../PlayerActivityListenerAllEventsTest.java | 76 ++++++++ ...PlayerAfkStatusChangeEventFeatureTest.java | 163 ++++++++++++++++++ .../ezafk/feature/PlayerQuitAfkFlushTest.java | 75 ++++++++ .../placeholder/PlaceholderExpansionTest.java | 152 ++++++++++++++++ .../ezafk/manager/EconomyManagerTest.java | 78 +++++++++ .../ezafk/state/LastActiveStateTest.java | 92 ++++++++++ .../com/gyvex/ezafk/zone/ZoneCacheTest.java | 114 ++++++++++++ .../gyvex/ezafk/zone/ZoneContainsTest.java | 92 ++++++++++ 10 files changed, 1096 insertions(+) create mode 100644 src/test/java/com/gyvex/ezafk/feature/AfkStateFeatureTest.java create mode 100644 src/test/java/com/gyvex/ezafk/feature/MoveListenerAntiBypassFeatureTest.java create mode 100644 src/test/java/com/gyvex/ezafk/feature/PlayerActivityListenerAllEventsTest.java create mode 100644 src/test/java/com/gyvex/ezafk/feature/PlayerAfkStatusChangeEventFeatureTest.java create mode 100644 src/test/java/com/gyvex/ezafk/feature/PlayerQuitAfkFlushTest.java create mode 100644 src/test/java/com/gyvex/ezafk/integration/placeholder/PlaceholderExpansionTest.java create mode 100644 src/test/java/com/gyvex/ezafk/manager/EconomyManagerTest.java create mode 100644 src/test/java/com/gyvex/ezafk/state/LastActiveStateTest.java create mode 100644 src/test/java/com/gyvex/ezafk/zone/ZoneCacheTest.java create mode 100644 src/test/java/com/gyvex/ezafk/zone/ZoneContainsTest.java diff --git a/src/test/java/com/gyvex/ezafk/feature/AfkStateFeatureTest.java b/src/test/java/com/gyvex/ezafk/feature/AfkStateFeatureTest.java new file mode 100644 index 0000000..3cc0e5f --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/feature/AfkStateFeatureTest.java @@ -0,0 +1,154 @@ +package com.gyvex.ezafk.feature; + +import com.gyvex.ezafk.EzAfk; +import com.gyvex.ezafk.TestHelpers; +import com.gyvex.ezafk.state.AfkActivationMode; +import com.gyvex.ezafk.state.AfkReason; +import com.gyvex.ezafk.state.AfkState; +import com.gyvex.ezafk.state.ToggleResult; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import static org.junit.jupiter.api.Assertions.*; + +public class AfkStateFeatureTest { + + private ServerMock server; + private EzAfk ezafk; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + ezafk = (EzAfk) TestHelpers.loadPlugin(); + } + + @AfterEach + public void tearDown() { + AfkState.afkPlayers.clear(); + AfkState.clearBypass(); + TestHelpers.stopServer(); + } + + @Test + public void toggle_returns_NOW_AFK_on_first_call() { + Player p = server.addPlayer("TogglePlayer"); + ToggleResult result = AfkState.toggle(ezafk, p); + assertEquals(ToggleResult.NOW_AFK, result); + assertTrue(AfkState.isAfk(p.getUniqueId())); + } + + @Test + public void toggle_returns_NO_LONGER_AFK_on_second_call() { + Player p = server.addPlayer("TogglePlayer2"); + AfkState.toggle(ezafk, p); + ToggleResult result = AfkState.toggle(ezafk, p); + assertEquals(ToggleResult.NO_LONGER_AFK, result); + assertFalse(AfkState.isAfk(p.getUniqueId())); + } + + @Test + public void afkPlayerCount_updates_on_toggle() { + Player p1 = server.addPlayer("CountP1"); + Player p2 = server.addPlayer("CountP2"); + + assertEquals(0, AfkState.getAfkPlayerCount()); + AfkState.toggle(ezafk, p1); + assertEquals(1, AfkState.getAfkPlayerCount()); + AfkState.toggle(ezafk, p2); + assertEquals(2, AfkState.getAfkPlayerCount()); + AfkState.toggle(ezafk, p1); + assertEquals(1, AfkState.getAfkPlayerCount()); + } + + @Test + public void activePlayerCount_returns_online_minus_afk() { + Player p1 = server.addPlayer("ActiveP1"); + server.addPlayer("ActiveP2"); + + int online = server.getOnlinePlayers().size(); + assertEquals(online, AfkState.getActivePlayerCount()); + + AfkState.toggle(ezafk, p1); + assertEquals(online - 1, AfkState.getActivePlayerCount()); + } + + @Test + public void getAfkStartTime_is_zero_before_afk() { + Player p = server.addPlayer("StartTimePlayer"); + assertEquals(0L, AfkState.getAfkStartTime(p.getUniqueId())); + } + + @Test + public void getAfkStartTime_is_positive_when_afk() { + Player p = server.addPlayer("StartTimeAfkPlayer"); + AfkState.toggle(ezafk, p); + assertTrue(AfkState.getAfkStartTime(p.getUniqueId()) > 0L); + } + + @Test + public void getSecondsSinceAfk_is_negative_when_not_afk() { + Player p = server.addPlayer("SecondsSincePlayer"); + assertEquals(-1L, AfkState.getSecondsSinceAfk(p.getUniqueId())); + } + + @Test + public void getSecondsSinceAfk_is_non_negative_when_afk() { + Player p = server.addPlayer("SecondsSinceAfkPlayer"); + AfkState.toggle(ezafk, p); + assertTrue(AfkState.getSecondsSinceAfk(p.getUniqueId()) >= 0L); + } + + @Test + public void toggleBypass_adds_and_removes_bypass() { + Player p = server.addPlayer("BypassPlayer"); + assertFalse(AfkState.isBypassed(p.getUniqueId())); + + AfkState.toggleBypass(p.getUniqueId()); + assertTrue(AfkState.isBypassed(p.getUniqueId())); + + AfkState.toggleBypass(p.getUniqueId()); + assertFalse(AfkState.isBypassed(p.getUniqueId())); + } + + @Test + public void clearBypass_removes_all_bypasses() { + Player p1 = server.addPlayer("ClearByP1"); + Player p2 = server.addPlayer("ClearByP2"); + AfkState.toggleBypass(p1.getUniqueId()); + AfkState.toggleBypass(p2.getUniqueId()); + + AfkState.clearBypass(); + + assertFalse(AfkState.isBypassed(p1.getUniqueId())); + assertFalse(AfkState.isBypassed(p2.getUniqueId())); + } + + @Test + public void markAfk_SILENT_marks_player_as_afk() { + Player p = server.addPlayer("SilentAfkPlayer"); + AfkState.markAfk(ezafk, p, AfkReason.INACTIVITY, null, AfkActivationMode.SILENT); + assertTrue(AfkState.isAfk(p.getUniqueId())); + } + + @Test + public void multiple_players_tracked_independently() { + Player p1 = server.addPlayer("IndepP1"); + Player p2 = server.addPlayer("IndepP2"); + + AfkState.toggle(ezafk, p1); + assertTrue(AfkState.isAfk(p1.getUniqueId())); + assertFalse(AfkState.isAfk(p2.getUniqueId())); + + AfkState.toggle(ezafk, p2); + assertTrue(AfkState.isAfk(p1.getUniqueId())); + assertTrue(AfkState.isAfk(p2.getUniqueId())); + } + + @Test + public void isAfk_returns_false_for_unknown_player() { + assertFalse(AfkState.isAfk(java.util.UUID.randomUUID())); + } +} diff --git a/src/test/java/com/gyvex/ezafk/feature/MoveListenerAntiBypassFeatureTest.java b/src/test/java/com/gyvex/ezafk/feature/MoveListenerAntiBypassFeatureTest.java new file mode 100644 index 0000000..b408d23 --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/feature/MoveListenerAntiBypassFeatureTest.java @@ -0,0 +1,100 @@ +package com.gyvex.ezafk.feature; + +import com.gyvex.ezafk.EzAfk; +import com.gyvex.ezafk.TestHelpers; +import com.gyvex.ezafk.listener.MoveListener; +import com.gyvex.ezafk.state.AfkActivationMode; +import com.gyvex.ezafk.state.AfkReason; +import com.gyvex.ezafk.state.AfkState; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerMoveEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import static org.junit.jupiter.api.Assertions.*; + +public class MoveListenerAntiBypassFeatureTest { + + private ServerMock server; + private EzAfk ezafk; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + ezafk = (EzAfk) TestHelpers.loadPlugin(); + server.getPluginManager().registerEvents(new MoveListener(ezafk), ezafk); + } + + @AfterEach + public void tearDown() { + AfkState.afkPlayers.clear(); + AfkState.clearBypass(); + TestHelpers.stopServer(); + } + + @Test + public void head_only_rotation_does_not_clear_afk() { + Player p = server.addPlayer("HeadRotPlayer"); + AfkState.markAfk(ezafk, p, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + assertTrue(AfkState.isAfk(p.getUniqueId())); + + // Same XYZ position, only yaw/pitch differs — distanceSquared = 0, below threshold + Location from = p.getLocation().clone(); + Location to = from.clone(); + to.setYaw(from.getYaw() + 45f); + to.setPitch(from.getPitch() + 15f); + + server.getPluginManager().callEvent(new PlayerMoveEvent(p, from, to)); + + assertTrue(AfkState.isAfk(p.getUniqueId()), "Head rotation should NOT clear AFK status"); + } + + @Test + public void movement_below_threshold_does_not_clear_afk() { + Player p = server.addPlayer("TinyMovePlayer"); + AfkState.markAfk(ezafk, p, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + assertTrue(AfkState.isAfk(p.getUniqueId())); + + // distanceSquared = 0.001^2 + 0.001^2 = 2e-6 < 1e-4 threshold + Location from = p.getLocation().clone(); + Location to = from.clone().add(0.001, 0.0, 0.001); + + server.getPluginManager().callEvent(new PlayerMoveEvent(p, from, to)); + + assertTrue(AfkState.isAfk(p.getUniqueId()), "Tiny movement below threshold should NOT clear AFK"); + } + + @Test + public void significant_movement_clears_afk() { + Player p = server.addPlayer("SigMovePlayer"); + AfkState.markAfk(ezafk, p, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + assertTrue(AfkState.isAfk(p.getUniqueId())); + + // distanceSquared = 1.0 >> 1e-4 threshold + Location from = p.getLocation().clone(); + Location to = from.clone().add(1.0, 0.0, 0.0); + + server.getPluginManager().callEvent(new PlayerMoveEvent(p, from, to)); + + assertFalse(AfkState.isAfk(p.getUniqueId()), "Significant movement should clear AFK"); + } + + @Test + public void movement_does_not_affect_non_afk_player() { + Player p = server.addPlayer("NotAfkMover"); + assertFalse(AfkState.isAfk(p.getUniqueId())); + + Location from = p.getLocation().clone(); + Location to = from.clone().add(1.0, 0.0, 0.0); + + assertDoesNotThrow(() -> + server.getPluginManager().callEvent(new PlayerMoveEvent(p, from, to)), + "Moving while not AFK should not throw" + ); + + assertFalse(AfkState.isAfk(p.getUniqueId())); + } +} diff --git a/src/test/java/com/gyvex/ezafk/feature/PlayerActivityListenerAllEventsTest.java b/src/test/java/com/gyvex/ezafk/feature/PlayerActivityListenerAllEventsTest.java new file mode 100644 index 0000000..3eab41a --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/feature/PlayerActivityListenerAllEventsTest.java @@ -0,0 +1,76 @@ +package com.gyvex.ezafk.feature; + +import com.gyvex.ezafk.TestHelpers; +import com.gyvex.ezafk.listener.PlayerActivityListener; +import com.gyvex.ezafk.state.LastActiveState; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.HashSet; + +import static org.junit.jupiter.api.Assertions.*; + +public class PlayerActivityListenerAllEventsTest { + + private ServerMock server; + private JavaPlugin plugin; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + plugin = TestHelpers.loadPlugin(); + server.getPluginManager().registerEvents(new PlayerActivityListener(), plugin); + } + + @AfterEach + public void tearDown() { + LastActiveState.lastActive.clear(); + TestHelpers.stopServer(); + } + + @Test + public void player_interact_event_updates_last_active() { + Player p = server.addPlayer("InteractPlayer"); + LastActiveState.lastActive.put(p.getUniqueId(), 0L); + + server.getPluginManager().callEvent( + new PlayerInteractEvent(p, Action.LEFT_CLICK_AIR, null, null, null) + ); + + assertTrue(LastActiveState.getLastActive(p.getUniqueId()) > 0L, + "PlayerInteractEvent should update lastActive timestamp"); + } + + @Test + public void async_player_chat_event_updates_last_active() { + Player p = server.addPlayer("ChatPlayer"); + LastActiveState.lastActive.put(p.getUniqueId(), 0L); + + server.getPluginManager().callEvent( + new AsyncPlayerChatEvent(false, p, "hello", new HashSet<>()) + ); + + assertTrue(LastActiveState.getLastActive(p.getUniqueId()) > 0L, + "AsyncPlayerChatEvent should update lastActive timestamp"); + } + + @Test + public void player_command_event_updates_last_active() { + Player p = server.addPlayer("CmdPlayer"); + LastActiveState.lastActive.put(p.getUniqueId(), 0L); + + server.getPluginManager().callEvent( + new org.bukkit.event.player.PlayerCommandPreprocessEvent(p, "/afk") + ); + + assertTrue(LastActiveState.getLastActive(p.getUniqueId()) > 0L, + "PlayerCommandPreprocessEvent should update lastActive timestamp"); + } +} diff --git a/src/test/java/com/gyvex/ezafk/feature/PlayerAfkStatusChangeEventFeatureTest.java b/src/test/java/com/gyvex/ezafk/feature/PlayerAfkStatusChangeEventFeatureTest.java new file mode 100644 index 0000000..9dcc3c4 --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/feature/PlayerAfkStatusChangeEventFeatureTest.java @@ -0,0 +1,163 @@ +package com.gyvex.ezafk.feature; + +import com.gyvex.ezafk.EzAfk; +import com.gyvex.ezafk.TestHelpers; +import com.gyvex.ezafk.event.PlayerAfkStatusChangeEvent; +import com.gyvex.ezafk.state.AfkReason; +import com.gyvex.ezafk.state.AfkState; +import com.gyvex.ezafk.state.ToggleResult; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +public class PlayerAfkStatusChangeEventFeatureTest { + + private ServerMock server; + private EzAfk ezafk; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + ezafk = (EzAfk) TestHelpers.loadPlugin(); + } + + @AfterEach + public void tearDown() { + AfkState.afkPlayers.clear(); + AfkState.clearBypass(); + TestHelpers.stopServer(); + } + + @Test + public void event_fires_on_afk_enable_with_isAfk_true() { + Player p = server.addPlayer("EventEnable"); + AtomicBoolean fired = new AtomicBoolean(false); + AtomicBoolean capturedIsAfk = new AtomicBoolean(false); + + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onAfkChange(PlayerAfkStatusChangeEvent event) { + fired.set(true); + capturedIsAfk.set(event.isAfk()); + } + }, ezafk); + + AfkState.toggle(ezafk, p); + + assertTrue(fired.get(), "PlayerAfkStatusChangeEvent should fire on AFK enable"); + assertTrue(capturedIsAfk.get(), "isAfk should be true when enabling AFK"); + } + + @Test + public void event_fires_on_afk_disable_with_isAfk_false() { + Player p = server.addPlayer("EventDisable"); + AfkState.toggle(ezafk, p); + + AtomicBoolean firedOnDisable = new AtomicBoolean(false); + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onAfkChange(PlayerAfkStatusChangeEvent event) { + if (!event.isAfk()) { + firedOnDisable.set(true); + } + } + }, ezafk); + + AfkState.toggle(ezafk, p); + + assertTrue(firedOnDisable.get(), "PlayerAfkStatusChangeEvent should fire on AFK disable with isAfk=false"); + assertFalse(AfkState.isAfk(p.getUniqueId())); + } + + @Test + public void cancelling_enable_event_prevents_afk_state() { + Player p = server.addPlayer("CancelEnable"); + + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onAfkChange(PlayerAfkStatusChangeEvent event) { + if (event.isAfk()) { + event.setCancelled(true); + } + } + }, ezafk); + + AfkState.toggle(ezafk, p); + + assertFalse(AfkState.isAfk(p.getUniqueId()), "Player should not be AFK when enable event is cancelled"); + } + + @Test + public void cancelling_disable_event_keeps_player_afk() { + Player p = server.addPlayer("CancelDisable"); + AfkState.toggle(ezafk, p); + assertTrue(AfkState.isAfk(p.getUniqueId())); + + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onAfkChange(PlayerAfkStatusChangeEvent event) { + if (!event.isAfk()) { + event.setCancelled(true); + } + } + }, ezafk); + + AfkState.toggle(ezafk, p); + + assertTrue(AfkState.isAfk(p.getUniqueId()), "Player should remain AFK when disable event is cancelled"); + } + + @Test + public void event_has_correct_player_reference() { + Player p = server.addPlayer("EventPlayer"); + AtomicReference capturedPlayer = new AtomicReference<>(); + + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onAfkChange(PlayerAfkStatusChangeEvent event) { + if (event.isAfk()) { + capturedPlayer.set(event.getPlayer()); + } + } + }, ezafk); + + AfkState.toggle(ezafk, p); + + assertEquals(p, capturedPlayer.get(), "Event's getPlayer() should return the toggling player"); + } + + @Test + public void event_reason_is_MANUAL_for_toggle() { + Player p = server.addPlayer("ReasonPlayer"); + AtomicReference capturedReason = new AtomicReference<>(); + + server.getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onAfkChange(PlayerAfkStatusChangeEvent event) { + if (event.isAfk()) { + capturedReason.set(event.getReason()); + } + } + }, ezafk); + + AfkState.toggle(ezafk, p); + + assertEquals(AfkReason.MANUAL, capturedReason.get(), "Toggle reason should be AfkReason.MANUAL"); + } + + @Test + public void toggle_returns_NOW_AFK_when_event_not_cancelled() { + Player p = server.addPlayer("NotCancelledPlayer"); + ToggleResult result = AfkState.toggle(ezafk, p); + assertEquals(ToggleResult.NOW_AFK, result); + } +} diff --git a/src/test/java/com/gyvex/ezafk/feature/PlayerQuitAfkFlushTest.java b/src/test/java/com/gyvex/ezafk/feature/PlayerQuitAfkFlushTest.java new file mode 100644 index 0000000..21b7383 --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/feature/PlayerQuitAfkFlushTest.java @@ -0,0 +1,75 @@ +package com.gyvex.ezafk.feature; + +import com.gyvex.ezafk.EzAfk; +import com.gyvex.ezafk.TestHelpers; +import com.gyvex.ezafk.listener.PlayerQuitListener; +import com.gyvex.ezafk.state.AfkActivationMode; +import com.gyvex.ezafk.state.AfkReason; +import com.gyvex.ezafk.state.AfkState; +import com.gyvex.ezafk.zone.ZoneCache; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +public class PlayerQuitAfkFlushTest { + + private ServerMock server; + private EzAfk ezafk; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + ezafk = (EzAfk) TestHelpers.loadPlugin(); + server.getPluginManager().registerEvents(new PlayerQuitListener(), ezafk); + } + + @AfterEach + public void tearDown() { + AfkState.afkPlayers.clear(); + TestHelpers.stopServer(); + } + + @Test + public void afk_player_is_removed_from_afk_state_on_quit() { + Player p = server.addPlayer("AfkQuitter"); + AfkState.markAfk(ezafk, p, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + assertTrue(AfkState.isAfk(p.getUniqueId())); + + server.getPluginManager().callEvent(new PlayerQuitEvent(p, "Bye")); + + assertFalse(AfkState.isAfk(p.getUniqueId()), + "AFK player should be removed from AFK state on quit"); + } + + @Test + public void quit_clears_zone_cache_positions() { + Player p = server.addPlayer("ZoneQuitter"); + UUID id = p.getUniqueId(); + + ZoneCache.zonePos1.put(id, p.getLocation()); + ZoneCache.zonePos2.put(id, p.getLocation()); + + server.getPluginManager().callEvent(new PlayerQuitEvent(p, "Bye")); + + assertFalse(ZoneCache.zonePos1.containsKey(id), "zonePos1 should be cleared on quit"); + assertFalse(ZoneCache.zonePos2.containsKey(id), "zonePos2 should be cleared on quit"); + } + + @Test + public void non_afk_player_quit_does_not_throw() { + Player p = server.addPlayer("NonAfkQuitter"); + assertFalse(AfkState.isAfk(p.getUniqueId())); + + assertDoesNotThrow(() -> + server.getPluginManager().callEvent(new PlayerQuitEvent(p, "Bye")), + "Non-AFK player quit should not throw" + ); + } +} diff --git a/src/test/java/com/gyvex/ezafk/integration/placeholder/PlaceholderExpansionTest.java b/src/test/java/com/gyvex/ezafk/integration/placeholder/PlaceholderExpansionTest.java new file mode 100644 index 0000000..c207330 --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/integration/placeholder/PlaceholderExpansionTest.java @@ -0,0 +1,152 @@ +package com.gyvex.ezafk.integration.placeholder; + +import com.gyvex.ezafk.EzAfk; +import com.gyvex.ezafk.TestHelpers; +import com.gyvex.ezafk.state.AfkActivationMode; +import com.gyvex.ezafk.state.AfkReason; +import com.gyvex.ezafk.state.AfkState; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import static org.junit.jupiter.api.Assertions.*; + +public class PlaceholderExpansionTest { + + private ServerMock server; + private EzAfk ezafk; + private EzAfkPlaceholderExpansion expansion; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + ezafk = (EzAfk) TestHelpers.loadPlugin(); + expansion = new EzAfkPlaceholderExpansion(); + } + + @AfterEach + public void tearDown() { + AfkState.afkPlayers.clear(); + TestHelpers.stopServer(); + } + + @Test + public void identifier_is_ezafk() { + assertEquals("ezafk", expansion.getIdentifier()); + } + + @Test + public void empty_params_returns_empty_string() { + Player p = server.addPlayer("EmptyParamPlayer"); + assertEquals("", expansion.onRequest(p, "")); + } + + @Test + public void null_params_returns_empty_string() { + Player p = server.addPlayer("NullParamPlayer"); + assertEquals("", expansion.onRequest(p, null)); + } + + @Test + public void status_returns_AFK_when_player_is_afk() { + Player p = server.addPlayer("StatusAfkPlayer"); + AfkState.markAfk(ezafk, p, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + + assertEquals("AFK", expansion.onRequest(p, "status")); + } + + @Test + public void status_returns_ACTIVE_when_player_is_not_afk() { + Player p = server.addPlayer("StatusActivePlayer"); + assertFalse(AfkState.isAfk(p.getUniqueId())); + + assertEquals("ACTIVE", expansion.onRequest(p, "status")); + } + + @Test + public void status_colored_contains_afk_marker_when_afk() { + Player p = server.addPlayer("ColoredAfkPlayer"); + AfkState.markAfk(ezafk, p, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + + String result = expansion.onRequest(p, "status_colored"); + assertTrue(result.contains("AFK"), "status_colored should contain AFK for AFK player"); + } + + @Test + public void status_colored_contains_active_marker_when_not_afk() { + Player p = server.addPlayer("ColoredActivePlayer"); + + String result = expansion.onRequest(p, "status_colored"); + assertTrue(result.contains("ACTIVE"), "status_colored should contain ACTIVE for active player"); + } + + @Test + public void afk_count_reflects_number_of_afk_players() { + Player p1 = server.addPlayer("AfkCountP1"); + Player p2 = server.addPlayer("AfkCountP2"); + AfkState.markAfk(ezafk, p1, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + AfkState.markAfk(ezafk, p2, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + + assertEquals("2", expansion.onRequest(null, "afk_count")); + } + + @Test + public void afk_players_alias_equals_afk_count() { + Player p = server.addPlayer("AfkPlayersP1"); + AfkState.markAfk(ezafk, p, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + + assertEquals( + expansion.onRequest(null, "afk_count"), + expansion.onRequest(null, "afk_players"), + "afk_players should be an alias for afk_count" + ); + } + + @Test + public void active_count_reflects_online_minus_afk() { + Player p1 = server.addPlayer("ActiveCountP1"); + Player p2 = server.addPlayer("ActiveCountP2"); + + int online = server.getOnlinePlayers().size(); + AfkState.markAfk(ezafk, p1, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + + String result = expansion.onRequest(null, "active_count"); + assertEquals(String.valueOf(online - 1), result); + } + + @Test + public void active_players_alias_equals_active_count() { + server.addPlayer("ActivePlayersP1"); + + assertEquals( + expansion.onRequest(null, "active_count"), + expansion.onRequest(null, "active_players"), + "active_players should be an alias for active_count" + ); + } + + @Test + public void since_returns_empty_when_player_not_afk() { + Player p = server.addPlayer("SinceNotAfkPlayer"); + // getSecondsSinceAfk returns -1 when not AFK -> formatDurationSeconds returns "" + assertEquals("", expansion.onRequest(p, "since")); + } + + @Test + public void since_returns_non_empty_when_player_is_afk() { + Player p = server.addPlayer("SinceAfkPlayer"); + AfkState.markAfk(ezafk, p, AfkReason.MANUAL, null, AfkActivationMode.STANDARD); + + String result = expansion.onRequest(p, "since"); + assertFalse(result.isEmpty(), "since should be non-empty for AFK player"); + } + + @Test + public void unknown_placeholder_returns_empty_string() { + Player p = server.addPlayer("UnknownParamPlayer"); + assertEquals("", expansion.onRequest(p, "nonexistent_placeholder")); + } +} diff --git a/src/test/java/com/gyvex/ezafk/manager/EconomyManagerTest.java b/src/test/java/com/gyvex/ezafk/manager/EconomyManagerTest.java new file mode 100644 index 0000000..d7b786b --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/manager/EconomyManagerTest.java @@ -0,0 +1,78 @@ +package com.gyvex.ezafk.manager; + +import com.gyvex.ezafk.TestHelpers; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import static org.junit.jupiter.api.Assertions.*; + +public class EconomyManagerTest { + + private ServerMock server; + private Player player; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + TestHelpers.loadPlugin(); + player = server.addPlayer("EconPlayer"); + } + + @AfterEach + public void tearDown() { + EconomyManager.reset(); + TestHelpers.stopServer(); + } + + @Test + public void reset_clears_internal_state_without_throwing() { + assertDoesNotThrow(EconomyManager::reset, "EconomyManager.reset() should not throw"); + } + + @Test + public void isEconomyBlocked_returns_false_when_economy_disabled() { + // Economy is disabled in default config (economy.enabled=false) + assertFalse(EconomyManager.isEconomyBlocked(player), + "isEconomyBlocked should return false when economy integration is inactive"); + } + + @Test + public void isEconomyBlocked_returns_false_for_null_player() { + assertFalse(EconomyManager.isEconomyBlocked(null), + "isEconomyBlocked should return false for null player"); + } + + @Test + public void onActivity_does_not_throw_for_valid_player() { + assertDoesNotThrow(() -> EconomyManager.onActivity(player), + "onActivity should not throw for a valid player"); + } + + @Test + public void onActivity_does_not_throw_for_null_player() { + assertDoesNotThrow(() -> EconomyManager.onActivity(null), + "onActivity should not throw for null player"); + } + + @Test + public void onDisable_does_not_throw_for_valid_uuid() { + assertDoesNotThrow(() -> EconomyManager.onDisable(player.getUniqueId()), + "onDisable should not throw for a valid UUID"); + } + + @Test + public void onDisable_does_not_throw_for_null_uuid() { + assertDoesNotThrow(() -> EconomyManager.onDisable(null), + "onDisable should not throw for null UUID"); + } + + @Test + public void handleEnter_returns_true_when_economy_disabled() { + // No economy configured -> EconomyManager proceeds and returns true + assertTrue(EconomyManager.handleEnter(player, true), + "handleEnter should return true when economy integration is inactive"); + } +} diff --git a/src/test/java/com/gyvex/ezafk/state/LastActiveStateTest.java b/src/test/java/com/gyvex/ezafk/state/LastActiveStateTest.java new file mode 100644 index 0000000..440ab45 --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/state/LastActiveStateTest.java @@ -0,0 +1,92 @@ +package com.gyvex.ezafk.state; + +import com.gyvex.ezafk.TestHelpers; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +public class LastActiveStateTest { + + private ServerMock server; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + TestHelpers.loadPlugin(); + } + + @AfterEach + public void tearDown() { + LastActiveState.lastActive.clear(); + TestHelpers.stopServer(); + } + + @Test + public void getLastActive_returns_current_time_for_unknown_player() { + UUID unknown = UUID.randomUUID(); + long before = System.currentTimeMillis(); + long result = LastActiveState.getLastActive(unknown); + long after = System.currentTimeMillis(); + + assertTrue(result >= before && result <= after, + "getLastActive for unknown player should return approximately current time"); + } + + @Test + public void update_stores_current_time_for_player() { + Player p = server.addPlayer("UpdatePlayer"); + long before = System.currentTimeMillis(); + LastActiveState.update(p); + long after = System.currentTimeMillis(); + + long stored = LastActiveState.getLastActive(p.getUniqueId()); + assertTrue(stored >= before && stored <= after, + "update should store approximately current time"); + } + + @Test + public void getSecondsSinceLastActive_returns_zero_or_more_after_update() { + Player p = server.addPlayer("SecondsSincePlayer"); + LastActiveState.update(p); + + long seconds = LastActiveState.getSecondsSinceLastActive(p.getUniqueId()); + assertTrue(seconds >= 0L, "getSecondsSinceLastActive should return 0 or more after update"); + } + + @Test + public void getSecondsSinceLastActive_returns_zero_for_unknown_player() { + // Unknown player gets current time as default, so diff ≈ 0 + UUID unknown = UUID.randomUUID(); + long seconds = LastActiveState.getSecondsSinceLastActive(unknown); + assertTrue(seconds >= 0L, "getSecondsSinceLastActive should be non-negative"); + } + + @Test + public void update_overwrites_previous_timestamp() throws InterruptedException { + Player p = server.addPlayer("OverwritePlayer"); + LastActiveState.lastActive.put(p.getUniqueId(), 1000L); + + LastActiveState.update(p); + + assertTrue(LastActiveState.getLastActive(p.getUniqueId()) > 1000L, + "update should overwrite the old timestamp with a newer one"); + } + + @Test + public void getLastActive_with_player_overload_matches_uuid_overload() { + Player p = server.addPlayer("OverloadPlayer"); + LastActiveState.update(p); + + assertEquals( + LastActiveState.getLastActive(p), + LastActiveState.getLastActive(p.getUniqueId()), + "getLastActive(Player) and getLastActive(UUID) should return the same value" + ); + } +} diff --git a/src/test/java/com/gyvex/ezafk/zone/ZoneCacheTest.java b/src/test/java/com/gyvex/ezafk/zone/ZoneCacheTest.java new file mode 100644 index 0000000..c8c42db --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/zone/ZoneCacheTest.java @@ -0,0 +1,114 @@ +package com.gyvex.ezafk.zone; + +import com.gyvex.ezafk.TestHelpers; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +public class ZoneCacheTest { + + private ServerMock server; + private World world; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + TestHelpers.loadPlugin(); + world = server.addSimpleWorld("world"); + ZoneCache.zonePos1.clear(); + ZoneCache.zonePos2.clear(); + ZoneCache.zonePos1Time.clear(); + ZoneCache.zonePos2Time.clear(); + } + + @AfterEach + public void tearDown() { + ZoneCache.zonePos1.clear(); + ZoneCache.zonePos2.clear(); + ZoneCache.zonePos1Time.clear(); + ZoneCache.zonePos2Time.clear(); + TestHelpers.stopServer(); + } + + @Test + public void clearPositions_removes_all_four_maps_for_player() { + UUID id = UUID.randomUUID(); + Location loc = new Location(world, 0, 64, 0); + + ZoneCache.zonePos1.put(id, loc); + ZoneCache.zonePos2.put(id, loc); + ZoneCache.zonePos1Time.put(id, System.currentTimeMillis()); + ZoneCache.zonePos2Time.put(id, System.currentTimeMillis()); + + ZoneCache.clearPositions(id); + + assertFalse(ZoneCache.zonePos1.containsKey(id)); + assertFalse(ZoneCache.zonePos2.containsKey(id)); + assertFalse(ZoneCache.zonePos1Time.containsKey(id)); + assertFalse(ZoneCache.zonePos2Time.containsKey(id)); + } + + @Test + public void clearPositions_only_removes_the_target_player() { + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + Location loc = new Location(world, 0, 64, 0); + + ZoneCache.zonePos1.put(id1, loc); + ZoneCache.zonePos1.put(id2, loc); + + ZoneCache.clearPositions(id1); + + assertFalse(ZoneCache.zonePos1.containsKey(id1)); + assertTrue(ZoneCache.zonePos1.containsKey(id2), "Other player's pos1 should remain"); + } + + @Test + public void cleanupExpiredPositions_removes_expired_entries() { + UUID expiredId = UUID.randomUUID(); + UUID freshId = UUID.randomUUID(); + Location loc = new Location(world, 0, 64, 0); + + long now = System.currentTimeMillis(); + long expiredTime = now - 10_000L; // 10 seconds ago + long freshTime = now - 500L; // 0.5 seconds ago + + ZoneCache.zonePos1.put(expiredId, loc); + ZoneCache.zonePos1Time.put(expiredId, expiredTime); + + ZoneCache.zonePos1.put(freshId, loc); + ZoneCache.zonePos1Time.put(freshId, freshTime); + + // Expire anything older than 5 seconds + ZoneCache.cleanupExpiredPositions(5_000L); + + assertFalse(ZoneCache.zonePos1.containsKey(expiredId), "Expired pos1 should be removed"); + assertTrue(ZoneCache.zonePos1.containsKey(freshId), "Fresh pos1 should remain"); + } + + @Test + public void cleanupExpiredPositions_removes_expired_pos2_entries() { + UUID expiredId = UUID.randomUUID(); + UUID freshId = UUID.randomUUID(); + Location loc = new Location(world, 0, 64, 0); + + long now = System.currentTimeMillis(); + ZoneCache.zonePos2.put(expiredId, loc); + ZoneCache.zonePos2Time.put(expiredId, now - 10_000L); + + ZoneCache.zonePos2.put(freshId, loc); + ZoneCache.zonePos2Time.put(freshId, now - 500L); + + ZoneCache.cleanupExpiredPositions(5_000L); + + assertFalse(ZoneCache.zonePos2.containsKey(expiredId), "Expired pos2 should be removed"); + assertTrue(ZoneCache.zonePos2.containsKey(freshId), "Fresh pos2 should remain"); + } +} diff --git a/src/test/java/com/gyvex/ezafk/zone/ZoneContainsTest.java b/src/test/java/com/gyvex/ezafk/zone/ZoneContainsTest.java new file mode 100644 index 0000000..326b748 --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/zone/ZoneContainsTest.java @@ -0,0 +1,92 @@ +package com.gyvex.ezafk.zone; + +import com.gyvex.ezafk.TestHelpers; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.ServerMock; + +import static org.junit.jupiter.api.Assertions.*; + +public class ZoneContainsTest { + + private ServerMock server; + private World world; + private Zone zone; + + @BeforeEach + public void setUp() { + server = TestHelpers.startServer(); + TestHelpers.loadPlugin(); + world = server.addSimpleWorld("world"); + // Zone bounds: x=[0,10], y=[60,80], z=[0,10] + zone = new Zone( + "TestZone", world.getName(), + 0, 60, 0, 10, 80, 10, + false, 60, -1, 0, "economy", null, null, 1, -1, 0 + ); + } + + @AfterEach + public void tearDown() { + TestHelpers.stopServer(); + } + + @Test + public void player_inside_bounds_returns_true() { + Player p = server.addPlayer("InsidePlayer"); + p.teleport(new Location(world, 5, 70, 5)); + assertTrue(zone.contains(p), "Player at center of zone should be inside"); + } + + @Test + public void player_on_min_boundary_returns_true() { + Player p = server.addPlayer("MinBoundPlayer"); + p.teleport(new Location(world, 0, 60, 0)); + assertTrue(zone.contains(p), "Player on minimum boundary should be inside"); + } + + @Test + public void player_on_max_boundary_returns_true() { + Player p = server.addPlayer("MaxBoundPlayer"); + p.teleport(new Location(world, 10, 80, 10)); + assertTrue(zone.contains(p), "Player on maximum boundary should be inside"); + } + + @Test + public void player_outside_x_returns_false() { + Player p = server.addPlayer("OutsideXPlayer"); + p.teleport(new Location(world, 11, 70, 5)); + assertFalse(zone.contains(p), "Player outside X bound should not be inside"); + } + + @Test + public void player_outside_y_returns_false() { + Player p = server.addPlayer("OutsideYPlayer"); + p.teleport(new Location(world, 5, 55, 5)); + assertFalse(zone.contains(p), "Player outside Y bound (below) should not be inside"); + } + + @Test + public void player_outside_z_returns_false() { + Player p = server.addPlayer("OutsideZPlayer"); + p.teleport(new Location(world, 5, 70, 11)); + assertFalse(zone.contains(p), "Player outside Z bound should not be inside"); + } + + @Test + public void player_in_different_world_returns_false() { + World otherWorld = server.addSimpleWorld("nether"); + Zone worldZone = new Zone( + "WorldZone", "overworld", + 0, 60, 0, 10, 80, 10, + false, 60, -1, 0, "economy", null, null, 1, -1, 0 + ); + Player p = server.addPlayer("OtherWorldPlayer"); + p.teleport(new Location(otherWorld, 5, 70, 5)); + assertFalse(worldZone.contains(p), "Player in wrong world should not be inside zone"); + } +} From 8016cfd670932c70d2580e59f20f054d73524838 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Wed, 22 Apr 2026 19:58:44 +0200 Subject: [PATCH 03/15] docs: add Features section with per-feature configuration pages Add docs/features/ directory with a dedicated page for each plugin feature, following the afk-kick-warnings style (config snippet + bullet explanations + How It Works + Related). New pages: - features/index.md: section overview with feature summary table - features/afk-detection.md: idle timeout, broadcasts, titles, display names, sound - features/anti-bypass.md: water flow, vehicle, bubble column detection - features/afk-kick.md: kick system, enabledWhenFull, timeout - features/gui.md: gui.yml reference, all action types, filler, back button - features/afk-zones.md: zones.yml, all three reward types, management commands - features/economy-costs.md: entry/recurring costs, Vault requirements - features/leaderboard.md: AFK time tracking, flush interval, /afk top --- docs/features/afk-detection.md | 108 +++++++++++++++++++++++++++ docs/features/afk-kick.md | 68 +++++++++++++++++ docs/features/afk-zones.md | 129 +++++++++++++++++++++++++++++++++ docs/features/anti-bypass.md | 63 ++++++++++++++++ docs/features/economy-costs.md | 100 +++++++++++++++++++++++++ docs/features/gui.md | 127 ++++++++++++++++++++++++++++++++ docs/features/index.md | 26 +++++++ docs/features/leaderboard.md | 74 +++++++++++++++++++ 8 files changed, 695 insertions(+) create mode 100644 docs/features/afk-detection.md create mode 100644 docs/features/afk-kick.md create mode 100644 docs/features/afk-zones.md create mode 100644 docs/features/anti-bypass.md create mode 100644 docs/features/economy-costs.md create mode 100644 docs/features/gui.md create mode 100644 docs/features/index.md create mode 100644 docs/features/leaderboard.md diff --git a/docs/features/afk-detection.md b/docs/features/afk-detection.md new file mode 100644 index 0000000..babd11c --- /dev/null +++ b/docs/features/afk-detection.md @@ -0,0 +1,108 @@ +--- +title: AFK Detection +nav_order: 1 +parent: Features +--- + +# AFK Detection + +The core feature of EzAfk. A player is marked AFK when they have not performed any tracked activity +(movement, block interaction, chat, etc.) for a configurable number of seconds. When they return, +the plugin marks them active and optionally announces the change. + +## Configuration + +In your `config.yml`: + +```yaml +afk: + timeout: 300 # seconds of inactivity before a player is marked AFK + bypass: + enabled: true # allow ezafk.bypass permission to skip AFK detection + broadcast: + enabled: true # announce AFK state in chat + title: + enabled: true # send a title screen when going AFK + hide-screen: + enabled: false # apply a blindness-blur overlay while AFK + animation: + enabled: true # bob the player's name-tag while AFK (per-viewer) + display-name: + enabled: false # prepend/append a prefix/suffix to the in-game display name + prefix: "&7[AFK] " + suffix: "" + format: "%prefix%%player%%suffix%" + sound: + enabled: true + file: "mp3/ezafk-sound.mp3" # relative to plugin folder + +unafk: + broadcast: + enabled: true # announce return from AFK in chat + title: + enabled: true # send a title screen when returning + animation: + enabled: true # stop name-tag animation on return + sound: + enabled: true + file: "mp3/ezafk-sound.mp3" +``` + +- **`afk.timeout`**: (integer, seconds) How long a player must be idle before EzAfk marks them AFK. + Default: `300` (5 minutes). +- **`afk.bypass.enabled`**: (bool) When `true`, players with the `ezafk.bypass` permission are never + marked AFK automatically. Default: `true`. +- **`afk.broadcast.enabled`** / **`unafk.broadcast.enabled`**: (bool) Send a chat message to all + online players when someone goes or returns from AFK. Messages are configured in your language file. +- **`afk.title.enabled`** / **`unafk.title.enabled`**: (bool) Show a large title overlay to the + player themselves when their AFK state changes. Text is configured in your language file. +- **`afk.hide-screen.enabled`**: (bool) Apply a blindness blur to the player while they are AFK, + preventing them from seeing the world. Default: `false`. +- **`afk.animation.enabled`** / **`unafk.animation.enabled`**: (bool) Toggle a bobbing animation on + the AFK player's name-tag as seen by other players. Default: `true`. +- **`afk.display-name.enabled`**: (bool) Modify the player's display name using `prefix`, `suffix`, + and `format`. Visible in chat and commands that echo display names. Default: `false`. +- **`afk.display-name.prefix`** / **`suffix`**: (string, supports `&` colour codes) Text prepended or + appended to the player's name. +- **`afk.display-name.format`**: (string) Full format string. Available placeholders: `%prefix%`, + `%player%`, `%suffix%`. +- **`afk.sound.enabled`** / **`unafk.sound.enabled`**: (bool) Play a sound to the player when their + AFK state changes. +- **`afk.sound.file`** / **`unafk.sound.file`**: (string) Path to an `.mp3` file inside the plugin's + data folder, relative to the plugin root. + +## Customising Messages + +Edit your active language file in `messages/` (e.g. `messages/en.yml`): + +```yaml +afk: + broadcast: "&e{player} &7is now AFK." + title: "&eYou are AFK" + subtitle: "&7You have been marked as AFK." + +unafk: + broadcast: "&e{player} &7is no longer AFK." + title: "&aWelcome back!" + subtitle: "&7You are no longer AFK." +``` + +See the [Messages](../messages) page for the full reference. + +## How It Works + +1. On every player action (move, interact, chat, etc.) a timestamp is updated in EzAfk's session map. +2. The idle-check task runs on a fixed interval and compares `now − lastActivity` against `afk.timeout`. +3. Once the threshold is exceeded, the player is marked AFK and the configured feedback is triggered + (broadcast, title, display-name change, animation, sound, blindness). +4. The moment any tracked activity is received from an AFK player, they are immediately marked active + and the `unafk` feedback fires. +5. Players with `ezafk.bypass` are skipped entirely unless `afk.bypass.enabled` is `false`. + +## Related + +- [Anti-Bypass Protection](anti-bypass) — prevent waterflow/vehicle tricks from resetting idle time +- [AFK Kick](afk-kick) — kick players that stay AFK too long +- [Tab Prefix Integration](../integrations/TabIntegration) — show AFK status in the tab list +- [PlaceholderAPI Integration](../integrations/PlaceholderApiIntegration) — use AFK placeholders in other plugins +- [Permissions](../permissions) — `ezafk.bypass`, `ezafk.afk` diff --git a/docs/features/afk-kick.md b/docs/features/afk-kick.md new file mode 100644 index 0000000..767e5d3 --- /dev/null +++ b/docs/features/afk-kick.md @@ -0,0 +1,68 @@ +--- +title: AFK Kick +nav_order: 3 +parent: Features +--- + +# AFK Kick + +EzAfk can automatically kick players that have been AFK for too long. You can choose to kick all AFK +players after a fixed timer, or limit the kick to situations when the server is at capacity — freeing +up slots for new players. + +Multi-stage warnings before the kick are configured separately; see +[AFK Kick Warnings](../afk-kick-warnings). + +## Configuration + +In your `config.yml`: + +```yaml +kick: + enabled: false # master switch — enable AFK kicking + enabledWhenFull: false # kick only when the server is at max player count + timeout: 600 # seconds of AFK time before the kick is issued +``` + +- **`kick.enabled`**: (bool) Master switch. When `false` no players are ever kicked by EzAfk, + regardless of other settings. Default: `false`. +- **`kick.enabledWhenFull`**: (bool) When `true`, EzAfk will only kick AFK players when the server + player count equals `max-players` in `server.properties`. Useful for keeping AFK players around on + quieter servers while still freeing slots during peak times. Requires `kick.enabled: true`. + Default: `false`. +- **`kick.timeout`**: (integer, seconds) How long a player must be continuously AFK before EzAfk kicks + them. This timer starts from the moment the player was marked AFK (i.e. after the initial + `afk.timeout` has already elapsed). Default: `600` (10 minutes). + +## Customising the Kick Message + +Edit your active language file in `messages/` (e.g. `messages/en.yml`): + +```yaml +kick: + message: "&cYou have been kicked for being AFK too long." +``` + +See the [Messages](../messages) page for the full reference. + +## How It Works + +1. When a player goes AFK, a kick countdown starts alongside the existing AFK state. +2. The countdown runs for `kick.timeout` seconds. +3. If the player remains AFK for the full duration, EzAfk calls `Player#kickPlayer()` with the + configured kick message. +4. If `enabledWhenFull` is `true`, EzAfk first checks whether `online players ≥ max players` before + issuing the kick. If the server is not full, the kick is skipped even if the timer expired. +5. Returning from AFK at any point resets the kick countdown. + +## Notes + +- The kick timer is separate from (and always longer than) the `afk.timeout` idle timer. +- To warn players before the kick fires, enable the [AFK Kick Warnings](../afk-kick-warnings) system. +- Players with the `ezafk.kick.bypass` permission are not kicked. + +## Related + +- [AFK Kick Warnings](../afk-kick-warnings) — send countdown messages before kicking +- [AFK Detection](afk-detection) — the upstream idle detection that triggers the kick timer +- [Permissions](../permissions) — `ezafk.kick.bypass` diff --git a/docs/features/afk-zones.md b/docs/features/afk-zones.md new file mode 100644 index 0000000..dc1eb78 --- /dev/null +++ b/docs/features/afk-zones.md @@ -0,0 +1,129 @@ +--- +title: AFK Zones +nav_order: 6 +parent: Features +--- + +# AFK Zones + +AFK Zones let you define cuboid regions where players earn rewards for being AFK. Each zone has its +own coordinates, world, and reward configuration. Rewards can be economy money (via Vault), console +commands, or items dropped into the player's inventory. + +Zones are stored in `zones.yml` and managed in-game with `/afk zone`. + +## Configuration + +`zones.yml` (separate file from `config.yml`): + +```yaml +enabled: false # master switch for the AFK Zones system + +regions: + - name: spawn + world: world + x1: 100 + y1: 50 + z1: 100 + x2: 120 + y2: 70 + z2: 120 + reward: + enabled: true + interval-seconds: 60 # grant reward every N seconds the player is AFK in this zone + type: economy # economy | command | item + amount: 5.0 # for economy type: amount of currency + max-stack: 3 # maximum times the reward can accumulate (0 = unlimited) + + # Command reward example + - name: arena + world: world_nether + x1: -50 + y1: 60 + z1: -50 + x2: 50 + y2: 120 + z2: 50 + reward: + enabled: true + interval-seconds: 120 + type: command + command: "give %player% diamond 1" # run as console; %player% = player name + max-stack: 0 + + # Item reward example + - name: market + world: world + x1: 200 + y1: 64 + z1: 200 + x2: 220 + y2: 80 + z2: 220 + reward: + enabled: true + interval-seconds: 300 + type: item + item: + material: EMERALD + amount: 3 + max-stack: 5 +``` + +### Global + +- **`enabled`**: (bool) Master switch. Must be `true` for any zone to function. Default: `false`. + +### Per-Region Fields + +- **`name`**: (string) Unique identifier for the zone. Used in commands and logs. +- **`world`**: (string) Bukkit world name where the zone exists. +- **`x1` / `y1` / `z1`** and **`x2` / `y2` / `z2`**: (integer) Opposite corners of the cuboid. + The order of corners does not matter — EzAfk normalises min/max automatically. + +### Reward Fields + +- **`reward.enabled`**: (bool) Toggle rewards for this specific zone without removing its definition. +- **`reward.interval-seconds`**: (integer) How often (in seconds) the reward is granted to each AFK + player inside the zone. +- **`reward.type`**: (string) Reward delivery method. + - `economy` — transfers `amount` currency via Vault. Requires a Vault-compatible economy plugin. + - `command` — runs `command` as the console once per interval. Use `%player%` for the player name. + - `item` — places the configured item directly into the player's inventory. +- **`reward.amount`**: (decimal) Currency amount. Only used when `type: economy`. +- **`reward.max-stack`**: (integer) Maximum number of reward intervals that can accumulate before the + reward stops. `0` means unlimited. Useful to prevent excessive overnight gains. +- **`reward.command`**: (string) Console command template. Only used when `type: command`. +- **`reward.item.material`**: (string) Bukkit material name. Only used when `type: item`. +- **`reward.item.amount`**: (integer) Stack size of the item given per interval. + +## In-Game Zone Management + +Zones can be created and managed with `/afk zone` without editing `zones.yml` directly: + +| Subcommand | Description | +|------------|-------------| +| `/afk zone pos1` | Set the first corner to your current location | +| `/afk zone pos2` | Set the second corner to your current location | +| `/afk zone add ` | Create a zone between the two selected positions | +| `/afk zone remove ` | Delete a zone by name | +| `/afk zone list` | List all defined zones | + +If you have WorldEdit installed you can also use your WorldEdit wand selection as the zone corners. +See [WorldGuard Integration](../integrations/WorldGuardIntegration) for details. + +## How It Works + +1. Every `interval-seconds`, EzAfk scans all AFK players and checks whether their location falls + inside any enabled zone. +2. For each matching zone, EzAfk checks that the player's accumulated reward count is below `max-stack` + (or that `max-stack` is 0). +3. The reward is delivered (economy transfer, console command, or item give). +4. The stack counter increments. It resets when the player leaves the zone or returns from AFK. + +## Related + +- [Economy Integration](../integrations/EconomyIntegration) — required for `type: economy` rewards +- [WorldGuard Integration](../integrations/WorldGuardIntegration) — use WorldEdit selections for zones +- [Commands](../commands) — full `/afk zone` command reference +- [Permissions](../permissions) — `ezafk.zone.manage`, `ezafk.zone.list` diff --git a/docs/features/anti-bypass.md b/docs/features/anti-bypass.md new file mode 100644 index 0000000..8d10ebb --- /dev/null +++ b/docs/features/anti-bypass.md @@ -0,0 +1,63 @@ +--- +title: Anti-Bypass Protection +nav_order: 2 +parent: Features +--- + +# Anti-Bypass Protection + +Some automated farms or clients exploit game mechanics — such as flowing water, rideable entities, or +bubble columns — to produce continuous movement events that prevent AFK detection. EzAfk's anti-bypass +system intercepts each of these exploit vectors and suppresses the resulting activity signal so idle +players are flagged correctly. + +## Configuration + +In your `config.yml`: + +```yaml +afk: + anti: + infinite-waterflow: false # ignore movement caused by flowing water + infinite-vehicle: false # ignore movement while riding a vehicle/entity + bubble-column: false # ignore upward push from bubble columns + flag-only: false # if true, only mark AFK silently; do not warn/eject +``` + +- **`afk.anti.infinite-waterflow`**: (bool) When `true`, movement events caused by a flowing water + current are not counted as player activity. Useful for servers with water-based AFK fish farms. + Default: `false`. +- **`afk.anti.infinite-vehicle`**: (bool) When `true`, movement events while the player is riding a + mob or minecart are ignored. Prevents AFK grinders that rely on riding entity movement to stay + "active". Default: `false`. +- **`afk.anti.bubble-column`**: (bool) When `true`, the upward velocity force from a soul-sand bubble + column is not counted as player activity. Default: `false`. +- **`afk.anti.flag-only`**: (bool) When `true`, exploiting players are silently marked AFK without any + warning message or ejection. When `false` (default), EzAfk may warn the player and/or interrupt the + exploit. Default: `false`. + +## How It Works + +1. EzAfk listens for the relevant Bukkit events (`PlayerMoveEvent`, `VehicleMoveEvent`, etc.). +2. Before crediting activity to the player, it checks the cause of the movement against the enabled + anti-bypass rules. +3. Movement that matches an enabled rule is discarded — the player's last-activity timestamp is **not** + updated. +4. After the normal `afk.timeout` elapses without legitimate activity, the player is marked AFK as + usual. +5. If `flag-only` is `false`, EzAfk may send a warning to the player or interrupt the exploit source + (e.g. eject from a vehicle). If `flag-only` is `true`, the transition happens silently. + +## Notes + +- Anti-bypass rules are independent — enable only the ones relevant to your server's gameplay. +- WorldGuard region flags can restrict AFK behaviour on a per-region basis. See + [WorldGuard Integration](../integrations/WorldGuardIntegration). +- Players with the `ezafk.bypass` permission are not subject to anti-bypass checks when + `afk.bypass.enabled` is `true`. + +## Related + +- [AFK Detection](afk-detection) — the core idle detection system +- [WorldGuard Integration](../integrations/WorldGuardIntegration) — region-based AFK flags +- [Permissions](../permissions) — `ezafk.bypass` diff --git a/docs/features/economy-costs.md b/docs/features/economy-costs.md new file mode 100644 index 0000000..a58707e --- /dev/null +++ b/docs/features/economy-costs.md @@ -0,0 +1,100 @@ +--- +title: Economy Costs +nav_order: 7 +parent: Features +--- + +# Economy Costs + +EzAfk can charge players a currency fee when they go AFK or while they remain AFK. Both one-time +entry costs and recurring interval costs are supported. Costs are deducted via Vault, so any +Vault-compatible economy plugin (EssentialsX Economy, CMI, etc.) will work. + +## Requirements + +- [Vault](https://www.spigotmc.org/resources/vault.34315/) installed and enabled. +- A Vault-compatible economy plugin providing balances. +- `economy.enabled: true` in `config.yml`. + +## Configuration + +In your `config.yml`: + +```yaml +economy: + enabled: false + bypass-permission: "ezafk.economy.bypass" + + cost: + enter: + enabled: true + amount: 25.0 # currency deducted when a player first goes AFK + require-funds: true # if true, prevent going AFK when the player cannot afford it + retry-delay: 60 # seconds before re-trying if require-funds blocked the transition + + recurring: + enabled: false + amount: 5.0 # currency deducted every interval while AFK + interval: 300 # seconds between each deduction + require-funds: true # if true, apply kick-on-fail behaviour when funds are insufficient + kick-on-fail: false # kick the player when they can no longer afford the recurring cost +``` + +- **`economy.enabled`**: (bool) Master switch. When `false`, no economy activity occurs. Default: `false`. +- **`economy.bypass-permission`**: (string) Permission node that exempts a player from all economy + costs. Default: `"ezafk.economy.bypass"`. + +### Entry Cost (`cost.enter`) + +- **`cost.enter.enabled`**: (bool) Charge a one-time fee when the player is first marked AFK. + Default: `true`. +- **`cost.enter.amount`**: (decimal) Amount to deduct. Uses the economy plugin's default currency. +- **`cost.enter.require-funds`**: (bool) When `true`, EzAfk checks the player's balance before marking + them AFK. If they cannot afford the fee, they are **not** marked AFK and receive an error message. +- **`cost.enter.retry-delay`**: (integer, seconds) If `require-funds` blocked the AFK transition, + EzAfk waits this many seconds before trying again. This prevents the check from firing on every + movement event. + +### Recurring Cost (`cost.recurring`) + +- **`cost.recurring.enabled`**: (bool) Deduct currency periodically while the player remains AFK. + Default: `false`. +- **`cost.recurring.amount`**: (decimal) Amount deducted per interval. +- **`cost.recurring.interval`**: (integer, seconds) How often the deduction fires. +- **`cost.recurring.require-funds`**: (bool) When `true`, a failed deduction (insufficient balance) + triggers the `kick-on-fail` behaviour instead of silently skipping. +- **`cost.recurring.kick-on-fail`**: (bool) When `true`, a player who can no longer afford the + recurring cost is kicked from the server. When `false`, the recurring deduction is simply skipped. + +## Customising Messages + +Edit your active language file in `messages/` (e.g. `messages/en.yml`): + +```yaml +economy: + enter-cost: "&eGoing AFK costs &6{amount}&e." + insufficient-funds: "&cYou don't have enough funds to go AFK." + recurring-cost: "&eAFK cost: &6{amount} &ededucted." + kicked-no-funds: "&cYou were kicked because you ran out of AFK funds." +``` + +See the [Messages](../messages) page for the full reference. + +## How It Works + +1. When a player is about to be marked AFK, EzAfk checks whether `cost.enter` is enabled. +2. If `require-funds` is `true` and the player cannot afford `cost.enter.amount`, the AFK transition + is blocked and a message is sent. The check retries after `retry-delay` seconds. +3. If the player can afford it (or `require-funds` is `false`), the amount is deducted and the player + is marked AFK. +4. Once AFK, a recurring task fires every `cost.recurring.interval` seconds if `cost.recurring.enabled` + is `true`. +5. On each interval, EzAfk deducts `cost.recurring.amount`. If the deduction fails and + `require-funds + kick-on-fail` are both `true`, the player is kicked. +6. Players with the bypass permission (or WorldGuard region bypass) skip all economy checks. + +## Related + +- [Economy / Vault Integration](../integrations/EconomyIntegration) — setup guide +- [AFK Zones](afk-zones) — grant economy rewards for being AFK in specific areas +- [Permissions](../permissions) — `ezafk.economy.bypass` diff --git a/docs/features/gui.md b/docs/features/gui.md new file mode 100644 index 0000000..4cc7a79 --- /dev/null +++ b/docs/features/gui.md @@ -0,0 +1,127 @@ +--- +title: In-Game GUI +nav_order: 5 +parent: Features +--- + +# In-Game GUI + +EzAfk provides a chest-inventory GUI that lets staff quickly view all AFK players and take immediate +action — kick, message, teleport, or run a command — without leaving the game. The GUI is accessed with +`/afk gui` and is fully configurable in `gui.yml`. + +## Configuration + +All GUI settings live in `gui.yml` (not `config.yml`): + +```yaml +inventory-size: 9 # multiples of 9 (9 – 54); one slot per AFK player head + +actions: + kick: + slot: 0 + material: BARRIER + display-name: "&cKick" + lore: + - "&7Click to kick %player%" + type: KICK + feedback-message: "&aKicked %player%." + + message: + slot: 1 + material: PAPER + display-name: "&eSend Message" + lore: + - "&7Click to message %player%" + type: MESSAGE + target-message: "&e[Staff] %executor% wants your attention!" + feedback-message: "&aMessage sent to %player%." + + teleport: + slot: 2 + material: ENDER_PEARL + display-name: "&aTeleport" + lore: + - "&7Teleport to %player%" + type: TELEPORT + feedback-message: "&aTeleported to %player%." + + command: + slot: 3 + material: COMMAND_BLOCK + display-name: "&6Run Command" + lore: + - "&7Runs a command as the console" + type: COMMAND + command: "say %player% is AFK!" + feedback-message: "&aCommand executed." + +empty-slot-filler: + enabled: true + material: GRAY_STAINED_GLASS_PANE + display-name: " " + lore: [] + +back-button: + display-name: "&7← Back" + lore: + - "&7Return to previous page" +``` + +### Inventory + +- **`inventory-size`**: (integer) Chest size in slots. Must be a multiple of 9 between 9 and 54. + Each AFK player occupies one slot (shown as their head). When more players are AFK than there are + slots, a paged navigation is provided. Default: `9`. + +### Actions + +Each entry under `actions` defines a clickable button shown in the per-player detail view: + +- **`slot`**: (integer, 0-based) Inventory slot position of this action button. +- **`material`**: (string) Bukkit material name for the button icon. +- **`display-name`**: (string, `&` colour codes) Button title text. +- **`lore`**: (list of strings) Tooltip lines displayed below the display name. +- **`type`**: (string) What happens on click. One of: + - `KICK` — kicks the target player. + - `MESSAGE` — sends `target-message` to the target player. + - `TELEPORT` — teleports the executor to the target player. + - `COMMAND` — runs `command` as the console. +- **`target-message`**: (string) Message delivered to the AFK player. Used by `MESSAGE` type. +- **`feedback-message`**: (string) Message sent back to the staff member after the action completes. +- **`command`**: (string) Console command to run. Used by `COMMAND` type. + +**Placeholders available in all text fields:** + +| Placeholder | Value | +|-------------|-------| +| `%player%` | Name of the AFK player being acted upon | +| `%executor%` | Name of the staff member using the GUI | + +### Empty Slot Filler + +- **`empty-slot-filler.enabled`**: (bool) Fill all unused inventory slots with a decorative item. + Default: `true`. +- **`empty-slot-filler.material`**: Background item material (e.g. `GRAY_STAINED_GLASS_PANE`). +- **`empty-slot-filler.display-name`**: Display name for filler items (use `" "` for invisible). +- **`empty-slot-filler.lore`**: Lore lines for the filler item. + +### Back Button + +- **`back-button.display-name`** / **`back-button.lore`**: Appearance of the paged-navigation back + arrow shown when the AFK list spans multiple pages. + +## How It Works + +1. `/afk gui` opens the inventory, populating each slot with the head of a currently AFK player. +2. Clicking a player head opens a detail view showing all configured action buttons. +3. Clicking an action button performs the action immediately and sends the `feedback-message` to the + executor. +4. The `empty-slot-filler` fills any remaining slots that are not occupied by a player head. +5. If there are more AFK players than `inventory-size`, navigation buttons let staff page through the + list. + +## Related + +- [Commands](../commands) — `/afk gui` and GUI-related subcommands +- [Permissions](../permissions) — `ezafk.gui`, `ezafk.gui.view-active`, `ezafk.gui.actions` diff --git a/docs/features/index.md b/docs/features/index.md new file mode 100644 index 0000000..2075f36 --- /dev/null +++ b/docs/features/index.md @@ -0,0 +1,26 @@ +--- +title: Features +nav_order: 5 +has_children: true +nav_fold: true +--- + +# Features + +EzAfk's capabilities are split into focused, independently configurable features. +Each page below explains what the feature does, shows the exact configuration snippet, and links to related pages. + +| Feature | Summary | +|---------|---------| +| [AFK Detection](afk-detection) | Idle timeout, broadcasts, titles, animations, display names | +| [Anti-Bypass Protection](anti-bypass) | Water flow, vehicle, and bubble column detection | +| [AFK Kick](afk-kick) | Remove inactive players after a configurable timeout | +| [AFK Kick Warnings](../afk-kick-warnings) | Multi-stage countdown messages before a kick | +| [In-Game GUI](gui) | Staff overview panel with one-click player actions | +| [AFK Zones](afk-zones) | Coordinate-based regions with custom AFK rules and rewards | +| [Economy Costs](economy-costs) | Charge players via Vault when they go or stay AFK | +| [AFK Time & Leaderboard](leaderboard) | Per-player AFK tracking and `/afk top` leaderboard | + +--- + +All configuration is live-reloadable with [`/afk reload`](../commands#afk-reload). diff --git a/docs/features/leaderboard.md b/docs/features/leaderboard.md new file mode 100644 index 0000000..77b63cd --- /dev/null +++ b/docs/features/leaderboard.md @@ -0,0 +1,74 @@ +--- +title: AFK Time & Leaderboard +nav_order: 8 +parent: Features +--- + +# AFK Time & Leaderboard + +EzAfk records the total amount of time each player has spent AFK since they first joined. This +cumulative counter persists across server restarts (stored in your configured storage backend) and is +exposed via in-game commands and PlaceholderAPI. + +## Configuration + +In your `config.yml`: + +```yaml +storage: + type: yaml # yaml | sqlite | mysql + flush-interval-seconds: 30 # how often in-memory totals are written to disk/database +``` + +- **`storage.type`**: (string) Backend used to persist AFK time. All backends support the full + leaderboard feature. + - `yaml` — stores data in `plugins/EzAfk/data/` as per-player YAML files (no external dependencies). + - `sqlite` — stores data in a single `ezafk.db` SQLite database file. + - `mysql` — stores data in a remote MySQL database. See the [Storage / MySQL](../mysql) page for + additional MySQL connection settings. +- **`storage.flush-interval-seconds`**: (integer) EzAfk accumulates AFK time in memory and writes it + to the storage backend on this interval (in seconds) to reduce I/O. Data is also flushed on plugin + shutdown and on player disconnect. Default: `30`. + +## Commands + +| Command | Description | +|---------|-------------| +| `/afk time` | View your own total AFK time | +| `/afk time ` | View another player's total AFK time | +| `/afk top` | Show the server-wide AFK time leaderboard | +| `/afk time reset ` | Reset a player's AFK time counter to zero | + +## PlaceholderAPI Placeholders + +If [PlaceholderAPI](../integrations/PlaceholderApiIntegration) is installed: + +| Placeholder | Returns | +|-------------|---------| +| `%ezafk_total_seconds%` | Total lifetime AFK seconds (raw integer) | +| `%ezafk_total%` | Total AFK time in `HH:MM:SS` format | +| `%ezafk_total_formatted%` | Verbose format, e.g. `2 hours 15 minutes` | +| `%ezafk_since%` | Seconds since the current AFK session started (empty if not AFK) | + +## How It Works + +1. When a player goes AFK a session start timestamp is recorded. +2. When the player returns from AFK (or disconnects while AFK) the session duration is added to their + cumulative total in memory. +3. Every `flush-interval-seconds` the in-memory map is written to the storage backend. +4. `/afk top` reads the stored totals, sorts them, and displays the top entries. +5. AFK time resets (`/afk time reset`) immediately update both the in-memory map and the storage + backend. + +## Notes + +- AFK time is tracked per UUID, so name changes do not lose data. +- The leaderboard is sorted by total AFK time descending. +- Lowering `flush-interval-seconds` increases I/O but reduces data loss on unexpected crashes. + +## Related + +- [Storage / MySQL](../mysql) — MySQL connection settings and schema +- [PlaceholderAPI Integration](../integrations/PlaceholderApiIntegration) — all available placeholders +- [Commands](../commands) — full command reference +- [Permissions](../permissions) — `ezafk.time`, `ezafk.time.others`, `ezafk.top`, `ezafk.time.reset` From 471bc2488ee12ca2b052b8549d073197b325475a Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Wed, 22 Apr 2026 19:58:56 +0200 Subject: [PATCH 04/15] docs: improve existing pages with nav fixes, cross-links, and accurate content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation: - Move afk-kick-warnings.md parent: Configuration → Features (nav_order: 4) - Bump nav_order: permissions 5→6, messages 6→7, mysql 7→8, integrations 8→9, faq 9→10 - Add Features row to index.md documentation table - Add Features row to getting-started.md next steps Cross-links: - configuration.md: add feature-page links per config section; remove has_children - permissions.md: rewrite with feature-grouped sections and links - afk-kick-warnings.md: add Related section Integrations: - PlaceholderApiIntegration.md: replace all wrong placeholder names (%ezafk_is_afk%, %ezafk_afk_time%, %ezafk_afk_reason%) with the 16 real placeholders sourced from EzAfkPlaceholderExpansion.java - EconomyIntegration.md: add config snippet, setup steps, and feature cross-links --- docs/afk-kick-warnings.md | 14 ++- docs/configuration.md | 16 ++- docs/faq.md | 2 +- docs/getting-started.md | 5 +- docs/index.md | 2 +- docs/integrations.md | 2 +- docs/integrations/EconomyIntegration.md | 57 +++++++-- .../integrations/PlaceholderApiIntegration.md | 63 ++++++---- docs/messages.md | 2 +- docs/mysql.md | 2 +- docs/permissions.md | 110 +++++++++++------- 11 files changed, 188 insertions(+), 87 deletions(-) diff --git a/docs/afk-kick-warnings.md b/docs/afk-kick-warnings.md index 94ad93d..fd0adeb 100644 --- a/docs/afk-kick-warnings.md +++ b/docs/afk-kick-warnings.md @@ -1,10 +1,10 @@ --- title: AFK Kick Warnings -nav_order: 1 -parent: Configuration +nav_order: 4 +parent: Features --- -# AFK Kick Warnings Feature +# AFK Kick Warnings EzAfk supports configurable warning messages before a player is kicked for being AFK. This helps notify players and gives them a chance to return before being removed from the server. @@ -49,5 +49,9 @@ You can use `%seconds%` as a placeholder for the time remaining. If your kick timeout is 600 seconds (10 minutes) and intervals are `[60, 30, 10]`, players will be warned at 9:00, 9:30, and 9:50 of inactivity. ---- -For more, see the main documentation or contact the plugin author. +## Related + +- [AFK Kick](afk-kick) — the underlying kick system this warning feature extends +- [AFK Detection](afk-detection) — idle detection that starts the kick countdown +- [Messages](../messages) — customise all warning message text +- [Permissions](../permissions) — `ezafk.kick.bypass` diff --git a/docs/configuration.md b/docs/configuration.md index fad67ff..3f62ea9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,12 +1,12 @@ --- title: Configuration nav_order: 4 -has_children: true --- -# EzAfk Configuration Guide (Advanced) +# EzAfk Configuration Guide -This document provides advanced documentation for every configuration option in EzAfk's main configuration files. +This page lists every configuration option in EzAfk's main files. +For feature-level documentation — explained config options, behaviour walkthroughs, and examples — see the **[Features](features/)** section. --- @@ -18,6 +18,8 @@ This document provides advanced documentation for every configuration option in ### afk +See [AFK Detection](features/afk-detection) for a full walkthrough of these options. + - `timeout`: (int, seconds) Time of inactivity before a player is marked AFK. - `bypass.enabled`: (bool) If true, enables the `ezafk.bypass` permission (OPs by default). - `broadcast.enabled`: (bool) Broadcasts a message to all players when someone goes AFK. @@ -53,6 +55,8 @@ This document provides advanced documentation for every configuration option in ### economy +See [Economy Costs](features/economy-costs) for a full walkthrough of these options. + - `enabled`: (bool) Enable economy-based AFK costs (requires Vault). - `bypass-permission`: (string) Permission to bypass AFK costs. - `cost.enter.enabled`: (bool) Charge when a player becomes AFK. @@ -67,6 +71,8 @@ This document provides advanced documentation for every configuration option in ### kick +See [AFK Kick](features/afk-kick) and [AFK Kick Warnings](afk-kick-warnings) for full walkthroughs. + - `enabled`: (bool) Enable kicking after being AFK too long. - `enabledWhenFull`: (bool) Enable kicking when server is full. - `timeout`: (int, seconds) Time before kicking for AFK. @@ -91,6 +97,8 @@ This document provides advanced documentation for every configuration option in ## gui.yml +See [In-Game GUI](features/gui) for a full walkthrough of these options. + ### inventory-size - (int) Number of slots in the GUI. Must be a multiple of 9, between 9 and 54. @@ -110,6 +118,8 @@ Each action is a named section (e.g., `kick`, `alert`, `teleport`). ## mysql.yml +See [Storage / MySQL](mysql) for setup instructions. + - `enabled`: (bool) Enable MySQL storage for AFK data. - `host`: (string) MySQL server address. - `port`: (int) MySQL server port. diff --git a/docs/faq.md b/docs/faq.md index c9bbb44..646af32 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,6 +1,6 @@ --- title: FAQ & Troubleshooting -nav_order: 9 +nav_order: 10 --- # FAQ & Troubleshooting diff --git a/docs/getting-started.md b/docs/getting-started.md index 2564a56..a4b09c5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,8 +11,8 @@ This page walks you through installing EzAfk on your server and running it for t ## Requirements -- Paper, Spigot, Bukkit, or Purpur **1.19+** -- Java **17** or newer +- Paper, Spigot, Bukkit, or Purpur **1.26+** +- Java **25** or newer --- @@ -97,6 +97,7 @@ Run `/afk reload` to hot-reload config changes at any time (requires `ezafk.relo |---|---| | See all commands | [Commands](commands) | | Tune every config option | [Configuration](configuration) | +| Understand each feature in depth | [Features](features/) | | Set up MySQL for persistence | [Storage](mysql) | | Connect PlaceholderAPI / WorldGuard / Economy | [Integrations](integrations) | | Customise plugin messages | [Messages](messages) | diff --git a/docs/index.md b/docs/index.md index 146c50d..dcb8373 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,7 +64,7 @@ Grant `ezafk.*` to administrators or assign individual nodes — see the | [Getting Started](getting-started) | Install, first config, verify it works | | [Commands](commands) | All `/afk` commands, arguments, and permission nodes | | [Configuration](configuration) | Every config option explained | -| [Configuration → AFK Kick Warnings](afk-kick-warnings) | Multi-stage kick warning system | +| [Features](features/) | AFK detection, anti-bypass, kick, GUI, zones, economy, leaderboard — each with config | | [Permissions](permissions) | Permission nodes and defaults | | [Messages](messages) | Customising plugin messages and language files | | [Storage](mysql) | YAML, SQLite, and MySQL storage backends | diff --git a/docs/integrations.md b/docs/integrations.md index df18f13..fd7ecc5 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1,6 +1,6 @@ --- title: Integrations -nav_order: 8 +nav_order: 9 has_children: true nav_fold: true --- diff --git a/docs/integrations/EconomyIntegration.md b/docs/integrations/EconomyIntegration.md index f917ca6..f0e70e0 100644 --- a/docs/integrations/EconomyIntegration.md +++ b/docs/integrations/EconomyIntegration.md @@ -4,15 +4,58 @@ nav_order: 2 parent: Integrations --- -# EconomyIntegration +# Economy / Vault Integration -## Features +EzAfk integrates with any Vault-compatible economy plugin to charge players when they go AFK or +while they remain AFK. Zones can also reward economy currency to incentivise AFK in specific areas. -- Integrates with server economy plugins to reward or charge players based on AFK status or actions. +## Requirements + +- [Vault](https://www.spigotmc.org/resources/vault.34315/) installed and enabled. +- A Vault-compatible economy plugin (e.g. [EzEconomy](https://www.spigotmc.org/resources/1-7-1-21-ezeconomy-modern-vault-economy-plugin-for-minecraft-servers.130975/), + EssentialsX Economy, CMI, etc.). ## Setup -1. Make sure both Vault and an economy plugin are installed on your server. - We recommend [EzEconomy](https://www.spigotmc.org/resources/1-7-1-21-ezeconomy-modern-vault-economy-plugin-for-minecraft-servers.130975/) for best compatibility. -2. Enable EconomyIntegration in the EzAfk configuration if required. -3. Restart the server to apply changes. +1. Install Vault and your economy plugin. +2. Enable the economy feature in `config.yml`: + +```yaml +economy: + enabled: true +``` + +3. Restart the server. EzAfk will detect Vault automatically and activate economy integration. + +## Configuration Summary + +Full documentation for all economy settings is on the [Economy Costs](../features/economy-costs) feature page. + +```yaml +economy: + enabled: false + bypass-permission: "ezafk.economy.bypass" + cost: + enter: + enabled: true + amount: 25.0 + require-funds: true + retry-delay: 60 + recurring: + enabled: false + amount: 5.0 + interval: 300 + require-funds: true + kick-on-fail: false +``` + +## AFK Zone Rewards + +AFK Zones can grant economy rewards to players who stay AFK inside a defined region. Configure this +in `zones.yml` with `reward.type: economy`. See [AFK Zones](../features/afk-zones) for details. + +## Related + +- [Economy Costs](../features/economy-costs) — detailed config reference +- [AFK Zones](../features/afk-zones) — reward economy currency in specific regions +- [Permissions](../permissions) — `ezafk.economy.bypass` diff --git a/docs/integrations/PlaceholderApiIntegration.md b/docs/integrations/PlaceholderApiIntegration.md index 3f2f8f0..391623c 100644 --- a/docs/integrations/PlaceholderApiIntegration.md +++ b/docs/integrations/PlaceholderApiIntegration.md @@ -4,27 +4,50 @@ nav_order: 3 parent: Integrations --- -# PlaceholderApiIntegration +# PlaceholderAPI Integration + +EzAfk registers a custom PlaceholderAPI expansion when the PlaceholderAPI plugin is detected. +This makes all AFK data available to any plugin that supports PlaceholderAPI — scoreboards, +chat formatters, holograms, GUIs, and more. + +## Setup + +1. Install [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) on your server. +2. EzAfk detects it automatically — no extra configuration is required. +3. Use the placeholders below anywhere PlaceholderAPI syntax is accepted. + +## Available Placeholders + +| Placeholder | Returns | +|-------------|---------| +| `%ezafk_status%` | `AFK` if the player is AFK, otherwise `ACTIVE` | +| `%ezafk_status_colored%` | `&cAFK` or `&aACTIVE` (colour-coded) | +| `%ezafk_since%` | Seconds elapsed since the current AFK session started (empty if not AFK) | +| `%ezafk_last_active%` | Seconds since the player last performed a tracked activity | +| `%ezafk_total_seconds%` | Total lifetime AFK time in seconds (raw integer) | +| `%ezafk_total%` | Total AFK time in `HH:MM:SS` format | +| `%ezafk_total_formatted%` | Total AFK time in verbose format, e.g. `2 hours 15 minutes` | +| `%ezafk_prefix%` | The configured AFK display name prefix (empty when not AFK) | +| `%ezafk_suffix%` | The configured AFK display name suffix (empty when not AFK) | +| `%ezafk_playtime_active_seconds%` | Active (non-AFK) playtime in seconds — requires Playtime integration | +| `%ezafk_playtime_active%` | Active playtime in `HH:MM:SS` format | +| `%ezafk_playtime_active_formatted%` | Active playtime in verbose format | +| `%ezafk_afk_count%` | Number of currently AFK players on the server | +| `%ezafk_afk_players%` | Same as `%ezafk_afk_count%` (alias) | +| `%ezafk_active_count%` | Number of non-AFK online players | +| `%ezafk_active_players%` | Same as `%ezafk_active_count%` (alias) | + +Run `/papi list ezafk` in-game to confirm the expansion is loaded. -## Overview -The PlaceholderApiIntegration module enables seamless integration between EzAfk and the PlaceholderAPI plugin, allowing you to display dynamic AFK-related data in other plugins, chat, scoreboards, and GUIs. - -## Features - -- Provides custom EzAfk placeholders for use in supported plugins and server configurations. -- Enables real-time display of player AFK status, AFK duration, and other relevant data. -- Compatible with any plugin or configuration that supports PlaceholderAPI. - -## Setup & Usage +## Notes -1. Install the [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) plugin on your server. -2. Ensure EzAfk is installed and running. -3. EzAfk placeholders will be automatically registered and available for use. -4. Use EzAfk placeholders in supported plugins, chat formats, scoreboards, or GUIs. - Available placeholders: `%ezafk_is_afk%`, `%ezafk_afk_time%`, `%ezafk_afk_reason%`. -5. Refer to your other plugins' documentation for instructions on adding placeholders to their configuration files. +- Placeholders update in real time — there is no caching delay. +- `%ezafk_since%` returns an empty string when the player is not AFK, making it safe to use in + conditional display contexts. +- `%ezafk_playtime_active_*` placeholders require the Playtime integration to be configured in + `config.yml` (`integration.playtime.enabled: true`). -## Notes +## Related -- No additional configuration is required; integration is automatic. -- For a full list of available EzAfk placeholders, consult the EzAfk documentation or use `/papi list ezafk` in-game. +- [AFK Detection](../features/afk-detection) — AFK state and display-name prefix/suffix settings +- [AFK Time & Leaderboard](../features/leaderboard) — total AFK time tracking diff --git a/docs/messages.md b/docs/messages.md index 69346bb..2842bab 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -1,6 +1,6 @@ --- title: Messages -nav_order: 6 +nav_order: 7 --- # EzAfk Messages & Localization Guide diff --git a/docs/mysql.md b/docs/mysql.md index 5555f20..eb1a635 100644 --- a/docs/mysql.md +++ b/docs/mysql.md @@ -1,6 +1,6 @@ --- title: Storage -nav_order: 7 +nav_order: 8 --- # Storage diff --git a/docs/permissions.md b/docs/permissions.md index 7ee2409..ebc3980 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -1,61 +1,81 @@ --- title: Permissions -nav_order: 5 +nav_order: 6 --- -# EzAfk Permissions Documentation +# EzAfk Permissions -This document lists all permissions used by EzAfk, their effects, and recommended assignment. +This page lists all permission nodes, their defaults, and which feature each one belongs to. --- -## Permission List - -| Permission | Description | Default | -|-----------------------------|----------------------------------------------------------|--------------| -| ezafk.reload | Allows reloading the plugin configuration | OP | -| ezafk.gui | Allows opening the AFK player overview GUI | OP | -| ezafk.toggle | Allows toggling AFK status for other players | OP | -| ezafk.bypass | Exempts player from AFK detection | OP | -| ezafk.bypass.manage | Allows toggling AFK bypass for other players | OP | -| ezafk.info | Allows viewing detailed AFK info for other players | OP | -| ezafk.time | Allows viewing your own total AFK time | true | -| ezafk.time.others | Allows viewing total AFK time for other players | OP | -| ezafk.top | Allows viewing the AFK leaderboard | OP | -| ezafk.economy.bypass | Exempts player from economy-based AFK costs | OP | -| ezafk.time.reset | Allows resetting a player's total AFK time | OP | -| ezafk.zone.list | Allows listing configured AFK zones and players in them | OP | -| ezafk.zone.manage | Allows creating/removing/managing AFK zones and positions| OP | -| ezafk.gui.view-active | Allows viewing active (online) players in the AFK GUI | OP | -| ezafk.gui.actions | Allows using player actions in the AFK GUI | OP | +## Quick Reference + +| Permission | Description | Default | +|------------|-------------|---------| +| `ezafk.reload` | Reload plugin configuration | OP | +| `ezafk.bypass` | Never be marked AFK automatically | OP | +| `ezafk.bypass.manage` | Toggle AFK bypass for other players | OP | +| `ezafk.toggle` | Toggle AFK status for other players | OP | +| `ezafk.info` | View detailed AFK info for other players | OP | +| `ezafk.kick.bypass` | Never be kicked by EzAfk's AFK kick | OP | +| `ezafk.gui` | Open the AFK overview GUI | OP | +| `ezafk.gui.view-active` | View active players in the GUI | OP | +| `ezafk.gui.actions` | Use player actions in the GUI | OP | +| `ezafk.time` | View your own total AFK time | `true` | +| `ezafk.time.others` | View another player's AFK time | OP | +| `ezafk.time.reset` | Reset a player's total AFK time | OP | +| `ezafk.top` | View the AFK leaderboard | OP | +| `ezafk.economy.bypass` | Bypass economy AFK costs | OP | +| `ezafk.zone.list` | List AFK zones and view zone players | OP | +| `ezafk.zone.manage` | Create and remove AFK zones | OP | --- -## Permission Details - -- **ezafk.reload**: Required to use `/afk reload`. -- **ezafk.gui**: Required to use `/afk gui`. -- **ezafk.toggle**: Required to use `/afk toggle `. -- **ezafk.bypass**: Players with this permission are never marked as AFK automatically. -- **ezafk.bypass.manage**: Required to use `/afk bypass `. -- **ezafk.info**: Required to use `/afk info `. -- **ezafk.time**: Allows `/afk time` for self. -- **ezafk.time.others**: Allows `/afk time ` for others. -- **ezafk.top**: Allows `/afk top`. -- **ezafk.economy.bypass**: Exempts from all AFK-related economy costs. -- **ezafk.time.reset**: Required to use `/afk time reset ` to reset a player's stored AFK time. -- **ezafk.zone.list**: Required to list configured AFK zones and view players in zones (`/afk zone list`, `/afk zone players`). -- **ezafk.zone.manage**: Required to manage AFK zones (`/afk zone add`, `/afk zone remove`, `pos1`, `pos2`, `clearpos`, `reset`). -- **ezafk.gui.view-active**: Allows viewing active (online) players inside the AFK overview GUI. -- **ezafk.gui.actions**: Allows using configured player actions from the AFK overview GUI. +## By Feature ---- +### [AFK Detection](features/afk-detection) & General + +- **`ezafk.bypass`** — Players with this node are never automatically marked AFK (requires + `afk.bypass.enabled: true` in `config.yml`). +- **`ezafk.bypass.manage`** — Required for `/afk bypass `. +- **`ezafk.toggle`** — Required for `/afk toggle ` to force-toggle another player's AFK state. +- **`ezafk.info`** — Required for `/afk info ` to view another player's session details. +- **`ezafk.reload`** — Required for `/afk reload`. + +### [AFK Kick](features/afk-kick) & [Kick Warnings](features/afk-kick-warnings) + +- **`ezafk.kick.bypass`** — Players with this node are never kicked by the AFK kick system, + even if kick warnings have fired. + +### [In-Game GUI](features/gui) + +- **`ezafk.gui`** — Required to open `/afk gui`. +- **`ezafk.gui.view-active`** — Allows viewing active (non-AFK) players in the GUI, not just AFK ones. +- **`ezafk.gui.actions`** — Allows clicking action buttons (kick, message, teleport, command) in the GUI. -## Managing Permissions +### [AFK Time & Leaderboard](features/leaderboard) -- Use a permissions plugin (LuckPerms, PermissionsEx, etc.) to assign these permissions. -- By default, most permissions are granted to OPs only, except `ezafk.time` (all players). -- For a public server, assign only necessary permissions to trusted staff. +- **`ezafk.time`** — Allows `/afk time` to view your own total AFK time. Default: all players. +- **`ezafk.time.others`** — Allows `/afk time ` to view another player's AFK time. +- **`ezafk.time.reset`** — Allows `/afk time reset `. +- **`ezafk.top`** — Allows `/afk top` to view the full leaderboard. + +### [Economy Costs](features/economy-costs) + +- **`ezafk.economy.bypass`** — Exempts the player from all economy enter and recurring costs. + The permission node is configurable via `economy.bypass-permission` in `config.yml`. + +### [AFK Zones](features/afk-zones) + +- **`ezafk.zone.list`** — Required for `/afk zone list` and `/afk zone players`. +- **`ezafk.zone.manage`** — Required for `/afk zone add`, `remove`, `pos1`, `pos2`, `clearpos`, `reset`. --- -For more information, see the README.md and commands documentation. + +## Notes + +- Most permissions default to **OP only**. Use a permissions plugin such as LuckPerms to grant + specific nodes to staff groups or all players. +- `ezafk.time` is granted to all players by default so every player can check their own AFK stats. +- For a public server, grant `ezafk.gui` and `ezafk.gui.actions` only to trusted staff. From 64c41055db613e661f4eac9e9546be2930be9f95 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Wed, 22 Apr 2026 19:59:12 +0200 Subject: [PATCH 05/15] docs: rewrite BBCode topic for v3.0.0 and add Modrinth Markdown topic bbcode-topic.md: - Update versions: v3.0.0, Minecraft 26.1, Java 25 - Replace GitHub Releases link with Modrinth download link - Add AFK Zones, German translation (de), and zone commands to feature list - Expand permissions and commands tables to include zone management - Update config spoiler to current v3.0.0 structure - Remove stale version badges pointing to GitHub releases API topics/modrinth-topic.md (new): - Full Markdown description suitable for Modrinth/Hangar plugin pages - Sections: overview, feature table, installation, commands, permissions, PlaceholderAPI placeholders, configuration overview, integrations, links docs/ezafk-bbcode.txt: remove (superseded by bbcode-topic.md at repo root) --- bbcode-topic.md | 609 +++------------------ docs/ezafk-bbcode.txt => topics/bbcode.txt | 0 topics/modrinth-topic.md | 179 ++++++ 3 files changed, 265 insertions(+), 523 deletions(-) rename docs/ezafk-bbcode.txt => topics/bbcode.txt (100%) create mode 100644 topics/modrinth-topic.md diff --git a/bbcode-topic.md b/bbcode-topic.md index f59db2b..82a5e54 100644 --- a/bbcode-topic.md +++ b/bbcode-topic.md @@ -7,604 +7,167 @@ [B][SIZE=6]Keep AFK management simple[/SIZE][/B] -[B]EzAfk[/B] is a modern, lightweight AFK management plugin that keeps your staff informed while gently nudging idle players back into the action. Built for contemporary Paper and Spigot servers (1.7.0 - 1.21.*) and Java 21, it automates AFK detection, provides configurable staff tools, and integrates with the systems you already use, all without sacrificing performance. +[B]EzAfk[/B] is a modern, lightweight AFK management plugin built for Paper and Spigot servers running Minecraft 26.1+ and Java 25. It automates AFK detection, rewards or charges players based on AFK state, provides staff overview tools, and integrates with the systems you already use — all without sacrificing performance. -[IMG]https://img.shields.io/github/v/release/ez-plugins/EzAfk[/IMG] -[IMG]https://img.shields.io/github/issues/ez-plugins/EzAfk[/IMG] -[IMG]https://img.shields.io/badge/Minecraft%20version-1.7.0%20to%201.21.*-blue[/IMG] +[IMG]https://img.shields.io/badge/version-3.0.0-blue[/IMG] +[IMG]https://img.shields.io/badge/Minecraft-26.1-green[/IMG] +[IMG]https://img.shields.io/badge/Java-25-orange[/IMG] -Found an issue or have a question? Please contact me through [URL='https://discord.gg/yWP95XfmBS']the EzPlugins Discord server[/URL]. -Advanced documentation can be found in the [URL='https://github.com/ez-plugins/EzAfk']Github repository[/URL] +Download on [URL='https://modrinth.com/plugin/ezafk'][B]Modrinth[/B][/URL] · Found an issue or have a question? Join [URL='https://discord.gg/yWP95XfmBS']the EzPlugins Discord server[/URL]. [IMG]https://i.ibb.co/ch6q5J0X/image.png[/IMG] [SIZE=5][B]Feature highlights[/B][/SIZE] [LIST] -[*][B]Automatic AFK detection[/B]: Detect idle players after a configurable timeout, send chat or title messages, play optional animations, apply blindness, and broadcast status changes server-wide. -[*][B]Staff workflow tools[/B]: Open the AFK player overview GUI and trigger customizable actions (kick, alert, teleport, run console commands) per-player from an intuitive menu. -[*][B]Anti-bypass protections[/B]: Block common AFK bypass tricks with vehicle and water-flow checks, optionally gated behind the `ezafk.bypass` permission or WorldGuard regions. -[*][B]Automatic punishments[/B]: Kick AFK players after configurable grace periods or only when the server is full, with personalized kick reasons. -[*][B]AFK kick warnings[/B]: Send configurable chat and/or title warnings at multiple intervals before a player is kicked for being AFK, giving them a chance to return. -[*][B]Economy-aware AFK[/B]: Optionally charge players using Vault-supported economies when they go or stay AFK, with recurring billing plus bypass permissions or WorldGuard regions. -[*][URL='https://www.spigotmc.org/resources/1-21-ezeconomy-modern-vault-economy-plugin-for-minecraft-servers.130975/'][B]EzEconomy integration[/B][/URL]: For best results, pair EzAfk with [B]EzEconomy[/B] for modern, reliable Vault economy support! -[*][B]Integrations that matter[/B]: Use the WorldGuard `afk-bypass` flag, track usage with bStats, receive console reminders when new releases are available, and surface AFK prefixes in the tab list without any external dependencies. -[*][B]Simple Voice Chat integration (> v2.2.0)[/B]: Optionally play a custom MP3 sound to players when they go AFK, using the Simple Voice Chat mod and its API. -[*][B]Custom display names & tab styling[/B]: Mirror AFK status in chat and name tags with configurable prefixes, suffixes, and formats that work with TAB or the built-in formatter. -[*][B]Persistent storage[/B]: Optionally connect to MySQL to store the last active timestamp for every tracked player across restarts, plus per-player YAML totals for the AFK time leaderboard. -[*][B]AFK analytics[/B]: Surface `/afk time`, `/afk info`, and `/afk top` so staff can investigate reports and highlight the most idle players with a cached leaderboard. -[*][B]Player-friendly deterrents[/B]: Combine optional blindness and configurable animations to nudge players back to activity without being heavy-handed. -[*][B]Built-in translations[/B]: Ship ready-to-use English, Spanish, Dutch, Russian, and Chinese message packs with automatic fallbacks and per-server overrides. +[*][B]Automatic AFK detection[/B]: Detect idle players after a configurable timeout (default 5 min). Send chat or title messages, trigger animations, apply a blindness blur, and broadcast status changes server-wide. +[*][B]Anti-bypass protections[/B]: Block common AFK farm tricks — infinite water flow, vehicle riding, and bubble columns — with individual toggle switches. Combine with the `ezafk.bypass` permission or WorldGuard regions for fine-grained control. +[*][B]AFK kick with warnings[/B]: Kick players after a configurable idle period (default 10 min). Send multi-stage chat and/or title warnings at custom intervals (e.g. 60 s, 30 s, 10 s) before the kick fires. Optionally kick only when the server is full to free up slots. +[*][B]In-game staff GUI[/B]: Open `/afk gui` to see all AFK players at a glance. One-click buttons let staff kick, message, teleport to, or run console commands against any AFK player. Fully configurable layout in `gui.yml`. +[*][B]AFK Zones with rewards[/B]: Define coordinate-based cuboid regions where players earn rewards for being AFK. Reward types: economy currency (Vault), console commands, or item drops. Each zone has its own interval and reward cap. +[*][B]Economy-aware AFK[/B]: Charge players a one-time fee when going AFK and/or a recurring fee while staying AFK. Requires Vault. Block AFK if insufficient funds, or kick when recurring funds run out. +[*][B]AFK analytics & leaderboard[/B]: Track cumulative AFK time per player. Use `/afk time`, `/afk info`, and `/afk top` for detailed reports and server-wide leaderboards. Data persists across restarts. +[*][B]Custom display names & tab styling[/B]: Mirror AFK status in chat, name tags, and the tab list with configurable prefixes, suffixes, and formats. Works with the TAB plugin or EzAfk's built-in formatter. +[*][B]Persistent storage[/B]: Store AFK data in YAML (default), SQLite, or MySQL. The storage backend is swappable without data loss. +[*][B]Multi-language support[/B]: Ships with English, Spanish, Dutch, Russian, Chinese, and German message packs. Override any message per-server without touching the source. +[*][B]Simple Voice Chat integration[/B]: Play a custom MP3 sound to players when they go AFK, via the Simple Voice Chat mod API. +[*][B]PlaceholderAPI support[/B]: Expose 16 AFK placeholders to any PAPI-compatible plugin — status, session length, total time, prefix/suffix, player counts, and more. [/LIST] [SIZE=5][B]Commands[/B][/SIZE] [LIST] -[*][B]/afk[/B]: Toggle your own AFK state (alias of `/ezafk`). -[*][B]/afk reload[/B]: Reload the configuration and refresh integrations. -[*][B]/afk gui[/B]: Open the AFK player overview GUI (includes pagination and quick actions). -[*][B]/afk toggle [/B]: Force another player's AFK state. -[*][B]/afk bypass [/B]: Toggle the bypass flag for a player when `afk.bypass.enabled` is active. -[*][B]/afk info [/B]: Review why a player was flagged AFK, how long they've been idle, and their last activity. -[*][B]/afk time [player][/B]: Show lifetime AFK totals for yourself or, with permission, another player. -[*][B]/afk top[/B]: Display the cached AFK time leaderboard (also available via `/afktop`). -[I]Aliases[/I]: `/ezafk`, `/afk`, `/ea`, `/afktime`, `/afktop` +[*][B]/afk[/B] — Toggle your own AFK status. +[*][B]/afk reload[/B] — Reload all configuration files. +[*][B]/afk gui[/B] — Open the AFK player overview GUI. +[*][B]/afk toggle [/B] — Force another player's AFK state. +[*][B]/afk bypass [/B] — Toggle the AFK bypass flag for a player. +[*][B]/afk info [/B] — View a player's current AFK state, idle reason, and session info. +[*][B]/afk time [player][/B] — View total AFK time for yourself or another player. +[*][B]/afk time reset [/B] — Reset a player's cumulative AFK time counter. +[*][B]/afk top[/B] — Show the server-wide AFK time leaderboard. +[*][B]/afk zone pos1[/B] / [B]pos2[/B] — Select zone corners. +[*][B]/afk zone add [/B] — Create a new AFK zone between the two selected corners. +[*][B]/afk zone remove [/B] — Delete a zone. +[*][B]/afk zone list[/B] — List all configured zones. [/LIST] +[I]Aliases[/I]: `/ezafk`, `/ea`, `/afktime`, `/afktop` [SIZE=5][B]Permissions[/B][/SIZE] [LIST] -[*][B]ezafk.reload[/B]: Allows using `/afk reload`. -[*][B]ezafk.gui[/B]: Allows opening the overview GUI. -[*][B]ezafk.gui.view-active[/B]: Allows viewing active (non-AFK) players in the GUI. -[*][B]ezafk.gui.actions[/B]: Allows using the player action buttons in the GUI. -[*][B]ezafk.toggle[/B]: Allows toggling other players' AFK state. -[*][B]ezafk.bypass[/B]: Lets a player ignore the automatic AFK trigger when bypass checking is enabled. -[*][B]ezafk.bypass.manage[/B]: Allows toggling bypass mode for other players via `/afk bypass`. -[*][B]ezafk.economy.bypass[/B]: Exempts a player from economy charges when AFK costs are enabled. -[*][B]ezafk.info[/B]: Allows viewing AFK reports for other players via `/afk info`. -[*][B]ezafk.time[/B]: Allows checking your own AFK total. -[*][B]ezafk.time.others[/B]: Allows checking someone else's AFK total with `/afk time `. -[*][B]ezafk.top[/B]: Allows viewing the AFK time leaderboard. +[*][B]ezafk.reload[/B] — Reload configuration. +[*][B]ezafk.bypass[/B] — Never be marked AFK automatically. +[*][B]ezafk.bypass.manage[/B] — Toggle bypass for other players. +[*][B]ezafk.toggle[/B] — Toggle other players' AFK state. +[*][B]ezafk.info[/B] — View AFK details for other players. +[*][B]ezafk.kick.bypass[/B] — Never be kicked by EzAfk's AFK kick. +[*][B]ezafk.gui[/B] — Open the staff GUI. +[*][B]ezafk.gui.view-active[/B] — View active players in the GUI. +[*][B]ezafk.gui.actions[/B] — Use action buttons in the GUI. +[*][B]ezafk.time[/B] — View own AFK time (granted to all players by default). +[*][B]ezafk.time.others[/B] — View other players' AFK time. +[*][B]ezafk.time.reset[/B] — Reset a player's AFK time. +[*][B]ezafk.top[/B] — View the leaderboard. +[*][B]ezafk.economy.bypass[/B] — Skip economy charges. +[*][B]ezafk.zone.list[/B] — List AFK zones. +[*][B]ezafk.zone.manage[/B] — Create and remove zones. [/LIST] [SIZE=5][B]GUI overview[/B][/SIZE] -The GUI is available with the command [U]/afk gui[/U], the permission [U]ezafk.gui[/U], or OP status. Default buttons let staff kick, alert, or teleport to AFK players, and you can add extra items that run console commands with `%player%` and `%executor%` placeholders. Configure the layout in `gui.yml`. -[IMG]https://i.ibb.co/Hx8BwCj/ezafk-gui.png[/IMG] +Open the GUI with [U]/afk gui[/U] (requires [U]ezafk.gui[/U] or OP). Default buttons let staff kick, message, or teleport to AFK players. Additional slots can run any console command with `%player%` and `%executor%` placeholders. Configure the full layout in `gui.yml`. -[SIZE=5][B]Customizable admin actions[/B][/SIZE] -Beyond the preconfigured buttons, EzAfk lets you build your own action items directly in `gui.yml`. Each slot can execute one or more console or player commands, display custom icons, and include hover descriptions so staff understand what the action does. Combine placeholders such as `%player%`, `%executor%`, or `%world%` with permission checks to craft targeted moderation workflows—anything from warning messages to teleport chains or integrations with external moderation plugins. +[IMG]https://i.ibb.co/Hx8BwCj/ezafk-gui.png[/IMG] [SIZE=5][B]Integrations[/B][/SIZE] [B]Tab list styling (built-in)[/B] -Enable the `afk.tab-prefix.enabled` setting to display a custom prefix or suffix whenever a player is marked AFK. Customize the prefix, suffix, and final format using placeholders like `%prefix%`, `%player%`, and `%suffix%`. EzAfk can either rely on the TAB plugin for formatting or use its own built-in implementation - choose your preferred behaviour with `afk.tab-prefix.mode` (options: `auto`, `tab`, or `custom`). +Set `integration.tab-prefix.enabled: true` in `config.yml` to display a custom prefix/suffix when a player is AFK. EzAfk can use its own formatter or delegate to the TAB plugin (`mode: tab`). [IMG]https://i.ibb.co/nD4dbQj/afk-tab.png[/IMG] -[B]WorldGuard (> v1.2)[/B] -Enable the integration in `config.yml` to unlock the custom `afk-bypass` flag, allowing specific regions where players can idle without triggering punishments or economy charges. -[URL]https://dev.bukkit.org/projects/worldguard[/URL] - -[I]Flag name[/I]: afk-bypass +[B]WorldGuard[/B] +Enable WorldGuard integration in `config.yml` to unlock the custom `afk-bypass` flag. Set it on a region to allow players to idle there without triggering kick timers or economy charges. -[U]How to add the flag to your region?[/U] [code] /rg flag afk-bypass allow [/code] -[B]MySQL storage (> v1.3)[/B] -Store AFK player state in a central database. EzAfk automatically handles inserts, updates, and cleanup based on player UUIDs. - -[B]Simple Voice Chat integration (> v2.2)[/B]: Optionally play a custom MP3 sound to players when they go AFK, using the Simple Voice Chat mod and its API. Enable in `config.yml` under `integration.voicechat`, and place your sound file in `plugins/EzAfk/mp3/ezafk-sound.mp3`. The plugin will automatically detect and use the sound if Simple Voice Chat is installed. See the documentation for setup and troubleshooting tips. - +[B]PlaceholderAPI[/B] +Install [URL='https://www.spigotmc.org/resources/placeholderapi.6245/']PlaceholderAPI[/URL] and the expansion registers automatically. Full list of placeholders: -[B]Metrics & updates[/B] -Anonymous usage statistics are collected via bStats, and the plugin optionally checks SpigotMC for updates during startup. Both features can be disabled through `config.yml`. - -[B]PlaceholderAPI (> v1.7)[/B] -Install [URL='https://www.spigotmc.org/resources/placeholderapi.6245/']PlaceholderAPI[/URL] to expose EzAfk's placeholders. The expansion registers itself automatically when the plugin is detected, so no extra permissions or config toggles are required. - -[I]Provided placeholders[/I] -[LIST] [LIST] -[*][ICODE]%ezafk_status%[/ICODE] — Returns [ICODE]AFK[/ICODE] or [ICODE]ACTIVE[/ICODE] for the targeted player. -[*][ICODE]%ezafk_status_colored%[/ICODE] — Returns the color-formatted status string (e.g., [ICODE]&cAFK[/ICODE]). -[*][ICODE]%ezafk_since%[/ICODE] — Seconds since the player was marked AFK. Empty when they are active. -[*][ICODE]%ezafk_last_active%[/ICODE] — Seconds since the player last moved. Always available. -[*][ICODE]%ezafk_prefix%[/ICODE] — The configured AFK prefix applied to their display name while AFK. -[*][ICODE]%ezafk_suffix%[/ICODE] — The configured AFK suffix applied to their display name while AFK. -[*][ICODE]%ezafk_afk_count%[/ICODE] — Total number of players currently marked as AFK. -[*][ICODE]%ezafk_active_count%[/ICODE] — Total number of online players not marked as AFK. -[/LIST] +[*][ICODE]%ezafk_status%[/ICODE] — [ICODE]AFK[/ICODE] or [ICODE]ACTIVE[/ICODE] +[*][ICODE]%ezafk_status_colored%[/ICODE] — Colour-coded status string +[*][ICODE]%ezafk_since%[/ICODE] — Seconds since the current AFK session started +[*][ICODE]%ezafk_last_active%[/ICODE] — Seconds since last activity +[*][ICODE]%ezafk_total_seconds%[/ICODE] / [ICODE]%ezafk_total%[/ICODE] / [ICODE]%ezafk_total_formatted%[/ICODE] — Total lifetime AFK time +[*][ICODE]%ezafk_prefix%[/ICODE] / [ICODE]%ezafk_suffix%[/ICODE] — Configured AFK display-name prefix/suffix +[*][ICODE]%ezafk_playtime_active_seconds%[/ICODE] / [ICODE]%ezafk_playtime_active%[/ICODE] — Active (non-AFK) playtime +[*][ICODE]%ezafk_afk_count%[/ICODE] / [ICODE]%ezafk_active_count%[/ICODE] — Server-wide AFK / active player counts [/LIST] -[I]Usage example[/I] -[code] -&7Status: %ezafk_status_colored% -&7AFK for: %ezafk_since%s -[/code] +[B]Simple Voice Chat[/B] +Place an MP3 file in `plugins/EzAfk/mp3/` and set `afk.sound.enabled: true`. EzAfk plays it to the player when they go AFK (and optionally on return) using the Simple Voice Chat API. + +[B]Economy / Vault[/B] +Pair with any Vault-compatible economy plugin for AFK entry costs, recurring charges, and zone rewards. Recommended: [URL='https://www.spigotmc.org/resources/1-7-1-21-ezeconomy-modern-vault-economy-plugin-for-minecraft-servers.130975/'][B]EzEconomy[/B][/URL]. [SIZE=5][B]Configuration[/B][/SIZE] -EzAfk ships with dedicated files to keep settings organized: +EzAfk ships with dedicated files to keep settings organised: [LIST] -[*][B]config.yml[/B]: Core AFK behaviour, broadcasts, display-name styling, TAB integration, and punishment logic. -[*][B]gui.yml[/B]: Inventory size and per-slot actions for the staff GUI. -[*][B]mysql.yml[/B]: Connection details for optional persistent storage. -[*][B]messages_*.yml[/B]: Localised player-facing messages for English, Spanish, Dutch, Russian, Chinese, and German out of the box. +[*][B]config.yml[/B] — Core AFK behaviour, broadcasts, display-name styling, tab integration, kick, anti-bypass, economy, and integrations. +[*][B]gui.yml[/B] — Inventory size and per-slot actions for the staff GUI. +[*][B]zones.yml[/B] — AFK zone definitions with world, coordinates, and reward settings. +[*][B]mysql.yml[/B] — Connection details for optional MySQL storage. +[*][B]messages/[/B] — Per-language message files: `en`, `es`, `nl`, `ru`, `zh`, `de`. [/LIST] -[spoiler=Core config (config.yml)] +[spoiler=Core config snippet (config.yml)] [code=YAML] -# Config for EzAfk 1.8.0 -# GUI settings have moved to gui.yml. -# MySQL settings have moved to mysql.yml. messages: - # Default language for the generated messages file. Supported values: en, es, nl, ru, zh, de - # Language files are located in the messages/ directory. - language: en + language: en # en | es | nl | ru | zh | de + afk: - # Timeout in seconds timeout: 300 - # Enabling this will activate the function of the permission "ezafk.bypass" - # By default OP players have this permission. - bypass: - enabled: true broadcast: - # Enable broadcast message to all online players when player is AFK enabled: true - # Placeholders: - # %player% - Display name of player - # %afk_count% - Current number of players marked as AFK - # %active_count% - Current number of online players not marked as AFK title: enabled: true hide-screen: - # Apply a blindness effect to AFK players to hide their screen until they return. enabled: false animation: enabled: true - storage: - # Interval in seconds between asynchronous flushes of AFK time data to disk. - # Lower values write more frequently while higher values reduce disk activity. - flush-interval-seconds: 30 - # Prevent players from bypassing AFK anti: - # Toggle the infinite water flow protection (config path: afk.anti.infinite-waterflow) infinite-waterflow: false infinite-vehicle: false - # When true, bypass attempts silently mark the player as AFK instead of alerting them. + bubble-column: false flag-only: false - # TAB integration - # Requires: https://www.spigotmc.org/resources/tab-1-5-1-21.57806/ - tab-prefix: - enabled: false - # Strategy for applying AFK names when the TAB plugin is installed. - # Options: - # auto - Use TAB when available, otherwise fall back to EzAfk's custom list handling. - # tab - Require TAB for name changes. Falls back to the custom implementation if the plugin is missing. - # custom - Always use EzAfk's built-in implementation, even if TAB is installed. - mode: auto - prefix: "&7[AFK] " - # Text to append to the end of the player's name while AFK. - suffix: "" - # Full format for the displayed name. Available placeholders: %prefix%, %player%, %suffix% - format: "%prefix%%player%%suffix%" - # Change the in-game display name (e.g., chat) while the player is AFK. - display-name: - enabled: false - prefix: "&7[AFK] " - suffix: "" - format: "%prefix%%player%%suffix%" + kick: - # Enable the kick function after being AFK for x amount of time enabled: false - # Enable the kick function when the lobby is full enabledWhenFull: false - # Timeout in seconds timeout: 600 warnings: enabled: true - # List of warning intervals (in seconds before kick) to send warnings at. - # Example: [60, 30, 10] will warn at 60s, 30s, and 10s before kick. intervals: [60, 30, 10] - # How to send warnings: 'chat', 'title', or 'both'. - # - chat: Sends a chat message only. - # - title: Sends a title/subtitle only. - # - both: Sends both chat and title/subtitle. - mode: both - # Messages are configurable in messages.yml under kick.warning - # Warnings are only sent once per interval per AFK session. - # If a player returns from AFK or is kicked, the warning state resets. -unafk: - broadcast: - # Broadcast a message when a player is not longer AFK - enabled: true - # Placeholders: - # %player% - Display name of player - # %afk_count% - Current number of players marked as AFK - # %active_count% - Current number of online players not marked as AFK - title: - enabled: true - animation: - enabled: true + mode: both # chat | title | both + economy: - # Enable economy-based costs for going AFK. Requires Vault with an economy provider installed. enabled: false - # Players with this permission bypass all AFK costs. - bypass-permission: "ezafk.economy.bypass" - # Players inside a WorldGuard region with the afk-bypass flag will also skip costs. cost: enter: - # Charge a player when they are marked as AFK (either manually or automatically). enabled: true amount: 25.0 - # When true, the AFK toggle is cancelled if the player cannot afford the cost. - # When false, the charge is skipped and the player still becomes AFK. - require-funds: true - # Seconds to wait before attempting to automatically toggle a player AFK again after a failed charge. - retry-delay: 60 recurring: - # Continuously charge AFK players while they remain AFK. enabled: false amount: 5.0 - # Time between recurring charges in seconds. interval: 300 - # When true, the player must have enough funds for the charge to succeed. - # Failing to pay while this is true always removes their AFK state. - require-funds: true - # Controls optional charges (when require-funds is false): true removes the AFK state on failure, false retries later. - kick-on-fail: false -kick: - # Enable the kick function after being AFK for x amount of time - enabled: false - # Enable the kick function when the lobby is full - enabledWhenFull: false - # Timeout in seconds - timeout: 600 -integration: - # https://dev.bukkit.org/projects/worldguard - # Automatically skipped when WorldGuard is not installed. - worldguard: true - # https://www.spigotmc.org/resources/tab-1-5-1-21.57806/ - tab: true - # Check for EzAfk updates while startup server - # This is being done async and will not affect the startup time of your server - spigot: - check-for-update: true -[/code] -[/spoiler] - -[spoiler=GUI actions (gui.yml)] -[code=YAML] -inventory-size: 9 -actions: - kick: - slot: 0 - material: IRON_BOOTS - display-name: "&cKick Player" - type: KICK - target-message: "&cYou were kicked for being AFK too long." - feedback-message: "&aSuccessfully kicked %player%" - alert: - slot: 1 - material: PAPER - display-name: "&eSend Alert" - type: MESSAGE - target-message: "&eYou are marked as AFK. Keep active to prevent getting kicked!" - feedback-message: "&aSent alert to %player%" - teleport: - slot: 2 - material: COMPASS - display-name: "&aTeleport to Player" - type: TELEPORT - feedback-message: "&aTeleported to %player%" -[/code] -[/spoiler] - -[spoiler=Database (mysql.yml)] -[code=YAML] -enabled: false -host: "localhost" -port: 3306 -database: "ezafk" -username: "root" -password: "" -[/code] -[/spoiler] - -[SIZE=5][B]Multiple language support[/B][/SIZE] -Every alert, warning, or confirmation shown to players can be tailored in the language-specific files under `messages/`. EzAfk bundles fully translated packs for English, Spanish, Dutch, Russian, and Simplified Chinese, and automatically falls back to English if a language is missing. - -Set [ICODE]messages.language[/ICODE] in [ICODE]config.yml[/ICODE] to match one of the bundled codes ([ICODE]en[/ICODE], [ICODE]es[/ICODE], [ICODE]nl[/ICODE], [ICODE]ru[/ICODE], or [ICODE]zh[/ICODE]) and the plugin will copy the corresponding file on first launch. Want to localise EzAfk for your own community? Copy one of the provided files, translate the values, drop it back into the [ICODE]messages/[/ICODE] folder, and point [ICODE]messages.language[/ICODE] at your new filename (for example, [ICODE]messages_fr[/ICODE]). - -Messages include everything from AFK toggle confirmations and bypass notifications to GUI errors, blindness prompts, and tab-prefix text—making it easy to deliver a consistent experience in your players' preferred language. - -[SIZE=5][B]Support[/B][/SIZE] -For support, suggestions, or bug reports, join our [URL='https://discord.gg/yWP95XfmBS']Discord server[/URL] or visit the support thread on SpigotMC.org. - -Keep your server active and free from idle players with EzAfk! Download now and take control of AFK players on your server. - -[IMG]https://bstats.org/signatures/bukkit/ezafk.svg[/IMG] -[CENTER][URL='https://www.spigotmc.org/resources/authors/shadow48402.25936/'][IMG]https://i.ibb.co/PzfjNjh0/ezplugins-try-other-plugins.png[/IMG][/URL][/CENTER] - ---- - -# Markdown Variant - -``` -███████╗███████╗░█████╗░███████╗██╗░░██╗ -██╔════╝╚════██║██╔══██╗██╔════╝██║░██╔╝ -█████╗░░░░███╔═╝███████║█████╗░░█████═╝░ -██╔══╝░░██╔══╝░░██╔══██║██╔══╝░░██╔═██╗░ -███████╗███████╗██║░░██║██║░░░░░██║░╚██╗ -╚══════╝╚══════╝╚═╝░░╚═╝╚═╝░░░░░╚═╝░░╚═╝ -``` - -## **Keep AFK management simple** - -**EzAfk** is a modern, lightweight AFK management plugin that keeps your staff informed while gently nudging idle players back into the action. Built for Paper and Spigot servers (1.7 – 1.21.*), it automates AFK detection, provides configurable staff tools, and integrates with the systems you already use — all without sacrificing performance. - -![Latest version](https://img.shields.io/badge/Latest%20version-2.0.0-blue) -![Minecraft version](https://img.shields.io/badge/Minecraft%20version-1.7%20to%201.21.*-blue) - ---- - -### **Feature highlights** - -- **Automatic AFK detection**: Detect idle players after a configurable timeout, send chat or title messages, play optional animations, apply blindness, and broadcast status changes server-wide. -- **Staff workflow tools**: Open the AFK player overview GUI and trigger customizable actions (kick, alert, teleport, run console commands) per-player from an intuitive menu. -- **Anti-bypass protections**: Block common AFK bypass tricks with vehicle and water-flow checks, optionally gated behind the `ezafk.bypass` permission or WorldGuard regions. -- **Automatic punishments**: Kick AFK players after configurable grace periods or only when the server is full, with personalized kick reasons. -- **AFK kick warnings**: Send configurable chat and/or title warnings at multiple intervals before a player is kicked for being AFK, giving them a chance to return. -- **Economy-aware AFK**: Optionally charge players using Vault-supported economies when they go or stay AFK, with recurring billing plus bypass permissions or WorldGuard regions. -- **[EzEconomy integration](https://www.spigotmc.org/resources/1-21-ezeconomy-modern-vault-economy-plugin-for-minecraft-servers.130975/)**: For best results, pair EzAfk with **EzEconomy** for modern, reliable Vault economy support! -- **Simple Voice Chat integration** (> v2.2.0): Optionally play a custom MP3 sound to players when they go AFK, using the Simple Voice Chat mod and its API. -- **Integrations that matter**: Use the WorldGuard `afk-bypass` flag, track usage with bStats, receive console reminders when new releases are available, and surface AFK prefixes in the tab list without any external dependencies. -- **Custom display names & tab styling**: Mirror AFK status in chat and name tags with configurable prefixes, suffixes, and formats that work with TAB or the built-in formatter. -- **Persistent storage**: Optionally connect to MySQL to store the last active timestamp for every tracked player across restarts, plus per-player YAML totals for the AFK time leaderboard. -- **AFK analytics**: Surface `/afk time`, `/afk info`, and `/afk top` so staff can investigate reports and highlight the most idle players with a cached leaderboard. -- **Player-friendly deterrents**: Combine optional blindness and configurable animations to nudge players back to activity without being heavy-handed. -- **Built-in translations**: Ship ready-to-use English, Spanish, Dutch, Russian, and Chinese message packs with automatic fallbacks and per-server overrides. - ---- - -### **Commands** - -- **/afk**: Toggle your own AFK state (alias of `/ezafk`). -- **/afk reload**: Reload the configuration and refresh integrations. -- **/afk gui**: Open the AFK player overview GUI (includes pagination and quick actions). -- **/afk toggle **: Force another player's AFK state. -- **/afk bypass **: Toggle the bypass flag for a player when `afk.bypass.enabled` is active. -- **/afk info **: Review why a player was flagged AFK, how long they've been idle, and their last activity. -- **/afk time [player]**: Show lifetime AFK totals for yourself or, with permission, another player. -- **/afk top**: Display the cached AFK time leaderboard (also available via `/afktop`). - -*Aliases*: `/ezafk`, `/afk`, `/ea`, `/afktime`, `/afktop` - ---- - -### **Permissions** - -- **ezafk.reload**: Allows using `/afk reload`. -- **ezafk.gui**: Allows opening the overview GUI. -- **ezafk.gui.view-active**: Allows viewing active (non-AFK) players in the GUI. -- **ezafk.gui.actions**: Allows using the player action buttons in the GUI. -- **ezafk.toggle**: Allows toggling other players' AFK state. -- **ezafk.bypass**: Lets a player ignore the automatic AFK trigger when bypass checking is enabled. -- **ezafk.bypass.manage**: Allows toggling bypass mode for other players via `/afk bypass`. -- **ezafk.economy.bypass**: Exempts a player from economy charges when AFK costs are enabled. -- **ezafk.info**: Allows viewing AFK reports for other players via `/afk info`. -- **ezafk.time**: Allows checking your own AFK total. -- **ezafk.time.others**: Allows checking someone else's AFK total with `/afk time `. -- **ezafk.top**: Allows viewing the AFK time leaderboard. - ---- - -### **GUI overview** - -The GUI is available with the command `/afk gui`, the permission `ezafk.gui`, or OP status. Default buttons let staff kick, alert, or teleport to AFK players, and you can add extra items that run console commands with `%player%` and `%executor%` placeholders. Configure the layout in `gui.yml`. - -![ezafk-gui](https://i.ibb.co/Hx8BwCj/ezafk-gui.png) - ---- - -### **Customizable admin actions** - -Beyond the preconfigured buttons, EzAfk lets you build your own action items directly in `gui.yml`. Each slot can execute one or more console or player commands, display custom icons, and include hover descriptions so staff understand what the action does. Combine placeholders such as `%player%`, `%executor%`, or `%world%` with permission checks to craft targeted moderation workflows—anything from warning messages to teleport chains or integrations with external moderation plugins. - ---- - -### **Integrations** -#### **Tab list styling (built-in)** +storage: + type: yaml # yaml | sqlite | mysql + flush-interval-seconds: 30 -Enable the `afk.tab-prefix.enabled` setting to display a custom prefix or suffix whenever a player is marked AFK. Customize the prefix, suffix, and final format using placeholders like `%prefix%`, `%player%`, and `%suffix%`. EzAfk can either rely on the TAB plugin for formatting or use its own built-in implementation - choose your preferred behaviour with `afk.tab-prefix.mode` (options: `auto`, `tab`, or `custom`). - -![afk-tab](https://i.ibb.co/nD4dbQj/afk-tab.png) - -#### **WorldGuard (> v1.2)** - -Enable the integration in `config.yml` to unlock the custom `afk-bypass` flag, allowing specific regions where players can idle without triggering punishments or economy charges. -[WorldGuard on BukkitDev](https://dev.bukkit.org/projects/worldguard) - -**Flag name**: `afk-bypass` - -**How to add the flag to your region?** -```shell -/rg flag afk-bypass allow -``` - -#### **MySQL storage (> v1.3)** - -Store AFK player state in a central database. EzAfk automatically handles inserts, updates, and cleanup based on player UUIDs. - -#### **Metrics & updates** - -Anonymous usage statistics are collected via bStats, and the plugin optionally checks SpigotMC for updates during startup. Both features can be disabled through `config.yml`. - -#### **PlaceholderAPI (> v1.7)** - -Install [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) to expose EzAfk's placeholders. The expansion registers itself automatically when the plugin is detected, so no extra permissions or config toggles are required. - -**Provided placeholders:** -- `%ezafk_status%` — Returns `AFK` or `ACTIVE` for the targeted player. -- `%ezafk_status_colored%` — Returns the color-formatted status string (e.g., `&cAFK`). -- `%ezafk_since%` — Seconds since the player was marked AFK. Empty when they are active. -- `%ezafk_last_active%` — Seconds since the player last moved. Always available. -- `%ezafk_prefix%` — The configured AFK prefix applied to their display name while AFK. -- `%ezafk_suffix%` — The configured AFK suffix applied to their display name while AFK. -- `%ezafk_afk_count%` — Total number of players currently marked as AFK. -- `%ezafk_active_count%` — Total number of online players not marked as AFK. - -**Usage example:** -```yaml -&7Status: %ezafk_status_colored%[/ICODE] -[ICODE]&7AFK for: %ezafk_since%s -``` - ---- - -### **Configuration** - -EzAfk ships with dedicated files to keep settings organized: - -- **config.yml**: Core AFK behaviour, broadcasts, display-name styling, TAB integration, and punishment logic. -- **gui.yml**: Inventory size and per-slot actions for the staff GUI. -- **mysql.yml**: Connection details for optional persistent storage. -- **messages_*.yml**: Localised player-facing messages for English, Spanish, Dutch, Russian, and Chinese out of the box. - -
-Core config (config.yml) - -```yaml -# Config for EzAfk 2.0.0 -# GUI settings have moved to gui.yml. -# MySQL settings have moved to mysql.yml. -messages: - language: en -afk: - timeout: 300 - bypass: - enabled: true - broadcast: - enabled: true - title: - enabled: true - hide-screen: - enabled: false - animation: - enabled: true - storage: - flush-interval-seconds: 30 - anti: - infinite-waterflow: false - infinite-vehicle: false - flag-only: false - tab-prefix: - enabled: false - mode: auto - prefix: "&7[AFK] " - suffix: "" - format: "%prefix%%player%%suffix%" - display-name: - enabled: false - prefix: "&7[AFK] " - suffix: "" - format: "%prefix%%player%%suffix%" -kick: - enabled: false - enabledWhenFull: false - timeout: 600 - warnings: - enabled: true - intervals: [60, 30, 10] - mode: both -unafk: - broadcast: - enabled: true - title: - enabled: true - animation: - enabled: true -economy: - enabled: false - bypass-permission: "ezafk.economy.bypass" - cost: - enter: - enabled: true - amount: 25.0 - require-funds: true - retry-delay: 60 - recurring: - enabled: false - amount: 5.0 - interval: 300 - require-funds: true - kick-on-fail: false integration: worldguard: true tab: true - spigot: - check-for-update: true -``` -
- -
-GUI actions (gui.yml) - -```yaml -inventory-size: 9 -actions: - kick: - slot: 0 - material: IRON_BOOTS - display-name: "&cKick Player" - type: KICK - target-message: "&cYou were kicked for being AFK too long." - feedback-message: "&aSuccessfully kicked %player%" - alert: - slot: 1 - material: PAPER - display-name: "&eSend Alert" - type: MESSAGE - target-message: "&eYou are marked as AFK. Keep active to prevent getting kicked!" - feedback-message: "&aSent alert to %player%" - teleport: - slot: 2 - material: COMPASS - display-name: "&aTeleport to Player" - type: TELEPORT - feedback-message: "&aTeleported to %player%" -``` -
- -
-Database (mysql.yml) - -```yaml -enabled: false -host: "localhost" -port: 3306 -database: "ezafk" -username: "root" -password: "" -``` -
- ---- - -### **Multiple language support** - -Every alert, warning, or confirmation shown to players can be tailored in the language-specific files under `messages/`. EzAfk bundles fully translated packs for English, Spanish, Dutch, Russian, and Simplified Chinese, and automatically falls back to English if a language is missing. - -Set `messages.language` in `config.yml` to match one of the bundled codes (`en`, `es`, `nl`, `ru`, or `zh`) and the plugin will copy the corresponding file on first launch. Want to localise EzAfk for your own community? Copy one of the provided files, translate the values, drop it back into the `messages/` folder, and point `messages.language` at your new filename (for example, `messages_fr`). - -Messages include everything from AFK toggle confirmations and bypass notifications to GUI errors, blindness prompts, and tab-prefix text—making it easy to deliver a consistent experience in your players' preferred language. - ---- - -### **Support** - -For support, suggestions, or bug reports, join our [Discord server](https://discord.gg/yWP95XfmBS) or visit the support thread on SpigotMC.org. - -Keep your server active and free from idle players with EzAfk! Download now and take control of AFK players on your server. - -![bStats](https://bstats.org/signatures/bukkit/ezafk.svg) \ No newline at end of file + voicechat: auto # true | false | auto +[/code] +[/spoiler] diff --git a/docs/ezafk-bbcode.txt b/topics/bbcode.txt similarity index 100% rename from docs/ezafk-bbcode.txt rename to topics/bbcode.txt diff --git a/topics/modrinth-topic.md b/topics/modrinth-topic.md new file mode 100644 index 0000000..f078edc --- /dev/null +++ b/topics/modrinth-topic.md @@ -0,0 +1,179 @@ +# EzAfk + +**Keep AFK management simple.** + +EzAfk is a modern, lightweight AFK management plugin for Paper and Spigot servers. +It automates AFK detection, rewards or charges players based on idle state, gives staff +a real-time overview panel, and integrates with the tools you already run — all without +sacrificing performance. + +> **v3.0.0** · Minecraft 26.1+ · Java 25 · Paper / Spigot / Bukkit / Purpur + +--- + +## Feature Highlights + +| Feature | Summary | +|---------|---------| +| **AFK Detection** | Idle timeout with configurable broadcasts, titles, animations, display-name changes, and a blindness-blur overlay | +| **Anti-Bypass** | Block infinite water flow, vehicle riding, and bubble column tricks from resetting idle timers | +| **AFK Kick** | Kick idle players after a configurable period, optionally only when the server is full | +| **Kick Warnings** | Multi-stage countdown messages (chat and/or title) at custom intervals before the kick fires | +| **Staff GUI** | `/afk gui` panel showing all AFK players with one-click kick, message, teleport, and custom command buttons | +| **AFK Zones** | Cuboid regions where players earn rewards (economy, commands, or items) for being AFK | +| **Economy Costs** | Charge a one-time entry fee and/or a recurring fee while AFK via any Vault economy plugin | +| **AFK Leaderboard** | Per-player cumulative AFK time, `/afk top` leaderboard, persisted across restarts | +| **PlaceholderAPI** | 16 placeholders for status, session time, totals, counts, prefix/suffix, and playtime | +| **Multi-language** | EN, ES, NL, RU, ZH, DE — fully overridable per server | +| **Storage backends** | YAML (default), SQLite, or MySQL | +| **Simple Voice Chat** | Play a custom MP3 sound when a player goes AFK | + +--- + +## Installation + +1. Download `EzAfk-x.x.x.jar` from the files tab above. +2. Drop it into your server's `plugins/` folder. +3. Restart the server. EzAfk generates all config files automatically. +4. Edit `plugins/EzAfk/config.yml` to configure your desired features. +5. Run `/afk reload` in-game to apply changes without a restart. + +--- + +## Commands + +| Command | Description | Permission | +|---------|-------------|------------| +| `/afk` | Toggle your own AFK status | — | +| `/afk reload` | Reload all configuration files | `ezafk.reload` | +| `/afk gui` | Open the AFK staff overview panel | `ezafk.gui` | +| `/afk toggle ` | Force a player's AFK state | `ezafk.toggle` | +| `/afk bypass ` | Toggle AFK bypass for a player | `ezafk.bypass.manage` | +| `/afk info ` | View a player's AFK session details | `ezafk.info` | +| `/afk time [player]` | View total AFK time | `ezafk.time` | +| `/afk time reset ` | Reset a player's AFK time | `ezafk.time.reset` | +| `/afk top` | Show the AFK time leaderboard | `ezafk.top` | +| `/afk zone pos1` / `pos2` | Select zone corners | `ezafk.zone.manage` | +| `/afk zone add ` | Create an AFK zone | `ezafk.zone.manage` | +| `/afk zone remove ` | Remove an AFK zone | `ezafk.zone.manage` | +| `/afk zone list` | List all zones | `ezafk.zone.list` | + +Aliases: `/ezafk`, `/ea`, `/afktime`, `/afktop` + +--- + +## Permissions + +| Node | Default | Description | +|------|---------|-------------| +| `ezafk.reload` | OP | Reload configuration | +| `ezafk.bypass` | OP | Never be marked AFK automatically | +| `ezafk.bypass.manage` | OP | Toggle bypass for other players | +| `ezafk.toggle` | OP | Toggle other players' AFK state | +| `ezafk.info` | OP | View AFK details for other players | +| `ezafk.kick.bypass` | OP | Never be kicked by EzAfk | +| `ezafk.gui` | OP | Open the staff GUI | +| `ezafk.gui.view-active` | OP | View non-AFK players in the GUI | +| `ezafk.gui.actions` | OP | Use action buttons in the GUI | +| `ezafk.time` | **all** | View own AFK time | +| `ezafk.time.others` | OP | View another player's AFK time | +| `ezafk.time.reset` | OP | Reset a player's AFK time | +| `ezafk.top` | OP | View the leaderboard | +| `ezafk.economy.bypass` | OP | Skip economy AFK charges | +| `ezafk.zone.list` | OP | List AFK zones | +| `ezafk.zone.manage` | OP | Create and remove zones | + +--- + +## PlaceholderAPI Placeholders + +Requires [PlaceholderAPI](https://hangar.papermc.io/HelpChat/PlaceholderAPI). The expansion registers automatically — no extra setup needed. + +| Placeholder | Returns | +|-------------|---------| +| `%ezafk_status%` | `AFK` or `ACTIVE` | +| `%ezafk_status_colored%` | `&cAFK` or `&aACTIVE` | +| `%ezafk_since%` | Seconds since AFK session started (empty if not AFK) | +| `%ezafk_last_active%` | Seconds since last player activity | +| `%ezafk_total_seconds%` | Total lifetime AFK seconds | +| `%ezafk_total%` | Total AFK time `HH:MM:SS` | +| `%ezafk_total_formatted%` | Verbose, e.g. `2 hours 15 minutes` | +| `%ezafk_prefix%` | Configured AFK display-name prefix | +| `%ezafk_suffix%` | Configured AFK display-name suffix | +| `%ezafk_playtime_active_seconds%` | Active (non-AFK) playtime in seconds | +| `%ezafk_playtime_active%` | Active playtime `HH:MM:SS` | +| `%ezafk_playtime_active_formatted%` | Active playtime verbose | +| `%ezafk_afk_count%` / `%ezafk_afk_players%` | Number of AFK players | +| `%ezafk_active_count%` / `%ezafk_active_players%` | Number of non-AFK players | + +--- + +## Configuration Overview + +EzAfk's settings are spread across focused files for clarity: + +```text +config.yml — AFK detection, kick, anti-bypass, economy, integrations +gui.yml — Staff GUI layout and action buttons +zones.yml — AFK zone definitions with coordinates and rewards +mysql.yml — Database connection (only when storage.type: mysql) +messages/ — Language files: en.yml, es.yml, nl.yml, ru.yml, zh.yml, de.yml +``` + +### Core settings (`config.yml`) + +```yaml +afk: + timeout: 300 # seconds of inactivity → AFK + broadcast: + enabled: true # announce AFK in chat + title: + enabled: true # show title to the AFK player + anti: + infinite-waterflow: false + infinite-vehicle: false + bubble-column: false + +kick: + enabled: false # kick AFK players + enabledWhenFull: false # only kick when server is at capacity + timeout: 600 # seconds AFK before kick + warnings: + enabled: true + intervals: [60, 30, 10] + mode: both # chat | title | both + +economy: + enabled: false + cost: + enter: + enabled: true + amount: 25.0 + +storage: + type: yaml # yaml | sqlite | mysql + flush-interval-seconds: 30 +``` + +--- + +## Integrations + +| Integration | Notes | +|-------------|-------| +| **Vault / Economy** | Required for economy costs and zone economy rewards | +| **PlaceholderAPI** | Auto-detected; provides 16 AFK placeholders | +| **WorldGuard** | Adds `afk-bypass` region flag; use `/rg flag afk-bypass allow` | +| **TAB** | Delegate tab-list AFK prefix formatting to the TAB plugin | +| **Simple Voice Chat** | Play MP3 sounds on AFK state changes | +| **bStats** | Anonymous usage statistics (opt-out in `config.yml`) | +| **Spigot update checker** | Console reminder when a new version is available | + +--- + +## Links + +- [Documentation](https://ez-plugins.github.io/EzAfk/) — full configuration guide, feature pages, and API reference +- [Discord](https://discord.gg/yWP95XfmBS) — support, bug reports, and feature requests +- [GitHub](https://github.com/ez-plugins/EzAfk) — source code and issue tracker +- [Developer API](https://ez-plugins.github.io/EzAfk/api/) — `PlayerAfkStatusChangeEvent` and `AfkReason` enum From b72e5a85cb95c4855e09301ac5f2dc94aac41ad5 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Wed, 22 Apr 2026 20:22:42 +0200 Subject: [PATCH 06/15] docs: remove em dashes, simplify language for server owners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all em dashes with colons or plain punctuation throughout all documentation pages, feature pages, integration pages, API pages, bbcode-topic.md, and topics/modrinth-topic.md - Remove (bool), (integer, seconds), (string), (decimal) type annotations from config field descriptions so server owners can read them easily - Simplify How It Works sections: replace Bukkit/Java internal references (PlayerMoveEvent, VehicleMoveEvent, Player#kickPlayer(), session map) with plain behaviour descriptions - Fix TabIntegration.md: convert informal 'Steps — X' headers to proper ### headings; remove stray AI-assistant prompt paragraph at the end - Use direct language throughout the non-API docs --- bbcode-topic.md | 90 +++++++++---------- docs/afk-kick-warnings.md | 8 +- docs/api/AfkReasons.md | 14 +-- docs/api/events.md | 8 +- docs/api/index.md | 4 +- docs/configuration.md | 2 +- docs/features/afk-detection.md | 52 +++++------ docs/features/afk-kick.md | 20 ++--- docs/features/afk-zones.md | 42 ++++----- docs/features/anti-bypass.md | 43 +++++---- docs/features/economy-costs.md | 35 ++++---- docs/features/gui.md | 40 ++++----- docs/features/leaderboard.md | 22 ++--- docs/getting-started.md | 8 +- docs/index.md | 30 +++---- docs/integrations/EconomyIntegration.md | 6 +- .../integrations/PlaceholderApiIntegration.md | 12 +-- docs/integrations/TabIntegration.md | 17 ++-- docs/permissions.md | 32 +++---- topics/modrinth-topic.md | 26 +++--- 20 files changed, 255 insertions(+), 256 deletions(-) diff --git a/bbcode-topic.md b/bbcode-topic.md index 82a5e54..3a9bf0e 100644 --- a/bbcode-topic.md +++ b/bbcode-topic.md @@ -7,7 +7,7 @@ [B][SIZE=6]Keep AFK management simple[/SIZE][/B] -[B]EzAfk[/B] is a modern, lightweight AFK management plugin built for Paper and Spigot servers running Minecraft 26.1+ and Java 25. It automates AFK detection, rewards or charges players based on AFK state, provides staff overview tools, and integrates with the systems you already use — all without sacrificing performance. +[B]EzAfk[/B] is a modern, lightweight AFK management plugin built for Paper and Spigot servers running Minecraft 26.1+ and Java 25. It automates AFK detection, rewards or charges players based on AFK state, provides staff overview tools, and integrates with the systems you already use, all without sacrificing performance. [IMG]https://img.shields.io/badge/version-3.0.0-blue[/IMG] [IMG]https://img.shields.io/badge/Minecraft-26.1-green[/IMG] @@ -20,7 +20,7 @@ Download on [URL='https://modrinth.com/plugin/ezafk'][B]Modrinth[/B][/URL] · Fo [SIZE=5][B]Feature highlights[/B][/SIZE] [LIST] [*][B]Automatic AFK detection[/B]: Detect idle players after a configurable timeout (default 5 min). Send chat or title messages, trigger animations, apply a blindness blur, and broadcast status changes server-wide. -[*][B]Anti-bypass protections[/B]: Block common AFK farm tricks — infinite water flow, vehicle riding, and bubble columns — with individual toggle switches. Combine with the `ezafk.bypass` permission or WorldGuard regions for fine-grained control. +[*][B]Anti-bypass protections[/B]: Block common AFK farm tricks including infinite water flow, vehicle riding, and bubble columns, with individual toggle switches. Combine with the `ezafk.bypass` permission or WorldGuard regions for fine-grained control. [*][B]AFK kick with warnings[/B]: Kick players after a configurable idle period (default 10 min). Send multi-stage chat and/or title warnings at custom intervals (e.g. 60 s, 30 s, 10 s) before the kick fires. Optionally kick only when the server is full to free up slots. [*][B]In-game staff GUI[/B]: Open `/afk gui` to see all AFK players at a glance. One-click buttons let staff kick, message, teleport to, or run console commands against any AFK player. Fully configurable layout in `gui.yml`. [*][B]AFK Zones with rewards[/B]: Define coordinate-based cuboid regions where players earn rewards for being AFK. Reward types: economy currency (Vault), console commands, or item drops. Each zone has its own interval and reward cap. @@ -30,45 +30,45 @@ Download on [URL='https://modrinth.com/plugin/ezafk'][B]Modrinth[/B][/URL] · Fo [*][B]Persistent storage[/B]: Store AFK data in YAML (default), SQLite, or MySQL. The storage backend is swappable without data loss. [*][B]Multi-language support[/B]: Ships with English, Spanish, Dutch, Russian, Chinese, and German message packs. Override any message per-server without touching the source. [*][B]Simple Voice Chat integration[/B]: Play a custom MP3 sound to players when they go AFK, via the Simple Voice Chat mod API. -[*][B]PlaceholderAPI support[/B]: Expose 16 AFK placeholders to any PAPI-compatible plugin — status, session length, total time, prefix/suffix, player counts, and more. +[*][B]PlaceholderAPI support[/B]: Expose 16 AFK placeholders to any PAPI-compatible plugin: status, session length, total time, prefix/suffix, player counts, and more. [/LIST] [SIZE=5][B]Commands[/B][/SIZE] [LIST] -[*][B]/afk[/B] — Toggle your own AFK status. -[*][B]/afk reload[/B] — Reload all configuration files. -[*][B]/afk gui[/B] — Open the AFK player overview GUI. -[*][B]/afk toggle [/B] — Force another player's AFK state. -[*][B]/afk bypass [/B] — Toggle the AFK bypass flag for a player. -[*][B]/afk info [/B] — View a player's current AFK state, idle reason, and session info. -[*][B]/afk time [player][/B] — View total AFK time for yourself or another player. -[*][B]/afk time reset [/B] — Reset a player's cumulative AFK time counter. -[*][B]/afk top[/B] — Show the server-wide AFK time leaderboard. -[*][B]/afk zone pos1[/B] / [B]pos2[/B] — Select zone corners. -[*][B]/afk zone add [/B] — Create a new AFK zone between the two selected corners. -[*][B]/afk zone remove [/B] — Delete a zone. -[*][B]/afk zone list[/B] — List all configured zones. +[*][B]/afk[/B]: Toggle your own AFK status. +[*][B]/afk reload[/B]: Reload all configuration files. +[*][B]/afk gui[/B]: Open the AFK player overview GUI. +[*][B]/afk toggle [/B]: Force another player's AFK state. +[*][B]/afk bypass [/B]: Toggle the AFK bypass flag for a player. +[*][B]/afk info [/B]: View a player's current AFK state, idle reason, and session info. +[*][B]/afk time [player][/B]: View total AFK time for yourself or another player. +[*][B]/afk time reset [/B]: Reset a player's cumulative AFK time counter. +[*][B]/afk top[/B]: Show the server-wide AFK time leaderboard. +[*][B]/afk zone pos1[/B] / [B]pos2[/B]: Select zone corners. +[*][B]/afk zone add [/B]: Create a new AFK zone between the two selected corners. +[*][B]/afk zone remove [/B]: Delete a zone. +[*][B]/afk zone list[/B]: List all configured zones. [/LIST] [I]Aliases[/I]: `/ezafk`, `/ea`, `/afktime`, `/afktop` [SIZE=5][B]Permissions[/B][/SIZE] [LIST] -[*][B]ezafk.reload[/B] — Reload configuration. -[*][B]ezafk.bypass[/B] — Never be marked AFK automatically. -[*][B]ezafk.bypass.manage[/B] — Toggle bypass for other players. -[*][B]ezafk.toggle[/B] — Toggle other players' AFK state. -[*][B]ezafk.info[/B] — View AFK details for other players. -[*][B]ezafk.kick.bypass[/B] — Never be kicked by EzAfk's AFK kick. -[*][B]ezafk.gui[/B] — Open the staff GUI. -[*][B]ezafk.gui.view-active[/B] — View active players in the GUI. -[*][B]ezafk.gui.actions[/B] — Use action buttons in the GUI. -[*][B]ezafk.time[/B] — View own AFK time (granted to all players by default). -[*][B]ezafk.time.others[/B] — View other players' AFK time. -[*][B]ezafk.time.reset[/B] — Reset a player's AFK time. -[*][B]ezafk.top[/B] — View the leaderboard. -[*][B]ezafk.economy.bypass[/B] — Skip economy charges. -[*][B]ezafk.zone.list[/B] — List AFK zones. -[*][B]ezafk.zone.manage[/B] — Create and remove zones. +[*][B]ezafk.reload[/B]: Reload configuration. +[*][B]ezafk.bypass[/B]: Never be marked AFK automatically. +[*][B]ezafk.bypass.manage[/B]: Toggle bypass for other players. +[*][B]ezafk.toggle[/B]: Toggle other players' AFK state. +[*][B]ezafk.info[/B]: View AFK details for other players. +[*][B]ezafk.kick.bypass[/B]: Never be kicked by EzAfk's AFK kick. +[*][B]ezafk.gui[/B]: Open the staff GUI. +[*][B]ezafk.gui.view-active[/B]: View active players in the GUI. +[*][B]ezafk.gui.actions[/B]: Use action buttons in the GUI. +[*][B]ezafk.time[/B]: View own AFK time (granted to all players by default). +[*][B]ezafk.time.others[/B]: View other players' AFK time. +[*][B]ezafk.time.reset[/B]: Reset a player's AFK time. +[*][B]ezafk.top[/B]: View the leaderboard. +[*][B]ezafk.economy.bypass[/B]: Skip economy charges. +[*][B]ezafk.zone.list[/B]: List AFK zones. +[*][B]ezafk.zone.manage[/B]: Create and remove zones. [/LIST] [SIZE=5][B]GUI overview[/B][/SIZE] @@ -94,14 +94,14 @@ Enable WorldGuard integration in `config.yml` to unlock the custom `afk-bypass` Install [URL='https://www.spigotmc.org/resources/placeholderapi.6245/']PlaceholderAPI[/URL] and the expansion registers automatically. Full list of placeholders: [LIST] -[*][ICODE]%ezafk_status%[/ICODE] — [ICODE]AFK[/ICODE] or [ICODE]ACTIVE[/ICODE] -[*][ICODE]%ezafk_status_colored%[/ICODE] — Colour-coded status string -[*][ICODE]%ezafk_since%[/ICODE] — Seconds since the current AFK session started -[*][ICODE]%ezafk_last_active%[/ICODE] — Seconds since last activity -[*][ICODE]%ezafk_total_seconds%[/ICODE] / [ICODE]%ezafk_total%[/ICODE] / [ICODE]%ezafk_total_formatted%[/ICODE] — Total lifetime AFK time -[*][ICODE]%ezafk_prefix%[/ICODE] / [ICODE]%ezafk_suffix%[/ICODE] — Configured AFK display-name prefix/suffix -[*][ICODE]%ezafk_playtime_active_seconds%[/ICODE] / [ICODE]%ezafk_playtime_active%[/ICODE] — Active (non-AFK) playtime -[*][ICODE]%ezafk_afk_count%[/ICODE] / [ICODE]%ezafk_active_count%[/ICODE] — Server-wide AFK / active player counts +[*][ICODE]%ezafk_status%[/ICODE]: [ICODE]AFK[/ICODE] or [ICODE]ACTIVE[/ICODE] +[*][ICODE]%ezafk_status_colored%[/ICODE]: Colour-coded status string +[*][ICODE]%ezafk_since%[/ICODE]: Seconds since the current AFK session started +[*][ICODE]%ezafk_last_active%[/ICODE]: Seconds since last activity +[*][ICODE]%ezafk_total_seconds%[/ICODE] / [ICODE]%ezafk_total%[/ICODE] / [ICODE]%ezafk_total_formatted%[/ICODE]: Total lifetime AFK time +[*][ICODE]%ezafk_prefix%[/ICODE] / [ICODE]%ezafk_suffix%[/ICODE]: Configured AFK display-name prefix/suffix +[*][ICODE]%ezafk_playtime_active_seconds%[/ICODE] / [ICODE]%ezafk_playtime_active%[/ICODE]: Active (non-AFK) playtime +[*][ICODE]%ezafk_afk_count%[/ICODE] / [ICODE]%ezafk_active_count%[/ICODE]: Server-wide AFK / active player counts [/LIST] [B]Simple Voice Chat[/B] @@ -113,11 +113,11 @@ Pair with any Vault-compatible economy plugin for AFK entry costs, recurring cha [SIZE=5][B]Configuration[/B][/SIZE] EzAfk ships with dedicated files to keep settings organised: [LIST] -[*][B]config.yml[/B] — Core AFK behaviour, broadcasts, display-name styling, tab integration, kick, anti-bypass, economy, and integrations. -[*][B]gui.yml[/B] — Inventory size and per-slot actions for the staff GUI. -[*][B]zones.yml[/B] — AFK zone definitions with world, coordinates, and reward settings. -[*][B]mysql.yml[/B] — Connection details for optional MySQL storage. -[*][B]messages/[/B] — Per-language message files: `en`, `es`, `nl`, `ru`, `zh`, `de`. +[*][B]config.yml[/B]: Core AFK behaviour, broadcasts, display-name styling, tab integration, kick, anti-bypass, economy, and integrations. +[*][B]gui.yml[/B]: Inventory size and per-slot actions for the staff GUI. +[*][B]zones.yml[/B]: AFK zone definitions with world, coordinates, and reward settings. +[*][B]mysql.yml[/B]: Connection details for optional MySQL storage. +[*][B]messages/[/B]: Per-language message files: `en`, `es`, `nl`, `ru`, `zh`, `de`. [/LIST] [spoiler=Core config snippet (config.yml)] diff --git a/docs/afk-kick-warnings.md b/docs/afk-kick-warnings.md index fd0adeb..1a7da08 100644 --- a/docs/afk-kick-warnings.md +++ b/docs/afk-kick-warnings.md @@ -51,7 +51,7 @@ If your kick timeout is 600 seconds (10 minutes) and intervals are `[60, 30, 10] ## Related -- [AFK Kick](afk-kick) — the underlying kick system this warning feature extends -- [AFK Detection](afk-detection) — idle detection that starts the kick countdown -- [Messages](../messages) — customise all warning message text -- [Permissions](../permissions) — `ezafk.kick.bypass` +- [AFK Kick](afk-kick): the underlying kick system this warning feature extends +- [AFK Detection](afk-detection): idle detection that starts the kick countdown +- [Messages](../messages): customise all warning message text +- [Permissions](../permissions): `ezafk.kick.bypass` diff --git a/docs/api/AfkReasons.md b/docs/api/AfkReasons.md index 2262093..2170f3a 100644 --- a/docs/api/AfkReasons.md +++ b/docs/api/AfkReasons.md @@ -10,12 +10,12 @@ EzAfk uses the `AfkReason` enum to describe why a player's AFK status changed. B ## Possible AfkReasons -- `MANUAL` — Player toggled AFK manually. -- `COMMAND_FORCED` — Marked AFK by a staff command. -- `INACTIVITY` — No recent player activity was detected. -- `ANTI_INFINITE_WATER` — Bypass detection: sustained water flow movement. -- `ANTI_VEHICLE` — Bypass detection: vehicle movement without input. -- `ANTI_BUBBLE_COLUMN` — Bypass detection: bubble column movement. -- `OTHER` — AFK status updated by the plugin. +- `MANUAL`: Player toggled AFK manually. +- `COMMAND_FORCED`: Marked AFK by a staff command. +- `INACTIVITY`: No recent player activity was detected. +- `ANTI_INFINITE_WATER`: Bypass detection: sustained water flow movement. +- `ANTI_VEHICLE`: Bypass detection: vehicle movement without input. +- `ANTI_BUBBLE_COLUMN`: Bypass detection: bubble column movement. +- `OTHER`: AFK status updated by the plugin. Refer to the [PlayerAfkStatusChangeEvent](./events.md#playerafkstatuschangeevent) for how these reasons are used in events. diff --git a/docs/api/events.md b/docs/api/events.md index 15832f2..d93cf0c 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -16,10 +16,10 @@ EzAfk exposes custom events to allow other plugins to hook into AFK status chang - **Fired:** When a player goes AFK or returns from AFK. - **Cancellable:** Yes. Plugins can cancel AFK status changes. - **Fields:** - - `Player getPlayer()` — The player whose status changed - - `boolean isAfk()` — `true` if now AFK, `false` if returned - - `AfkReason getReason()` — Reason for AFK status change (see [AfkReasons](./AfkReasons.md)) - - `String getDetail()` — Additional details about the change + - `Player getPlayer()`: The player whose status changed + - `boolean isAfk()`: `true` if now AFK, `false` if returned + - `AfkReason getReason()`: Reason for AFK status change (see [AfkReasons](./AfkReasons.md)) + - `String getDetail()`: Additional details about the change ### Example Listener diff --git a/docs/api/index.md b/docs/api/index.md index 677855b..c4bc7c5 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -11,5 +11,5 @@ EzAfk exposes a developer API for other plugins to integrate with AFK state chan | Page | What it covers | |------|----------------| -| [Events](events) | `PlayerAfkStatusChangeEvent` — hook into AFK status changes | -| [AfkReasons](AfkReasons) | `AfkReason` enum — reasons for AFK status changes | +| [Events](events) | `PlayerAfkStatusChangeEvent`: hook into AFK status changes | +| [AfkReasons](AfkReasons) | `AfkReason` enum: reasons for AFK status changes | diff --git a/docs/configuration.md b/docs/configuration.md index 3f62ea9..2edadf8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,7 +6,7 @@ nav_order: 4 # EzAfk Configuration Guide This page lists every configuration option in EzAfk's main files. -For feature-level documentation — explained config options, behaviour walkthroughs, and examples — see the **[Features](features/)** section. +For feature-level documentation, including explained config options, behaviour walkthroughs, and examples, see the **[Features](features/)** section. --- diff --git a/docs/features/afk-detection.md b/docs/features/afk-detection.md index babd11c..8cf1eee 100644 --- a/docs/features/afk-detection.md +++ b/docs/features/afk-detection.md @@ -48,28 +48,28 @@ unafk: file: "mp3/ezafk-sound.mp3" ``` -- **`afk.timeout`**: (integer, seconds) How long a player must be idle before EzAfk marks them AFK. +- **`afk.timeout`**: How long (in seconds) a player must be idle before EzAfk marks them AFK. Default: `300` (5 minutes). -- **`afk.bypass.enabled`**: (bool) When `true`, players with the `ezafk.bypass` permission are never +- **`afk.bypass.enabled`**: When `true`, players with the `ezafk.bypass` permission are never marked AFK automatically. Default: `true`. -- **`afk.broadcast.enabled`** / **`unafk.broadcast.enabled`**: (bool) Send a chat message to all - online players when someone goes or returns from AFK. Messages are configured in your language file. -- **`afk.title.enabled`** / **`unafk.title.enabled`**: (bool) Show a large title overlay to the - player themselves when their AFK state changes. Text is configured in your language file. -- **`afk.hide-screen.enabled`**: (bool) Apply a blindness blur to the player while they are AFK, +- **`afk.broadcast.enabled`** / **`unafk.broadcast.enabled`**: Send a chat message to all online + players when someone goes or returns from AFK. Messages are configured in your language file. +- **`afk.title.enabled`** / **`unafk.title.enabled`**: Show a large title overlay to the player + when their AFK state changes. Text is configured in your language file. +- **`afk.hide-screen.enabled`**: Apply a blindness effect to players while they are AFK, preventing them from seeing the world. Default: `false`. -- **`afk.animation.enabled`** / **`unafk.animation.enabled`**: (bool) Toggle a bobbing animation on - the AFK player's name-tag as seen by other players. Default: `true`. -- **`afk.display-name.enabled`**: (bool) Modify the player's display name using `prefix`, `suffix`, - and `format`. Visible in chat and commands that echo display names. Default: `false`. -- **`afk.display-name.prefix`** / **`suffix`**: (string, supports `&` colour codes) Text prepended or - appended to the player's name. -- **`afk.display-name.format`**: (string) Full format string. Available placeholders: `%prefix%`, +- **`afk.animation.enabled`** / **`unafk.animation.enabled`**: Toggle a bobbing animation on + the AFK player's name tag as seen by nearby players. Default: `true`. +- **`afk.display-name.enabled`**: Add a prefix and/or suffix to the player's display name while + AFK. Visible in chat and commands. Default: `false`. +- **`afk.display-name.prefix`** / **`suffix`**: Text to add before or after the player's name. + Supports `&` colour codes. +- **`afk.display-name.format`**: Full name format. Available placeholders: `%prefix%`, `%player%`, `%suffix%`. -- **`afk.sound.enabled`** / **`unafk.sound.enabled`**: (bool) Play a sound to the player when their - AFK state changes. -- **`afk.sound.file`** / **`unafk.sound.file`**: (string) Path to an `.mp3` file inside the plugin's - data folder, relative to the plugin root. +- **`afk.sound.enabled`** / **`unafk.sound.enabled`**: Play a sound when a player's AFK state + changes. +- **`afk.sound.file`** / **`unafk.sound.file`**: Path to an `.mp3` file in the plugin's data + folder. ## Customising Messages @@ -91,8 +91,10 @@ See the [Messages](../messages) page for the full reference. ## How It Works -1. On every player action (move, interact, chat, etc.) a timestamp is updated in EzAfk's session map. -2. The idle-check task runs on a fixed interval and compares `now − lastActivity` against `afk.timeout`. +1. Every time a player moves, interacts, chats, or performs another tracked action, EzAfk records + when it happened. +2. A background task runs at a fixed interval and checks each player's idle time against + `afk.timeout`. 3. Once the threshold is exceeded, the player is marked AFK and the configured feedback is triggered (broadcast, title, display-name change, animation, sound, blindness). 4. The moment any tracked activity is received from an AFK player, they are immediately marked active @@ -101,8 +103,8 @@ See the [Messages](../messages) page for the full reference. ## Related -- [Anti-Bypass Protection](anti-bypass) — prevent waterflow/vehicle tricks from resetting idle time -- [AFK Kick](afk-kick) — kick players that stay AFK too long -- [Tab Prefix Integration](../integrations/TabIntegration) — show AFK status in the tab list -- [PlaceholderAPI Integration](../integrations/PlaceholderApiIntegration) — use AFK placeholders in other plugins -- [Permissions](../permissions) — `ezafk.bypass`, `ezafk.afk` +- [Anti-Bypass Protection](anti-bypass): prevent waterflow/vehicle tricks from resetting idle time +- [AFK Kick](afk-kick): kick players that stay AFK too long +- [Tab Prefix Integration](../integrations/TabIntegration): show AFK status in the tab list +- [PlaceholderAPI Integration](../integrations/PlaceholderApiIntegration): use AFK placeholders in other plugins +- [Permissions](../permissions): `ezafk.bypass`, `ezafk.afk` diff --git a/docs/features/afk-kick.md b/docs/features/afk-kick.md index 767e5d3..75083e6 100644 --- a/docs/features/afk-kick.md +++ b/docs/features/afk-kick.md @@ -7,7 +7,7 @@ parent: Features # AFK Kick EzAfk can automatically kick players that have been AFK for too long. You can choose to kick all AFK -players after a fixed timer, or limit the kick to situations when the server is at capacity — freeing +players after a fixed timer, or limit the kick to situations when the server is at capacity, freeing up slots for new players. Multi-stage warnings before the kick are configured separately; see @@ -19,18 +19,18 @@ In your `config.yml`: ```yaml kick: - enabled: false # master switch — enable AFK kicking + enabled: false # enable AFK kicking enabledWhenFull: false # kick only when the server is at max player count timeout: 600 # seconds of AFK time before the kick is issued ``` -- **`kick.enabled`**: (bool) Master switch. When `false` no players are ever kicked by EzAfk, +- **`kick.enabled`**: Master switch. When `false`, no players are ever kicked by EzAfk, regardless of other settings. Default: `false`. -- **`kick.enabledWhenFull`**: (bool) When `true`, EzAfk will only kick AFK players when the server - player count equals `max-players` in `server.properties`. Useful for keeping AFK players around on +- **`kick.enabledWhenFull`**: When `true`, EzAfk only kicks AFK players when the server player + count equals `max-players` in `server.properties`. Useful for keeping AFK players around on quieter servers while still freeing slots during peak times. Requires `kick.enabled: true`. Default: `false`. -- **`kick.timeout`**: (integer, seconds) How long a player must be continuously AFK before EzAfk kicks +- **`kick.timeout`**: How long (in seconds) a player must be continuously AFK before EzAfk kicks them. This timer starts from the moment the player was marked AFK (i.e. after the initial `afk.timeout` has already elapsed). Default: `600` (10 minutes). @@ -49,7 +49,7 @@ See the [Messages](../messages) page for the full reference. 1. When a player goes AFK, a kick countdown starts alongside the existing AFK state. 2. The countdown runs for `kick.timeout` seconds. -3. If the player remains AFK for the full duration, EzAfk calls `Player#kickPlayer()` with the +3. If the player remains AFK for the full duration, EzAfk kicks the player with the configured kick message. 4. If `enabledWhenFull` is `true`, EzAfk first checks whether `online players ≥ max players` before issuing the kick. If the server is not full, the kick is skipped even if the timer expired. @@ -63,6 +63,6 @@ See the [Messages](../messages) page for the full reference. ## Related -- [AFK Kick Warnings](../afk-kick-warnings) — send countdown messages before kicking -- [AFK Detection](afk-detection) — the upstream idle detection that triggers the kick timer -- [Permissions](../permissions) — `ezafk.kick.bypass` +- [AFK Kick Warnings](../afk-kick-warnings): send countdown messages before kicking +- [AFK Detection](afk-detection): the idle detection system that starts the kick timer +- [Permissions](../permissions): `ezafk.kick.bypass` diff --git a/docs/features/afk-zones.md b/docs/features/afk-zones.md index dc1eb78..7ce1c74 100644 --- a/docs/features/afk-zones.md +++ b/docs/features/afk-zones.md @@ -72,30 +72,30 @@ regions: ### Global -- **`enabled`**: (bool) Master switch. Must be `true` for any zone to function. Default: `false`. +- **`enabled`**: Master switch. Must be `true` for any zone to function. Default: `false`. ### Per-Region Fields -- **`name`**: (string) Unique identifier for the zone. Used in commands and logs. -- **`world`**: (string) Bukkit world name where the zone exists. -- **`x1` / `y1` / `z1`** and **`x2` / `y2` / `z2`**: (integer) Opposite corners of the cuboid. - The order of corners does not matter — EzAfk normalises min/max automatically. +- **`name`**: Unique identifier for the zone. Used in commands and logs. +- **`world`**: World name where the zone exists. +- **`x1` / `y1` / `z1`** and **`x2` / `y2` / `z2`**: Opposite corners of the cuboid. + The order of corners does not matter. EzAfk normalises min/max automatically. ### Reward Fields -- **`reward.enabled`**: (bool) Toggle rewards for this specific zone without removing its definition. -- **`reward.interval-seconds`**: (integer) How often (in seconds) the reward is granted to each AFK +- **`reward.enabled`**: Toggle rewards for this specific zone without removing its definition. +- **`reward.interval-seconds`**: How often (in seconds) the reward is granted to each AFK player inside the zone. -- **`reward.type`**: (string) Reward delivery method. - - `economy` — transfers `amount` currency via Vault. Requires a Vault-compatible economy plugin. - - `command` — runs `command` as the console once per interval. Use `%player%` for the player name. - - `item` — places the configured item directly into the player's inventory. -- **`reward.amount`**: (decimal) Currency amount. Only used when `type: economy`. -- **`reward.max-stack`**: (integer) Maximum number of reward intervals that can accumulate before the - reward stops. `0` means unlimited. Useful to prevent excessive overnight gains. -- **`reward.command`**: (string) Console command template. Only used when `type: command`. -- **`reward.item.material`**: (string) Bukkit material name. Only used when `type: item`. -- **`reward.item.amount`**: (integer) Stack size of the item given per interval. +- **`reward.type`**: Reward delivery method. + - `economy`: transfers `amount` currency via Vault. Requires a Vault-compatible economy plugin. + - `command`: runs `command` as the console once per interval. Use `%player%` for the player name. + - `item`: places the configured item directly into the player's inventory. +- **`reward.amount`**: Currency amount. Only used when `type: economy`. +- **`reward.max-stack`**: Maximum number of reward intervals that can accumulate before rewards + stop. `0` means unlimited. Useful to prevent excessive overnight gains. +- **`reward.command`**: Console command template. Only used when `type: command`. +- **`reward.item.material`**: Item material name. Only used when `type: item`. +- **`reward.item.amount`**: Stack size of the item given per interval. ## In-Game Zone Management @@ -123,7 +123,7 @@ See [WorldGuard Integration](../integrations/WorldGuardIntegration) for details. ## Related -- [Economy Integration](../integrations/EconomyIntegration) — required for `type: economy` rewards -- [WorldGuard Integration](../integrations/WorldGuardIntegration) — use WorldEdit selections for zones -- [Commands](../commands) — full `/afk zone` command reference -- [Permissions](../permissions) — `ezafk.zone.manage`, `ezafk.zone.list` +- [Economy Integration](../integrations/EconomyIntegration): required for `type: economy` rewards +- [WorldGuard Integration](../integrations/WorldGuardIntegration): use WorldEdit selections for zones +- [Commands](../commands): full `/afk zone` command reference +- [Permissions](../permissions): `ezafk.zone.manage`, `ezafk.zone.list` diff --git a/docs/features/anti-bypass.md b/docs/features/anti-bypass.md index 8d10ebb..2a3f129 100644 --- a/docs/features/anti-bypass.md +++ b/docs/features/anti-bypass.md @@ -6,10 +6,9 @@ parent: Features # Anti-Bypass Protection -Some automated farms or clients exploit game mechanics — such as flowing water, rideable entities, or -bubble columns — to produce continuous movement events that prevent AFK detection. EzAfk's anti-bypass -system intercepts each of these exploit vectors and suppresses the resulting activity signal so idle -players are flagged correctly. +Some automated farms or clients exploit game mechanics such as flowing water, rideable entities, or +bubble columns to trigger continuous movement that prevents AFK detection. EzAfk's anti-bypass +system detects each of these tricks and ignores the movement so idle players are flagged correctly. ## Configuration @@ -24,33 +23,33 @@ afk: flag-only: false # if true, only mark AFK silently; do not warn/eject ``` -- **`afk.anti.infinite-waterflow`**: (bool) When `true`, movement events caused by a flowing water - current are not counted as player activity. Useful for servers with water-based AFK fish farms. +- **`afk.anti.infinite-waterflow`**: When `true`, movement caused by a flowing water current is + not counted as player activity. Useful for servers with water-based AFK fish farms. Default: `false`. -- **`afk.anti.infinite-vehicle`**: (bool) When `true`, movement events while the player is riding a - mob or minecart are ignored. Prevents AFK grinders that rely on riding entity movement to stay +- **`afk.anti.infinite-vehicle`**: When `true`, movement while the player is riding a mob or + minecart is ignored. Prevents AFK grinders that rely on riding entity movement to stay "active". Default: `false`. -- **`afk.anti.bubble-column`**: (bool) When `true`, the upward velocity force from a soul-sand bubble - column is not counted as player activity. Default: `false`. -- **`afk.anti.flag-only`**: (bool) When `true`, exploiting players are silently marked AFK without any - warning message or ejection. When `false` (default), EzAfk may warn the player and/or interrupt the - exploit. Default: `false`. +- **`afk.anti.bubble-column`**: When `true`, the upward force from a soul-sand bubble column is + not counted as player activity. Default: `false`. +- **`afk.anti.flag-only`**: When `true`, players using bypass tricks are silently marked AFK + without any warning or ejection. When `false` (default), EzAfk may warn the player and/or + interrupt the exploit. Default: `false`. ## How It Works -1. EzAfk listens for the relevant Bukkit events (`PlayerMoveEvent`, `VehicleMoveEvent`, etc.). -2. Before crediting activity to the player, it checks the cause of the movement against the enabled - anti-bypass rules. -3. Movement that matches an enabled rule is discarded — the player's last-activity timestamp is **not** +1. EzAfk monitors player movement events and checks the cause of each movement. +2. Before crediting activity to the player, it checks whether the movement matches an enabled + anti-bypass rule. +3. Movement that matches an enabled rule is discarded; the player's last-activity timestamp is **not** updated. 4. After the normal `afk.timeout` elapses without legitimate activity, the player is marked AFK as usual. -5. If `flag-only` is `false`, EzAfk may send a warning to the player or interrupt the exploit source +5. If `flag-only` is `false`, EzAfk may send a warning to the player or interrupt the bypass source (e.g. eject from a vehicle). If `flag-only` is `true`, the transition happens silently. ## Notes -- Anti-bypass rules are independent — enable only the ones relevant to your server's gameplay. +- Anti-bypass rules are independent. Enable only the ones relevant to your server's gameplay. - WorldGuard region flags can restrict AFK behaviour on a per-region basis. See [WorldGuard Integration](../integrations/WorldGuardIntegration). - Players with the `ezafk.bypass` permission are not subject to anti-bypass checks when @@ -58,6 +57,6 @@ afk: ## Related -- [AFK Detection](afk-detection) — the core idle detection system -- [WorldGuard Integration](../integrations/WorldGuardIntegration) — region-based AFK flags -- [Permissions](../permissions) — `ezafk.bypass` +- [AFK Detection](afk-detection): the core idle detection system +- [WorldGuard Integration](../integrations/WorldGuardIntegration): region-based AFK flags +- [Permissions](../permissions): `ezafk.bypass` diff --git a/docs/features/economy-costs.md b/docs/features/economy-costs.md index a58707e..42646da 100644 --- a/docs/features/economy-costs.md +++ b/docs/features/economy-costs.md @@ -40,31 +40,30 @@ economy: kick-on-fail: false # kick the player when they can no longer afford the recurring cost ``` -- **`economy.enabled`**: (bool) Master switch. When `false`, no economy activity occurs. Default: `false`. -- **`economy.bypass-permission`**: (string) Permission node that exempts a player from all economy - costs. Default: `"ezafk.economy.bypass"`. +- **`economy.enabled`**: Master switch. When `false`, no economy activity occurs. Default: `false`. +- **`economy.bypass-permission`**: Permission node that exempts a player from all economy costs. + Default: `"ezafk.economy.bypass"`. ### Entry Cost (`cost.enter`) -- **`cost.enter.enabled`**: (bool) Charge a one-time fee when the player is first marked AFK. +- **`cost.enter.enabled`**: Charge a one-time fee when the player is first marked AFK. Default: `true`. -- **`cost.enter.amount`**: (decimal) Amount to deduct. Uses the economy plugin's default currency. -- **`cost.enter.require-funds`**: (bool) When `true`, EzAfk checks the player's balance before marking +- **`cost.enter.amount`**: Amount to deduct. Uses the economy plugin's default currency. +- **`cost.enter.require-funds`**: When `true`, EzAfk checks the player's balance before marking them AFK. If they cannot afford the fee, they are **not** marked AFK and receive an error message. -- **`cost.enter.retry-delay`**: (integer, seconds) If `require-funds` blocked the AFK transition, - EzAfk waits this many seconds before trying again. This prevents the check from firing on every - movement event. +- **`cost.enter.retry-delay`**: If `require-funds` blocked the AFK transition, EzAfk waits this many + seconds before trying again. This prevents the check from firing on every movement event. ### Recurring Cost (`cost.recurring`) -- **`cost.recurring.enabled`**: (bool) Deduct currency periodically while the player remains AFK. +- **`cost.recurring.enabled`**: Deduct currency periodically while the player remains AFK. Default: `false`. -- **`cost.recurring.amount`**: (decimal) Amount deducted per interval. -- **`cost.recurring.interval`**: (integer, seconds) How often the deduction fires. -- **`cost.recurring.require-funds`**: (bool) When `true`, a failed deduction (insufficient balance) +- **`cost.recurring.amount`**: Amount deducted per interval. +- **`cost.recurring.interval`**: How often (in seconds) the deduction fires. +- **`cost.recurring.require-funds`**: When `true`, a failed deduction (insufficient balance) triggers the `kick-on-fail` behaviour instead of silently skipping. -- **`cost.recurring.kick-on-fail`**: (bool) When `true`, a player who can no longer afford the - recurring cost is kicked from the server. When `false`, the recurring deduction is simply skipped. +- **`cost.recurring.kick-on-fail`**: When `true`, a player who can no longer afford the recurring + cost is kicked from the server. When `false`, the recurring deduction is simply skipped. ## Customising Messages @@ -95,6 +94,6 @@ See the [Messages](../messages) page for the full reference. ## Related -- [Economy / Vault Integration](../integrations/EconomyIntegration) — setup guide -- [AFK Zones](afk-zones) — grant economy rewards for being AFK in specific areas -- [Permissions](../permissions) — `ezafk.economy.bypass` +- [Economy / Vault Integration](../integrations/EconomyIntegration): setup guide +- [AFK Zones](afk-zones): grant economy rewards for being AFK in specific areas +- [Permissions](../permissions): `ezafk.economy.bypass` diff --git a/docs/features/gui.md b/docs/features/gui.md index 4cc7a79..88f6ec7 100644 --- a/docs/features/gui.md +++ b/docs/features/gui.md @@ -7,7 +7,7 @@ parent: Features # In-Game GUI EzAfk provides a chest-inventory GUI that lets staff quickly view all AFK players and take immediate -action — kick, message, teleport, or run a command — without leaving the game. The GUI is accessed with +action (kick, message, teleport, or run a command) without leaving the game. The GUI is accessed with `/afk gui` and is fully configurable in `gui.yml`. ## Configuration @@ -70,26 +70,26 @@ back-button: ### Inventory -- **`inventory-size`**: (integer) Chest size in slots. Must be a multiple of 9 between 9 and 54. - Each AFK player occupies one slot (shown as their head). When more players are AFK than there are - slots, a paged navigation is provided. Default: `9`. +- **`inventory-size`**: Chest size in slots. Must be a multiple of 9 between 9 and 54. + Each AFK player occupies one slot (shown as their head). When more players are AFK than available + slots, paged navigation is provided. Default: `9`. ### Actions Each entry under `actions` defines a clickable button shown in the per-player detail view: -- **`slot`**: (integer, 0-based) Inventory slot position of this action button. -- **`material`**: (string) Bukkit material name for the button icon. -- **`display-name`**: (string, `&` colour codes) Button title text. -- **`lore`**: (list of strings) Tooltip lines displayed below the display name. -- **`type`**: (string) What happens on click. One of: - - `KICK` — kicks the target player. - - `MESSAGE` — sends `target-message` to the target player. - - `TELEPORT` — teleports the executor to the target player. - - `COMMAND` — runs `command` as the console. -- **`target-message`**: (string) Message delivered to the AFK player. Used by `MESSAGE` type. -- **`feedback-message`**: (string) Message sent back to the staff member after the action completes. -- **`command`**: (string) Console command to run. Used by `COMMAND` type. +- **`slot`**: Inventory slot position of this action button (0-based). +- **`material`**: Item material for the button icon (e.g. `BARRIER`, `PAPER`). +- **`display-name`**: Button title text. Supports `&` colour codes. +- **`lore`**: Tooltip lines shown below the display name. +- **`type`**: What happens when the button is clicked. One of: + - `KICK`: kicks the target player. + - `MESSAGE`: sends `target-message` to the target player. + - `TELEPORT`: teleports the staff member to the target player. + - `COMMAND`: runs `command` as the console. +- **`target-message`**: Message sent to the AFK player. Used by `MESSAGE` type. +- **`feedback-message`**: Confirmation message sent to the staff member after the action runs. +- **`command`**: Console command to run. Used by `COMMAND` type. **Placeholders available in all text fields:** @@ -100,8 +100,8 @@ Each entry under `actions` defines a clickable button shown in the per-player de ### Empty Slot Filler -- **`empty-slot-filler.enabled`**: (bool) Fill all unused inventory slots with a decorative item. - Default: `true`. +- **`empty-slot-filler.enabled`**: When `true`, fills all unused inventory slots with a decorative + item. Default: `true`. - **`empty-slot-filler.material`**: Background item material (e.g. `GRAY_STAINED_GLASS_PANE`). - **`empty-slot-filler.display-name`**: Display name for filler items (use `" "` for invisible). - **`empty-slot-filler.lore`**: Lore lines for the filler item. @@ -123,5 +123,5 @@ Each entry under `actions` defines a clickable button shown in the per-player de ## Related -- [Commands](../commands) — `/afk gui` and GUI-related subcommands -- [Permissions](../permissions) — `ezafk.gui`, `ezafk.gui.view-active`, `ezafk.gui.actions` +- [Commands](../commands): `/afk gui` and GUI-related subcommands +- [Permissions](../permissions): `ezafk.gui`, `ezafk.gui.view-active`, `ezafk.gui.actions` diff --git a/docs/features/leaderboard.md b/docs/features/leaderboard.md index 77b63cd..dba5445 100644 --- a/docs/features/leaderboard.md +++ b/docs/features/leaderboard.md @@ -20,14 +20,14 @@ storage: flush-interval-seconds: 30 # how often in-memory totals are written to disk/database ``` -- **`storage.type`**: (string) Backend used to persist AFK time. All backends support the full - leaderboard feature. - - `yaml` — stores data in `plugins/EzAfk/data/` as per-player YAML files (no external dependencies). - - `sqlite` — stores data in a single `ezafk.db` SQLite database file. - - `mysql` — stores data in a remote MySQL database. See the [Storage / MySQL](../mysql) page for +- **`storage.type`**: Backend used to persist AFK time. All backends support the full leaderboard + feature. + - `yaml`: stores data in `plugins/EzAfk/data/` as per-player YAML files (no external dependencies). + - `sqlite`: stores data in a single `ezafk.db` SQLite database file. + - `mysql`: stores data in a remote MySQL database. See the [Storage / MySQL](../mysql) page for additional MySQL connection settings. -- **`storage.flush-interval-seconds`**: (integer) EzAfk accumulates AFK time in memory and writes it - to the storage backend on this interval (in seconds) to reduce I/O. Data is also flushed on plugin +- **`storage.flush-interval-seconds`**: EzAfk accumulates AFK time in memory and writes it to the + storage backend on this interval (in seconds) to reduce I/O. Data is also flushed on plugin shutdown and on player disconnect. Default: `30`. ## Commands @@ -68,7 +68,7 @@ If [PlaceholderAPI](../integrations/PlaceholderApiIntegration) is installed: ## Related -- [Storage / MySQL](../mysql) — MySQL connection settings and schema -- [PlaceholderAPI Integration](../integrations/PlaceholderApiIntegration) — all available placeholders -- [Commands](../commands) — full command reference -- [Permissions](../permissions) — `ezafk.time`, `ezafk.time.others`, `ezafk.top`, `ezafk.time.reset` +- [Storage / MySQL](../mysql): MySQL connection settings and schema +- [PlaceholderAPI Integration](../integrations/PlaceholderApiIntegration): all available placeholders +- [Commands](../commands): full command reference +- [Permissions](../permissions): `ezafk.time`, `ezafk.time.others`, `ezafk.top`, `ezafk.time.reset` diff --git a/docs/getting-started.md b/docs/getting-started.md index a4b09c5..74b0ee2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,7 +30,7 @@ After first startup, EzAfk creates the following inside `plugins/EzAfk/`: | File | Purpose | |------|---------| -| `config.yml` | Main settings — AFK timeout, kick, GUI, zones, anti-bypass | +| `config.yml` | Main settings: AFK timeout, kick, GUI, zones, anti-bypass | | `gui.yml` | In-game GUI layout and item definitions | | `mysql.yml` | MySQL/SQLite database connection settings | | `messages/` | One YAML file per language (en, es, nl, ru, zh, de) | @@ -66,9 +66,9 @@ See [Configuration](configuration) for every available option. The minimum set for an admin: ```text -ezafk.reload — reload config without restarting -ezafk.gui — open the AFK player overview GUI -ezafk.afk.others — toggle AFK for another player +ezafk.reload - reload config without restarting +ezafk.gui - open the AFK player overview GUI +ezafk.afk.others - toggle AFK for another player ``` See [Permissions](permissions) for the full node list. diff --git a/docs/index.md b/docs/index.md index dcb8373..4eccb20 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,15 +20,15 @@ It provides advanced AFK detection, player management, and deep integration with ## Features -- **Automatic AFK detection** — idle timeout with configurable thresholds -- **AFK kick warnings** — multi-stage countdown messages before kicking inactive players -- **GUI overview** — in-game panel to view and manage AFK players -- **AFK zones** — WorldGuard-region-based AFK rules and overrides -- **Multi-language support** — EN, ES, NL, RU, ZH, DE out of the box -- **Storage backends** — YAML, SQLite, and MySQL with a unified repository API -- **Anti-bypass detection** — catches water flow, vehicle, and bubble column movement -- **Developer API** — cancellable `PlayerAfkStatusChangeEvent` and `AfkReason` enum -- **Integrations** — Economy/Vault, PlaceholderAPI, Tab, WorldGuard, Simple Voice Chat +- **Automatic AFK detection**: idle timeout with configurable thresholds +- **AFK kick warnings**: multi-stage countdown messages before kicking inactive players +- **GUI overview**: in-game panel to view and manage AFK players +- **AFK zones**: region-based AFK rules and rewards +- **Multi-language support**: EN, ES, NL, RU, ZH, DE out of the box +- **Storage backends**: YAML, SQLite, and MySQL supported +- **Anti-bypass detection**: catches water flow, vehicle, and bubble column tricks +- **Developer API**: cancellable `PlayerAfkStatusChangeEvent` and `AfkReason` enum +- **Integrations**: Economy/Vault, PlaceholderAPI, Tab, WorldGuard, Simple Voice Chat --- @@ -44,15 +44,15 @@ and place it in your server's `plugins/` folder. Restart the server. Edit the files generated in `plugins/EzAfk/`: ```text -config.yml — main settings (AFK timeout, kick, zones, …) -gui.yml — GUI layout and item settings -mysql.yml — database connection (if using MySQL) -messages/ — per-language message files +config.yml main settings (AFK timeout, kick, zones) +gui.yml GUI layout and item settings +mysql.yml database connection (only needed for MySQL) +messages/ per-language message files ``` **3. Set permissions:** -Grant `ezafk.*` to administrators or assign individual nodes — see the +Grant `ezafk.*` to administrators, or assign individual nodes. See the [Permissions](permissions) page for the full list. --- @@ -64,7 +64,7 @@ Grant `ezafk.*` to administrators or assign individual nodes — see the | [Getting Started](getting-started) | Install, first config, verify it works | | [Commands](commands) | All `/afk` commands, arguments, and permission nodes | | [Configuration](configuration) | Every config option explained | -| [Features](features/) | AFK detection, anti-bypass, kick, GUI, zones, economy, leaderboard — each with config | +| [Features](features/) | AFK detection, anti-bypass, kick, GUI, zones, economy, leaderboard (each with config) | | [Permissions](permissions) | Permission nodes and defaults | | [Messages](messages) | Customising plugin messages and language files | | [Storage](mysql) | YAML, SQLite, and MySQL storage backends | diff --git a/docs/integrations/EconomyIntegration.md b/docs/integrations/EconomyIntegration.md index f0e70e0..07c534c 100644 --- a/docs/integrations/EconomyIntegration.md +++ b/docs/integrations/EconomyIntegration.md @@ -56,6 +56,6 @@ in `zones.yml` with `reward.type: economy`. See [AFK Zones](../features/afk-zone ## Related -- [Economy Costs](../features/economy-costs) — detailed config reference -- [AFK Zones](../features/afk-zones) — reward economy currency in specific regions -- [Permissions](../permissions) — `ezafk.economy.bypass` +- [Economy Costs](../features/economy-costs): detailed config reference +- [AFK Zones](../features/afk-zones): reward economy currency in specific regions +- [Permissions](../permissions): `ezafk.economy.bypass` diff --git a/docs/integrations/PlaceholderApiIntegration.md b/docs/integrations/PlaceholderApiIntegration.md index 391623c..c82a1d3 100644 --- a/docs/integrations/PlaceholderApiIntegration.md +++ b/docs/integrations/PlaceholderApiIntegration.md @@ -7,13 +7,13 @@ parent: Integrations # PlaceholderAPI Integration EzAfk registers a custom PlaceholderAPI expansion when the PlaceholderAPI plugin is detected. -This makes all AFK data available to any plugin that supports PlaceholderAPI — scoreboards, +This makes all AFK data available to any plugin that supports PlaceholderAPI: scoreboards, chat formatters, holograms, GUIs, and more. ## Setup 1. Install [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) on your server. -2. EzAfk detects it automatically — no extra configuration is required. +2. EzAfk detects it automatically. No extra configuration is required. 3. Use the placeholders below anywhere PlaceholderAPI syntax is accepted. ## Available Placeholders @@ -29,7 +29,7 @@ chat formatters, holograms, GUIs, and more. | `%ezafk_total_formatted%` | Total AFK time in verbose format, e.g. `2 hours 15 minutes` | | `%ezafk_prefix%` | The configured AFK display name prefix (empty when not AFK) | | `%ezafk_suffix%` | The configured AFK display name suffix (empty when not AFK) | -| `%ezafk_playtime_active_seconds%` | Active (non-AFK) playtime in seconds — requires Playtime integration | +| `%ezafk_playtime_active_seconds%` | Active (non-AFK) playtime in seconds (requires Playtime integration) | | `%ezafk_playtime_active%` | Active playtime in `HH:MM:SS` format | | `%ezafk_playtime_active_formatted%` | Active playtime in verbose format | | `%ezafk_afk_count%` | Number of currently AFK players on the server | @@ -41,7 +41,7 @@ Run `/papi list ezafk` in-game to confirm the expansion is loaded. ## Notes -- Placeholders update in real time — there is no caching delay. +- Placeholders update in real time, with no caching delay. - `%ezafk_since%` returns an empty string when the player is not AFK, making it safe to use in conditional display contexts. - `%ezafk_playtime_active_*` placeholders require the Playtime integration to be configured in @@ -49,5 +49,5 @@ Run `/papi list ezafk` in-game to confirm the expansion is loaded. ## Related -- [AFK Detection](../features/afk-detection) — AFK state and display-name prefix/suffix settings -- [AFK Time & Leaderboard](../features/leaderboard) — total AFK time tracking +- [AFK Detection](../features/afk-detection): AFK state and display-name prefix/suffix settings +- [AFK Time & Leaderboard](../features/leaderboard): total AFK time tracking diff --git a/docs/integrations/TabIntegration.md b/docs/integrations/TabIntegration.md index a8e4d80..5580f41 100644 --- a/docs/integrations/TabIntegration.md +++ b/docs/integrations/TabIntegration.md @@ -38,7 +38,7 @@ Quick summary: - Enable EzAfk's TAB placeholder and prefix behavior in EzAfk's `config.yml`. - Insert `%afk%` into TAB's name / tablist templates where you want the AFK marker to appear. -Steps — EzAfk configuration +### EzAfk Configuration 1. Open EzAfk's `config.yml` (located in `plugins/EzAfk/config.yml`). 2. Under the `integration` section enable EzAfk's tab-prefix support: @@ -58,20 +58,20 @@ integration: - `mode`: controls whether EzAfk prefers TAB (`tab`), always uses EzAfk's built-in list handling (`custom`), or auto-detects (`auto`). - `prefix` / `suffix` / `format`: control the text EzAfk returns via `%afk%` and how it composes player display names. -Steps — TAB plugin configuration +### TAB Plugin Configuration 1. Open TAB's `config.yml` (usually `plugins/TAB/config.yml`). -2. Find the section that controls player name formatting or the tablist layout. This depends on your TAB version and layout configuration — common places are `tablist`, `groups`, or `player-names`. +2. Find the section that controls player name formatting or the tablist layout. This depends on your TAB version and layout configuration. Common places are `tablist`, `groups`, or `player-names`. 3. Insert `%afk%` into the template where you want the AFK indicator to appear. Examples: -Example A — add AFK prefix to the display name template: +Example A: add AFK prefix to the display name template: ```yml # TAB example (conceptual) player-placeholder-format: "%afk%%displayname%" ``` -Example B — include AFK in a group format or global tablist format: +Example B: include AFK in a group format or global tablist format: ```yml tablist-format: "%afk%%player%%suffix%" @@ -80,14 +80,14 @@ tablist-format: "%afk%%player%%suffix%" Notes for TAB templates: - TAB configuration and template keys vary across TAB versions and setups (layouts, per-group templates, per-world templates). Search TAB's `config.yml` for `displayname`, `format`, or `tablist` to find the right template. -- TAB uses its own placeholders and supports external placeholders registered by plugins — `%afk%` will appear in the same placeholder namespace. +- TAB uses its own placeholders and supports external placeholders registered by plugins. `%afk%` will appear in the same placeholder namespace. Troubleshooting - No `%afk%` replacement shown: - Ensure EzAfk's `integration.tab-prefix.enabled: true` and `integration.tab: true`. - Make sure `integration.tab-prefix.prefix` is not empty (EzAfk will return an empty string if prefix is empty). - - Check server startup logs for EzAfk TAB diagnostics — EzAfk logs whether the TAB adapter and `%afk%` placeholder registered successfully. + - Check server startup logs for EzAfk TAB diagnostics. EzAfk logs whether the TAB adapter and `%afk%` placeholder registered successfully. - If EzAfk logs a `REFLECTION_ERROR` or `LINKAGE_ERROR`, update EzAfk to a build that matches your server and TAB versions (or check for shaded/class visibility issues). See EzAfk logs for a detailed stack trace. - `%afk%` sometimes appears late on player join: - TAB processes players asynchronously. Use TAB's events (PlayerLoadEvent / TabLoadEvent) if you need guaranteed ordering. EzAfk retries initialization on startup to handle plugin load ordering. @@ -95,6 +95,5 @@ Troubleshooting Best practices - Keep AFK text and color configuration in EzAfk so a single plugin controls AFK formatting across chat, tab, and other integrations. -- If you manage complex TAB layouts, test changes on a staging server — templates differ per TAB version. +- If you manage complex TAB layouts, test changes on a staging server. Templates differ per TAB version. -If you give me your `plugins/TAB/config.yml` (the formatting/layout section), I can provide the exact edit to insert `%afk%` in your current setup. diff --git a/docs/permissions.md b/docs/permissions.md index ebc3980..89988d3 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -36,40 +36,40 @@ This page lists all permission nodes, their defaults, and which feature each one ### [AFK Detection](features/afk-detection) & General -- **`ezafk.bypass`** — Players with this node are never automatically marked AFK (requires +- **`ezafk.bypass`**: Players with this node are never automatically marked AFK (requires `afk.bypass.enabled: true` in `config.yml`). -- **`ezafk.bypass.manage`** — Required for `/afk bypass `. -- **`ezafk.toggle`** — Required for `/afk toggle ` to force-toggle another player's AFK state. -- **`ezafk.info`** — Required for `/afk info ` to view another player's session details. -- **`ezafk.reload`** — Required for `/afk reload`. +- **`ezafk.bypass.manage`**: Required for `/afk bypass `. +- **`ezafk.toggle`**: Required for `/afk toggle ` to force-toggle another player's AFK state. +- **`ezafk.info`**: Required for `/afk info ` to view another player's session details. +- **`ezafk.reload`**: Required for `/afk reload`. ### [AFK Kick](features/afk-kick) & [Kick Warnings](features/afk-kick-warnings) -- **`ezafk.kick.bypass`** — Players with this node are never kicked by the AFK kick system, +- **`ezafk.kick.bypass`**: Players with this node are never kicked by the AFK kick system, even if kick warnings have fired. ### [In-Game GUI](features/gui) -- **`ezafk.gui`** — Required to open `/afk gui`. -- **`ezafk.gui.view-active`** — Allows viewing active (non-AFK) players in the GUI, not just AFK ones. -- **`ezafk.gui.actions`** — Allows clicking action buttons (kick, message, teleport, command) in the GUI. +- **`ezafk.gui`**: Required to open `/afk gui`. +- **`ezafk.gui.view-active`**: Allows viewing active (non-AFK) players in the GUI, not just AFK ones. +- **`ezafk.gui.actions`**: Allows clicking action buttons (kick, message, teleport, command) in the GUI. ### [AFK Time & Leaderboard](features/leaderboard) -- **`ezafk.time`** — Allows `/afk time` to view your own total AFK time. Default: all players. -- **`ezafk.time.others`** — Allows `/afk time ` to view another player's AFK time. -- **`ezafk.time.reset`** — Allows `/afk time reset `. -- **`ezafk.top`** — Allows `/afk top` to view the full leaderboard. +- **`ezafk.time`**: Allows `/afk time` to view your own total AFK time. Default: all players. +- **`ezafk.time.others`**: Allows `/afk time ` to view another player's AFK time. +- **`ezafk.time.reset`**: Allows `/afk time reset `. +- **`ezafk.top`**: Allows `/afk top` to view the full leaderboard. ### [Economy Costs](features/economy-costs) -- **`ezafk.economy.bypass`** — Exempts the player from all economy enter and recurring costs. +- **`ezafk.economy.bypass`**: Exempts the player from all economy enter and recurring costs. The permission node is configurable via `economy.bypass-permission` in `config.yml`. ### [AFK Zones](features/afk-zones) -- **`ezafk.zone.list`** — Required for `/afk zone list` and `/afk zone players`. -- **`ezafk.zone.manage`** — Required for `/afk zone add`, `remove`, `pos1`, `pos2`, `clearpos`, `reset`. +- **`ezafk.zone.list`**: Required for `/afk zone list` and `/afk zone players`. +- **`ezafk.zone.manage`**: Required for `/afk zone add`, `remove`, `pos1`, `pos2`, `clearpos`, `reset`. --- diff --git a/topics/modrinth-topic.md b/topics/modrinth-topic.md index f078edc..e15c687 100644 --- a/topics/modrinth-topic.md +++ b/topics/modrinth-topic.md @@ -4,7 +4,7 @@ EzAfk is a modern, lightweight AFK management plugin for Paper and Spigot servers. It automates AFK detection, rewards or charges players based on idle state, gives staff -a real-time overview panel, and integrates with the tools you already run — all without +a real-time overview panel, and integrates with the tools you already run, all without sacrificing performance. > **v3.0.0** · Minecraft 26.1+ · Java 25 · Paper / Spigot / Bukkit / Purpur @@ -24,7 +24,7 @@ sacrificing performance. | **Economy Costs** | Charge a one-time entry fee and/or a recurring fee while AFK via any Vault economy plugin | | **AFK Leaderboard** | Per-player cumulative AFK time, `/afk top` leaderboard, persisted across restarts | | **PlaceholderAPI** | 16 placeholders for status, session time, totals, counts, prefix/suffix, and playtime | -| **Multi-language** | EN, ES, NL, RU, ZH, DE — fully overridable per server | +| **Multi-language** | EN, ES, NL, RU, ZH, DE, fully overridable per server | | **Storage backends** | YAML (default), SQLite, or MySQL | | **Simple Voice Chat** | Play a custom MP3 sound when a player goes AFK | @@ -44,7 +44,7 @@ sacrificing performance. | Command | Description | Permission | |---------|-------------|------------| -| `/afk` | Toggle your own AFK status | — | +| `/afk` | Toggle your own AFK status | (none) | | `/afk reload` | Reload all configuration files | `ezafk.reload` | | `/afk gui` | Open the AFK staff overview panel | `ezafk.gui` | | `/afk toggle ` | Force a player's AFK state | `ezafk.toggle` | @@ -87,7 +87,7 @@ Aliases: `/ezafk`, `/ea`, `/afktime`, `/afktop` ## PlaceholderAPI Placeholders -Requires [PlaceholderAPI](https://hangar.papermc.io/HelpChat/PlaceholderAPI). The expansion registers automatically — no extra setup needed. +Requires [PlaceholderAPI](https://hangar.papermc.io/HelpChat/PlaceholderAPI). The expansion registers automatically. No extra setup needed. | Placeholder | Returns | |-------------|---------| @@ -113,11 +113,11 @@ Requires [PlaceholderAPI](https://hangar.papermc.io/HelpChat/PlaceholderAPI). Th EzAfk's settings are spread across focused files for clarity: ```text -config.yml — AFK detection, kick, anti-bypass, economy, integrations -gui.yml — Staff GUI layout and action buttons -zones.yml — AFK zone definitions with coordinates and rewards -mysql.yml — Database connection (only when storage.type: mysql) -messages/ — Language files: en.yml, es.yml, nl.yml, ru.yml, zh.yml, de.yml +config.yml AFK detection, kick, anti-bypass, economy, integrations +gui.yml Staff GUI layout and action buttons +zones.yml AFK zone definitions with coordinates and rewards +mysql.yml Database connection (only when storage.type: mysql) +messages/ Language files: en.yml, es.yml, nl.yml, ru.yml, zh.yml, de.yml ``` ### Core settings (`config.yml`) @@ -173,7 +173,7 @@ storage: ## Links -- [Documentation](https://ez-plugins.github.io/EzAfk/) — full configuration guide, feature pages, and API reference -- [Discord](https://discord.gg/yWP95XfmBS) — support, bug reports, and feature requests -- [GitHub](https://github.com/ez-plugins/EzAfk) — source code and issue tracker -- [Developer API](https://ez-plugins.github.io/EzAfk/api/) — `PlayerAfkStatusChangeEvent` and `AfkReason` enum +- [Documentation](https://ez-plugins.github.io/EzAfk/): full configuration guide, feature pages, and API reference +- [Discord](https://discord.gg/yWP95XfmBS): support, bug reports, and feature requests +- [GitHub](https://github.com/ez-plugins/EzAfk): source code and issue tracker +- [Developer API](https://ez-plugins.github.io/EzAfk/api/): `PlayerAfkStatusChangeEvent` and `AfkReason` enum From 07cb07b71eed744b72e699955cd0ad0a80161493 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Wed, 22 Apr 2026 21:12:34 +0200 Subject: [PATCH 07/15] refactor: migrate storage back-end to Jaloquent 1.1.0 Replace raw JDBC/YAML implementations with Jaloquent's Model/ModelRepository pattern. Each backend now implements Jaloquent's DataStore or JdbcStore interface and delegates all CRUD to a ModelRepository. - Add AfkTimeModel (extends Model) with getSeconds/setSeconds - Add YamlDataStore (DataStore + QueryableStorage) for YAML persistence - Rewrite YamlStorage: YamlDataStore + ModelRepository (flat-map path) - Rewrite MySQLStorage: JdbcStore + ModelRepository (SqlDialect.MYSQL) - Rewrite SQLiteStorage: JdbcStore + ModelRepository (SqlDialect.SQLITE) - Shade Jaloquent/JavaQueryBuilder under com.gyvex.ezafk.libs.ezframework --- pom.xml | 15 +- .../gyvex/ezafk/repository/AfkTimeModel.java | 27 +++ .../ezafk/repository/mysql/MySQLStorage.java | 164 ++++++++++++++---- .../repository/sqlite/SQLiteStorage.java | 159 +++++++++++++---- .../ezafk/repository/yaml/YamlDataStore.java | 152 ++++++++++++++++ .../ezafk/repository/yaml/YamlStorage.java | 91 +++++----- 6 files changed, 488 insertions(+), 120 deletions(-) create mode 100644 src/main/java/com/gyvex/ezafk/repository/AfkTimeModel.java create mode 100644 src/main/java/com/gyvex/ezafk/repository/yaml/YamlDataStore.java diff --git a/pom.xml b/pom.xml index 0e02183..2b9f3ff 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,8 @@ 3.1.0 7.0.13 3.13.0 - 3.6.0 + 3.6.2 + 1.1.0 org.mockbukkit.mockbukkit mockbukkit-v26.1 dev-d245e0a @@ -137,6 +138,12 @@ 2.11.5 provided + + com.github.EzFramework + jaloquent + ${jaloquent.version} + compile + org.junit.jupiter junit-jupiter @@ -213,6 +220,8 @@ net.kyori:examination-api net.kyori:examination-string javazoom:jlayer + com.github.EzFramework:jaloquent + com.github.EzFramework:JavaQueryBuilder @@ -228,6 +237,10 @@ javazoom com.gyvex.ezafk.libs.javazoom + + com.github.ezframework + com.gyvex.ezafk.libs.ezframework + diff --git a/src/main/java/com/gyvex/ezafk/repository/AfkTimeModel.java b/src/main/java/com/gyvex/ezafk/repository/AfkTimeModel.java new file mode 100644 index 0000000..bcf48f4 --- /dev/null +++ b/src/main/java/com/gyvex/ezafk/repository/AfkTimeModel.java @@ -0,0 +1,27 @@ +package com.gyvex.ezafk.repository; + +import com.github.ezframework.jaloquent.model.Model; +import com.github.ezframework.jaloquent.model.ModelFactory; + +public final class AfkTimeModel extends Model { + + public static final String TABLE_PREFIX = "afk_times"; + + public static final ModelFactory FACTORY = (id, data) -> { + AfkTimeModel m = new AfkTimeModel(id); + m.fromMap(data); + return m; + }; + + public AfkTimeModel(String id) { + super(id); + } + + public long getSeconds() { + return getAs("seconds", Long.class, 0L); + } + + public void setSeconds(long seconds) { + set("seconds", seconds); + } +} diff --git a/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java b/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java index b4f2f50..d933bdd 100644 --- a/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java +++ b/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java @@ -1,37 +1,66 @@ package com.gyvex.ezafk.repository.mysql; +import com.github.ezframework.jaloquent.exception.StorageException; +import com.github.ezframework.jaloquent.model.Model; +import com.github.ezframework.jaloquent.model.ModelRepository; +import com.github.ezframework.jaloquent.model.TableRegistry; +import com.github.ezframework.jaloquent.store.DataStore; +import com.github.ezframework.jaloquent.store.sql.JdbcStore; +import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; import com.gyvex.ezafk.bootstrap.Registry; +import com.gyvex.ezafk.repository.AfkTimeModel; import com.gyvex.ezafk.repository.StorageRepository; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.ResultSetMetaData; import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; /** - * Minimal MySQL storage skeleton. Reads connection info from mysql.yml and attempts - * to maintain per-player AFK seconds. This implementation is lightweight and tolerant - * to missing configuration (will log and fall back to no-op). + * MySQL storage backend using Jaloquent's ModelRepository with a JdbcStore + * backed by a DriverManager JDBC connection. */ -public class MySQLStorage implements StorageRepository { +public class MySQLStorage implements StorageRepository, DataStore, JdbcStore { + + private static final Map TABLE_COLUMNS = Map.of( + "id", "VARCHAR(36) PRIMARY KEY", + "seconds", "BIGINT NOT NULL DEFAULT 0" + ); + private Connection connection; + private ModelRepository repo; @Override public void init() throws Exception { try { - org.bukkit.configuration.file.FileConfiguration cfg = Registry.get().getConfigManager().getMysqlConfig(); + org.bukkit.configuration.file.FileConfiguration cfg = + Registry.get().getConfigManager().getMysqlConfig(); String host = cfg.getString("host", "localhost"); int port = cfg.getInt("port", 3306); String db = cfg.getString("database", "ezafk"); String user = cfg.getString("username", "root"); String pass = cfg.getString("password", ""); - String url = String.format("jdbc:mysql://%s:%d/%s?autoReconnect=true&useSSL=false", host, port, db); + String url = String.format( + "jdbc:mysql://%s:%d/%s?autoReconnect=true&useSSL=false", host, port, db); connection = DriverManager.getConnection(url, user, pass); + try (Statement stmt = connection.createStatement()) { - stmt.executeUpdate("CREATE TABLE IF NOT EXISTS afk_times (uuid VARCHAR(36) PRIMARY KEY, seconds BIGINT)"); + stmt.executeUpdate( + "CREATE TABLE IF NOT EXISTS afk_times " + + "(id VARCHAR(36) PRIMARY KEY, seconds BIGINT NOT NULL DEFAULT 0)"); } + + TableRegistry.register(AfkTimeModel.TABLE_PREFIX, "afk_times", TABLE_COLUMNS); + repo = new ModelRepository<>(this, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY, + SqlDialect.MYSQL); } catch (Exception e) { Registry.get().getLogger().warning("MySQLStorage init failed: " + e.getMessage()); throw e; @@ -39,33 +68,27 @@ public void init() throws Exception { } @Override - public java.util.Map loadAll() { - java.util.Map map = new java.util.HashMap<>(); - if (connection == null) return map; - try (PreparedStatement ps = connection.prepareStatement("SELECT uuid, seconds FROM afk_times")) { - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - try { - UUID id = UUID.fromString(rs.getString(1)); - long s = rs.getLong(2); - map.put(id, s); - } catch (Exception ignored) {} - } + public Map loadAll() { + final Map result = new java.util.HashMap<>(); + try { + for (final AfkTimeModel m : repo.query(Model.queryBuilder().build())) { + try { + result.put(UUID.fromString(m.getId()), m.getSeconds()); + } catch (IllegalArgumentException ignored) {} } } catch (Exception e) { Registry.get().getLogger().warning("MySQL loadAll failed: " + e.getMessage()); } - return map; + return result; } @Override public void savePlayerAfkTime(UUID player, long seconds) { if (player == null || connection == null) return; - try (PreparedStatement ps = connection.prepareStatement("INSERT INTO afk_times(uuid, seconds) VALUES(?, ?) ON DUPLICATE KEY UPDATE seconds = ?")) { - ps.setString(1, player.toString()); - ps.setLong(2, seconds); - ps.setLong(3, seconds); - ps.executeUpdate(); + final AfkTimeModel model = new AfkTimeModel(player.toString()); + model.setSeconds(seconds); + try { + repo.save(model); } catch (Exception e) { Registry.get().getLogger().warning("MySQL save failed: " + e.getMessage()); } @@ -74,31 +97,29 @@ public void savePlayerAfkTime(UUID player, long seconds) { @Override public long getPlayerAfkTime(UUID player) { if (player == null || connection == null) return 0L; - try (PreparedStatement ps = connection.prepareStatement("SELECT seconds FROM afk_times WHERE uuid = ?")) { - ps.setString(1, player.toString()); - try (ResultSet rs = ps.executeQuery()) { - if (rs.next()) return rs.getLong(1); - } + try { + return repo.find(player.toString()) + .map(AfkTimeModel::getSeconds) + .orElse(0L); } catch (Exception e) { Registry.get().getLogger().warning("MySQL read failed: " + e.getMessage()); + return 0L; } - return 0L; } @Override public void deletePlayer(UUID player) { if (player == null || connection == null) return; - try (PreparedStatement ps = connection.prepareStatement("DELETE FROM afk_times WHERE uuid = ?")) { - ps.setString(1, player.toString()); - ps.executeUpdate(); + try { + repo.delete(player.toString()); } catch (Exception e) { Registry.get().getLogger().warning("MySQL delete failed: " + e.getMessage()); } } @Override - public void saveAll() throws Exception { - // Connections are immediate; nothing to flush by default + public void saveAll() { + // Writes are immediate via repo.save(); nothing to flush. } @Override @@ -107,4 +128,75 @@ public void shutdown() { if (connection != null && !connection.isClosed()) connection.close(); } catch (Exception ignored) {} } + + // ========================================================================= + // JdbcStore — called by ModelRepository on the SQL path + // ========================================================================= + + @Override + public List> query(String sql, List params) throws Exception { + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + bindParams(stmt, params); + try (ResultSet rs = stmt.executeQuery()) { + return mapResultSet(rs); + } + } + } + + @Override + public int executeUpdate(String sql, List params) throws Exception { + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + bindParams(stmt, params); + return stmt.executeUpdate(); + } + } + + // ========================================================================= + // DataStore — flat-map path not used when TableRegistry entry exists + // ========================================================================= + + @Override + public void save(String path, Map data) throws Exception { + throw new StorageException("MySQLStorage uses the SQL path via TableRegistry"); + } + + @Override + public Optional> load(String path) throws Exception { + throw new StorageException("MySQLStorage uses the SQL path via TableRegistry"); + } + + @Override + public void delete(String path) throws Exception { + throw new StorageException("MySQLStorage uses the SQL path via TableRegistry"); + } + + @Override + public boolean exists(String path) throws Exception { + throw new StorageException("MySQLStorage uses the SQL path via TableRegistry"); + } + + // ========================================================================= + // Private helpers + // ========================================================================= + + private void bindParams(PreparedStatement stmt, List params) throws Exception { + if (params == null) return; + for (int i = 0; i < params.size(); i++) { + stmt.setObject(i + 1, params.get(i)); + } + } + + private List> mapResultSet(ResultSet rs) throws Exception { + final ResultSetMetaData meta = rs.getMetaData(); + final int count = meta.getColumnCount(); + final List> rows = new ArrayList<>(); + while (rs.next()) { + final Map row = new LinkedHashMap<>(); + for (int i = 1; i <= count; i++) { + row.put(meta.getColumnLabel(i).toLowerCase(), rs.getObject(i)); + } + rows.add(row); + } + return rows; + } } diff --git a/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java b/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java index d010ce4..1f5036c 100644 --- a/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java +++ b/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java @@ -1,61 +1,83 @@ package com.gyvex.ezafk.repository.sqlite; +import com.github.ezframework.jaloquent.exception.StorageException; +import com.github.ezframework.jaloquent.model.Model; +import com.github.ezframework.jaloquent.model.ModelRepository; +import com.github.ezframework.jaloquent.model.TableRegistry; +import com.github.ezframework.jaloquent.store.DataStore; +import com.github.ezframework.jaloquent.store.sql.JdbcStore; +import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; import com.gyvex.ezafk.bootstrap.Registry; +import com.gyvex.ezafk.repository.AfkTimeModel; import com.gyvex.ezafk.repository.StorageRepository; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.ResultSetMetaData; import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; /** - * Minimal SQLite storage stub. Creates a simple table and stores per-player seconds. - * This is intentionally small but functional for local file-based storage. + * SQLite storage backend using Jaloquent's ModelRepository with a JdbcStore + * backed by a DriverManager JDBC connection. */ -public class SQLiteStorage implements StorageRepository { +public class SQLiteStorage implements StorageRepository, DataStore, JdbcStore { + + private static final Map TABLE_COLUMNS = Map.of( + "id", "TEXT PRIMARY KEY", + "seconds", "INTEGER NOT NULL DEFAULT 0" + ); + private Connection connection; + private ModelRepository repo; @Override public void init() throws Exception { java.io.File dataFolder = Registry.get().getPlugin().getDataFolder(); if (!dataFolder.exists()) dataFolder.mkdirs(); java.io.File dbFile = new java.io.File(dataFolder, "ezafk.db"); - String url = "jdbc:sqlite:" + dbFile.getAbsolutePath(); - connection = DriverManager.getConnection(url); + connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath()); + try (Statement stmt = connection.createStatement()) { - stmt.executeUpdate("CREATE TABLE IF NOT EXISTS afk_times (uuid TEXT PRIMARY KEY, seconds INTEGER)"); + stmt.executeUpdate( + "CREATE TABLE IF NOT EXISTS afk_times " + + "(id TEXT PRIMARY KEY, seconds INTEGER NOT NULL DEFAULT 0)"); } + + TableRegistry.register(AfkTimeModel.TABLE_PREFIX, "afk_times", TABLE_COLUMNS); + repo = new ModelRepository<>(this, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY, + SqlDialect.SQLITE); } @Override - public java.util.Map loadAll() { - java.util.Map map = new java.util.HashMap<>(); - if (connection == null) return map; - try (PreparedStatement ps = connection.prepareStatement("SELECT uuid, seconds FROM afk_times")) { - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - try { - UUID id = UUID.fromString(rs.getString(1)); - long s = rs.getLong(2); - map.put(id, s); - } catch (Exception ignored) {} - } + public Map loadAll() { + final Map result = new java.util.HashMap<>(); + try { + for (final AfkTimeModel m : repo.query(Model.queryBuilder().build())) { + try { + result.put(UUID.fromString(m.getId()), m.getSeconds()); + } catch (IllegalArgumentException ignored) {} } } catch (Exception e) { Registry.get().getLogger().warning("SQLite loadAll failed: " + e.getMessage()); } - return map; + return result; } @Override public void savePlayerAfkTime(UUID player, long seconds) { if (player == null) return; - try (PreparedStatement ps = connection.prepareStatement("INSERT INTO afk_times(uuid, seconds) VALUES(?, ?) ON CONFLICT(uuid) DO UPDATE SET seconds=excluded.seconds")) { - ps.setString(1, player.toString()); - ps.setLong(2, seconds); - ps.executeUpdate(); + final AfkTimeModel model = new AfkTimeModel(player.toString()); + model.setSeconds(seconds); + try { + repo.save(model); } catch (Exception e) { Registry.get().getLogger().warning("SQLite save failed: " + e.getMessage()); } @@ -64,9 +86,8 @@ public void savePlayerAfkTime(UUID player, long seconds) { @Override public void deletePlayer(UUID player) { if (player == null || connection == null) return; - try (PreparedStatement ps = connection.prepareStatement("DELETE FROM afk_times WHERE uuid = ?")) { - ps.setString(1, player.toString()); - ps.executeUpdate(); + try { + repo.delete(player.toString()); } catch (Exception e) { Registry.get().getLogger().warning("SQLite delete failed: " + e.getMessage()); } @@ -75,20 +96,19 @@ public void deletePlayer(UUID player) { @Override public long getPlayerAfkTime(UUID player) { if (player == null) return 0L; - try (PreparedStatement ps = connection.prepareStatement("SELECT seconds FROM afk_times WHERE uuid = ?")) { - ps.setString(1, player.toString()); - try (ResultSet rs = ps.executeQuery()) { - if (rs.next()) return rs.getLong(1); - } + try { + return repo.find(player.toString()) + .map(AfkTimeModel::getSeconds) + .orElse(0L); } catch (Exception e) { Registry.get().getLogger().warning("SQLite read failed: " + e.getMessage()); + return 0L; } - return 0L; } @Override - public void saveAll() throws Exception { - // no-op; writes are immediate + public void saveAll() { + // Writes are immediate via repo.save(); nothing to flush. } @Override @@ -97,4 +117,75 @@ public void shutdown() { if (connection != null && !connection.isClosed()) connection.close(); } catch (Exception ignored) {} } + + // ========================================================================= + // JdbcStore — called by ModelRepository on the SQL path + // ========================================================================= + + @Override + public List> query(String sql, List params) throws Exception { + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + bindParams(stmt, params); + try (ResultSet rs = stmt.executeQuery()) { + return mapResultSet(rs); + } + } + } + + @Override + public int executeUpdate(String sql, List params) throws Exception { + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + bindParams(stmt, params); + return stmt.executeUpdate(); + } + } + + // ========================================================================= + // DataStore — flat-map path not used when TableRegistry entry exists + // ========================================================================= + + @Override + public void save(String path, Map data) throws Exception { + throw new StorageException("SQLiteStorage uses the SQL path via TableRegistry"); + } + + @Override + public Optional> load(String path) throws Exception { + throw new StorageException("SQLiteStorage uses the SQL path via TableRegistry"); + } + + @Override + public void delete(String path) throws Exception { + throw new StorageException("SQLiteStorage uses the SQL path via TableRegistry"); + } + + @Override + public boolean exists(String path) throws Exception { + throw new StorageException("SQLiteStorage uses the SQL path via TableRegistry"); + } + + // ========================================================================= + // Private helpers + // ========================================================================= + + private void bindParams(PreparedStatement stmt, List params) throws Exception { + if (params == null) return; + for (int i = 0; i < params.size(); i++) { + stmt.setObject(i + 1, params.get(i)); + } + } + + private List> mapResultSet(ResultSet rs) throws Exception { + final ResultSetMetaData meta = rs.getMetaData(); + final int count = meta.getColumnCount(); + final List> rows = new ArrayList<>(); + while (rs.next()) { + final Map row = new LinkedHashMap<>(); + for (int i = 1; i <= count; i++) { + row.put(meta.getColumnLabel(i).toLowerCase(), rs.getObject(i)); + } + rows.add(row); + } + return rows; + } } diff --git a/src/main/java/com/gyvex/ezafk/repository/yaml/YamlDataStore.java b/src/main/java/com/gyvex/ezafk/repository/yaml/YamlDataStore.java new file mode 100644 index 0000000..fbbaee1 --- /dev/null +++ b/src/main/java/com/gyvex/ezafk/repository/yaml/YamlDataStore.java @@ -0,0 +1,152 @@ +package com.gyvex.ezafk.repository.yaml; + +import com.github.ezframework.jaloquent.store.DataStore; +import com.github.ezframework.javaquerybuilder.query.Query; +import com.github.ezframework.javaquerybuilder.query.QueryableStorage; +import com.github.ezframework.javaquerybuilder.query.condition.ConditionEntry; +import com.github.ezframework.javaquerybuilder.query.condition.Connector; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Jaloquent DataStore backed by a Bukkit YAML file. + * + * Entries are kept in a ConcurrentHashMap (keyed by path) for fast in-memory + * access and persisted to YAML on flush(). The path format used by Jaloquent is + * {@code prefix/id}, which maps to the YAML section {@code prefix.id}. + */ +public final class YamlDataStore implements DataStore, QueryableStorage { + + private final File file; + private final Map> store = new ConcurrentHashMap<>(); + + public YamlDataStore(File file) throws IOException { + this.file = file; + if (!file.exists()) { + file.createNewFile(); + } + load(); + } + + // ========================================================================= + // DataStore + // ========================================================================= + + @Override + public void save(String path, Map data) throws Exception { + store.put(path, new LinkedHashMap<>(data)); + } + + @Override + public Optional> load(String path) throws Exception { + return Optional.ofNullable(store.get(path)); + } + + @Override + public void delete(String path) throws Exception { + store.remove(path); + } + + @Override + public boolean exists(String path) throws Exception { + return store.containsKey(path); + } + + // ========================================================================= + // QueryableStorage + // ========================================================================= + + @Override + public List query(Query q) throws Exception { + final List conditions = q.getConditions(); + final Integer rawLimit = q.getLimit(); + final Integer limit = (rawLimit != null && rawLimit >= 0) ? rawLimit : null; + + final List results = new ArrayList<>(); + for (final Map.Entry> entry : store.entrySet()) { + if (matchesConditions(entry.getValue(), conditions)) { + final String path = entry.getKey(); + final int slash = path.lastIndexOf('/'); + final String id = slash >= 0 ? path.substring(slash + 1) : path; + results.add(id); + if (limit != null && results.size() >= limit) { + break; + } + } + } + return results; + } + + // ========================================================================= + // Persistence + // ========================================================================= + + /** + * Flush all in-memory entries to the backing YAML file. + */ + public void flush() throws IOException { + final FileConfiguration config = new YamlConfiguration(); + for (final Map.Entry> pathEntry : store.entrySet()) { + final String path = pathEntry.getKey(); + final Map attrs = pathEntry.getValue(); + // "afk_times/uuid" → "afk_times.uuid" section in YAML + final String section = path.replace('/', '.'); + for (final Map.Entry attr : attrs.entrySet()) { + config.set(section + "." + attr.getKey(), attr.getValue()); + } + } + config.save(file); + } + + // ========================================================================= + // Private helpers + // ========================================================================= + + private void load() { + final FileConfiguration config = YamlConfiguration.loadConfiguration(file); + for (final String prefix : config.getKeys(false)) { + if (!config.isConfigurationSection(prefix)) continue; + final var prefixSection = config.getConfigurationSection(prefix); + if (prefixSection == null) continue; + for (final String id : prefixSection.getKeys(false)) { + if (!prefixSection.isConfigurationSection(id)) continue; + final var entrySection = prefixSection.getConfigurationSection(id); + if (entrySection == null) continue; + final Map data = new LinkedHashMap<>(); + for (final String key : entrySection.getKeys(false)) { + data.put(key, entrySection.get(key)); + } + store.put(prefix + "/" + id, data); + } + } + } + + private boolean matchesConditions(Map row, List conditions) { + if (conditions == null || conditions.isEmpty()) { + return true; + } + boolean result = false; + boolean first = true; + for (final ConditionEntry entry : conditions) { + final boolean matches = entry.getCondition().matches(row, entry.getColumn()); + if (first) { + result = matches; + first = false; + } else if (entry.getConnector() == Connector.OR) { + result = result || matches; + } else { + result = result && matches; + } + } + return result; + } +} diff --git a/src/main/java/com/gyvex/ezafk/repository/yaml/YamlStorage.java b/src/main/java/com/gyvex/ezafk/repository/yaml/YamlStorage.java index 6ca1f16..f0f8df7 100644 --- a/src/main/java/com/gyvex/ezafk/repository/yaml/YamlStorage.java +++ b/src/main/java/com/gyvex/ezafk/repository/yaml/YamlStorage.java @@ -1,9 +1,10 @@ package com.gyvex.ezafk.repository.yaml; +import com.github.ezframework.jaloquent.model.Model; +import com.github.ezframework.jaloquent.model.ModelRepository; import com.gyvex.ezafk.bootstrap.Registry; +import com.gyvex.ezafk.repository.AfkTimeModel; import com.gyvex.ezafk.repository.StorageRepository; -import org.bukkit.configuration.file.FileConfiguration; -import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; import java.io.IOException; @@ -12,91 +13,83 @@ import java.util.UUID; public class YamlStorage implements StorageRepository { - private File dataFile; - private FileConfiguration config; - private final Map cache = new HashMap<>(); + + private YamlDataStore store; + private ModelRepository repo; @Override public void init() throws Exception { File dataFolder = Registry.get().getPlugin().getDataFolder(); if (!dataFolder.exists()) dataFolder.mkdirs(); - dataFile = new File(dataFolder, "afk_times.yml"); - if (!dataFile.exists()) { - try { - dataFile.createNewFile(); - } catch (IOException e) { - throw new IOException("Unable to create afk_times.yml", e); - } - } - config = YamlConfiguration.loadConfiguration(dataFile); - loadIntoCache(); + store = new YamlDataStore(new File(dataFolder, "afk_times.yml")); + repo = new ModelRepository<>(store, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY); } @Override public Map loadAll() { - return new java.util.HashMap<>(cache); - } - - private void loadIntoCache() { - cache.clear(); - if (config == null) return; - for (String key : config.getKeys(false)) { - try { - UUID id = UUID.fromString(key); - long v = config.getLong(key, 0L); - cache.put(id, v); - } catch (Exception ignored) {} + final Map result = new HashMap<>(); + try { + for (final AfkTimeModel m : repo.query(Model.queryBuilder().build())) { + try { + result.put(UUID.fromString(m.getId()), m.getSeconds()); + } catch (IllegalArgumentException ignored) {} + } + } catch (Exception e) { + Registry.get().getLogger().warning("Failed to load AFK times from YAML: " + e.getMessage()); } + return result; } @Override public void savePlayerAfkTime(UUID player, long seconds) { if (player == null) return; - cache.put(player, seconds); - if (config != null) config.set(player.toString(), seconds); + final AfkTimeModel model = new AfkTimeModel(player.toString()); + model.setSeconds(seconds); + try { + repo.save(model); + } catch (Exception e) { + Registry.get().getLogger().warning("YAML save failed: " + e.getMessage()); + } } @Override public void deletePlayer(UUID player) { if (player == null) return; - cache.remove(player); - if (config != null) { - config.set(player.toString(), null); - try { - config.save(dataFile); - } catch (IOException e) { - Registry.get().getLogger().warning("Failed to delete player from YamlStorage: " + e.getMessage()); - } + try { + repo.delete(player.toString()); + } catch (Exception e) { + Registry.get().getLogger().warning("YAML delete failed: " + e.getMessage()); } } @Override public long getPlayerAfkTime(UUID player) { if (player == null) return 0L; - Long v = cache.get(player); - return v != null ? v : 0L; + try { + return repo.find(player.toString()) + .map(AfkTimeModel::getSeconds) + .orElse(0L); + } catch (Exception e) { + Registry.get().getLogger().warning("YAML read failed: " + e.getMessage()); + return 0L; + } } @Override public void saveAll() throws Exception { - if (config == null || dataFile == null) return; - for (Map.Entry e : cache.entrySet()) { - config.set(e.getKey().toString(), e.getValue()); - } try { - config.save(dataFile); - } catch (IOException ex) { - throw new IOException("Failed to save afk_times.yml", ex); + store.flush(); + } catch (IOException e) { + throw new IOException("Failed to save afk_times.yml", e); } } @Override public void shutdown() { try { - saveAll(); + store.flush(); } catch (Exception e) { - Registry.get().getLogger().warning("Failed to save YamlStorage on shutdown: " + e.getMessage()); + Registry.get().getLogger().warning("Failed to flush YAML storage on shutdown: " + e.getMessage()); } - cache.clear(); } } From 07c10d7ce729eeb95dd9afff14c2502c60c59321 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Wed, 22 Apr 2026 21:22:07 +0200 Subject: [PATCH 08/15] feat: add Jaloquent model improvements and schema migration system Models: - AfkTimeModel now implements HasFactory and declares setFillable("seconds") to guard against mass-assignment of unexpected attributes - Static factory() method delegates to Factory.discover() for test fixture generation via AfkTimeModelFactory Migrations: - Migration interface: version(), description(), up(JdbcStore) - SqlMigration record: executes a single SQL string as a migration - MigrationRunner: ensures schema_migrations tracking table exists, then runs any pending migrations in ascending version order exactly once - MySQLStorage and SQLiteStorage: replace manual CREATE TABLE IF NOT EXISTS with MigrationRunner (v1 migration per dialect) Factories: - AfkTimeModelFactory (test scope): extends Factory; generates random AFK time fixtures using Jaker's NumberProvider - Add jaker 1.0.0 and jaker-data-en-US 1.0.0 as test-scoped dependencies --- pom.xml | 12 ++++ .../gyvex/ezafk/repository/AfkTimeModel.java | 9 ++- .../ezafk/repository/migration/Migration.java | 12 ++++ .../repository/migration/MigrationRunner.java | 66 +++++++++++++++++++ .../repository/migration/SqlMigration.java | 15 +++++ .../ezafk/repository/mysql/MySQLStorage.java | 14 ++-- .../repository/sqlite/SQLiteStorage.java | 14 ++-- .../ezafk/repository/AfkTimeModelFactory.java | 14 ++++ 8 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/gyvex/ezafk/repository/migration/Migration.java create mode 100644 src/main/java/com/gyvex/ezafk/repository/migration/MigrationRunner.java create mode 100644 src/main/java/com/gyvex/ezafk/repository/migration/SqlMigration.java create mode 100644 src/test/java/com/gyvex/ezafk/repository/AfkTimeModelFactory.java diff --git a/pom.xml b/pom.xml index 2b9f3ff..f92ca20 100644 --- a/pom.xml +++ b/pom.xml @@ -144,6 +144,18 @@ ${jaloquent.version} compile + + com.github.EzFramework + jaker + 1.0.0 + test + + + com.github.EzFramework.Jaker + jaker-data-en-US + 1.0.0 + test + org.junit.jupiter junit-jupiter diff --git a/src/main/java/com/gyvex/ezafk/repository/AfkTimeModel.java b/src/main/java/com/gyvex/ezafk/repository/AfkTimeModel.java index bcf48f4..211a4dd 100644 --- a/src/main/java/com/gyvex/ezafk/repository/AfkTimeModel.java +++ b/src/main/java/com/gyvex/ezafk/repository/AfkTimeModel.java @@ -1,9 +1,11 @@ package com.gyvex.ezafk.repository; +import com.github.ezframework.jaloquent.model.Factory; +import com.github.ezframework.jaloquent.model.HasFactory; import com.github.ezframework.jaloquent.model.Model; import com.github.ezframework.jaloquent.model.ModelFactory; -public final class AfkTimeModel extends Model { +public final class AfkTimeModel extends Model implements HasFactory { public static final String TABLE_PREFIX = "afk_times"; @@ -15,6 +17,11 @@ public final class AfkTimeModel extends Model { public AfkTimeModel(String id) { super(id); + setFillable("seconds"); + } + + public static Factory factory() { + return Factory.discover(AfkTimeModel.class); } public long getSeconds() { diff --git a/src/main/java/com/gyvex/ezafk/repository/migration/Migration.java b/src/main/java/com/gyvex/ezafk/repository/migration/Migration.java new file mode 100644 index 0000000..e357ef0 --- /dev/null +++ b/src/main/java/com/gyvex/ezafk/repository/migration/Migration.java @@ -0,0 +1,12 @@ +package com.gyvex.ezafk.repository.migration; + +import com.github.ezframework.jaloquent.store.sql.JdbcStore; + +/** + * A single versioned schema change that can be applied to a {@link JdbcStore}. + */ +public interface Migration { + int version(); + String description(); + void up(JdbcStore store) throws Exception; +} diff --git a/src/main/java/com/gyvex/ezafk/repository/migration/MigrationRunner.java b/src/main/java/com/gyvex/ezafk/repository/migration/MigrationRunner.java new file mode 100644 index 0000000..191b09b --- /dev/null +++ b/src/main/java/com/gyvex/ezafk/repository/migration/MigrationRunner.java @@ -0,0 +1,66 @@ +package com.gyvex.ezafk.repository.migration; + +import com.github.ezframework.jaloquent.store.sql.JdbcStore; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Applies pending schema migrations to a {@link JdbcStore} in ascending version order. + * Tracks applied migrations in a {@code schema_migrations} table so each version + * runs exactly once. + */ +public final class MigrationRunner { + + private static final String CREATE_MIGRATIONS_TABLE = + "CREATE TABLE IF NOT EXISTS schema_migrations " + + "(version INTEGER PRIMARY KEY, description VARCHAR(255) NOT NULL, " + + "applied_at VARCHAR(64) NOT NULL)"; + + private final JdbcStore store; + private final List migrations; + + public MigrationRunner(JdbcStore store, Migration... migrations) { + this.store = store; + this.migrations = Arrays.stream(migrations) + .sorted(Comparator.comparingInt(Migration::version)) + .toList(); + } + + /** + * Ensures the migrations tracking table exists, then runs any pending + * migrations in ascending version order. + * + * @throws Exception if any migration or tracking operation fails + */ + public void run() throws Exception { + store.executeUpdate(CREATE_MIGRATIONS_TABLE, List.of()); + final Set applied = loadAppliedVersions(); + for (final Migration migration : migrations) { + if (!applied.contains(migration.version())) { + migration.up(store); + store.executeUpdate( + "INSERT INTO schema_migrations (version, description, applied_at) VALUES (?, ?, ?)", + List.of(migration.version(), migration.description(), Instant.now().toString())); + } + } + } + + private Set loadAppliedVersions() throws Exception { + final List> rows = store.query( + "SELECT version FROM schema_migrations", List.of()); + final Set versions = new HashSet<>(); + for (final Map row : rows) { + final Object v = row.get("version"); + if (v instanceof Number n) { + versions.add(n.intValue()); + } + } + return versions; + } +} diff --git a/src/main/java/com/gyvex/ezafk/repository/migration/SqlMigration.java b/src/main/java/com/gyvex/ezafk/repository/migration/SqlMigration.java new file mode 100644 index 0000000..192ff43 --- /dev/null +++ b/src/main/java/com/gyvex/ezafk/repository/migration/SqlMigration.java @@ -0,0 +1,15 @@ +package com.gyvex.ezafk.repository.migration; + +import com.github.ezframework.jaloquent.store.sql.JdbcStore; + +import java.util.List; + +/** + * A migration that executes a single SQL statement. + */ +public record SqlMigration(int version, String description, String sql) implements Migration { + @Override + public void up(JdbcStore store) throws Exception { + store.executeUpdate(sql, List.of()); + } +} diff --git a/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java b/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java index d933bdd..b75aac3 100644 --- a/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java +++ b/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java @@ -11,12 +11,14 @@ import com.gyvex.ezafk.repository.AfkTimeModel; import com.gyvex.ezafk.repository.StorageRepository; +import com.gyvex.ezafk.repository.migration.MigrationRunner; +import com.gyvex.ezafk.repository.migration.SqlMigration; + import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; -import java.sql.Statement; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -52,11 +54,11 @@ public void init() throws Exception { "jdbc:mysql://%s:%d/%s?autoReconnect=true&useSSL=false", host, port, db); connection = DriverManager.getConnection(url, user, pass); - try (Statement stmt = connection.createStatement()) { - stmt.executeUpdate( - "CREATE TABLE IF NOT EXISTS afk_times " + - "(id VARCHAR(36) PRIMARY KEY, seconds BIGINT NOT NULL DEFAULT 0)"); - } + new MigrationRunner(this, + new SqlMigration(1, "Create afk_times table", + "CREATE TABLE IF NOT EXISTS afk_times " + + "(id VARCHAR(36) PRIMARY KEY, seconds BIGINT NOT NULL DEFAULT 0)") + ).run(); TableRegistry.register(AfkTimeModel.TABLE_PREFIX, "afk_times", TABLE_COLUMNS); repo = new ModelRepository<>(this, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY, diff --git a/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java b/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java index 1f5036c..df95e42 100644 --- a/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java +++ b/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java @@ -11,12 +11,14 @@ import com.gyvex.ezafk.repository.AfkTimeModel; import com.gyvex.ezafk.repository.StorageRepository; +import com.gyvex.ezafk.repository.migration.MigrationRunner; +import com.gyvex.ezafk.repository.migration.SqlMigration; + import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; -import java.sql.Statement; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -45,11 +47,11 @@ public void init() throws Exception { java.io.File dbFile = new java.io.File(dataFolder, "ezafk.db"); connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath()); - try (Statement stmt = connection.createStatement()) { - stmt.executeUpdate( - "CREATE TABLE IF NOT EXISTS afk_times " + - "(id TEXT PRIMARY KEY, seconds INTEGER NOT NULL DEFAULT 0)"); - } + new MigrationRunner(this, + new SqlMigration(1, "Create afk_times table", + "CREATE TABLE IF NOT EXISTS afk_times " + + "(id TEXT PRIMARY KEY, seconds INTEGER NOT NULL DEFAULT 0)") + ).run(); TableRegistry.register(AfkTimeModel.TABLE_PREFIX, "afk_times", TABLE_COLUMNS); repo = new ModelRepository<>(this, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY, diff --git a/src/test/java/com/gyvex/ezafk/repository/AfkTimeModelFactory.java b/src/test/java/com/gyvex/ezafk/repository/AfkTimeModelFactory.java new file mode 100644 index 0000000..918cee6 --- /dev/null +++ b/src/test/java/com/gyvex/ezafk/repository/AfkTimeModelFactory.java @@ -0,0 +1,14 @@ +package com.gyvex.ezafk.repository; + +import com.github.ezframework.jaloquent.model.Factory; +import com.github.ezframework.jaker.Faker; + +import java.util.Map; + +public class AfkTimeModelFactory extends Factory { + + @Override + protected Map definition(Faker faker) { + return Map.of("seconds", (long) faker.number().numberBetween(0, 86_400)); + } +} From e932407142d3691a56d9ce5c899b91116f638c8a Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sat, 25 Apr 2026 22:51:22 +0200 Subject: [PATCH 09/15] feat: Jaloquent integration --- pom.xml | 2 +- .../ezafk/repository/migration/Migration.java | 12 -- .../repository/migration/MigrationRunner.java | 66 -------- .../repository/migration/SqlMigration.java | 15 -- .../ezafk/repository/mysql/MySQLStorage.java | 153 ++++++------------ .../repository/sqlite/SQLiteStorage.java | 129 +++++---------- 6 files changed, 90 insertions(+), 287 deletions(-) delete mode 100644 src/main/java/com/gyvex/ezafk/repository/migration/Migration.java delete mode 100644 src/main/java/com/gyvex/ezafk/repository/migration/MigrationRunner.java delete mode 100644 src/main/java/com/gyvex/ezafk/repository/migration/SqlMigration.java diff --git a/pom.xml b/pom.xml index f92ca20..4651ac3 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ 7.0.13 3.13.0 3.6.2 - 1.1.0 + 1.2.1 org.mockbukkit.mockbukkit mockbukkit-v26.1 dev-d245e0a diff --git a/src/main/java/com/gyvex/ezafk/repository/migration/Migration.java b/src/main/java/com/gyvex/ezafk/repository/migration/Migration.java deleted file mode 100644 index e357ef0..0000000 --- a/src/main/java/com/gyvex/ezafk/repository/migration/Migration.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.gyvex.ezafk.repository.migration; - -import com.github.ezframework.jaloquent.store.sql.JdbcStore; - -/** - * A single versioned schema change that can be applied to a {@link JdbcStore}. - */ -public interface Migration { - int version(); - String description(); - void up(JdbcStore store) throws Exception; -} diff --git a/src/main/java/com/gyvex/ezafk/repository/migration/MigrationRunner.java b/src/main/java/com/gyvex/ezafk/repository/migration/MigrationRunner.java deleted file mode 100644 index 191b09b..0000000 --- a/src/main/java/com/gyvex/ezafk/repository/migration/MigrationRunner.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.gyvex.ezafk.repository.migration; - -import com.github.ezframework.jaloquent.store.sql.JdbcStore; - -import java.time.Instant; -import java.util.Arrays; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Applies pending schema migrations to a {@link JdbcStore} in ascending version order. - * Tracks applied migrations in a {@code schema_migrations} table so each version - * runs exactly once. - */ -public final class MigrationRunner { - - private static final String CREATE_MIGRATIONS_TABLE = - "CREATE TABLE IF NOT EXISTS schema_migrations " + - "(version INTEGER PRIMARY KEY, description VARCHAR(255) NOT NULL, " + - "applied_at VARCHAR(64) NOT NULL)"; - - private final JdbcStore store; - private final List migrations; - - public MigrationRunner(JdbcStore store, Migration... migrations) { - this.store = store; - this.migrations = Arrays.stream(migrations) - .sorted(Comparator.comparingInt(Migration::version)) - .toList(); - } - - /** - * Ensures the migrations tracking table exists, then runs any pending - * migrations in ascending version order. - * - * @throws Exception if any migration or tracking operation fails - */ - public void run() throws Exception { - store.executeUpdate(CREATE_MIGRATIONS_TABLE, List.of()); - final Set applied = loadAppliedVersions(); - for (final Migration migration : migrations) { - if (!applied.contains(migration.version())) { - migration.up(store); - store.executeUpdate( - "INSERT INTO schema_migrations (version, description, applied_at) VALUES (?, ?, ?)", - List.of(migration.version(), migration.description(), Instant.now().toString())); - } - } - } - - private Set loadAppliedVersions() throws Exception { - final List> rows = store.query( - "SELECT version FROM schema_migrations", List.of()); - final Set versions = new HashSet<>(); - for (final Map row : rows) { - final Object v = row.get("version"); - if (v instanceof Number n) { - versions.add(n.intValue()); - } - } - return versions; - } -} diff --git a/src/main/java/com/gyvex/ezafk/repository/migration/SqlMigration.java b/src/main/java/com/gyvex/ezafk/repository/migration/SqlMigration.java deleted file mode 100644 index 192ff43..0000000 --- a/src/main/java/com/gyvex/ezafk/repository/migration/SqlMigration.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.gyvex.ezafk.repository.migration; - -import com.github.ezframework.jaloquent.store.sql.JdbcStore; - -import java.util.List; - -/** - * A migration that executes a single SQL statement. - */ -public record SqlMigration(int version, String description, String sql) implements Migration { - @Override - public void up(JdbcStore store) throws Exception { - store.executeUpdate(sql, List.of()); - } -} diff --git a/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java b/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java index b75aac3..8c670e7 100644 --- a/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java +++ b/src/main/java/com/gyvex/ezafk/repository/mysql/MySQLStorage.java @@ -1,43 +1,36 @@ package com.gyvex.ezafk.repository.mysql; -import com.github.ezframework.jaloquent.exception.StorageException; +import com.github.ezframework.jaloquent.config.DatabaseSettings; +import com.github.ezframework.jaloquent.config.JdbcScheme; +import com.github.ezframework.jaloquent.config.JaloquentConfig; +import com.github.ezframework.jaloquent.migration.Migration; +import com.github.ezframework.jaloquent.migration.MigrationRunner; +import com.github.ezframework.jaloquent.migration.Schema; import com.github.ezframework.jaloquent.model.Model; import com.github.ezframework.jaloquent.model.ModelRepository; import com.github.ezframework.jaloquent.model.TableRegistry; -import com.github.ezframework.jaloquent.store.DataStore; -import com.github.ezframework.jaloquent.store.sql.JdbcStore; +import com.github.ezframework.jaloquent.store.sql.DataSourceJdbcStore; import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; import com.gyvex.ezafk.bootstrap.Registry; import com.gyvex.ezafk.repository.AfkTimeModel; import com.gyvex.ezafk.repository.StorageRepository; -import com.gyvex.ezafk.repository.migration.MigrationRunner; -import com.gyvex.ezafk.repository.migration.SqlMigration; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.ResultSetMetaData; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.UUID; /** - * MySQL storage backend using Jaloquent's ModelRepository with a JdbcStore - * backed by a DriverManager JDBC connection. + * MySQL storage backend using Jaloquent's ModelRepository with a + * DataSourceJdbcStore backed by a DriverManager JDBC connection. */ -public class MySQLStorage implements StorageRepository, DataStore, JdbcStore { +public class MySQLStorage implements StorageRepository { private static final Map TABLE_COLUMNS = Map.of( "id", "VARCHAR(36) PRIMARY KEY", "seconds", "BIGINT NOT NULL DEFAULT 0" ); - private Connection connection; + private DataSourceJdbcStore store; private ModelRepository repo; @Override @@ -45,23 +38,28 @@ public void init() throws Exception { try { org.bukkit.configuration.file.FileConfiguration cfg = Registry.get().getConfigManager().getMysqlConfig(); - String host = cfg.getString("host", "localhost"); - int port = cfg.getInt("port", 3306); - String db = cfg.getString("database", "ezafk"); - String user = cfg.getString("username", "root"); - String pass = cfg.getString("password", ""); - String url = String.format( - "jdbc:mysql://%s:%d/%s?autoReconnect=true&useSSL=false", host, port, db); - connection = DriverManager.getConnection(url, user, pass); - - new MigrationRunner(this, - new SqlMigration(1, "Create afk_times table", - "CREATE TABLE IF NOT EXISTS afk_times " + - "(id VARCHAR(36) PRIMARY KEY, seconds BIGINT NOT NULL DEFAULT 0)") - ).run(); + String host = cfg.getString("host", "localhost"); + int port = cfg.getInt("port", 3306); + String db = cfg.getString("database", "ezafk"); + String user = cfg.getString("username", "root"); + String pass = cfg.getString("password", ""); + + DatabaseSettings settings = DatabaseSettings.builder() + .jdbcScheme(JdbcScheme.MYSQL) + .host(host) + .port(port) + .databaseName(db) + .username(user) + .password(pass) + .build(); + JaloquentConfig.setDatabaseSettings(settings); + store = JaloquentConfig.buildStore(); + + new MigrationRunner(store, SqlDialect.MYSQL, List.of(new CreateAfkTimesTable())) + .run(); TableRegistry.register(AfkTimeModel.TABLE_PREFIX, "afk_times", TABLE_COLUMNS); - repo = new ModelRepository<>(this, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY, + repo = new ModelRepository<>(store, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY, SqlDialect.MYSQL); } catch (Exception e) { Registry.get().getLogger().warning("MySQLStorage init failed: " + e.getMessage()); @@ -86,7 +84,7 @@ public Map loadAll() { @Override public void savePlayerAfkTime(UUID player, long seconds) { - if (player == null || connection == null) return; + if (player == null || store == null) return; final AfkTimeModel model = new AfkTimeModel(player.toString()); model.setSeconds(seconds); try { @@ -98,7 +96,7 @@ public void savePlayerAfkTime(UUID player, long seconds) { @Override public long getPlayerAfkTime(UUID player) { - if (player == null || connection == null) return 0L; + if (player == null || store == null) return 0L; try { return repo.find(player.toString()) .map(AfkTimeModel::getSeconds) @@ -111,7 +109,7 @@ public long getPlayerAfkTime(UUID player) { @Override public void deletePlayer(UUID player) { - if (player == null || connection == null) return; + if (player == null || store == null) return; try { repo.delete(player.toString()); } catch (Exception e) { @@ -126,79 +124,32 @@ public void saveAll() { @Override public void shutdown() { - try { - if (connection != null && !connection.isClosed()) connection.close(); - } catch (Exception ignored) {} + // DataSourceJdbcStore closes connections on its own; no explicit shutdown needed. } // ========================================================================= - // JdbcStore — called by ModelRepository on the SQL path + // Inner migration class // ========================================================================= - @Override - public List> query(String sql, List params) throws Exception { - try (PreparedStatement stmt = connection.prepareStatement(sql)) { - bindParams(stmt, params); - try (ResultSet rs = stmt.executeQuery()) { - return mapResultSet(rs); - } + private static final class CreateAfkTimesTable implements Migration { + @Override + public String getId() { return "2026_04_23_001_create_afk_times"; } + + @Override + public void up(Schema schema) throws com.github.ezframework.jaloquent.exception.MigrationException { + schema.create("afk_times", t -> t + .column("id", "VARCHAR(36) NOT NULL") + .primaryKey("id") + .column("seconds", "BIGINT NOT NULL DEFAULT 0") + .ifNotExists() + ); } - } - @Override - public int executeUpdate(String sql, List params) throws Exception { - try (PreparedStatement stmt = connection.prepareStatement(sql)) { - bindParams(stmt, params); - return stmt.executeUpdate(); + @Override + public void down(Schema schema) throws com.github.ezframework.jaloquent.exception.MigrationException { + schema.dropIfExists("afk_times"); } } +} - // ========================================================================= - // DataStore — flat-map path not used when TableRegistry entry exists - // ========================================================================= - - @Override - public void save(String path, Map data) throws Exception { - throw new StorageException("MySQLStorage uses the SQL path via TableRegistry"); - } - - @Override - public Optional> load(String path) throws Exception { - throw new StorageException("MySQLStorage uses the SQL path via TableRegistry"); - } - - @Override - public void delete(String path) throws Exception { - throw new StorageException("MySQLStorage uses the SQL path via TableRegistry"); - } - - @Override - public boolean exists(String path) throws Exception { - throw new StorageException("MySQLStorage uses the SQL path via TableRegistry"); - } - - // ========================================================================= - // Private helpers - // ========================================================================= - - private void bindParams(PreparedStatement stmt, List params) throws Exception { - if (params == null) return; - for (int i = 0; i < params.size(); i++) { - stmt.setObject(i + 1, params.get(i)); - } - } - private List> mapResultSet(ResultSet rs) throws Exception { - final ResultSetMetaData meta = rs.getMetaData(); - final int count = meta.getColumnCount(); - final List> rows = new ArrayList<>(); - while (rs.next()) { - final Map row = new LinkedHashMap<>(); - for (int i = 1; i <= count; i++) { - row.put(meta.getColumnLabel(i).toLowerCase(), rs.getObject(i)); - } - rows.add(row); - } - return rows; - } -} diff --git a/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java b/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java index df95e42..47381e5 100644 --- a/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java +++ b/src/main/java/com/gyvex/ezafk/repository/sqlite/SQLiteStorage.java @@ -1,43 +1,35 @@ package com.gyvex.ezafk.repository.sqlite; -import com.github.ezframework.jaloquent.exception.StorageException; +import com.github.ezframework.jaloquent.config.DatabaseSettings; +import com.github.ezframework.jaloquent.config.JaloquentConfig; +import com.github.ezframework.jaloquent.migration.Migration; +import com.github.ezframework.jaloquent.migration.MigrationRunner; +import com.github.ezframework.jaloquent.migration.Schema; import com.github.ezframework.jaloquent.model.Model; import com.github.ezframework.jaloquent.model.ModelRepository; import com.github.ezframework.jaloquent.model.TableRegistry; -import com.github.ezframework.jaloquent.store.DataStore; -import com.github.ezframework.jaloquent.store.sql.JdbcStore; +import com.github.ezframework.jaloquent.store.sql.DataSourceJdbcStore; import com.github.ezframework.javaquerybuilder.query.sql.SqlDialect; import com.gyvex.ezafk.bootstrap.Registry; import com.gyvex.ezafk.repository.AfkTimeModel; import com.gyvex.ezafk.repository.StorageRepository; -import com.gyvex.ezafk.repository.migration.MigrationRunner; -import com.gyvex.ezafk.repository.migration.SqlMigration; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.ResultSetMetaData; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.UUID; /** - * SQLite storage backend using Jaloquent's ModelRepository with a JdbcStore - * backed by a DriverManager JDBC connection. + * SQLite storage backend using Jaloquent's ModelRepository with a + * DataSourceJdbcStore backed by a DriverManager JDBC connection. */ -public class SQLiteStorage implements StorageRepository, DataStore, JdbcStore { +public class SQLiteStorage implements StorageRepository { private static final Map TABLE_COLUMNS = Map.of( "id", "TEXT PRIMARY KEY", "seconds", "INTEGER NOT NULL DEFAULT 0" ); - private Connection connection; + private DataSourceJdbcStore store; private ModelRepository repo; @Override @@ -45,16 +37,18 @@ public void init() throws Exception { java.io.File dataFolder = Registry.get().getPlugin().getDataFolder(); if (!dataFolder.exists()) dataFolder.mkdirs(); java.io.File dbFile = new java.io.File(dataFolder, "ezafk.db"); - connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath()); - new MigrationRunner(this, - new SqlMigration(1, "Create afk_times table", - "CREATE TABLE IF NOT EXISTS afk_times " + - "(id TEXT PRIMARY KEY, seconds INTEGER NOT NULL DEFAULT 0)") - ).run(); + DatabaseSettings settings = DatabaseSettings.builder() + .url("jdbc:sqlite:" + dbFile.getAbsolutePath()) + .build(); + JaloquentConfig.setDatabaseSettings(settings); + store = JaloquentConfig.buildStore(); + + new MigrationRunner(store, SqlDialect.SQLITE, List.of(new CreateAfkTimesTable())) + .run(); TableRegistry.register(AfkTimeModel.TABLE_PREFIX, "afk_times", TABLE_COLUMNS); - repo = new ModelRepository<>(this, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY, + repo = new ModelRepository<>(store, AfkTimeModel.TABLE_PREFIX, AfkTimeModel.FACTORY, SqlDialect.SQLITE); } @@ -87,7 +81,7 @@ public void savePlayerAfkTime(UUID player, long seconds) { @Override public void deletePlayer(UUID player) { - if (player == null || connection == null) return; + if (player == null || store == null) return; try { repo.delete(player.toString()); } catch (Exception e) { @@ -115,79 +109,30 @@ public void saveAll() { @Override public void shutdown() { - try { - if (connection != null && !connection.isClosed()) connection.close(); - } catch (Exception ignored) {} - } - - // ========================================================================= - // JdbcStore — called by ModelRepository on the SQL path - // ========================================================================= - - @Override - public List> query(String sql, List params) throws Exception { - try (PreparedStatement stmt = connection.prepareStatement(sql)) { - bindParams(stmt, params); - try (ResultSet rs = stmt.executeQuery()) { - return mapResultSet(rs); - } - } - } - - @Override - public int executeUpdate(String sql, List params) throws Exception { - try (PreparedStatement stmt = connection.prepareStatement(sql)) { - bindParams(stmt, params); - return stmt.executeUpdate(); - } - } - - // ========================================================================= - // DataStore — flat-map path not used when TableRegistry entry exists - // ========================================================================= - - @Override - public void save(String path, Map data) throws Exception { - throw new StorageException("SQLiteStorage uses the SQL path via TableRegistry"); - } - - @Override - public Optional> load(String path) throws Exception { - throw new StorageException("SQLiteStorage uses the SQL path via TableRegistry"); - } - - @Override - public void delete(String path) throws Exception { - throw new StorageException("SQLiteStorage uses the SQL path via TableRegistry"); - } - - @Override - public boolean exists(String path) throws Exception { - throw new StorageException("SQLiteStorage uses the SQL path via TableRegistry"); + // DataSourceJdbcStore closes connections on its own; no explicit shutdown needed. } // ========================================================================= - // Private helpers + // Inner migration class // ========================================================================= - private void bindParams(PreparedStatement stmt, List params) throws Exception { - if (params == null) return; - for (int i = 0; i < params.size(); i++) { - stmt.setObject(i + 1, params.get(i)); + private static final class CreateAfkTimesTable implements Migration { + @Override + public String getId() { return "2026_04_23_001_create_afk_times"; } + + @Override + public void up(Schema schema) throws com.github.ezframework.jaloquent.exception.MigrationException { + schema.create("afk_times", t -> t + .column("id", "TEXT NOT NULL") + .primaryKey("id") + .column("seconds", "INTEGER NOT NULL DEFAULT 0") + .ifNotExists() + ); } - } - private List> mapResultSet(ResultSet rs) throws Exception { - final ResultSetMetaData meta = rs.getMetaData(); - final int count = meta.getColumnCount(); - final List> rows = new ArrayList<>(); - while (rs.next()) { - final Map row = new LinkedHashMap<>(); - for (int i = 1; i <= count; i++) { - row.put(meta.getColumnLabel(i).toLowerCase(), rs.getObject(i)); - } - rows.add(row); + @Override + public void down(Schema schema) throws com.github.ezframework.jaloquent.exception.MigrationException { + schema.dropIfExists("afk_times"); } - return rows; } } From 8054d66015b84e1229ec019b413b5e677d762c9a Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sat, 25 Apr 2026 22:59:13 +0200 Subject: [PATCH 10/15] fix: markdown lint issues --- docs/integrations/EconomyIntegration.md | 2 +- docs/integrations/TabIntegration.md | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/integrations/EconomyIntegration.md b/docs/integrations/EconomyIntegration.md index 07c534c..649ce2e 100644 --- a/docs/integrations/EconomyIntegration.md +++ b/docs/integrations/EconomyIntegration.md @@ -25,7 +25,7 @@ economy: enabled: true ``` -3. Restart the server. EzAfk will detect Vault automatically and activate economy integration. +1. Restart the server. EzAfk will detect Vault automatically and activate economy integration. ## Configuration Summary diff --git a/docs/integrations/TabIntegration.md b/docs/integrations/TabIntegration.md index 5580f41..ed0e296 100644 --- a/docs/integrations/TabIntegration.md +++ b/docs/integrations/TabIntegration.md @@ -96,4 +96,3 @@ Best practices - Keep AFK text and color configuration in EzAfk so a single plugin controls AFK formatting across chat, tab, and other integrations. - If you manage complex TAB layouts, test changes on a staging server. Templates differ per TAB version. - From f832a59aecfa85bd9d8fccd660c554f7b23ea52f Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sat, 25 Apr 2026 23:07:56 +0200 Subject: [PATCH 11/15] ci: fix target branch --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d7e7a8..f837dee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [ main, master ] + branches: [ v2.x, v3.x ] jobs: unit-tests: From b7e11d7aff16d94e3099f081c2954a9cbd2fbd7d Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sat, 25 Apr 2026 23:33:02 +0200 Subject: [PATCH 12/15] ci: changed workflows to run on Java 25 --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/maven-publish.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f837dee..c5815a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@v4 with: distribution: temurin - java-version: '21' + java-version: '25' - name: Cache Maven local repository uses: actions/cache@v4 @@ -49,11 +49,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@v4 with: distribution: temurin - java-version: '21' + java-version: '25' - name: Cache Maven local repository uses: actions/cache@v4 diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index f4045e8..88627b2 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -22,7 +22,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '21' + java-version: '25' distribution: 'temurin' server-id: github # Value of the distributionManagement/repository/id field of the pom.xml settings-path: ${{ github.workspace }} # location for the settings.xml file From 5daf7aea5626300342108b9319234332467ce8a4 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sun, 26 Apr 2026 09:01:51 +0200 Subject: [PATCH 13/15] fix: replaced jaloquent artifact to Jaloquent --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 4651ac3..2836b59 100644 --- a/pom.xml +++ b/pom.xml @@ -140,7 +140,7 @@ com.github.EzFramework - jaloquent + Jaloquent ${jaloquent.version} compile @@ -232,7 +232,7 @@ net.kyori:examination-api net.kyori:examination-string javazoom:jlayer - com.github.EzFramework:jaloquent + com.github.EzFramework:Jaloquent com.github.EzFramework:JavaQueryBuilder From d381d1cea059e22d26ed99a4ea37ba3a74d5d9c5 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sun, 26 Apr 2026 10:53:00 +0200 Subject: [PATCH 14/15] fix: updated MockBukkit to fixed dev/26.1 --- .github/workflows/ci.yml | 54 +++++++------------ pom.xml | 12 ++--- .../ezafk/integration/SpigotIntegration.java | 2 +- 3 files changed, 26 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5815a0..42c5671 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,62 +6,48 @@ on: jobs: unit-tests: + name: Unit tests runs-on: ubuntu-latest - strategy: - matrix: - include: - - paper-version: "1.21.11-R0.1-SNAPSHOT" - mockbukkit-artifactId: "mockbukkit-v1.21" - mockbukkit-version: "4.101.0" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - - name: Set up JDK 25 - uses: actions/setup-java@v4 + - name: Set up Java 25 + uses: actions/setup-java@v5 with: distribution: temurin java-version: '25' + cache: maven - - name: Cache Maven local repository - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-m2-${{ matrix.paper-version }}-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-m2-${{ matrix.paper-version }}- + - name: Build and install MockBukkit dev/26.1.1 + run: | + git clone --depth=1 --branch dev/26.1.1 https://github.com/MockBukkit/MockBukkit.git /tmp/MockBukkit + cd /tmp/MockBukkit && ./gradlew publishToMavenLocal -xtest -xjavadoc - name: Run unit tests - run: mvn -B -Dpaper.version=${{ matrix.paper-version }} -Dmockbukkit.artifactId=${{ matrix.mockbukkit-artifactId }} -Dmockbukkit.version=${{ matrix.mockbukkit-version }} test + run: mvn -B -ntp test feature-tests: + name: Feature tests runs-on: ubuntu-latest needs: unit-tests - strategy: - matrix: - include: - - paper-version: "1.21.11-R0.1-SNAPSHOT" - mockbukkit-artifactId: "mockbukkit-v1.21" - mockbukkit-version: "4.101.0" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - - name: Set up JDK 25 - uses: actions/setup-java@v4 + - name: Set up Java 25 + uses: actions/setup-java@v5 with: distribution: temurin java-version: '25' + cache: maven - - name: Cache Maven local repository - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-m2-feature-${{ matrix.paper-version }}-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-m2-feature-${{ matrix.paper-version }}- + - name: Build and install MockBukkit dev/26.1.1 + run: | + git clone --depth=1 --branch dev/26.1.1 https://github.com/MockBukkit/MockBukkit.git /tmp/MockBukkit + cd /tmp/MockBukkit && ./gradlew publishToMavenLocal -xtest -xjavadoc - name: Run feature tests - run: mvn -B -Dpaper.version=${{ matrix.paper-version }} -Dmockbukkit.artifactId=${{ matrix.mockbukkit-artifactId }} -Dmockbukkit.version=${{ matrix.mockbukkit-version }} -Pfeature-tests -Dtest=*FeatureTest test \ No newline at end of file + run: mvn -B -ntp -Pfeature-tests -Dtest=*FeatureTest test diff --git a/pom.xml b/pom.xml index 2836b59..a43cee5 100644 --- a/pom.xml +++ b/pom.xml @@ -15,11 +15,9 @@ [26.1.2.build,) 3.1.0 7.0.13 - 3.13.0 + 3.15.0 3.6.2 1.2.1 - org.mockbukkit.mockbukkit - mockbukkit-v26.1 dev-d245e0a ez-plugins/ezafk @@ -43,7 +41,7 @@ sk89q-repo https://maven.enginehub.org/repo/ - + jitpack.io https://jitpack.io @@ -164,8 +162,8 @@ - ${mockbukkit.groupId} - ${mockbukkit.artifactId} + org.mockbukkit.mockbukkit + mockbukkit-v26.1 ${mockbukkit.version} test @@ -261,7 +259,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.2.5 + 3.5.5 false diff --git a/src/main/java/com/gyvex/ezafk/integration/SpigotIntegration.java b/src/main/java/com/gyvex/ezafk/integration/SpigotIntegration.java index ead43ab..63cf8e4 100644 --- a/src/main/java/com/gyvex/ezafk/integration/SpigotIntegration.java +++ b/src/main/java/com/gyvex/ezafk/integration/SpigotIntegration.java @@ -16,7 +16,7 @@ public void load() { } else { Registry.get().getLogger().info("A new version (" + version + ") of EzAfk is available."); Registry.get().getLogger().info("Please visit the following link to download the latest update:"); - Registry.get().getLogger().info("https://www.spigotmc.org/resources/ezafk.117430/"); + Registry.get().getLogger().info("https://modrinth.com/plugin/ezafk"); } }); } From a70fa2d9c6b4e5af35ed3ab063f9ce215f2083a2 Mon Sep 17 00:00:00 2001 From: ez-plugins Date: Sun, 26 Apr 2026 11:02:37 +0200 Subject: [PATCH 15/15] fix: changed paper version to match MockBukkit --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a43cee5..9cc9518 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ UTF-8 provided 25 - [26.1.2.build,) + 26.1.2.build.7-alpha 3.1.0 7.0.13 3.15.0