diff --git a/src/main/java/tc/oc/occ/idly/Idly.java b/src/main/java/tc/oc/occ/idly/Idly.java index 023e837..6357a07 100644 --- a/src/main/java/tc/oc/occ/idly/Idly.java +++ b/src/main/java/tc/oc/occ/idly/Idly.java @@ -5,27 +5,45 @@ import net.kyori.adventure.platform.bukkit.BukkitAudiences; import org.bukkit.command.CommandSender; import org.bukkit.plugin.java.JavaPlugin; +import tc.oc.occ.idly.api.BaseIdlyAPI; +import tc.oc.occ.idly.api.IdlyAPI; public class Idly extends JavaPlugin { + private static Idly plugin; private BukkitAudiences adventure; private BukkitCommandManager commands; private IdlyConfig config; private IdlyManager manager; + private IdlyAPI api; @Override public void onEnable() { + plugin = this; this.saveDefaultConfig(); this.reloadConfig(); this.config = new IdlyConfig(this.getConfig()); this.commands = new BukkitCommandManager(this); this.adventure = BukkitAudiences.create(this); + this.api = new BaseIdlyAPI(); this.manager = new IdlyManager(this); this.commands.registerDependency(IdlyConfig.class, config); this.commands.registerCommand(new IdlyCommand()); - this.getServer().getPluginManager().registerEvents(new IdlyListener(manager), this); + this.getServer().getPluginManager().registerEvents(new IdlyListener(manager, config), this); + } + + public static Idly get() { + return plugin; + } + + public void setAPI(IdlyAPI api) { + this.api = api; + } + + public IdlyAPI getAPI() { + return api; } public IdlyConfig getIdlyConfig() { @@ -35,9 +53,4 @@ public IdlyConfig getIdlyConfig() { public Audience getViewer(CommandSender sender) { return adventure.sender(sender); } - - // TODO: command to view last movement for players - // TODO: ignore permission bypass - // TODO: eee - } diff --git a/src/main/java/tc/oc/occ/idly/IdlyConfig.java b/src/main/java/tc/oc/occ/idly/IdlyConfig.java index b9e004e..0218d24 100644 --- a/src/main/java/tc/oc/occ/idly/IdlyConfig.java +++ b/src/main/java/tc/oc/occ/idly/IdlyConfig.java @@ -1,12 +1,22 @@ package tc.oc.occ.idly; +import java.time.Duration; +import org.bukkit.ChatColor; import org.bukkit.configuration.Configuration; public class IdlyConfig { private boolean enabled; - private int afkDelay; - private int kickDelay; + private boolean kickMode; + private int participantDelay; + private int observerDelay; + private int warningDuration; + private int warningFrequency; + private boolean requireMatchRunning; + private boolean preciseMovement; + private boolean movementCheck; + private boolean chatCheck; + private String kickMessage; public IdlyConfig(Configuration config) { reload(config); @@ -16,17 +26,66 @@ public boolean isEnabled() { return enabled; } - public int getAfkDelay() { - return afkDelay; + public boolean isKickMode() { + return kickMode; } - public int getKickDelay() { - return kickDelay; + public int getParticipantDelay() { + return participantDelay; + } + + public int getObserverDelay() { + return observerDelay; + } + + public int getWarningDuration() { + return warningDuration; + } + + public int getWarningFrequency() { + return warningFrequency; + } + + public boolean isRequireMatchRunning() { + return requireMatchRunning; + } + + public boolean isPreciseMovement() { + return preciseMovement; + } + + public boolean isMovementCheck() { + return movementCheck; + } + + public boolean isChatCheck() { + return chatCheck; + } + + public String getKickMessage() { + return ChatColor.translateAlternateColorCodes('&', kickMessage); } public void reload(Configuration config) { this.enabled = config.getBoolean("enabled"); - this.afkDelay = config.getInt("afk-delay"); - this.kickDelay = config.getInt("kick-delay"); + this.kickMode = config.getBoolean("kick-mode"); + this.participantDelay = config.getInt("participant-delay"); + this.observerDelay = config.getInt("observer-delay"); + this.warningDuration = config.getInt("warning-duration"); + this.warningFrequency = config.getInt("warning-frequency"); + this.requireMatchRunning = config.getBoolean("require-match-running"); + this.preciseMovement = config.getBoolean("precise-movement"); + this.movementCheck = config.getBoolean("checks.movement"); + this.chatCheck = config.getBoolean("checks.chat"); + this.kickMessage = config.getString("kick-message"); + + this.participantDelay = convertSettingTime(participantDelay); + this.observerDelay = convertSettingTime(observerDelay); + this.warningFrequency = convertSettingTime(warningFrequency); + this.warningDuration = convertSettingTime(warningDuration); + } + + private int convertSettingTime(int value) { + return (int) Duration.ofSeconds(value).toMillis() / 50; } } diff --git a/src/main/java/tc/oc/occ/idly/IdlyListener.java b/src/main/java/tc/oc/occ/idly/IdlyListener.java index ebda029..66a4077 100644 --- a/src/main/java/tc/oc/occ/idly/IdlyListener.java +++ b/src/main/java/tc/oc/occ/idly/IdlyListener.java @@ -3,24 +3,42 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerMoveEvent; -import tc.oc.pgm.api.match.event.MatchPhaseChangeEvent; public class IdlyListener implements Listener { + private IdlyConfig config; private IdlyManager manager; - public IdlyListener(IdlyManager plugin) { - this.manager = plugin; + public IdlyListener(IdlyManager manager, IdlyConfig config) { + this.manager = manager; + this.config = config; } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onPlayerMoveEvent(PlayerMoveEvent event) { + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { this.manager.logMovement(event.getPlayer()); } - @EventHandler - public void onMatchStatusChange(MatchPhaseChangeEvent event) { - this.manager.reset(); + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerChatEvent(AsyncPlayerChatEvent event) { + if (config.isChatCheck()) { + this.manager.logMovement(event.getPlayer()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerMoveEvent(PlayerMoveEvent event) { + if (config.isMovementCheck()) { + boolean wasPitchYawMoved = + event.getFrom().getYaw() != event.getTo().getYaw() + || event.getFrom().getPitch() != event.getTo().getPitch(); + + if (config.isPreciseMovement() && !wasPitchYawMoved) return; + + this.manager.logMovement(event.getPlayer()); + } } } diff --git a/src/main/java/tc/oc/occ/idly/IdlyManager.java b/src/main/java/tc/oc/occ/idly/IdlyManager.java index 940da5a..0cd54c2 100644 --- a/src/main/java/tc/oc/occ/idly/IdlyManager.java +++ b/src/main/java/tc/oc/occ/idly/IdlyManager.java @@ -4,137 +4,122 @@ import static net.kyori.adventure.sound.Sound.sound; import static net.kyori.adventure.text.Component.text; -import java.time.Duration; -import java.time.Instant; +import com.google.common.base.Objects; import javax.annotation.Nullable; import net.kyori.adventure.audience.Audience; import net.kyori.adventure.sound.Sound; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.entity.Player; -import tc.oc.pgm.api.PGM; import tc.oc.pgm.api.match.Match; import tc.oc.pgm.api.player.MatchPlayer; import tc.oc.pgm.util.bukkit.OnlinePlayerMapAdapter; public class IdlyManager { + private static final int TICK_FREQUENCY = 10; + private static final int TICKS_PER_SECOND = 20; + private final Idly plugin; private final IdlyConfig config; - private final OnlinePlayerMapAdapter playerMovementCache; - private final OnlinePlayerMapAdapter playerAfkCache; + private final OnlinePlayerMapAdapter playerInactivityTicks; public IdlyManager(Idly plugin) { this.plugin = plugin; this.config = plugin.getIdlyConfig(); - this.playerMovementCache = new OnlinePlayerMapAdapter(plugin); - this.playerAfkCache = new OnlinePlayerMapAdapter(plugin); + this.playerInactivityTicks = new OnlinePlayerMapAdapter(plugin); + plugin .getServer() .getScheduler() - .scheduleSyncRepeatingTask(plugin, this::checkPlayers, 20L, 20L); + .scheduleSyncRepeatingTask(plugin, this::checkPlayers, 0L, TICK_FREQUENCY); } - public void reset() { - this.playerMovementCache.clear(); - this.playerAfkCache.clear(); + public void logMovement(Player player) { + playerInactivityTicks.put(player, 0); } - public Instant getLastMovement(Player player) { - return playerMovementCache.get(player); - } + private void checkPlayers() { + if (!config.isEnabled()) return; - public void logMovement(Player player) { - playerMovementCache.put(player, Instant.now()); - removeAFK(player); - } + for (Player player : this.playerInactivityTicks.keySet()) { + if (config.isRequireMatchRunning() && !plugin.getAPI().isMatchRunning(player)) continue; - public void resetMovement(Player player) { - playerMovementCache.remove(player); + checkPlayer(player, plugin.getAPI().isPlaying(player)); + } } - public boolean isAFK(Player player) { - return playerAfkCache.containsKey(player); - } + private void checkPlayer(Player player, boolean isPlaying) { + int inactivity = + playerInactivityTicks.compute( + player, (p, t) -> Objects.firstNonNull(t, 0) + TICK_FREQUENCY); - public void setAFK(Player player) { - this.playerAfkCache.put(player, Instant.now()); - } + // Don't track observers when kick mode is disabled + if (!config.isKickMode() && !isPlaying) return; - public void removeAFK(Player player) { - this.playerAfkCache.remove(player); - } + // Ignore those with the bypass permission + if (player.hasPermission(IdlyPermissions.BYPASS)) return; - private Instant getAFKSince(Player player) { - return this.playerAfkCache.get(player); + int duration = (isPlaying ? config.getParticipantDelay() : config.getObserverDelay()); + float remaining = duration - inactivity; + if (remaining <= 0) { + kick(player); + } else if (remaining <= config.getWarningDuration() + && (remaining % config.getWarningFrequency()) < TICK_FREQUENCY) { + sendWarningCountdown(player, remaining); + } } - private boolean checkAFK(MatchPlayer player) { - Match match = player.getMatch(); - Audience viewer = plugin.getViewer(player.getBukkit()); - - if (!isAFK(player.getBukkit())) return false; - if (!match.isRunning()) return false; - - Instant afkTime = getAFKSince(player.getBukkit()); - Duration timeSinceAfk = Duration.between(afkTime, Instant.now()); - long secondsLeft = (config.getKickDelay() - timeSinceAfk.getSeconds()) + 1; - Component observers = text("Observers", NamedTextColor.AQUA); - - if (secondsLeft > 0) { - sendWarning( - viewer, - text() - .append(text("You will be moved to ")) - .append(observers) - .append(text(" due to inactivity in ")) - .append(text(secondsLeft, NamedTextColor.YELLOW)) - .append(text(" second" + (secondsLeft != 1 ? "s" : ""))) - .color(NamedTextColor.RED) - .build(), - COUNTDOWN); + private void kick(Player player) { + if (config.isKickMode()) { + kickFromServer(player); } else { - match.setParty(player, match.getDefaultParty()); - sendWarning( - viewer, - text() - .append(text("Moved to ")) - .append(observers) - .append(text(" due to inactivity")) - .color(NamedTextColor.RED) - .build(), - KICK); - this.resetMovement(player.getBukkit()); - this.removeAFK(player.getBukkit()); + kickFromMatch(player); } + } - return true; + private void kickFromServer(Player player) { + player.kickPlayer(config.getKickMessage()); } - private void checkPlayers() { - if (!config.isEnabled()) return; - Match match = getMatch(); - if (match != null) { - // Only check participating players - for (MatchPlayer player : match.getParticipants()) { - // Ignore those with permission bypass - if (player.getBukkit().hasPermission(IdlyPermissions.BYPASS)) continue; - - Instant lastMovement = getLastMovement(player.getBukkit()); - if (lastMovement == null) { - // May be null if just joined match - // so we log an initial movement - logMovement(player.getBukkit()); - } else { - Duration timeElasped = Duration.between(lastMovement, Instant.now()); - if (!checkAFK(player) && timeElasped.getSeconds() > config.getAfkDelay()) { - setAFK(player.getBukkit()); - } - } - } - } + private void kickFromMatch(Player player) { + MatchPlayer mp = IdlyUtils.getMatchPlayer(player); + Match match = mp.getMatch(); + Audience viewer = plugin.getViewer(player); + + match.setParty(mp, match.getDefaultParty()); + sendWarning( + viewer, + text() + .append(text("Moved to ")) + .append(OBSERVERS) + .append(text(" due to inactivity")) + .color(NamedTextColor.RED) + .build(), + KICK); + } + + private void sendWarningCountdown(Player player, float tickTime) { + int time = Math.round(tickTime / TICKS_PER_SECOND); + Audience viewer = plugin.getViewer(player); + sendWarning( + viewer, + text() + .append(text("You will be ")) + .append( + config.isKickMode() + ? text("kicked", NamedTextColor.YELLOW) + : text().append(text("moved to ")).append(OBSERVERS)) + .append(text(" due to inactivity in ")) + .append(text(time, NamedTextColor.YELLOW)) + .append(text(" second" + (time != 1 ? "s" : ""))) + .color(NamedTextColor.RED) + .build(), + COUNTDOWN); + viewer.playSound(COUNTDOWN); } + private static final Component OBSERVERS = text("Observers", NamedTextColor.AQUA); private static final Component WARNING = text(" \u26a0 ", NamedTextColor.YELLOW); private static final Sound COUNTDOWN = sound(key("random.break"), Sound.Source.MASTER, 1f, 1.15f); private static final Sound KICK = @@ -146,10 +131,4 @@ private void sendWarning(Audience viewer, Component message, @Nullable Sound sou viewer.playSound(sound); } } - - private Match getMatch() { - return PGM.get().getMatchManager().getMatches().hasNext() - ? PGM.get().getMatchManager().getMatches().next() - : null; - } } diff --git a/src/main/java/tc/oc/occ/idly/IdlyUtils.java b/src/main/java/tc/oc/occ/idly/IdlyUtils.java new file mode 100644 index 0000000..32ecda2 --- /dev/null +++ b/src/main/java/tc/oc/occ/idly/IdlyUtils.java @@ -0,0 +1,14 @@ +package tc.oc.occ.idly; + +import javax.annotation.Nullable; +import org.bukkit.entity.Player; +import tc.oc.pgm.api.PGM; +import tc.oc.pgm.api.player.MatchPlayer; + +public class IdlyUtils { + + @Nullable + public static MatchPlayer getMatchPlayer(Player player) { + return PGM.get().getMatchManager().getPlayer(player); + } +} diff --git a/src/main/java/tc/oc/occ/idly/api/BaseIdlyAPI.java b/src/main/java/tc/oc/occ/idly/api/BaseIdlyAPI.java new file mode 100644 index 0000000..2eeba81 --- /dev/null +++ b/src/main/java/tc/oc/occ/idly/api/BaseIdlyAPI.java @@ -0,0 +1,20 @@ +package tc.oc.occ.idly.api; + +import org.bukkit.entity.Player; +import tc.oc.occ.idly.IdlyUtils; +import tc.oc.pgm.api.player.MatchPlayer; + +public class BaseIdlyAPI implements IdlyAPI { + + @Override + public boolean isMatchRunning(Player player) { + MatchPlayer mp = IdlyUtils.getMatchPlayer(player); + return mp != null && mp.getMatch().isRunning(); + } + + @Override + public boolean isPlaying(Player player) { + MatchPlayer mp = IdlyUtils.getMatchPlayer(player); + return mp != null && mp.isParticipating(); + } +} diff --git a/src/main/java/tc/oc/occ/idly/api/IdlyAPI.java b/src/main/java/tc/oc/occ/idly/api/IdlyAPI.java new file mode 100644 index 0000000..5504ecc --- /dev/null +++ b/src/main/java/tc/oc/occ/idly/api/IdlyAPI.java @@ -0,0 +1,10 @@ +package tc.oc.occ.idly.api; + +import org.bukkit.entity.Player; + +public interface IdlyAPI { + + boolean isMatchRunning(Player player); + + boolean isPlaying(Player player); +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a07dbf8..717e1b2 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,9 +1,35 @@ # Whether idle players are kicked or not enabled: true -# Delay in seconds before a player is marked as AFK -afk-delay: 60 # Seconds +# What to do when player is marked as inactive: +# true = Kick from server +# false = Move to observers +kick-mode: false -# Delay before player who is marked as AFK will be kicked -kick-delay: 10 # Seconds +# Amount of time (in seconds) before participant is kicked +participant-delay: 90 + +# Amount of time (in seconds) before observer is kicked +# (only works when kick-mode is true) +observer-delay: 120 + +# Duration of delay that will feature warning messages (in seconds) +warning-duration: 15 + +# Frequency between warning countdown messages (in seconds) +warning-frequency: 5 + +# Only check for inactivity when match is running +require-match-running: true + +# When true movement is tracked on pitch/yaw movement only +precise-movement: true + +# Types of checks which log activity +checks: + movement: true + chat: true + +# Message sent to player when kicked from the server +kick-message: "&c&lYou were kicked for being idle too long!"