diff --git a/src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java b/src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java index 9e2ebe81..a2b2fdb1 100644 --- a/src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java +++ b/src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java @@ -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.*; @@ -48,6 +49,7 @@ public class DeluxeMenus extends JavaPlugin { private PersistentMetaHandler persistentMetaHandler; private MenuItemMarker menuItemMarker; + private EphemeralCooldownManager ephemeralCooldownManager; private BukkitAudiences audiences; @@ -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(); @@ -122,6 +127,10 @@ public void onDisable() { Menu.unloadForShutdown(this); + if (this.ephemeralCooldownManager != null) { + this.ephemeralCooldownManager.clearAll(); + } + itemHooks.clear(); HandlerList.unregisterAll(this); @@ -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!"); diff --git a/src/main/java/com/extendedclip/deluxemenus/action/ActionType.java b/src/main/java/com/extendedclip/deluxemenus/action/ActionType.java index 50f65198..e862ddfe 100644 --- a/src/main/java/com/extendedclip/deluxemenus/action/ActionType.java +++ b/src/main/java/com/extendedclip/deluxemenus/action/ActionType.java @@ -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] ' where duration is seconds, optionally suffixed with s, m or h"); private static final Map BY_NAME = Arrays.stream(values()) .collect(Collectors.toMap(e -> e.name().toUpperCase(Locale.ROOT), Function.identity())); diff --git a/src/main/java/com/extendedclip/deluxemenus/action/ClickActionTask.java b/src/main/java/com/extendedclip/deluxemenus/action/ClickActionTask.java index 6d728108..e9cb9d3b 100644 --- a/src/main/java/com/extendedclip/deluxemenus/action/ClickActionTask.java +++ b/src/main/java/com/extendedclip/deluxemenus/action/ClickActionTask.java @@ -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; @@ -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] "); + 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: @@ -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. + *

