Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.extendedclip.deluxemenus.command.DeluxeMenusCommand;
import com.extendedclip.deluxemenus.config.DeluxeMenusConfig;
import com.extendedclip.deluxemenus.config.GeneralConfig;
import com.extendedclip.deluxemenus.cooldown.EphemeralCooldownManager;
import com.extendedclip.deluxemenus.dupe.DupeFixer;
import com.extendedclip.deluxemenus.dupe.MenuItemMarker;
import com.extendedclip.deluxemenus.hooks.*;
Expand Down Expand Up @@ -48,6 +49,7 @@ public class DeluxeMenus extends JavaPlugin {

private PersistentMetaHandler persistentMetaHandler;
private MenuItemMarker menuItemMarker;
private EphemeralCooldownManager ephemeralCooldownManager;

private BukkitAudiences audiences;

Expand Down Expand Up @@ -86,6 +88,9 @@ public void onEnable() {
this.menuItemMarker = new MenuItemMarker(this);
new DupeFixer(this, this.menuItemMarker).register();

this.ephemeralCooldownManager = new EphemeralCooldownManager(this);
this.ephemeralCooldownManager.startSweepTask();

this.audiences = BukkitAudiences.create(this);

hookIntoVault();
Expand Down Expand Up @@ -122,6 +127,10 @@ public void onDisable() {

Menu.unloadForShutdown(this);

if (this.ephemeralCooldownManager != null) {
this.ephemeralCooldownManager.clearAll();
}

itemHooks.clear();

HandlerList.unregisterAll(this);
Expand Down Expand Up @@ -198,6 +207,10 @@ public PersistentMetaHandler getPersistentMetaHandler() {
return persistentMetaHandler;
}

public EphemeralCooldownManager getEphemeralCooldownManager() {
return ephemeralCooldownManager;
}

public BukkitAudiences audiences() {
if (this.audiences == null) {
throw new IllegalStateException("Tried to access Adventure when the plugin was disabled!");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ public enum ActionType {
BROADCAST_JSON("[broadcastjson]", "Broadcast a json message to all online players",
"- '[broadcastjson] {\"text\":\"message\"}'"),
PLACEHOLDER("[placeholder]", "Parse placeholders for a player without any chat or console output",
"- '[placeholder] %placeholder%'");
"- '[placeholder] %placeholder%'"),
SET_EPHEMERAL_COOLDOWN("[ephemeralcooldown]",
"Start an ephemeral cooldown for the menu viewer. Cooldowns are kept in memory only and are lost on server restart. A duration of 0 clears the cooldown",
"- '[ephemeralcooldown] <id> <duration>' where duration is seconds, optionally suffixed with s, m or h");

private static final Map<String, ActionType> BY_NAME = Arrays.stream(values())
.collect(Collectors.toMap(e -> e.name().toUpperCase(Locale.ROOT), Function.identity()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.*;
import java.util.logging.Level;
Expand Down Expand Up @@ -383,6 +384,45 @@ public void run() {
plugin.getVault().takePermission(player, executable);
break;

case SET_EPHEMERAL_COOLDOWN:
final String[] cooldownParts = executable.trim().split("\\s+");

if (cooldownParts.length != 2) {
plugin.debug(
DebugLevel.HIGHEST,
Level.WARNING,
"Invalid ephemeral cooldown action: " + executable + "!",
"Correct usage: [ephemeralcooldown] <id> <duration>");
break;
}

final Double parsedCooldown = parseCooldownSeconds(cooldownParts[1]);

if (parsedCooldown == null) {
plugin.debug(
DebugLevel.HIGHEST,
Level.WARNING,
"Invalid ephemeral cooldown duration: " + cooldownParts[1] + "!",
"The duration is a number of seconds, optionally suffixed with s, m or h.");
break;
}

double cooldownSeconds = Math.max(0, parsedCooldown);
final int maxCooldownSeconds = plugin.getGeneralConfig().maxEphemeralCooldownSeconds();

// A max of 0 or less means the server owner opted out of the limit entirely.
if (maxCooldownSeconds > 0 && cooldownSeconds > maxCooldownSeconds) {
plugin.debug(
DebugLevel.HIGHEST,
Level.WARNING,
"Ephemeral cooldown '" + cooldownParts[0] + "' of " + cooldownSeconds + "s is longer than max_ephemeral_cooldown_seconds (" + maxCooldownSeconds + ")!",
"Clamping it. Ephemeral cooldowns are lost on restart, so use a dedicated cooldown plugin for longer ones.");
cooldownSeconds = maxCooldownSeconds;
}

plugin.getEphemeralCooldownManager().set(this.uuid, cooldownParts[0], (long) (cooldownSeconds * 1000L));
break;

case BROADCAST_SOUND:
case BROADCAST_RAW_SOUND:
case BROADCAST_WORLD_SOUND:
Expand Down Expand Up @@ -526,4 +566,43 @@ private boolean isRaw(ActionType actionType) {
return actionType == ActionType.PLAY_RAW_SOUND || actionType == ActionType.BROADCAST_RAW_SOUND || actionType == ActionType.BROADCAST_WORLD_RAW_SOUND;
}

/**
* Parses an ephemeral cooldown duration into seconds.
* <p>
* Accepts a plain number of seconds ({@code 30}, {@code 0.25}) or a number suffixed with
* {@code s}, {@code m} or {@code h} ({@code 30s}, {@code 5m}, {@code 1h}).
*
* @return the duration in seconds, or null if it could not be parsed
*/
private @Nullable Double parseCooldownSeconds(@NotNull final String input) {
if (input.isEmpty()) {
return null;
}

String amount = input;
double multiplier = 1;

switch (Character.toLowerCase(input.charAt(input.length() - 1))) {
case 'h':
multiplier = 3600;
amount = input.substring(0, input.length() - 1);
break;
case 'm':
multiplier = 60;
amount = input.substring(0, input.length() - 1);
break;
case 's':
amount = input.substring(0, input.length() - 1);
break;
default:
break;
}

try {
return Double.parseDouble(amount) * multiplier;
} catch (final NumberFormatException exception) {
return null;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import com.extendedclip.deluxemenus.menu.options.LoreAppendMode;
import com.extendedclip.deluxemenus.menu.options.MenuItemOptions;
import com.extendedclip.deluxemenus.menu.options.MenuOptions;
import com.extendedclip.deluxemenus.requirement.HasEphemeralCooldownRequirement;
import com.extendedclip.deluxemenus.requirement.HasExpRequirement;
import com.extendedclip.deluxemenus.requirement.HasItemRequirement;
import com.extendedclip.deluxemenus.requirement.HasMetaRequirement;
Expand Down Expand Up @@ -169,6 +170,8 @@ public boolean loadDefConfig() {
c.addDefault("check_updates", true);
c.addDefault("use_admin_commands_in_menus_list", false);
c.addDefault("menus_list_page_size", 10);
// Longest duration the [ephemeralcooldown] action may set, in seconds. 0 or less = no limit.
c.addDefault("max_ephemeral_cooldown_seconds", -1);
c.options().copyDefaults(true);

if (!c.contains("gui_menus")) {
Expand Down Expand Up @@ -972,6 +975,16 @@ private RequirementList getRequirements(FileConfiguration c, String path) {
plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Has Permission requirement at path: " + rPath + " does not contain a permission: entry");
}
break;
case HAS_EPHEMERAL_COOLDOWN:
case DOES_NOT_HAVE_EPHEMERAL_COOLDOWN:
final String cooldownId = c.getString(rPath + ".id");
if (cooldownId == null || cooldownId.trim().isEmpty()) {
plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Ephemeral cooldown requirement at path: " + rPath + " does not contain an id: entry");
break;
}
invert = type == RequirementType.DOES_NOT_HAVE_EPHEMERAL_COOLDOWN;
req = new HasEphemeralCooldownRequirement(plugin, cooldownId, invert);
break;
case HAS_PERMISSIONS:
case DOES_NOT_HAVE_PERMISSIONS:
if (c.contains(rPath + ".permissions")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public class GeneralConfig {
private boolean useAdminCommandsInMenusList = false;
private int menusListPageSize = 10;
private int metasListPageSize = 15;
private int maxEphemeralCooldownSeconds = -1;

public GeneralConfig(final @NotNull DeluxeMenus plugin) {
this.plugin = plugin;
Expand All @@ -23,12 +24,14 @@ public void load() {
plugin.getConfig().addDefault("use_admin_commands_in_menus_list", false);
plugin.getConfig().addDefault("menus_list_page_size", menusListPageSize);
plugin.getConfig().addDefault("metas_list_page_size", metasListPageSize);
plugin.getConfig().addDefault("max_ephemeral_cooldown_seconds", maxEphemeralCooldownSeconds);

checkForUpdates = plugin.getConfig().getBoolean("check_updates", false);
debugLevel = loadDebugLevel();
useAdminCommandsInMenusList = plugin.getConfig().getBoolean("use_admin_commands_in_menus_list", false);
menusListPageSize = plugin.getConfig().getInt("menus_list_page_size", 10);
metasListPageSize = plugin.getConfig().getInt("metas_list_page_size", 15);
maxEphemeralCooldownSeconds = plugin.getConfig().getInt("max_ephemeral_cooldown_seconds", maxEphemeralCooldownSeconds);
}

public void reload() {
Expand Down Expand Up @@ -56,6 +59,14 @@ public int metasListPageSize() {
return metasListPageSize;
}

/**
* The longest duration the {@code [ephemeralcooldown]} action is allowed to set, in seconds.
* A value of zero or less means there is no maximum.
*/
public int maxEphemeralCooldownSeconds() {
return maxEphemeralCooldownSeconds;
}

private @NotNull DebugLevel loadDebugLevel() {
String configDebugLevel = plugin.getConfig().getString("debug", "HIGHEST");

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.extendedclip.deluxemenus.cooldown;

import com.extendedclip.deluxemenus.DeluxeMenus;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;

import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
* In memory, per player cooldown store.
* <p>
* Cooldowns tracked here are <b>ephemeral</b>: they are never written to disk and are lost when the
* plugin is disabled or the server stops. They do survive a {@code /dm reload}. Anyone who needs a
* cooldown to outlive a restart should use a dedicated cooldown plugin or temporary permissions.
* <p>
* Every method is safe to call from any thread. Requirements are evaluated off the main thread when
* a menu is opened or refreshed, actions run on the main thread, and PlaceholderAPI may request a
* value from either, so the backing maps are concurrent.
*/
public class EphemeralCooldownManager {

/**
* How often the sweep task runs, in ticks.
*/
private static final long SWEEP_INTERVAL = 20L * 300L;

private final DeluxeMenus plugin;

/**
* Player uuid -> cooldown id -> epoch millis at which the cooldown ends.
*/
private final Map<UUID, Map<String, Long>> cooldowns = new ConcurrentHashMap<>();

public EphemeralCooldownManager(final @NotNull DeluxeMenus plugin) {
this.plugin = plugin;
}

/**
* Starts a cooldown for a player, replacing any cooldown already running under the same id.
* <p>
* A duration of zero or less clears the cooldown instead of storing it.
*
* @param uuid the player the cooldown belongs to
* @param id the cooldown id, case insensitive
* @param durationMillis how long the cooldown should last, in milliseconds
*/
public void set(final @NotNull UUID uuid, final @NotNull String id, final long durationMillis) {
final String key = normalize(id);

if (durationMillis <= 0) {
clear(uuid, key);
return;
}

cooldowns.computeIfAbsent(uuid, ignored -> new ConcurrentHashMap<>())
.put(key, System.currentTimeMillis() + durationMillis);
}

/**
* @return true if the player currently has an unexpired cooldown under this id
*/
public boolean isOnCooldown(final @NotNull UUID uuid, final @NotNull String id) {
return getRemainingMillis(uuid, id) > 0;
}

/**
* @return the milliseconds left on this cooldown, or 0 if there is none or it has expired
*/
public long getRemainingMillis(final @NotNull UUID uuid, final @NotNull String id) {
final Map<String, Long> playerCooldowns = cooldowns.get(uuid);

if (playerCooldowns == null) {
return 0L;
}

final Long expiry = playerCooldowns.get(normalize(id));

if (expiry == null) {
return 0L;
}

final long remaining = expiry - System.currentTimeMillis();

if (remaining <= 0) {
// Expire lazily so the sweep task is only ever a memory reclaim, never correctness.
clear(uuid, id);
return 0L;
}

return remaining;
}

/**
* Removes a single cooldown from a player.
*/
public void clear(final @NotNull UUID uuid, final @NotNull String id) {
final String key = normalize(id);

cooldowns.computeIfPresent(uuid, (ignored, playerCooldowns) -> {
playerCooldowns.remove(key);
return playerCooldowns.isEmpty() ? null : playerCooldowns;
});
}

/**
* Removes every cooldown belonging to a player.
*/
public void clearAll(final @NotNull UUID uuid) {
cooldowns.remove(uuid);
}

/**
* Removes every cooldown of every player.
*/
public void clearAll() {
cooldowns.clear();
}

/**
* Schedules the task that drops expired entries. Cancelled along with every other plugin task in
* {@link DeluxeMenus#onDisable()}.
*/
public void startSweepTask() {
Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::sweep, SWEEP_INTERVAL, SWEEP_INTERVAL);
}

private void sweep() {
final long now = System.currentTimeMillis();

cooldowns.entrySet().removeIf(entry -> {
entry.getValue().values().removeIf(expiry -> expiry <= now);
return entry.getValue().isEmpty();
});
}

private @NotNull String normalize(final @NotNull String id) {
return id.trim().toLowerCase(Locale.ROOT);
}
}
Loading
Loading