diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/ItemBuilder.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/ItemBuilder.java index 8d4564cc9..70264008e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/ItemBuilder.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/ItemBuilder.java @@ -43,6 +43,7 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; import com.bencodez.advancedcore.api.misc.MiscUtils; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.simpleapi.messages.MessageAPI; @@ -90,6 +91,7 @@ public class ItemBuilder { private String identifier; private HashMap placeholders = new HashMap<>(); + private HashMap displayPlaceholders; private String skull = ""; @@ -1720,10 +1722,16 @@ public ItemStack parsePlaceholders(OfflinePlayer player) { if (player == null) { return toItemStack(); } - setName(MessageAPI.colorize(PlaceholderUtils.replaceJavascript(player, - PlaceholderUtils.replacePlaceHolder(getName(), placeholders)))); - setLore(ArrayUtils.colorize(PlaceholderUtils.replaceJavascript(player, - PlaceholderUtils.replacePlaceHolder(getLore(), placeholders)))); + if (displayPlaceholders == null) { + setName(MessageAPI.colorize(PlaceholderUtils.replaceJavascript(player, + PlaceholderUtils.replacePlaceHolder(getName(), placeholders)))); + setLore(ArrayUtils.colorize(PlaceholderUtils.replaceJavascript(player, + PlaceholderUtils.replacePlaceHolder(getLore(), placeholders)))); + } else { + setName(MessageAPI.colorize(RewardDisplayPlaceholders.replaceFormattedJavascript(player, getName(), + displayPlaceholders))); + setLore(renderDisplayLore(player)); + } if (skull.contains("%")) { setSkullOwner(PlaceholderUtils.replaceJavascript(player, PlaceholderUtils.replacePlaceHolder(skull, placeholders))); @@ -2039,6 +2047,32 @@ public ItemBuilder setPlaceholders(HashMap placeholders) { return this; } + /** + * Sets placeholder values used only for player-facing item names and lore. + * Exact-value fields such as skull owners continue to use the ordinary + * placeholder map. + * + * @param displayPlaceholders the display placeholder map + * @return this ItemBuilder + */ + public ItemBuilder setDisplayPlaceholders(HashMap displayPlaceholders) { + this.displayPlaceholders = displayPlaceholders; + return this; + } + + private ArrayList renderDisplayLore(OfflinePlayer player) { + ArrayList rendered = new ArrayList<>(); + for (String line : getLore()) { + rendered.add(MessageAPI.colorize(RewardDisplayPlaceholders.replaceFormattedJavascript(player, line, + displayPlaceholders))); + } + return rendered; + } + + private HashMap getDisplayPlaceholders() { + return displayPlaceholders == null ? placeholders : displayPlaceholders; + } + /** * Sets the skull owner by offline player. * @@ -2120,10 +2154,16 @@ public ItemStack toItemStack() { return setConditional(new JavascriptEngine()).toItemStack(); } - setName(MessageAPI.colorize( - PlaceholderUtils.replaceJavascript(PlaceholderUtils.replacePlaceHolder(getName(), placeholders)))); - setLore(ArrayUtils.colorize( - PlaceholderUtils.replaceJavascript(PlaceholderUtils.replacePlaceHolder(getLore(), placeholders)))); + if (displayPlaceholders == null) { + setName(MessageAPI.colorize(PlaceholderUtils.replaceJavascript( + PlaceholderUtils.replacePlaceHolder(getName(), placeholders)))); + setLore(ArrayUtils.colorize(PlaceholderUtils.replaceJavascript( + PlaceholderUtils.replacePlaceHolder(getLore(), placeholders)))); + } else { + setName(MessageAPI.colorize(RewardDisplayPlaceholders.replaceFormattedJavascript(null, getName(), + displayPlaceholders))); + setLore(renderDisplayLore(null)); + } if (checkLoreLength) { checkLoreLength(); } @@ -2140,8 +2180,12 @@ public ItemStack toItemStack(OfflinePlayer player) { if (!placeholders.containsKey("player")) { placeholders.put("player", player.getName()); } + if (displayPlaceholders != null && !displayPlaceholders.containsKey("player")) { + displayPlaceholders.put("player", player.getName()); + } if (conditional) { - return getConditionItemBuilder(player).setPlaceholders(placeholders).toItemStack(player); + return getConditionItemBuilder(player).setPlaceholders(placeholders) + .setDisplayPlaceholders(displayPlaceholders).toItemStack(player); } parsePlaceholders(player); if (checkLoreLength) { @@ -2160,9 +2204,12 @@ public ItemStack toItemStack(Player player) { if (!placeholders.containsKey("player")) { placeholders.put("player", player.getName()); } + if (displayPlaceholders != null && !displayPlaceholders.containsKey("player")) { + displayPlaceholders.put("player", player.getName()); + } if (conditional) { return setConditional(new JavascriptEngine().addPlayer(player)).setPlaceholders(placeholders) - .toItemStack(player); + .setDisplayPlaceholders(displayPlaceholders).toItemStack(player); } parsePlaceholders(player); if (checkLoreLength) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java index 48bff8993..d83500f63 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java @@ -21,6 +21,7 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.function.Function; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -118,6 +119,26 @@ public void broadcast(String broadcastMsg, Collection players) } } + /** + * Broadcasts messages that have already been rendered for each player. The + * renderer must preserve exact interactive attributes because this method only + * parses the resulting message structure. + */ + public void broadcastPrepared(Function renderer, String consoleMessage) { + for (Player player : Bukkit.getOnlinePlayers()) { + String message = renderer.apply(player); + if (message == null || message.isEmpty()) continue; + for (String str1 : message.split(Pattern.quote("%newline%"))) { + for (String str : str1.split(Pattern.quote("%NewLine%"))) { + PlayerUtils.getServerHandle().sendMessage(player, PlaceholderUtils.parseJson(str)); + } + } + } + if (consoleMessage != null && !consoleMessage.isEmpty()) { + Bukkit.getServer().getConsoleSender().sendMessage(MessageAPI.colorize(consoleMessage)); + } + } + public boolean checkChance(double chance, double outOf) { if ((chance == 0) || (chance == outOf)) { return true; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java index 21a134e71..11583224f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java @@ -175,6 +175,15 @@ public RewardBuilder withPlaceHolder(String toReplace, String replaceWith) { return this; } + /** + * Adds a value used only by formatted reward output while preserving the raw + * placeholder for commands and other exact-value actions. + */ + public RewardBuilder withDisplayPlaceHolder(String toReplace, String replaceWith) { + RewardDisplayPlaceholders.put(this.rewardOptions.getPlaceholders(), toReplace, replaceWith); + return this; + } + public RewardBuilder withPrefix(String prefix) { this.rewardOptions.setPrefix(prefix); return this; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardDisplayPlaceholders.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardDisplayPlaceholders.java new file mode 100644 index 000000000..c5792e385 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardDisplayPlaceholders.java @@ -0,0 +1,437 @@ +package com.bencodez.advancedcore.api.rewards; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue; +import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.simpleapi.messages.MessageAPI; + +/** + * Stores optional display-only reward placeholder values alongside the raw + * values used by commands and other exact-value actions. + * + *

The reserved entries deliberately live in the ordinary placeholder map so + * delayed and offline reward serialization preserves them.

+ */ +public final class RewardDisplayPlaceholders { + private static final String PREFIX = "__AdvancedCoreDisplayPlaceholder__"; + private static final String MANIFEST_KEY = PREFIX + "manifest:"; + private static final String MANIFEST_VALUE = "v1"; + private static final String VALUE_PREFIX = PREFIX + "value:"; + private static final Pattern MESSAGE_ATTRIBUTE = Pattern.compile( + "(?i)(command|url|suggest_command|hover)\\s*=\\s*\"([^\"]*)\""); + + private RewardDisplayPlaceholders() { + } + + /** Adds a display-only value without replacing the raw action value. */ + public static void put(HashMap placeholders, String key, String value) { + if (placeholders == null || key == null) return; + placeholders.put(MANIFEST_KEY, MANIFEST_VALUE); + placeholders.keySet().removeIf(existing -> isEncodedOverrideFor(existing, key)); + placeholders.put(VALUE_PREFIX + encode(key), encode(value == null ? "" : value)); + } + + /** Returns whether the serialized placeholder map contains display overrides. */ + public static boolean hasDisplayValues(HashMap placeholders) { + return placeholders != null && MANIFEST_VALUE.equals(placeholders.get(MANIFEST_KEY)); + } + + /** + * Returns an isolated placeholder map for formatted output. Display-specific + * values replace their raw counterparts and internal storage keys are removed. + */ + public static HashMap forDisplay(HashMap placeholders) { + if (placeholders == null) return new HashMap<>(); + boolean hasDisplayValues = hasDisplayValues(placeholders); + if (!hasDisplayValues) return placeholders; + HashMap display = new HashMap<>(); + for (Map.Entry entry : placeholders.entrySet()) { + String key = entry.getKey(); + if (!hasDisplayValues || key == null + || (!key.equals(MANIFEST_KEY) && !key.startsWith(VALUE_PREFIX))) { + display.put(key, entry.getValue()); + } + } + if (hasDisplayValues) { + for (Map.Entry entry : displayOverrides(placeholders).entrySet()) { + display.keySet().removeIf(existing -> existing != null && existing.equalsIgnoreCase(entry.getKey())); + display.put(entry.getKey(), entry.getValue()); + } + } + return display; + } + + /** + * Renders a configured interactive message with display values in visible text + * and raw values in exact click-action attributes. + */ + public static String replaceFormattedMessage(String template, HashMap placeholders) { + return replaceFormattedMessage(null, template, placeholders); + } + + /** Player-aware form that completes visible-text rendering before JSON parsing. */ + public static String replaceFormattedMessage(Player player, String template, + HashMap placeholders) { + if (template == null || !hasDisplayValues(placeholders)) { + return PlaceholderUtils.replacePlaceHolder(template, placeholders); + } + return renderInteractive(template, value -> renderVisible(player, value, placeholders), + value -> renderExact(player, value, placeholders)); + } + + /** + * Applies configured JavaScript while keeping display placeholder values inert + * until evaluation is complete. The caller remains responsible for any + * sink-specific color processing. + */ + public static String replaceFormattedJavascript(OfflinePlayer player, String template, + HashMap placeholders) { + if (template == null || placeholders == null || placeholders.isEmpty()) return template; + VisibleValues values = visibleValues(placeholders); + HashMap textTokens = new HashMap<>(values.overrides().size()); + HashMap javascriptValues = new HashMap<>(values.overrides().size()); + HashMap tokenValues = new HashMap<>(values.overrides().size()); + int index = 0; + for (Map.Entry entry : values.overrides().entrySet()) { + String token; + do { + token = "\uE000AdvancedCoreDisplay" + index++ + "\uE001"; + } while (template.contains(token)); + String inert = neutralizeInteractiveSyntax(entry.getValue()); + textTokens.put(entry.getKey(), token); + javascriptValues.put(entry.getKey(), JavascriptPlaceholderValue.encode("\u2060" + inert + "\u2060")); + tokenValues.put(token, inert); + } + String protectedValue = JavascriptTextTemplate.parse(template).transform( + text -> PlaceholderUtils.replacePlaceHolder( + PlaceholderUtils.replacePlaceHolder(text, textTokens), values.ordinary()), + javascript -> PlaceholderUtils.replacePlaceHolder( + PlaceholderUtils.replacePlaceHolder(javascript, javascriptValues), values.ordinary())); + String rendered = player == null ? PlaceholderUtils.replaceJavascript(protectedValue) + : PlaceholderUtils.replaceJavascript(player, protectedValue); + for (Map.Entry entry : tokenValues.entrySet()) { + rendered = replaceInertToken(rendered, entry.getKey(), entry.getValue()); + } + return rendered; + } + + /** Renders the legacy two-stage broadcast path without enabling JavaScript. */ + public static String replaceFormattedBroadcast(Player triggeringPlayer, Player recipient, String template, + HashMap placeholders) { + if (template == null || !hasDisplayValues(placeholders)) { + return PlaceholderUtils.replacePlaceHolder(template, placeholders); + } + return renderInteractive(template, + value -> renderBroadcastVisible(triggeringPlayer, recipient, value, placeholders), + value -> renderBroadcastExact(triggeringPlayer, recipient, value, placeholders)); + } + + private static String renderInteractive(String template, Function visibleRenderer, + Function exactRenderer) { + StringBuilder rendered = new StringBuilder(template.length()); + int cursor = 0; + while (cursor < template.length()) { + int componentStart = indexOfIgnoreCase(template, "[Text=\"", cursor); + if (componentStart < 0) { + rendered.append(visibleRenderer.apply(template.substring(cursor))); + break; + } + rendered.append(visibleRenderer.apply(template.substring(cursor, componentStart))); + int componentEnd = template.indexOf("\"]", componentStart); + if (componentEnd < 0) { + rendered.append(visibleRenderer.apply(template.substring(componentStart))); + break; + } + componentEnd += 2; + rendered.append(renderInteractiveComponent(template.substring(componentStart, componentEnd), + visibleRenderer, exactRenderer)); + cursor = componentEnd; + } + return rendered.toString(); + } + + private static String renderInteractiveComponent(String component, Function visibleRenderer, + Function exactRenderer) { + int visibleStart = "[Text=\"".length(); + int attributesStart = component.indexOf("\","); + int visibleEnd = attributesStart >= 0 ? attributesStart : component.length() - "\"]".length(); + StringBuilder rendered = new StringBuilder(component.length()); + rendered.append(component, 0, visibleStart); + rendered.append(visibleRenderer.apply(component.substring(visibleStart, visibleEnd))); + if (attributesStart < 0) { + rendered.append(component.substring(visibleEnd)); + return rendered.toString(); + } + + rendered.append(component, attributesStart, attributesStart + 2); + Matcher matcher = MESSAGE_ATTRIBUTE.matcher(component); + matcher.region(attributesStart + 2, component.length()); + int cursor = attributesStart + 2; + while (matcher.find()) { + rendered.append(component, cursor, matcher.start(2)); + String type = matcher.group(1); + rendered.append(isExactAttribute(type) ? exactRenderer.apply(matcher.group(2)) + : visibleRenderer.apply(matcher.group(2))); + cursor = matcher.end(2); + } + rendered.append(component.substring(cursor)); + return rendered.toString(); + } + + private static boolean isExactAttribute(String type) { + return type.equalsIgnoreCase("command") || type.equalsIgnoreCase("url") + || type.equalsIgnoreCase("suggest_command"); + } + + private static int indexOfIgnoreCase(String text, String target, int fromIndex) { + for (int index = Math.max(0, fromIndex); index <= text.length() - target.length(); index++) { + if (text.regionMatches(true, index, target, 0, target.length())) return index; + } + return -1; + } + + private static String renderVisible(Player player, String value, HashMap placeholders) { + VisibleValues values = visibleValues(placeholders); + HashMap textTokens = new HashMap<>(values.overrides().size()); + HashMap javascriptValues = new HashMap<>(values.overrides().size()); + HashMap tokenValues = new HashMap<>(values.overrides().size()); + int index = 0; + for (Map.Entry entry : values.overrides().entrySet()) { + String token; + do { + token = "\uE000AdvancedCoreDisplay" + index++ + "\uE001"; + } while (value.contains(token)); + String inert = neutralizeInteractiveSyntax(entry.getValue()); + textTokens.put(entry.getKey(), token); + javascriptValues.put(entry.getKey(), JavascriptPlaceholderValue.encode("\u2060" + inert + "\u2060")); + tokenValues.put(token, inert); + } + String protectedValue = JavascriptTextTemplate.parse(value).transform( + text -> PlaceholderUtils.replacePlaceHolder( + PlaceholderUtils.replacePlaceHolder(text, textTokens), values.ordinary()), + javascript -> PlaceholderUtils.replacePlaceHolder( + PlaceholderUtils.replacePlaceHolder(javascript, javascriptValues), values.ordinary())); + String rendered = player == null ? PlaceholderUtils.parseText(protectedValue) + : PlaceholderUtils.parseText(player, protectedValue); + for (Map.Entry entry : tokenValues.entrySet()) { + rendered = replaceInertToken(rendered, entry.getKey(), entry.getValue()); + } + return rendered; + } + + private static String renderExact(Player player, String value, HashMap placeholders) { + VisibleValues values = exactValues(placeholders); + HashMap textTokens = new HashMap<>(values.overrides().size()); + HashMap javascriptValues = new HashMap<>(values.overrides().size()); + HashMap tokenValues = new HashMap<>(values.overrides().size()); + int index = 0; + for (Map.Entry entry : values.overrides().entrySet()) { + String token; + do { + token = "\uE000AdvancedCoreExact" + index++ + "\uE001"; + } while (value.contains(token)); + String raw = entry.getValue() == null ? "" : entry.getValue(); + textTokens.put(entry.getKey(), token); + javascriptValues.put(entry.getKey(), JavascriptPlaceholderValue.encode(raw)); + tokenValues.put(token, raw); + } + String protectedValue = JavascriptTextTemplate.parse(value).transform( + text -> PlaceholderUtils.replacePlaceHolder( + PlaceholderUtils.replacePlaceHolder(text, textTokens), values.ordinary()), + javascript -> PlaceholderUtils.replacePlaceHolder( + PlaceholderUtils.replacePlaceHolder(javascript, javascriptValues), values.ordinary())); + String rendered = player == null ? PlaceholderUtils.parseText(protectedValue) + : PlaceholderUtils.parseText(player, protectedValue); + for (Map.Entry entry : tokenValues.entrySet()) { + rendered = rendered.replace(entry.getKey(), entry.getValue()); + } + return rendered; + } + + private static String renderBroadcastVisible(Player triggeringPlayer, Player recipient, String value, + HashMap placeholders) { + VisibleValues values = visibleValues(placeholders); + HashMap tokens = new HashMap<>(values.overrides().size()); + HashMap tokenValues = new HashMap<>(values.overrides().size()); + int index = 0; + for (Map.Entry entry : values.overrides().entrySet()) { + String token; + do { + token = "\uE000AdvancedCoreDisplay" + index++ + "\uE001"; + } while (value.contains(token)); + tokens.put(entry.getKey(), token); + tokenValues.put(token, neutralizeInteractiveSyntax(entry.getValue())); + } + String rendered = PlaceholderUtils.replacePlaceHolder( + PlaceholderUtils.replacePlaceHolder(value, tokens), values.ordinary()); + if (triggeringPlayer != null) rendered = PlaceholderUtils.replacePlaceHolders(triggeringPlayer, rendered); + if (recipient != null) rendered = PlaceholderUtils.replacePlaceHolders(recipient, rendered); + rendered = MessageAPI.colorize(rendered); + for (Map.Entry entry : tokenValues.entrySet()) { + rendered = replaceInertToken(rendered, entry.getKey(), entry.getValue()); + } + return rendered; + } + + private static VisibleValues visibleValues(HashMap placeholders) { + if (!hasDisplayValues(placeholders)) { + return new VisibleValues(new HashMap<>(), new HashMap<>(placeholders)); + } + HashMap overrides = displayOverrides(placeholders); + HashMap ordinary = new HashMap<>(); + for (Map.Entry entry : placeholders.entrySet()) { + String key = entry.getKey(); + if (key == null || key.equals(MANIFEST_KEY) || key.startsWith(VALUE_PREFIX)) continue; + if (overrides.keySet().stream().noneMatch(override -> override.equalsIgnoreCase(key))) { + ordinary.put(key, entry.getValue()); + } + } + return new VisibleValues(overrides, ordinary); + } + + private static VisibleValues exactValues(HashMap placeholders) { + if (!hasDisplayValues(placeholders)) return new VisibleValues(new HashMap<>(), placeholders); + HashMap display = displayOverrides(placeholders); + HashMap exact = new HashMap<>(); + HashMap ordinary = new HashMap<>(); + for (Map.Entry entry : placeholders.entrySet()) { + String key = entry.getKey(); + if (key == null || key.equals(MANIFEST_KEY) || key.startsWith(VALUE_PREFIX)) continue; + String overrideKey = display.keySet().stream().filter(candidate -> candidate.equalsIgnoreCase(key)) + .findFirst().orElse(null); + if (overrideKey == null) ordinary.put(key, entry.getValue()); + else exact.put(overrideKey, entry.getValue()); + } + return new VisibleValues(exact, ordinary); + } + + private static HashMap displayOverrides(HashMap placeholders) { + HashMap overrides = new HashMap<>(); + if (!hasDisplayValues(placeholders)) return overrides; + for (Map.Entry entry : placeholders.entrySet()) { + String key = entry.getKey(); + if (key == null || !key.startsWith(VALUE_PREFIX)) continue; + try { + overrides.put(decode(key.substring(VALUE_PREFIX.length())), decode(entry.getValue())); + } catch (IllegalArgumentException ignored) { + // Malformed internal metadata is ignored rather than becoming output. + } + } + return overrides; + } + + private static boolean isEncodedOverrideFor(String encodedKey, String displayKey) { + if (encodedKey == null || !encodedKey.startsWith(VALUE_PREFIX)) return false; + try { + return decode(encodedKey.substring(VALUE_PREFIX.length())).equalsIgnoreCase(displayKey); + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private record VisibleValues(HashMap overrides, HashMap ordinary) { } + + private static String neutralizeInteractiveSyntax(String value) { + String input = value == null ? "" : value; + String boundary = "\u2060"; + StringBuilder safe = new StringBuilder(input.length()); + for (int index = 0; index < input.length(); index++) { + if (index + "[Text=\"".length() <= input.length() + && input.regionMatches(true, index, "[Text=\"", 0, "[Text=\"".length())) { + safe.append(input, index, index + "[Text=".length()).append(boundary).append('\"'); + index += "[Text=\"".length() - 1; + continue; + } + char current = input.charAt(index); + safe.append(current); + if (current == '\"' && index + 1 < input.length() + && (input.charAt(index + 1) == ']' || input.charAt(index + 1) == ',')) { + safe.append(boundary); + } + } + return safe.toString(); + } + + private static String replaceInertToken(String rendered, String token, String value) { + StringBuilder result = new StringBuilder(rendered.length() + value.length()); + int cursor = 0; + int tokenIndex; + while ((tokenIndex = rendered.indexOf(token, cursor)) >= 0) { + result.append(rendered, cursor, tokenIndex); + int afterToken = tokenIndex + token.length(); + int probeStart = Math.max(0, tokenIndex - "[Text=\"".length()); + int probeEnd = Math.min(rendered.length(), afterToken + "[Text=\"".length()); + String left = rendered.substring(probeStart, tokenIndex); + String probe = left + value + rendered.substring(afterToken, probeEnd); + if (crossesInteractiveSyntax(probe, left.length(), left.length() + value.length())) { + result.append('\u2060').append(value).append('\u2060'); + } else { + result.append(value); + } + cursor = afterToken; + } + result.append(rendered.substring(cursor)); + return result.toString(); + } + + private static boolean crossesInteractiveSyntax(String value, int insertedStart, int insertedEnd) { + return overlaps(value, "[Text=\"", insertedStart, insertedEnd, true) + || overlaps(value, "\"]", insertedStart, insertedEnd, false) + || overlaps(value, "\",", insertedStart, insertedEnd, false); + } + + private static boolean overlaps(String value, String pattern, int insertedStart, int insertedEnd, + boolean ignoreCase) { + for (int index = 0; index <= value.length() - pattern.length(); index++) { + boolean matches = ignoreCase ? value.regionMatches(true, index, pattern, 0, pattern.length()) + : value.startsWith(pattern, index); + if (matches && index < insertedEnd && index + pattern.length() > insertedStart) return true; + } + return false; + } + + private static String renderBroadcastExact(Player triggeringPlayer, Player recipient, String value, + HashMap placeholders) { + VisibleValues values = exactValues(placeholders); + HashMap tokens = new HashMap<>(values.overrides().size()); + HashMap tokenValues = new HashMap<>(values.overrides().size()); + int index = 0; + for (Map.Entry entry : values.overrides().entrySet()) { + String token; + do { + token = "\uE000AdvancedCoreExact" + index++ + "\uE001"; + } while (value.contains(token)); + tokens.put(entry.getKey(), token); + tokenValues.put(token, entry.getValue() == null ? "" : entry.getValue()); + } + String rendered = PlaceholderUtils.replacePlaceHolder( + PlaceholderUtils.replacePlaceHolder(value, tokens), values.ordinary()); + if (triggeringPlayer != null) rendered = PlaceholderUtils.replacePlaceHolders(triggeringPlayer, rendered); + if (recipient != null) rendered = PlaceholderUtils.replacePlaceHolders(recipient, rendered); + rendered = MessageAPI.colorize(rendered); + for (Map.Entry entry : tokenValues.entrySet()) { + rendered = rendered.replace(entry.getKey(), entry.getValue()); + } + return rendered; + } + + private static String encode(String value) { + return "b" + Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String decode(String value) { + if (value == null || !value.startsWith("b")) throw new IllegalArgumentException("Invalid display placeholder"); + return new String(Base64.getUrlDecoder().decode(value.substring(1)), StandardCharsets.UTF_8); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java index 4b6ff2ae9..c174557e8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java @@ -108,10 +108,16 @@ public class RewardOptions { public RewardOptions() { } - public RewardOptions addPlaceholder(String arg1, String arg2) { - getPlaceholders().put(arg1, arg2); - return this; - } + public RewardOptions addPlaceholder(String arg1, String arg2) { + getPlaceholders().put(arg1, arg2); + return this; + } + + /** Adds a formatted-output value while retaining the ordinary exact-value placeholder. */ + public RewardOptions addDisplayPlaceholder(String key, String value) { + RewardDisplayPlaceholders.put(getPlaceholders(), key, value); + return this; + } public RewardOptions disableDefaultWorlds() { useDefaultWorlds = false; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java index 763f34d2c..d196d03c3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java @@ -12,6 +12,7 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.editbuttons.RewardEditActionBar; @@ -30,8 +31,14 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { - user.sendActionBar(PlaceholderUtils.replacePlaceHolder(section.getString("Message", ""), placeholders), - section.getInt("Delay", 30)); + if (RewardDisplayPlaceholders.hasDisplayValues(placeholders)) { + user.sendPreparedActionBar(RewardDisplayPlaceholders.replaceFormattedJavascript(user.getPlayer(), + section.getString("Message", ""), placeholders), + section.getInt("Delay", 30)); + } else { + user.sendActionBar(PlaceholderUtils.replacePlaceHolder(section.getString("Message", ""), placeholders), + section.getInt("Delay", 30)); + } return null; } }.requiresPlayerWhen((data, placeholders) -> !data.getString("ActionBar.Message", "").isEmpty()) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java index 92ae4017f..65e774fb0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java @@ -11,6 +11,7 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.editbuttons.RewardEditBossBar; @@ -28,9 +29,16 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { if (section.getBoolean("Enabled")) { - user.sendBossBar(PlaceholderUtils.replacePlaceHolder(section.getString("Message", ""), placeholders), - section.getString("Color", "BLUE"), section.getString("Style", "SOLID"), - section.getDouble("Progress", .5), section.getInt("Delay", 30)); + if (RewardDisplayPlaceholders.hasDisplayValues(placeholders)) { + user.sendPreparedBossBar(RewardDisplayPlaceholders.replaceFormattedJavascript(user.getPlayer(), + section.getString("Message", ""), placeholders), + section.getString("Color", "BLUE"), section.getString("Style", "SOLID"), + section.getDouble("Progress", .5), section.getInt("Delay", 30)); + } else { + user.sendBossBar(PlaceholderUtils.replacePlaceHolder(section.getString("Message", ""), placeholders), + section.getString("Color", "BLUE"), section.getString("Style", "SOLID"), + section.getDouble("Progress", .5), section.getInt("Delay", 30)); + } } return null; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java index eeda55541..4d099fef6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java @@ -15,6 +15,7 @@ import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueInventory; import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.editbuttons.RewardEditItems; @@ -49,7 +50,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat replayItem(user, placeholders, "payload", () -> { ItemBuilder builder = new ItemBuilder(section); builder.setCheckLoreLength(false); - return builder; + return applyDisplayPlaceholders(builder, placeholders); }); return null; } @@ -71,7 +72,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set { ItemBuilder builder = new ItemBuilder(data.getConfigurationSection(item)); builder.setCheckLoreLength(false); - return builder.setPlaceholders(placeholders); + return applyDisplayPlaceholders(builder, placeholders); }); debug("Giving item " + item + ":" + selected.item()); if (selected.chancePassed() && oneChance) { @@ -162,6 +163,12 @@ private static String serializeItem(ItemStack item, boolean chancePassed) { return "v1:" + frozen.saveToString(); } + private static ItemBuilder applyDisplayPlaceholders(ItemBuilder builder, HashMap placeholders) { + if (!RewardDisplayPlaceholders.hasDisplayValues(placeholders)) return builder; + return builder.setPlaceholders(placeholders) + .setDisplayPlaceholders(placeholders); + } + private record ReplayedItem(ItemStack item, boolean chancePassed) { } private static RewardInjectValidator itemValidator() { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java index cd4fb85b1..dfc236bd6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java @@ -14,6 +14,7 @@ import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.misc.MiscUtils; import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.editbuttons.RewardEditMessages; @@ -33,7 +34,7 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value, HashMap placeholders) { - user.sendMessage(value, placeholders); + sendPlayerMessage(user, value, placeholders); return null; } }.requiresPlayerWhen((data, placeholders) -> !data.getString("Message", "").isEmpty()) @@ -56,7 +57,7 @@ public void setVal(String key, Object value) { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { - user.sendMessage(value, placeholders); + sendPlayerMessages(user, value, placeholders); return null; } }.requiresPlayerWhen((data, placeholders) -> !data.getStringList("Messages.Player").isEmpty())); @@ -65,7 +66,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { - user.sendMessage(value, placeholders); + sendPlayerMessages(user, value, placeholders); return null; } }.requiresPlayerWhen((data, placeholders) -> !data.getStringList("Message").isEmpty())); @@ -74,7 +75,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { - user.sendMessage(value.get(ThreadLocalRandom.current().nextInt(0, value.size())), placeholders); + sendPlayerMessage(user, value.get(ThreadLocalRandom.current().nextInt(0, value.size())), placeholders); return null; } }.requiresPlayerWhen((data, placeholders) -> !data.getStringList("RandomMessage").isEmpty())); @@ -83,7 +84,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList placeholders) { - user.sendMessage(value, placeholders); + sendPlayerMessage(user, value, placeholders); return null; } }.requiresPlayerWhen((data, placeholders) -> !data.getString("Messages.Player", "").isEmpty()) @@ -105,8 +106,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList placeholders) { + if (RewardDisplayPlaceholders.hasDisplayValues(placeholders)) { + user.sendPreparedMessage( + RewardDisplayPlaceholders.replaceFormattedMessage(user.getPlayer(), message, placeholders)); + } else { + user.sendMessage(message, placeholders); + } + } + + private static void sendPlayerMessages(AdvancedCoreUser user, ArrayList messages, + HashMap placeholders) { + if (RewardDisplayPlaceholders.hasDisplayValues(placeholders)) { + for (String message : messages) sendPlayerMessage(user, message, placeholders); + } else { + user.sendMessage(messages, placeholders); + } + } + + private static void broadcast(AdvancedCoreUser user, String message, HashMap placeholders) { + MiscUtils misc = MiscUtils.getInstance(); + if (RewardDisplayPlaceholders.hasDisplayValues(placeholders)) { + misc.broadcastPrepared( + player -> RewardDisplayPlaceholders.replaceFormattedBroadcast( + user.getPlayer(), player, message, placeholders), + RewardDisplayPlaceholders.replaceFormattedBroadcast( + user.getPlayer(), null, message, placeholders)); + } else { + misc.broadcast(PlaceholderUtils.replacePlaceHolders(user.getPlayer(), + PlaceholderUtils.replacePlaceHolder(message, placeholders))); + } + } + private static RewardInjectValidator playerMessageValidator() { return new RewardInjectValidator() { @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java index 8ac292c56..8dc72726a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java @@ -12,6 +12,7 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.editbuttons.RewardEditTitle; @@ -29,9 +30,17 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { if (section.getBoolean("Enabled")) { - user.sendTitle(PlaceholderUtils.replacePlaceHolder(section.getString("Title"), placeholders), - PlaceholderUtils.replacePlaceHolder(section.getString("SubTitle"), placeholders), - section.getInt("FadeIn", 10), section.getInt("ShowTime", 50), section.getInt("FadeOut", 10)); + if (RewardDisplayPlaceholders.hasDisplayValues(placeholders)) { + user.sendPreparedTitle(RewardDisplayPlaceholders.replaceFormattedJavascript(user.getPlayer(), + section.getString("Title"), placeholders), + RewardDisplayPlaceholders.replaceFormattedJavascript(user.getPlayer(), + section.getString("SubTitle"), placeholders), + section.getInt("FadeIn", 10), section.getInt("ShowTime", 50), section.getInt("FadeOut", 10)); + } else { + user.sendTitle(PlaceholderUtils.replacePlaceHolder(section.getString("Title"), placeholders), + PlaceholderUtils.replacePlaceHolder(section.getString("SubTitle"), placeholders), + section.getInt("FadeIn", 10), section.getInt("ShowTime", 50), section.getInt("FadeOut", 10)); + } } return null; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 4116fd1ca..40a8f6a43 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java @@ -2688,6 +2688,19 @@ public void sendActionBar(String msg, int delay) { } } + /** Sends an action bar whose configured formatting is already rendered. */ + public void sendPreparedActionBar(String msg, int delay) { + if (msg == null || msg.isEmpty()) return; + Player player = getPlayer(); + if (player == null) return; + try { + new ActionBar(msg, delay).send(player); + } catch (Exception ex) { + plugin.debug("Failed to send ActionBar, turn debug on to see stack trace"); + plugin.debug(ex); + } + } + /** * Send boss bar. * @@ -2713,6 +2726,20 @@ public void sendBossBar(String msg, String color, String style, double progress, } } + /** Sends a boss bar whose configured formatting is already rendered. */ + public void sendPreparedBossBar(String msg, String color, String style, double progress, int delay) { + if (msg == null || msg.isEmpty()) return; + Player player = getPlayer(); + if (player == null) return; + try { + BossBar bossBar = new BossBar(msg, color, style, progress); + bossBar.send(player, delay); + } catch (Exception ex) { + plugin.debug("Failed to send BossBar"); + plugin.debug(ex); + } + } + /** * Send json. * @@ -2799,6 +2826,20 @@ public void sendMessage(String msg) { } } + /** + * Sends a message whose visible text and exact interactive attributes have + * already been rendered. This method performs JSON parsing only. + * + * @param msg prepared message + */ + public void sendPreparedMessage(String msg) { + Player player = getPlayer(); + if (player == null || msg == null || msg.isEmpty()) return; + for (String str : msg.split("%NewLine%")) { + PlayerUtils.getServerHandle().sendMessage(player, PlaceholderUtils.parseJson(str)); + } + } + /** * Sends a message with placeholders to the player. * @@ -2872,6 +2913,18 @@ public void sendTitle(String title, String subTitle, int fadeIn, int showTime, i } } + /** Sends a title whose configured formatting is already rendered. */ + public void sendPreparedTitle(String title, String subTitle, int fadeIn, int showTime, int fadeOut) { + Player player = getPlayer(); + if (player == null) return; + try { + new Title(title, subTitle, fadeIn, showTime, fadeOut).send(player); + } catch (Exception ex) { + plugin.getLogger().info("Failed to send Title, turn debug on to see stack trace"); + plugin.debug(ex); + } + } + /** * Sets whether to check the world for the user. * diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 6e46f2dd4..0d56ed83b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -9,7 +9,9 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; @@ -50,6 +52,7 @@ import com.bencodez.advancedcore.api.misc.effects.FireworkHandler; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardBuilder; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.rewards.builtin.RewardActionBar; @@ -227,6 +230,20 @@ public void actionBarActuallySendsConfiguredMessage() { verify(user).sendActionBar("Hello Ben", 42); } + @Test + public void actionBarDisplayOverrideUsesPreparedSink() { + RewardActionBar.register(handler, plugin); + placeholders.put("player", "raw"); + RewardDisplayPlaceholders.put(placeholders, "player", "display"); + ConfigurationSection section = section("ActionBar"); + section.set("Message", "Hello %player%"); + section.set("Delay", 42); + + configInject(0).onRewardRequested(reward, user, section, placeholders); + + verify(user).sendPreparedActionBar("Hello display", 42); + } + @Test public void emptyActionBarDoesNotRequireAnOnlinePlayer() { RewardActionBar.register(handler, plugin); @@ -264,6 +281,20 @@ public void bossBarActuallySendsConfiguredBossBar() { verify(user).sendBossBar("Boss", "RED", "SEGMENTED_10", 0.75, 25); } + @Test + public void bossBarDisplayOverrideUsesPreparedSink() { + RewardBossBar.register(handler, plugin); + placeholders.put("player", "raw"); + RewardDisplayPlaceholders.put(placeholders, "player", "display"); + ConfigurationSection section = section("BossBar"); + section.set("Enabled", true); + section.set("Message", "Boss %player%"); + + configInject(0).onRewardRequested(reward, user, section, placeholders); + + verify(user).sendPreparedBossBar("Boss display", "BLUE", "SOLID", 0.5, 30); + } + @Test public void titleActuallySendsConfiguredTitle() { RewardTitle.register(handler, plugin); @@ -281,6 +312,21 @@ public void titleActuallySendsConfiguredTitle() { verify(user).sendTitle("Hi Ben", "Welcome", 2, 30, 4); } + @Test + public void titleDisplayOverrideUsesPreparedSink() { + RewardTitle.register(handler, plugin); + placeholders.put("player", "raw"); + RewardDisplayPlaceholders.put(placeholders, "player", "display"); + ConfigurationSection section = section("Title"); + section.set("Enabled", true); + section.set("Title", "Hi %player%"); + section.set("SubTitle", "Welcome"); + + configInject(0).onRewardRequested(reward, user, section, placeholders); + + verify(user).sendPreparedTitle("Hi display", "Welcome", 10, 50, 10); + } + @Test public void soundActuallyPlaysConfiguredSound() { RewardSound.register(handler, plugin); @@ -544,8 +590,13 @@ public void itemRewardsActuallyGiveBuiltItems() { .thenReturn(frozenStack))) { bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); bukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); + placeholders.put("ServiceSite", "be_secret%20"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", "\u2060be_secret%20\u2060"); RewardItems.registerItem(handler, plugin); configInject(0).onRewardRequested(reward, user, section("Item"), placeholders); + ItemBuilder singleItemBuilder = builders.constructed().get(builders.constructed().size() - 1); + verify(singleItemBuilder).setPlaceholders(same(placeholders)); + verify(singleItemBuilder).setDisplayPlaceholders(same(placeholders)); int buildersAfterItem = builders.constructed().size(); configInject(0).onRewardRequested(reward, user, section("Item"), placeholders); assertEquals(buildersAfterItem, builders.constructed().size()); @@ -558,6 +609,9 @@ public void itemRewardsActuallyGiveBuiltItems() { String selected = ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, random.getKeys(false), random, placeholders); assertEquals("OnlyItem", selected); + ItemBuilder randomItemBuilder = builders.constructed().get(builders.constructed().size() - 1); + verify(randomItemBuilder).setPlaceholders(same(placeholders)); + verify(randomItemBuilder).setDisplayPlaceholders(same(placeholders)); int buildersBeforeReplay = builders.constructed().size(); random.set("OnlyItem", null); random.createSection("OtherItem"); @@ -571,10 +625,22 @@ public void itemRewardsActuallyGiveBuiltItems() { ConfigurationSection items = section("Items"); items.createSection("FirstItem"); ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, items.getKeys(false), items, placeholders); + ItemBuilder itemBuilder = builders.constructed().get(builders.constructed().size() - 1); + verify(itemBuilder).setPlaceholders(same(placeholders)); + verify(itemBuilder).setDisplayPlaceholders(same(placeholders)); int buildersAfterItems = builders.constructed().size(); ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, items.getKeys(false), items, placeholders); assertEquals(buildersAfterItems, builders.constructed().size()); verify(user, atLeastOnce()).giveItem(any(ItemStack.class)); + + placeholders.clear(); + ConfigurationSection ordinaryItems = section("Items"); + ordinaryItems.createSection("OrdinaryItem"); + ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, + ordinaryItems.getKeys(false), ordinaryItems, placeholders); + ItemBuilder ordinaryItemBuilder = builders.constructed().get(builders.constructed().size() - 1); + verify(ordinaryItemBuilder, never()).setPlaceholders(any()); + verify(ordinaryItemBuilder, never()).setDisplayPlaceholders(any()); assertTrue(builders.constructed().size() >= 3); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index 75ccd27bb..45e3e4b06 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java @@ -322,6 +322,7 @@ void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() try (MockedConstruction builders = mockConstruction(ItemBuilder.class, (builder, context) -> { when(builder.setPlaceholders(any(HashMap.class))).thenReturn(builder); + when(builder.setDisplayPlaceholders(any(HashMap.class))).thenReturn(builder); when(builder.toItemStack(any(Player.class))).thenAnswer(ignored -> new ItemStack(itemType.get())); }); org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardDisplayPlaceholdersTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardDisplayPlaceholdersTest.java new file mode 100644 index 000000000..1ac282c8b --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardDisplayPlaceholdersTest.java @@ -0,0 +1,258 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.util.HashMap; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.builtin.RewardMessages; +import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.array.ArrayUtils; + +public class RewardDisplayPlaceholdersTest { + @TempDir + File tempDir; + + @Test + public void displayValueDoesNotReplaceExactActionValue() { + RewardBuilder builder = new RewardBuilder((org.bukkit.configuration.ConfigurationSection) null, "Reward") + .withPlaceHolder("ServiceSite", "be_secret%20") + .withDisplayPlaceHolder("ServiceSite", "\u2060be_secret%20\u2060"); + + HashMap stored = builder.getRewardOptions().getPlaceholders(); + assertEquals("be_secret%20", stored.get("ServiceSite")); + assertEquals("\u2060be_secret%20\u2060", RewardDisplayPlaceholders.forDisplay(stored).get("ServiceSite")); + } + + @Test + public void displayValueSurvivesOfflinePlaceholderSerialization() { + HashMap stored = new HashMap<>(); + stored.put("ServiceSite", "be_secret%20"); + RewardDisplayPlaceholders.put(stored, "ServiceSite", "\u2060value%entry%with%pair%delimiters\u2060"); + + HashMap restored = ArrayUtils.fromString(ArrayUtils.makeString(stored)); + HashMap display = RewardDisplayPlaceholders.forDisplay(restored); + + assertEquals("be_secret%20", restored.get("ServiceSite")); + assertEquals("\u2060value%entry%with%pair%delimiters\u2060", display.get("ServiceSite")); + assertFalse(display.keySet().stream().anyMatch(key -> key.startsWith("__AdvancedCoreDisplayPlaceholder__"))); + } + + @Test + public void ordinaryReservedPrefixAndNullKeysRemainVisibleWithoutManifest() { + HashMap placeholders = new HashMap<>(); + placeholders.put("__AdvancedCoreDisplayPlaceholder__legacy", "kept"); + placeholders.put(null, "null-key"); + + HashMap display = RewardDisplayPlaceholders.forDisplay(placeholders); + + assertSame(placeholders, display); + assertEquals("kept", display.get("__AdvancedCoreDisplayPlaceholder__legacy")); + assertEquals("null-key", display.get(null)); + } + + @Test + public void displayOverrideReplacesRawKeyCaseInsensitively() { + HashMap stored = new HashMap<>(); + stored.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(stored, "servicesite", "display"); + + HashMap display = RewardDisplayPlaceholders.forDisplay(stored); + + assertEquals(1, display.size()); + assertEquals("display", display.get("servicesite")); + assertFalse(display.containsKey("ServiceSite")); + } + + @Test + public void latestCaseVariantDisplayOverrideWins() { + HashMap stored = new HashMap<>(); + stored.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(stored, "ServiceSite", "first"); + RewardDisplayPlaceholders.put(stored, "servicesite", "second"); + + HashMap display = RewardDisplayPlaceholders.forDisplay(stored); + + assertEquals(1, display.size()); + assertEquals("second", display.get("servicesite")); + } + + @Test + public void emptyDisplayValueSurvivesOfflinePlaceholderSerialization() { + HashMap stored = new HashMap<>(); + stored.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(stored, "ServiceSite", null); + + HashMap restored = ArrayUtils.fromString(ArrayUtils.makeString(stored)); + + assertEquals("", RewardDisplayPlaceholders.forDisplay(restored).get("ServiceSite")); + } + + @Test + public void javascriptSinkEvaluatesBeforeRestoringDisplayValue() { + HashMap display = new HashMap<>(); + display.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(display, "ServiceSite", "[Javascript=danger()]"); + + try (MockedStatic placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())) + .thenAnswer(invocation -> { + String protectedText = invocation.getArgument(0); + assertFalse(protectedText.contains("[Javascript=danger()]")); + return protectedText; + }); + + assertEquals("[Javascript=danger()]", RewardDisplayPlaceholders.replaceFormattedJavascript(null, + "%ServiceSite%", display)); + } + } + + @Test + public void unrelatedOrdinaryPlaceholderKeepsLegacyEvaluationOrder() { + HashMap placeholders = new HashMap<>(); + placeholders.put("ServiceSite", "raw"); + placeholders.put("rank", "%vault_rank%"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", "[Javascript=danger()]"); + + try (MockedStatic placeholderUtils = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholderUtils.when(() -> PlaceholderUtils.replaceJavascript(anyString())) + .thenAnswer(invocation -> { + String protectedText = invocation.getArgument(0); + assertTrue(protectedText.contains("%vault_rank%")); + assertFalse(protectedText.contains("[Javascript=danger()]")); + return protectedText.replace("%vault_rank%", "Admin"); + }); + + assertEquals("Admin [Javascript=danger()]", RewardDisplayPlaceholders.replaceFormattedJavascript(null, + "%rank% %ServiceSite%", placeholders)); + } + } + + @Test + public void broadcastRenderingDoesNotEnableJavascript() { + HashMap placeholders = new HashMap<>(); + placeholders.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", "display"); + + String rendered = RewardDisplayPlaceholders.replaceFormattedBroadcast(null, null, + "[Javascript='%ServiceSite%']", placeholders); + + assertTrue(rendered.startsWith("[Javascript=")); + } + + @Test + public void actionLikeVisibleTextStillUsesDisplayValue() { + HashMap placeholders = new HashMap<>(); + placeholders.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", "display"); + + assertEquals("Use command=\"display\"", + RewardDisplayPlaceholders.replaceFormattedMessage("Use command=\"%ServiceSite%\"", placeholders)); + } + + @Test + public void configuredInteractiveComponentKeepsHarmlessDisplayTextUnchanged() { + HashMap placeholders = new HashMap<>(); + placeholders.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", "display"); + + assertEquals("[Text=\"display\",command=\"/trusted\"]", + RewardDisplayPlaceholders.replaceFormattedMessage( + "[Text=\"%ServiceSite%\",command=\"/trusted\"]", placeholders)); + } + + @Test + public void exactAttributeKeepsOrdinaryPlaceholderEvaluationOrder() { + HashMap placeholders = new HashMap<>(); + placeholders.put("ServiceSite", "raw"); + placeholders.put("rank", "%vault_rank%"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", "display"); + + try (MockedStatic placeholderUtils = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholderUtils.when(() -> PlaceholderUtils.parseText(anyString())) + .thenAnswer(invocation -> ((String) invocation.getArgument(0)).replace("%vault_rank%", "Admin")); + + assertEquals("[Text=\"display\",command=\"/rank Admin/raw\"]", + RewardDisplayPlaceholders.replaceFormattedMessage( + "[Text=\"%ServiceSite%\",command=\"/rank %rank%/%ServiceSite%\"]", placeholders)); + } + } + + @Test + public void displayValueCannotCreateInteractiveComponent() { + HashMap placeholders = new HashMap<>(); + placeholders.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", + "[Text=\"Click\",command=\"/untrusted\"]"); + + String rendered = RewardDisplayPlaceholders.replaceFormattedMessage("Site: %ServiceSite%", placeholders); + + assertFalse(rendered.contains("[Text=\"")); + assertFalse(rendered.contains("\",command=\"/untrusted")); + assertTrue(rendered.replace("\u2060", "").contains("[Text=\"Click\",command=\"/untrusted\"]")); + } + + @Test + public void displayValueCannotTerminateConfiguredVisibleText() { + HashMap placeholders = new HashMap<>(); + placeholders.put("ServiceSite", "raw"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", "shown\"],command=\"/untrusted"); + + String rendered = RewardDisplayPlaceholders.replaceFormattedMessage( + "[Text=\"%ServiceSite%\",command=\"/trusted\"]", placeholders); + + assertFalse(rendered.contains("shown\"],command=\"/untrusted")); + assertTrue(rendered.endsWith(",command=\"/trusted\"]")); + } + + @Test + public void playerMessageSinkUsesDisplayValue() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(tempDir); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + RewardHandler handler = new RewardHandler(plugin); + try { + RewardMessages.register(handler, plugin); + RewardInjectString message = handler.getInjectedRewards().stream() + .filter(RewardInjectString.class::isInstance) + .map(RewardInjectString.class::cast) + .filter(inject -> inject.getPath().equals("Message")) + .findFirst().orElseThrow(); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + HashMap placeholders = new HashMap<>(); + placeholders.put("ServiceSite", "be_secret&amount=20"); + RewardDisplayPlaceholders.put(placeholders, "ServiceSite", "\u2060be_secret%20\u2060"); + + message.onRewardRequest(mock(Reward.class), user, + "[Text=\"Vote at %ServiceSite%\",command=\"&a/vote %ServiceSite%\",url=\"https://x/?site=%ServiceSite%\"]", + placeholders); + + verify(user).sendPreparedMessage( + "[Text=\"Vote at \u2060be_secret%20\u2060\",command=\"\u00a7a/vote be_secret&amount=20\",url=\"https://x/?site=be_secret&amount=20\"]"); + assertEquals("be_secret&amount=20", placeholders.get("ServiceSite")); + } finally { + handler.getDelayedTimer().shutdownNow(); + } + } +}