+ * 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; + } + } + } \ No newline at end of file diff --git a/src/main/java/com/extendedclip/deluxemenus/config/DeluxeMenusConfig.java b/src/main/java/com/extendedclip/deluxemenus/config/DeluxeMenusConfig.java index 80859a1e..15c31ae1 100644 --- a/src/main/java/com/extendedclip/deluxemenus/config/DeluxeMenusConfig.java +++ b/src/main/java/com/extendedclip/deluxemenus/config/DeluxeMenusConfig.java @@ -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; @@ -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")) { @@ -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")) { diff --git a/src/main/java/com/extendedclip/deluxemenus/config/GeneralConfig.java b/src/main/java/com/extendedclip/deluxemenus/config/GeneralConfig.java index 0421b399..00619a37 100644 --- a/src/main/java/com/extendedclip/deluxemenus/config/GeneralConfig.java +++ b/src/main/java/com/extendedclip/deluxemenus/config/GeneralConfig.java @@ -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; @@ -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() { @@ -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"); diff --git a/src/main/java/com/extendedclip/deluxemenus/cooldown/EphemeralCooldownManager.java b/src/main/java/com/extendedclip/deluxemenus/cooldown/EphemeralCooldownManager.java new file mode 100644 index 00000000..83ae77d7 --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/cooldown/EphemeralCooldownManager.java @@ -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. + *

+ * Cooldowns tracked here are ephemeral: 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. + *

+ * 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> 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. + *

+ * 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 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); + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/placeholder/Expansion.java b/src/main/java/com/extendedclip/deluxemenus/placeholder/Expansion.java index 6a1a6ea0..93e86fd2 100644 --- a/src/main/java/com/extendedclip/deluxemenus/placeholder/Expansion.java +++ b/src/main/java/com/extendedclip/deluxemenus/placeholder/Expansion.java @@ -51,7 +51,11 @@ public boolean persist() { "%deluxemenus_opened_menu%", "%deluxemenus_last_menu%", "%deluxemenus_meta_has_value__[type]%", - "%deluxemenus_meta___[default-value]%" + "%deluxemenus_meta___[default-value]%", + "%deluxemenus_has_ephemeral_cooldown_%", + "%deluxemenus_ephemeral_cooldown_%", + "%deluxemenus_ephemeral_cooldown_millis_%", + "%deluxemenus_ephemeral_cooldown_formatted_%" ); } @@ -81,6 +85,29 @@ public boolean persist() { } } + // %deluxemenus_has_ephemeral_cooldown_% + if (parsedInputLower.startsWith("has_ephemeral_cooldown_")) { + return getBooleanAsString(plugin.getEphemeralCooldownManager() + .isOnCooldown(onlinePlayer.getUniqueId(), parsedInput.substring(23))); + } + + if (parsedInputLower.startsWith("ephemeral_cooldown_")) { + // %deluxemenus_ephemeral_cooldown_millis_% + if (parsedInputLower.startsWith("ephemeral_cooldown_millis_")) { + return String.valueOf(getRemainingMillis(onlinePlayer, parsedInput.substring(26))); + } + + // %deluxemenus_ephemeral_cooldown_formatted_% + if (parsedInputLower.startsWith("ephemeral_cooldown_formatted_")) { + return formatDuration(getRemainingMillis(onlinePlayer, parsedInput.substring(29))); + } + + // %deluxemenus_ephemeral_cooldown_% + // Rounded up so a cooldown never displays as 0 while it is still running. + final long remaining = getRemainingMillis(onlinePlayer, parsedInput.substring(19)); + return String.valueOf((remaining + 999L) / 1000L); + } + if (!parsedInputLower.startsWith("meta_")) { return null; } @@ -151,4 +178,44 @@ public boolean persist() { private @NotNull String getBooleanAsString(final boolean value) { return value ? PlaceholderAPIPlugin.booleanTrue() : PlaceholderAPIPlugin.booleanFalse(); } + + private long getRemainingMillis(final @NotNull Player player, final @NotNull String id) { + return plugin.getEphemeralCooldownManager().getRemainingMillis(player.getUniqueId(), id); + } + + /** + * Formats a remaining duration as {@code 1h 2m 3s}, leaving out the units that are zero. + * Returns an empty string when there is nothing left to wait for. + */ + private @NotNull String formatDuration(final long millis) { + if (millis <= 0) { + return ""; + } + + // Rounded up so a cooldown never displays as 0s while it is still running. + long seconds = (millis + 999L) / 1000L; + + final long hours = seconds / 3600L; + seconds -= hours * 3600L; + final long minutes = seconds / 60L; + seconds -= minutes * 60L; + + final StringBuilder builder = new StringBuilder(); + + if (hours > 0) { + builder.append(hours).append('h'); + } + + if (minutes > 0) { + if (builder.length() > 0) builder.append(' '); + builder.append(minutes).append('m'); + } + + if (seconds > 0) { + if (builder.length() > 0) builder.append(' '); + builder.append(seconds).append('s'); + } + + return builder.toString(); + } } diff --git a/src/main/java/com/extendedclip/deluxemenus/requirement/HasEphemeralCooldownRequirement.java b/src/main/java/com/extendedclip/deluxemenus/requirement/HasEphemeralCooldownRequirement.java new file mode 100644 index 00000000..9d63bc8c --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/requirement/HasEphemeralCooldownRequirement.java @@ -0,0 +1,26 @@ +package com.extendedclip.deluxemenus.requirement; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.menu.MenuHolder; + +public class HasEphemeralCooldownRequirement extends Requirement { + + private final DeluxeMenus plugin; + private final String id; + private final boolean invert; + + public HasEphemeralCooldownRequirement(DeluxeMenus plugin, String id, boolean invert) { + this.plugin = plugin; + this.id = id; + this.invert = invert; + } + + @Override + public boolean evaluate(MenuHolder holder) { + String check = holder.setPlaceholdersAndArguments(id); + boolean onCooldown = plugin.getEphemeralCooldownManager() + .isOnCooldown(holder.getViewer().getUniqueId(), check); + return invert != onCooldown; + } + +} diff --git a/src/main/java/com/extendedclip/deluxemenus/requirement/RequirementType.java b/src/main/java/com/extendedclip/deluxemenus/requirement/RequirementType.java index d36213f4..0e120873 100644 --- a/src/main/java/com/extendedclip/deluxemenus/requirement/RequirementType.java +++ b/src/main/java/com/extendedclip/deluxemenus/requirement/RequirementType.java @@ -91,7 +91,15 @@ public enum RequirementType { Arrays.asList("input", "min", "max")), IS_OBJECT(Arrays.asList("is object"), "Checks if the given string can be parsed as a given Java object.", - Arrays.asList("input", "object")); + Arrays.asList("input", "object")), + HAS_EPHEMERAL_COOLDOWN(Arrays.asList("has ephemeral cooldown", "hasephemeralcooldown", + "ephemeral cooldown", "ephemeralcooldown"), + "Checks if the player is currently on the given ephemeral cooldown.", + Arrays.asList("id")), + DOES_NOT_HAVE_EPHEMERAL_COOLDOWN(Arrays.asList("!has ephemeral cooldown", "!hasephemeralcooldown", + "!ephemeral cooldown", "!ephemeralcooldown", "does not have ephemeral cooldown"), + "Checks if the player is not currently on the given ephemeral cooldown.", + Arrays.asList("id")); private final List identifier; private final String description; diff --git a/src/main/resources/requirements_menu.yml b/src/main/resources/requirements_menu.yml index 621a5893..72143bb8 100644 --- a/src/main/resources/requirements_menu.yml +++ b/src/main/resources/requirements_menu.yml @@ -14,6 +14,11 @@ menu_title: 'Requirements Menu' open_command: requirementsmenu size: 9 # +# How often (in seconds) items with "update: true" have their placeholders re-parsed. +# Example 4 below uses this to show a live cooldown countdown in the item's lore. +# +update_interval: 1 +# # as always, only cool people can open this menu :) # open_requirement: @@ -237,5 +242,53 @@ items: # right_click_commands: - '[refresh]' - + # + # Example 4: Ephemeral cooldown + # + # Examples 2 and 3 use a temporary LuckPerms permission as a cooldown. If you don't need the + # cooldown to survive a server restart, DeluxeMenus can track it for you instead - no other + # plugin and no second item required. + # + # An ephemeral cooldown is kept in memory only. It survives /dm reload, but it is gone once the + # server stops. For anything longer lived, keep using temporary permissions or a cooldown plugin. + # + 'free_emerald': + material: EMERALD + slot: 2 + display_name: '&aFREE EMERALD!' + lore: + - '&7Click to get 1 free &aemerald&7!' + - '' + # %deluxemenus_ephemeral_cooldown_% is the whole seconds left, or 0 when it isn't running. + # There is also %deluxemenus_ephemeral_cooldown_formatted_%, which reads "1h 2m 3s" instead + # and is empty when the cooldown isn't running. + - '&7Cooldown: &f%deluxemenus_ephemeral_cooldown_free_emerald%&7s' + # + # "update: true" re-parses this item's placeholders every update_interval seconds, + # which is what makes the countdown in the lore above tick down. + # + update: true + click_requirement: + requirements: + not_on_cooldown: + # + # "!has ephemeral cooldown" passes only while the cooldown with this id is NOT running. + # The id is yours to pick and is shared across every menu, per player. + # + type: '!has ephemeral cooldown' + id: free_emerald + deny_commands: + - '[message] &cPlease wait &f%deluxemenus_ephemeral_cooldown_free_emerald%&c more seconds!' + # + # The requirement above only checks the cooldown, it never starts one. That is the + # [ephemeralcooldown] action's job, and it runs only after every requirement passed. + # + # The duration is in seconds and can be suffixed with s, m or h. It is capped by + # "max_ephemeral_cooldown_seconds" in config.yml. Passing 0 clears the cooldown. + # + click_commands: + - '[ephemeralcooldown] free_emerald 30' + - '[console] give %player_name% EMERALD 1' + - '[message] &aHere is your free emerald!' +