diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/LiteralMessageTemplate.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/LiteralMessageTemplate.java new file mode 100644 index 0000000000..6bfffcc01d --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/LiteralMessageTemplate.java @@ -0,0 +1,1350 @@ +package com.bencodez.advancedcore.api.messages; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.IdentityHashMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.Matcher; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Keeps literal placeholder values out of the formatting and component parsers. + * Values are inserted only after the trusted template has been parsed. + */ +public final class LiteralMessageTemplate { + private static final int MAX_TEMPLATE = 65536; + private static final int MAX_VALUES = 4096; + private static final int MAX_BINDINGS = 8192; + private static final int MAX_BINDING_CHARACTERS = 1048576; + private static final int MAX_LITERAL_KEY = 128; + private static final int MAX_ACTION_KEY = 1024; + private static final Pattern LITERAL_KEY = Pattern.compile("[A-Za-z_][A-Za-z0-9_]{0,127}"); + private static final int MAX_VALUE = 8192; + private static final int MAX_COMPONENTS = 4096; + private static final int MAX_RESULT = 262144; + private static final String TOKEN_PREFIX = "VCORELITERAL"; + // Bounds retained collision metadata even if callers repeatedly format an existing Prepared. + private static final int MAX_FORBIDDEN_TOKENS = 131072; + private static final Pattern EXACT_ATTRIBUTE = Pattern.compile( + "(?i)(? literalValues, Map actionValues) { + return prepare(template, literalValues, actionValues, null); + } + + /** Internal reward consumer: include legacy data in admission and collision checks before allocating any token. */ + public static Prepared prepare(String template, Map literalValues, Map actionValues, + LegacyValues legacyValues) { + return prepare(template, literalValues, actionValues, legacyValues, false); + } + + /** Reward consumer: admit placeholders introduced by trusted visible templates, expanding them once. */ + public static Prepared prepareRewardDisplay(String template, Map literalValues, + Map actionValues, LegacyValues legacyValues) { + return prepare(template, literalValues, actionValues, legacyValues, true); + } + + private static Prepared prepare(String template, Map literalValues, Map actionValues, + LegacyValues legacyValues, boolean ordinaryVisibleExpanded) { + String source = template == null ? "" : template; + if (source.length() > MAX_TEMPLATE) { + throw new IllegalArgumentException("template is too large"); + } + int bindingCharacters = validateBindings(literalValues, actionValues); + if (legacyValues != null && legacyValues.characters > MAX_BINDING_CHARACTERS - bindingCharacters) + throw new IllegalArgumentException("literal bindings exceed character limits"); + // Expand trusted visible templates before discovering their literal/display + // references. Exact attributes and authored script interiors retain their + // own later expansion; ordinary values are not evaluated twice. + if (ordinaryVisibleExpanded && legacyValues != null) source = legacyValues.expandVisibleOrdinary(source); + TokenIndex tokenIndex = new TokenIndex(Set.of()); + tokenIndex.addCorpus(source); + for (Map bindings : java.util.Arrays.asList(literalValues, actionValues)) { + if (bindings != null) bindings.forEach((key, value) -> { + tokenIndex.addCorpus(key); + tokenIndex.addCorpus(value); + }); + } + if (legacyValues != null) legacyValues.addCorpus(tokenIndex); + Set referenced = literalMarkerKeys(source); + Map values = new LinkedHashMap<>(); + Set keys = new HashSet<>(); + if (literalValues != null) { + for (Map.Entry entry : literalValues.entrySet()) { + String key = Objects.requireNonNull(entry.getKey(), "literal key"); + if (!keys.add(normalizeBindingKey(key))) throw new IllegalArgumentException("duplicate literal key"); + // A summary may have many sites but only a few occur in this line. + // Unreferenced bindings never enter a parser or the restoration map. + if (!referenced.contains(normalizeBindingKey(key))) continue; + if (values.size() >= MAX_VALUES) throw new IllegalArgumentException("too many referenced literal values"); + if (values.putIfAbsent(normalizeBindingKey(key), entry.getValue()) != null) { + throw new IllegalArgumentException("duplicate literal key"); + } + } + } + Map tokens = new LinkedHashMap<>(); + Map replacements = new LinkedHashMap<>(); + Map actionTokens = new LinkedHashMap<>(); + Map exact = new LinkedHashMap<>(); + if (actionValues != null) for (Map.Entry entry : actionValues.entrySet()) { + String key = normalizeBindingKey(Objects.requireNonNull(entry.getKey(), "action key")); + if (key.length() > MAX_ACTION_KEY) throw new IllegalArgumentException("invalid action key"); + if (exact.containsKey(key)) throw new IllegalArgumentException("duplicate action key"); + exact.put(key, entry.getValue()); + } + for (Map.Entry entry : values.entrySet()) { + String token = newToken(tokenIndex); + replacements.put(entry.getKey(), token); + tokens.put(token, entry.getValue()); + String action = exact.getOrDefault(entry.getKey(), ""); + if (action == null || action.length() > MAX_VALUE) throw new IllegalArgumentException("invalid action value"); + actionTokens.put(token, action); + } + + // Match the existing JavaScript parser's authored marker/first-closing-bracket + // grammar. A script containing literal display data is displayed as text; + // it is never evaluated with externally supplied program fragments. + MarkerMatcher literalMarkers = new MarkerMatcher(values); + MarkerMatcher replacementMarkers = new MarkerMatcher(replacements); + Map scriptActions = new LinkedHashMap<>(); + for (String key : values.keySet()) scriptActions.put(key, exact.getOrDefault(key, "")); + MarkerMatcher scriptActionMarkers = new MarkerMatcher(scriptActions); + StringBuilder protectedScripts = new StringBuilder(source.length()); + int cursor = 0; + int scriptTextCharacters = 0; + int scriptActionCharacters = 0; + while (cursor < source.length()) { + int start = indexOfIgnoreCase(source, "[Javascript=", cursor); + if (start < 0) break; + int end = source.indexOf(']', start + "[Javascript=".length()); + if (end < 0) break; + String block = source.substring(start, end + 1); + String substituted = literalMarkers.replace(block, MAX_RESULT); + protectedScripts.append(source, cursor, start); + if (!block.equals(replacementMarkers.replace(block))) { + if (tokens.size() >= MAX_VALUES) throw new IllegalArgumentException("too many literal script segments"); + String rawAction = scriptActionMarkers.replace(block, MAX_RESULT); + scriptTextCharacters += substituted.length(); + scriptActionCharacters += rawAction.length(); + if (scriptTextCharacters > MAX_RESULT || scriptActionCharacters > MAX_RESULT) + throw new IllegalArgumentException("literal scripts exceed limits"); + tokenIndex.addCorpus(substituted); + tokenIndex.addCorpus(rawAction); + String token = newToken(tokenIndex); + tokens.put(token, substituted); + // The visible script is kept as text, while an action containing the + // same protected block must retain the exact raw values (or empty + // values when no raw action was supplied). Only literal bindings + // are substituted here; unrelated provider markers remain for the + // normal action/provider pass. + actionTokens.put(token, rawAction); + protectedScripts.append(token); + } else { + protectedScripts.append(block); + } + cursor = end + 1; + } + protectedScripts.append(source, cursor, source.length()); + String protectedActions = protectExactAttributes(protectedScripts.toString(), values, exact, tokens, actionTokens, + tokenIndex); + return Prepared.create(replacementMarkers.replace(protectedActions), tokens, actionTokens, tokenIndex, 0, bindingCharacters, legacyValues == null ? null : legacyValues.admissionIdentity, ordinaryVisibleExpanded); + } + + /** Shield raw legacy display overrides only in administrator-authored click attributes. */ + private static String protectExactAttributes(String source, Map literalValues, + Map exactValues, Map tokens, Map actionTokens, + TokenIndex tokenIndex) { + Map replacements = new LinkedHashMap<>(); + Map actionOnlyValues = new LinkedHashMap<>(); + exactValues.forEach((key, value) -> { if (!literalValues.containsKey(key)) actionOnlyValues.put(key, value); }); + MarkerMatcher exactMarkers = new MarkerMatcher(actionOnlyValues); + Map bindingOrder = new HashMap<>(); + int ordinal = 0; + for (String key : exactValues.keySet()) bindingOrder.put(key, ordinal++); + mapExactAttributes(source, value -> { + Set referenced = new HashSet<>(); + exactMarkers.collectKeys(value, referenced); + List ordered = new ArrayList<>(referenced); + ordered.sort(java.util.Comparator.comparingInt(bindingOrder::get)); + // Preserve first-use admission across attributes, then input-map order + // within each attribute, without scanning its text once per binding. + for (String key : ordered) { + if (literalValues.containsKey(key) || replacements.containsKey(key)) continue; + String exact = exactValues.get(key); + if (exact == null) throw new IllegalArgumentException("invalid action value"); + if (tokens.size() >= MAX_VALUES) throw new IllegalArgumentException("too many action values"); + String token = newToken(tokenIndex); + replacements.put(key, token); + tokens.put(token, exact); + actionTokens.put(token, exact); + } + return value; + }); + MarkerMatcher replacementMarkers = new MarkerMatcher(replacements); + return mapExactAttributes(source, replacementMarkers::replace); + } + + /** Visit only administrator-authored attributes, before any inserted value is parsed. */ + private static String mapExactAttributes(String source, Function mapper) { + return mapMessageRegions(source, Function.identity(), mapper); + } + + /** Keep exact click values separate while transforming visible/hover regions. */ + private static String mapMessageRegions(String source, Function visible, Function exact) { + StringBuilder result = new StringBuilder(source.length()); + int cursor = 0; + while (cursor < source.length()) { + int componentStart = indexOfIgnoreCase(source, "[Text=\"", cursor); + int scriptStart = indexOfIgnoreCase(source, "[Javascript=", cursor); + if (scriptStart >= 0 && (componentStart < 0 || scriptStart < componentStart)) { + int scriptEnd = source.indexOf(']', scriptStart); + if (scriptEnd < 0) break; + appendMapped(result, visible, source.substring(cursor, scriptStart)); + appendBounded(result, source, scriptStart, scriptEnd + 1); + cursor = scriptEnd + 1; + continue; + } + if (componentStart < 0) break; + int componentEnd = source.indexOf("\"]", componentStart); + if (componentEnd < 0) break; + componentEnd += 2; + int attributesStart = source.indexOf("\",", componentStart); + appendMapped(result, visible, source.substring(cursor, componentStart)); + if (attributesStart < 0 || attributesStart >= componentEnd) { + appendMapped(result, visible, source.substring(componentStart, componentEnd)); + } else { + Matcher attributes = EXACT_ATTRIBUTE.matcher(source); + attributes.region(attributesStart + 2, componentEnd); + int attributeCursor = componentStart; + while (attributes.find()) { + appendMapped(result, visible, source.substring(attributeCursor, attributes.start(2))); + appendMapped(result, exact, attributes.group(2)); + attributeCursor = attributes.end(2); + } + appendMapped(result, visible, source.substring(attributeCursor, componentEnd)); + } + cursor = componentEnd; + } + appendMapped(result, visible, source.substring(cursor)); + return result.toString(); + } + + private static void appendMapped(StringBuilder result, Function mapper, String source) { + String mapped = mapper.apply(source); + appendBounded(result, mapped, 0, mapped.length()); + } + + private static String newToken(TokenIndex index) { + return newToken(index, () -> TOKEN_PREFIX + Long.toUnsignedString(ThreadLocalRandom.current().nextLong(), 36) + + "Q" + Long.toUnsignedString(ThreadLocalRandom.current().nextLong(), 36) + "Z"); + } + + private static String newToken(TokenIndex index, Supplier candidates) { + for (int attempts = 0; attempts < 16; attempts++) { + // No percent signs or underscores: this cannot introduce a PAPI provider + // token. The first character cannot complete a legacy color code. + String token = candidates.get(); + if (index.reserve(token)) return token; + } + throw new IllegalStateException("literal token allocation failed"); + } + + /** Recognize exactly the existing random-token grammar, including tokens embedded in other text. */ + private static int tokenEnd(CharSequence source, int start) { + if (start >= source.length() || source.charAt(start) != TOKEN_PREFIX.charAt(0) + || source.length() - start < TOKEN_PREFIX.length() + 4) return -1; + for (int offset = 1; offset < TOKEN_PREFIX.length(); offset++) { + if (source.charAt(start + offset) != TOKEN_PREFIX.charAt(offset)) return -1; + } + int cursor = start + TOKEN_PREFIX.length(); + for (int part = 0; part < 2; part++) { + char delimiter = part == 0 ? 'Q' : 'Z'; + int digits = 0; + while (cursor < source.length()) { + char character = source.charAt(cursor++); + if (character == delimiter) { + if (digits == 0) return -1; + break; + } + if (++digits > 13 || !((character >= '0' && character <= '9') + || (character >= 'a' && character <= 'z'))) return -1; + if (cursor == source.length()) return -1; + } + if (digits == 0 || source.charAt(cursor - 1) != delimiter) return -1; + } + return cursor; + } + + /** A shared immutable corpus plus operation-local additions; never rescan it for each allocation. */ + private static final class TokenIndex { + private final Set corpus; + private final Set additions = new HashSet<>(); + + private TokenIndex(Set corpus) { + this.corpus = corpus; + } + + private boolean reserve(String token) { + if (corpus.contains(token) || additions.contains(token)) return false; + if (corpus.size() + additions.size() >= MAX_FORBIDDEN_TOKENS) + throw new IllegalArgumentException("literal token corpus exceeds limits"); + additions.add(token); + return true; + } + + private void addCorpus(CharSequence source) { + if (source == null) return; + for (int cursor = 0; cursor < source.length();) { + int end = tokenEnd(source, cursor); + if (end < 0) cursor++; + else { + reserve(source.subSequence(cursor, end).toString()); + cursor = end; + } + } + } + + private Set snapshot() { + if (additions.isEmpty()) return corpus; + Set snapshot = new HashSet<>(corpus); + snapshot.addAll(additions); + return Collections.unmodifiableSet(snapshot); + } + } + + private static int indexOfIgnoreCase(String source, String target, int from) { + for (int index = from; index <= source.length() - target.length(); index++) { + if (source.regionMatches(true, index, target, 0, target.length())) return index; + } + return -1; + } + + /** Admit bounded input before constructing indexes or scanning the template. */ + private static int validateBindings(Map literals, Map actions) { + int characters = 0; + for (Map bindings : java.util.Arrays.asList(literals, actions)) { + if (bindings == null) continue; + if (bindings.size() > MAX_BINDINGS) throw new IllegalArgumentException("too many literal bindings"); + int count = 0; + Set keys = new HashSet<>(); + for (Map.Entry entry : bindings.entrySet()) { + if (++count > MAX_BINDINGS) throw new IllegalArgumentException("too many literal bindings"); + String key = Objects.requireNonNull(entry.getKey(), "binding key"); + String value = entry.getValue(); + if (bindings == literals) { + if (!LITERAL_KEY.matcher(key).matches() || value == null) + throw new IllegalArgumentException("invalid literal value"); + } else if (key.isEmpty() || key.length() > MAX_ACTION_KEY) { + throw new IllegalArgumentException("invalid action key"); + } + if (!keys.add(normalizeBindingKey(key))) + throw new IllegalArgumentException(bindings == literals ? "duplicate literal key" : "duplicate action key"); + if (value != null && value.length() > MAX_VALUE) + throw new IllegalArgumentException("invalid binding value"); + characters += key.length() + (value == null ? 0 : value.length()); + if (characters > MAX_BINDING_CHARACTERS) + throw new IllegalArgumentException("literal bindings exceed character limits"); + } + } + return characters; + } + + private static int foldCase(int value) { + return Character.toLowerCase(Character.toUpperCase(value)); + } + + /** Internal reward-key authority, identical to the marker index's Unicode codepoint fold. */ + public static String normalizeBindingKey(String key) { + Objects.requireNonNull(key, "binding key"); + StringBuilder result = new StringBuilder(key.length()); + for (int index = 0; index < key.length();) { + int character = key.codePointAt(index); + index += Character.charCount(character); + result.appendCodePoint(foldCase(character)); + } + return result.toString(); + } + + /** Index valid literal markers once, including markers sharing a percent boundary. */ + private static Set literalMarkerKeys(CharSequence source) { + Set keys = new HashSet<>(); + for (int start = 0; start < source.length(); start++) { + char opening = source.charAt(start); + if (opening != '%' && opening != '{') continue; + char closing = opening == '%' ? '%' : '}'; + StringBuilder key = new StringBuilder(); + for (int cursor = start + 1; cursor < source.length() && cursor <= start + MAX_LITERAL_KEY + 1; cursor++) { + char character = source.charAt(cursor); + if (character == closing) { + if (!key.isEmpty()) keys.add(key.toString()); + break; + } + character = (char) foldCase(character); + boolean letter = character >= 'a' && character <= 'z'; + boolean digit = character >= '0' && character <= '9'; + if ((!letter && character != '_' && (key.isEmpty() || !digit)) || key.length() == MAX_LITERAL_KEY) break; + key.append(character); + } + } + return keys; + } + + /** One bounded key index per replacement lane, never one source scan per binding. */ + private static final class MarkerMatcher { + private final MarkerNode root = new MarkerNode(); + private final List bindings = new ArrayList<>(); + + private MarkerMatcher(Map values) { + int order = 0; + for (Map.Entry entry : values.entrySet()) { + MarkerNode node = root; + for (int index = 0; index < entry.getKey().length();) { + int character = entry.getKey().codePointAt(index); + index += Character.charCount(character); + character = foldCase(character); + if (node.children == null) node.children = new HashMap<>(); + node = node.children.computeIfAbsent(character, ignored -> new MarkerNode()); + } + if (node.key == null) { + node.key = entry.getKey(); + node.value = entry.getValue(); + node.order = order; + bindings.add(node); + } else { + // Ordered ordinary maps may deliberately contain aliases; public action maps reject them at admission. + MarkerNode alias = new MarkerNode(); + alias.key = entry.getKey(); alias.value = entry.getValue(); alias.order = order; + if (node.aliases == null) node.aliases = new ArrayList<>(); + node.aliases.add(alias); bindings.add(alias); + } + order++; + } + } + + private MarkerNode match(CharSequence source, int start, Set keys) { + return match(source, start, keys, 0); + } + + private MarkerNode match(CharSequence source, int start, Set keys, int minimumOrder) { + char opening = source.charAt(start); + if (opening != '%' && opening != '{') return null; + char closing = opening == '%' ? '%' : '}'; + MarkerNode node = root; + MarkerNode selected = null; + for (int cursor = start + 1; cursor < source.length();) { + int character = Character.codePointAt(source, cursor); + if (character == closing && node.key != null) { + MarkerNode candidate = node.order >= minimumOrder ? node : firstAlias(node.aliases, minimumOrder); + if (candidate != null && (selected == null || candidate.order < selected.order)) selected = candidate; + // collectKeys always starts at order zero; enumerate a terminal's aliases only on its first visit. + if (keys != null && keys.add(node.key) && node.aliases != null) + for (MarkerNode alias : node.aliases) keys.add(alias.key); + } + cursor += Character.charCount(character); + character = foldCase(character); + node = node.children == null ? null : node.children.get(character); + if (node == null) break; + } + return selected; + } + + private static MarkerNode firstAlias(List aliases, int minimumOrder) { + if (aliases == null) return null; + int low = 0; + int high = aliases.size(); + while (low < high) { + int middle = (low + high) >>> 1; + if (aliases.get(middle).order < minimumOrder) low = middle + 1; + else high = middle; + } + return low == aliases.size() ? null : aliases.get(low); + } + + private void collectKeys(CharSequence source, Set keys) { + for (int cursor = 0; cursor < source.length(); cursor++) match(source, cursor, keys); + } + + /** Replace only original markers; inserted values are never scanned again. */ + private String replace(String source) { + return replace(source, Integer.MAX_VALUE); + } + + private String replace(String source, int limit) { + return replace((CharSequence) source, limit); + } + + private String replaceOpening(String source, char opening) { + StringBuilder result = new StringBuilder(Math.min(source.length(), MAX_RESULT)); + int cursor = 0; + while (cursor < source.length()) { + MarkerNode selected = source.charAt(cursor) == opening ? match(source, cursor, null) : null; + if (selected == null) { + appendBounded(result, source, cursor, cursor + 1); + cursor++; + } else { + appendBounded(result, selected.value, 0, selected.value.length()); + cursor += selected.key.length() + 2; + } + } + return result.toString(); + } + + private String replace(CharSequence source, int limit) { + return replace(source, limit, null); + } + + private String replace(CharSequence source, int limit, Map laneValues) { + StringBuilder result = new StringBuilder(Math.min(source.length(), limit)); + int cursor = 0; + while (cursor < source.length()) { + MarkerNode selected = match(source, cursor, null); + if (selected == null) { + if (result.length() >= limit) throw new IllegalArgumentException("literal scripts exceed limits"); + result.append(source.charAt(cursor++)); + } + else { + String value = laneValues == null ? selected.value : laneValues.get(selected.key); + if (value == null || value.length() > limit - result.length()) + throw new IllegalArgumentException("literal scripts exceed limits"); + result.append(value); + cursor += selected.key.length() + 2; + } + } + return result.toString(); + } + } + + /** Ordered trusted map expansion through the same key index, with no recursive Java calls. */ + private static final class OrderedMarkers { + private static final int MAX_SCAN_CHARACTERS = 16777216; + private final MarkerMatcher original; + private final MarkerMatcher expanded; + + private OrderedMarkers(Map values, String source) { + original = new MarkerMatcher(values); + boolean simple = values.keySet().stream().allMatch(key -> LITERAL_KEY.matcher(key).matches()); + boolean noDelimiters = values.values().stream().noneMatch(value -> value.indexOf('%') >= 0 + || value.indexOf('{') >= 0 || value.indexOf('}') >= 0); + boolean isolated = simple && isolatedMarkers(source, original, true); + if (isolated && !noDelimiters) { + for (MarkerNode binding : original.bindings) { + if (!isolatedMarkers(binding.value, original, true)) { isolated = false; break; } + // The legacy percent pass precedes the brace pass for the same key. + for (int cursor = 0; cursor < binding.value.length(); cursor++) { + if (binding.value.charAt(cursor) != '{') continue; + MarkerNode marker = original.match(binding.value, cursor, null, binding.order); + if (marker != null && marker.order == binding.order) { isolated = false; break; } + } + if (!isolated) break; + } + } + expanded = isolated ? new MarkerMatcher(expandReferenced(source, original)) : null; + } + + private static boolean isolatedMarkers(String source, MarkerMatcher index, boolean requireSeparation) { + int previousEnd = -1; + for (int cursor = 0; cursor < source.length(); cursor++) { + MarkerNode match = index.match(source, cursor, null); + if (match == null) continue; + int end = cursor + match.key.length() + 2; + if (cursor < previousEnd) return false; + if (requireSeparation && ((cursor > 0 && markerBoundaryCharacter(source.charAt(cursor - 1))) + || (end < source.length() && markerBoundaryCharacter(source.charAt(end))))) return false; + previousEnd = end; + } + return true; + } + + private static boolean markerBoundaryCharacter(char value) { + return Character.isLetterOrDigit(value) || value == '_' || value == '%' || value == '{' || value == '}'; + } + + private static Map expandReferenced(String source, MarkerMatcher index) { + Set referenced = new HashSet<>(); + index.collectKeys(source, referenced); + Map cache = new HashMap<>(); + int retainedCharacters = 0; + for (MarkerNode binding : index.bindings) { + if (!referenced.contains(binding.key) || cache.containsKey(binding.key)) continue; + ArrayDeque stack = new ArrayDeque<>(); + stack.push(new ExpansionFrame(binding)); + while (!stack.isEmpty()) { + ExpansionFrame frame = stack.peek(); + if (frame.cursor == frame.binding.value.length()) { + String value = frame.result.toString(); + cache.put(frame.binding.key, value); + stack.pop(); + continue; + } + MarkerNode next = index.match(frame.binding.value, frame.cursor, null, frame.binding.order + 1); + if (next == null) { + if (retainedCharacters >= MAX_BINDING_CHARACTERS) + throw new IllegalArgumentException("ordered placeholder expansion exceeds limits"); + retainedCharacters++; + appendBounded(frame.result, frame.binding.value, frame.cursor, frame.cursor + 1); + frame.cursor++; + } else if (!cache.containsKey(next.key)) { + if (stack.size() >= MAX_BINDINGS) throw new IllegalArgumentException("ordered placeholder depth exceeds limits"); + stack.push(new ExpansionFrame(next)); + } else { + String value = cache.get(next.key); + if (value.length() > MAX_BINDING_CHARACTERS - retainedCharacters) + throw new IllegalArgumentException("ordered placeholder expansion exceeds limits"); + retainedCharacters += value.length(); + appendBounded(frame.result, value, 0, value.length()); + frame.cursor += next.key.length() + 2; + } + } + } + Map result = new LinkedHashMap<>(); + for (MarkerNode binding : index.bindings) + result.put(binding.key, cache.getOrDefault(binding.key, binding.value)); + return result; + } + + private String replace(String source, int[] remainingWork) { + if (expanded != null) { + chargeScan(remainingWork, source.length()); + return expanded.replace(source, MAX_RESULT); + } + // Arbitrary keys and shared delimiter edges keep the historical global map order. + // Only keys actually present are visited, and total scan work has a fixed ceiling. + String result = source; + int minimumOrder = 0; + while (true) { + chargeScan(remainingWork, result.length()); + MarkerNode first = null; + for (int cursor = 0; cursor < result.length(); cursor++) { + MarkerNode candidate = original.match(result, cursor, null, minimumOrder); + if (candidate != null && (first == null || candidate.order < first.order)) first = candidate; + } + if (first == null) return result; + MarkerMatcher stage = new MarkerMatcher(Map.of(first.key, first.value)); + for (char opening : new char[] { '%', '{' }) { + chargeScan(remainingWork, result.length()); + result = stage.replaceOpening(result, opening); + } + minimumOrder = first.order + 1; + } + } + } + + private static void chargeScan(int[] remainingWork, int characters) { + if (characters > remainingWork[0]) throw new IllegalArgumentException("ordered placeholder scan exceeds limits"); + remainingWork[0] -= characters; + } + + private static final class ExpansionFrame { + private final MarkerNode binding; + private final StringBuilder result = new StringBuilder(); + private int cursor; + private ExpansionFrame(MarkerNode binding) { this.binding = binding; } + } + + /** Bounded decoded legacy inputs for the reward renderer; legacy string APIs keep their own contract. */ + public static final class LegacyValues { + private final Map visible; + private final Map exact; + private final Map ordinary; + private final int characters; + private final Set forbiddenTokens; + private final Object admissionIdentity = new Object(); + + public LegacyValues(Map visible, Map exact, Map ordinary) { + int[] admitted = { 0 }; + TokenIndex corpus = new TokenIndex(Set.of()); + this.visible = copyLegacyValues(visible, admitted, corpus, true); + this.exact = copyLegacyValues(exact, admitted, corpus, true); + this.ordinary = copyLegacyValues(ordinary, admitted, corpus, false); + characters = admitted[0]; + forbiddenTokens = corpus.snapshot(); + } + + private static Map copyLegacyValues(Map source, int[] characters, TokenIndex corpus, boolean normalize) { + if (source == null || source.isEmpty()) return Map.of(); + if (source.size() > MAX_BINDINGS) throw new IllegalArgumentException("too many legacy bindings"); + Map result = new LinkedHashMap<>(); + int count = 0; + for (Map.Entry entry : source.entrySet()) { + if (++count > MAX_BINDINGS) throw new IllegalArgumentException("too many legacy bindings"); + String key = Objects.requireNonNull(entry.getKey(), "legacy key"); + String value = entry.getValue() == null ? "" : entry.getValue(); + if (key.isEmpty() || key.length() > MAX_ACTION_KEY || value.length() > 32768) + throw new IllegalArgumentException("invalid legacy binding"); + characters[0] += key.length() + value.length(); + if (characters[0] > MAX_BINDING_CHARACTERS) + throw new IllegalArgumentException("legacy bindings exceed character limits"); + corpus.addCorpus(key); + corpus.addCorpus(value); + // Preserve the established first-entry priority for legacy case aliases. + result.putIfAbsent(normalize ? normalizeBindingKey(key) : key, value); + } + return Collections.unmodifiableMap(result); + } + + private String expandVisibleOrdinary(String source) { + OrderedMarkers index = new OrderedMarkers(ordinary, source); + int[] remainingWork = { OrderedMarkers.MAX_SCAN_CHARACTERS }; + return mapMessageRegions(source, + text -> com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate.parse(text).transform( + value -> index.replace(value, remainingWork), Function.identity()), + Function.identity()); + } + + private void addCorpus(TokenIndex index) { + for (String token : forbiddenTokens) index.reserve(token); + } + + /** Bind only to a Prepared that admitted these inputs before allocating its literal tokens. */ + public LegacyBindings bind(Prepared prepared) { + Objects.requireNonNull(prepared, "prepared"); + if (prepared.legacyAdmissionIdentity != admissionIdentity) + throw new IllegalArgumentException("legacy inputs were not admitted by this preparation"); + if (characters > MAX_BINDING_CHARACTERS - prepared.bindingCharacters) + throw new IllegalArgumentException("literal bindings exceed character limits"); + TokenIndex index = new TokenIndex(prepared.forbiddenTokens); + Map all = new LinkedHashMap<>(visible); + exact.forEach(all::putIfAbsent); + Set referenced = new HashSet<>(); + new MarkerMatcher(all).collectKeys(prepared.template, referenced); + Map replacements = new LinkedHashMap<>(); + Map visibleTokens = new LinkedHashMap<>(); + Map exactTokens = new LinkedHashMap<>(); + for (String key : all.keySet()) { + if (!referenced.contains(key)) continue; + if (prepared.tokens.size() + replacements.size() >= MAX_VALUES) + throw new IllegalArgumentException("too many referenced display values"); + String token = newToken(index); + replacements.put(key, token); + if (visible.containsKey(key)) visibleTokens.put(token, visible.get(key)); + if (exact.containsKey(key)) exactTokens.put(token, exact.get(key)); + } + return new LegacyBindings(this, replacements, visibleTokens, exactTokens, prepared.template, index, + prepared.tokens.size(), prepared.ordinaryVisibleExpanded); + } + } + + /** Immutable source/recipient indexes used only by the mixed literal reward rendering lane. */ + public static final class LegacyBindings { + private final boolean ordinaryVisibleExpanded; + private final MarkerMatcher visible; + private final MarkerMatcher exact; + private final Map visibleJavascript; + private final Map exactJavascript; + private final Map visibleBroadcastJavascript; + private final Map exactBroadcastJavascript; + private final OrderedMarkers ordinary; + private final Map ordinaryJavascript; + private final Map visibleTokens; + private final Map exactTokens; + private final boolean hasDisplayValues; + private final MarkerMatcher playerLineSeparators; + private final MarkerMatcher broadcastLineSeparators; + + private LegacyBindings(LegacyValues values, Map replacements, + Map visibleTokens, Map exactTokens, String source, TokenIndex index, + int literalTokenCount, boolean ordinaryVisibleExpanded) { + this.ordinaryVisibleExpanded = ordinaryVisibleExpanded; + Map visibleMarkers = new LinkedHashMap<>(); + Map exactMarkers = new LinkedHashMap<>(); + Map visibleScripts = new LinkedHashMap<>(); + Map exactScripts = new LinkedHashMap<>(); + Map visibleBroadcastScripts = new LinkedHashMap<>(); + Map exactBroadcastScripts = new LinkedHashMap<>(); + replacements.forEach((key, token) -> { + if (values.visible.containsKey(key)) { + visibleMarkers.put(key, token); + visibleScripts.put(key, com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue.encode( + "\u2060" + neutralizeLegacyInteractiveSyntax(values.visible.get(key)) + "\u2060")); + visibleBroadcastScripts.put(key, + com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue.encode(token)); + } + if (values.exact.containsKey(key)) { + exactMarkers.put(key, token); + exactScripts.put(key, + com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue.encode(values.exact.get(key))); + exactBroadcastScripts.put(key, + com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue.encode(token)); + } + }); + Map ordinaryScripts = new LinkedHashMap<>(); + values.ordinary.forEach((key, value) -> ordinaryScripts.put(key, + com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue.encode(value))); + visible = new MarkerMatcher(visibleMarkers); + exact = new MarkerMatcher(exactMarkers); + visibleJavascript = Collections.unmodifiableMap(visibleScripts); + exactJavascript = Collections.unmodifiableMap(exactScripts); + visibleBroadcastJavascript = Collections.unmodifiableMap(visibleBroadcastScripts); + exactBroadcastJavascript = Collections.unmodifiableMap(exactBroadcastScripts); + ordinary = new OrderedMarkers(values.ordinary, source); + ordinaryJavascript = Collections.unmodifiableMap(ordinaryScripts); + Map inertTokens = new LinkedHashMap<>(); + visibleTokens.forEach((token, value) -> inertTokens.put(token, neutralizeLegacyInteractiveSyntax(value))); + Map playerLines = new LinkedHashMap<>(); + Map broadcastLines = new LinkedHashMap<>(); + Map fragmentTokens = new HashMap<>(); + replacements.forEach((key, token) -> { + String value = inertTokens.get(token); + if (value == null) return; + if (value.contains("%NewLine%")) playerLines.put(key, protectLineFragments(value, false, + index, inertTokens, fragmentTokens, literalTokenCount)); + if (value.contains("%newline%")) broadcastLines.put(key, protectLineFragments(value, true, + index, inertTokens, fragmentTokens, literalTokenCount)); + else if (playerLines.containsKey(key)) broadcastLines.put(key, playerLines.get(key)); + }); + playerLineSeparators = new MarkerMatcher(playerLines); + broadcastLineSeparators = new MarkerMatcher(broadcastLines); + this.visibleTokens = Collections.unmodifiableMap(inertTokens); + this.exactTokens = Collections.unmodifiableMap(new LinkedHashMap<>(exactTokens)); + hasDisplayValues = !values.visible.isEmpty(); + } + + private static String protectLineFragments(String value, boolean broadcast, TokenIndex index, + Map tokens, Map fragments, int literalTokenCount) { + Matcher separators = Pattern.compile(broadcast ? "%NewLine%|%newline%" : "%NewLine%").matcher(value); + StringBuilder result = new StringBuilder(); + int start = 0; + while (separators.find()) { + appendProtectedFragment(result, value.substring(start, separators.start()), index, tokens, fragments, + literalTokenCount); + appendBounded(result, value, separators.start(), separators.end()); + start = separators.end(); + } + appendProtectedFragment(result, value.substring(start), index, tokens, fragments, literalTokenCount); + return result.toString(); + } + + private static void appendProtectedFragment(StringBuilder result, String value, TokenIndex index, + Map tokens, Map fragments, int literalTokenCount) { + if (value.isEmpty()) return; + String marker = fragments.get(value); + if (marker == null) { + if (tokens.size() + literalTokenCount >= MAX_VALUES) + throw new IllegalArgumentException("too many display line fragments"); + marker = newToken(index); + fragments.put(value, marker); + tokens.put(marker, value); + } + appendBounded(result, marker, 0, marker.length()); + } + + /** Reveal only trusted display separators; fragments and authored script values stay opaque. */ + public String revealDisplayLineSeparators(String source, boolean broadcast) { + MarkerMatcher markers = broadcast ? broadcastLineSeparators : playerLineSeparators; + // Ordinary visible templates were expanded before binding admission. + // Re-scope introduced components and protect raw action markers before + // revealing display separators; inserted literal data stays opaque. + String visibleSource = source; + if (!ordinaryVisibleExpanded) { + int[] remainingWork = { OrderedMarkers.MAX_SCAN_CHARACTERS }; + visibleSource = com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate.parse(source).transform( + text -> ordinary.replace(text, remainingWork), Function.identity()); + } + String protectedActions = mapExactAttributes(visibleSource, value -> exact.replace(value, MAX_RESULT)); + return com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate.parse(protectedActions).transform( + text -> markers.replace(text, MAX_RESULT), Function.identity()); + } + + public boolean hasDisplayValues() { + return hasDisplayValues; + } + + /** Transform original markers once per lane; opaque inserted values never enter another marker scan. */ + public String protect(String source, boolean action, boolean evaluateJavascript) { + return renderer().protect(source, action, evaluateJavascript); + } + + /** One operation-local scan budget shared by its segments and lines; never retained in the immutable indexes. */ + public Renderer renderer() { + return new Renderer(this); + } + + public static final class Renderer { + private final LegacyBindings values; + private final int[] remainingWork = { OrderedMarkers.MAX_SCAN_CHARACTERS }; + private Renderer(LegacyBindings values) { this.values = values; } + public String protect(String source, boolean action, boolean evaluateJavascript) { + return values.protect(source, action, evaluateJavascript, remainingWork); + } + public String protectAfterOrdinary(String source, boolean action, boolean evaluateJavascript) { + return values.protectAfterOrdinary(source, action, evaluateJavascript, remainingWork); + } + public String finish(String source, boolean action) { return values.finish(source, action); } + } + + private String protect(String source, boolean action, boolean evaluateJavascript, int[] remainingWork) { + MarkerMatcher text = action ? exact : visible; + Map script = evaluateJavascript ? (action ? exactJavascript : visibleJavascript) + : (action ? exactBroadcastJavascript : visibleBroadcastJavascript); + int[] characters = { source.length() }; + String result = com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate.parse(source).transform( + value -> boundedLegacyReplacement(value, input -> text.replace(input, MAX_RESULT), input -> ordinary.replace(input, remainingWork), characters), + value -> boundedLegacyReplacement(value, input -> text.replace(input, MAX_RESULT, script), + input -> ordinary.original.replace(input, MAX_RESULT, ordinaryJavascript), characters)); + if (result.length() > MAX_RESULT) throw new IllegalArgumentException("legacy text exceeds limits"); + return result; + } + + private String protectAfterOrdinary(String source, boolean action, boolean evaluateJavascript, int[] remainingWork) { + MarkerMatcher text = action ? exact : visible; + Map script = evaluateJavascript ? (action ? exactJavascript : visibleJavascript) + : (action ? exactBroadcastJavascript : visibleBroadcastJavascript); + int[] characters = { source.length() }; + String result = com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate.parse(source).transform( + value -> boundedLegacyReplacement(value, input -> text.replace(input, MAX_RESULT), Function.identity(), characters), + value -> boundedLegacyReplacement(value, input -> text.replace(input, MAX_RESULT, script), + input -> ordinary.original.replace(input, MAX_RESULT, ordinaryJavascript), characters)); + if (result.length() > MAX_RESULT) throw new IllegalArgumentException("legacy text exceeds limits"); + return result; + } + + private static String boundedLegacyReplacement(String source, Function overrides, Function ordinary, + int[] characters) { + String result = ordinary.apply(overrides.apply(source)); + characters[0] += result.length() - source.length(); + if (characters[0] > MAX_RESULT) throw new IllegalArgumentException("legacy text exceeds limits"); + return result; + } + + /** Restore one scan of the provider output, retaining legacy display colors and exact raw actions. */ + public String finish(String source, boolean action) { + return restoreTokens(source, action ? exactTokens : visibleTokens, false, false, !action); + } + } + + private static final class MarkerNode { + private Map children; + private List aliases; + private String key; + private String value; + private int order; + } + + private static ProtectedBoundaries protectSectionTokenBoundaries(String source, Map tokens, + Map actionTokens, TokenIndex tokenIndex) { + if (source.indexOf('§') < 0 || tokens.isEmpty()) return new ProtectedBoundaries(source, tokens, actionTokens); + String guard = null; + StringBuilder protectedText = new StringBuilder(source.length()); + for (int index = 0; index < source.length(); index++) { + if (source.charAt(index) == '§') { + int following = index + 1; + int end = tokenEnd(source, following); + if (end >= 0 && tokens.containsKey(source.substring(following, end))) { + if (guard == null) { + // A section-sign label may have a different raw action value. + // Reuse a boundary token only when both restoration lanes agree. + for (Map.Entry entry : tokens.entrySet()) { + if (entry.getValue().equals("§") && "§".equals(actionTokens.get(entry.getKey()))) { + guard = entry.getKey(); + break; + } + } + if (guard == null) { + guard = newToken(tokenIndex); + tokens = new LinkedHashMap<>(tokens); + actionTokens = new LinkedHashMap<>(actionTokens); + tokens.put(guard, "§"); + actionTokens.put(guard, "§"); + tokens = Collections.unmodifiableMap(tokens); + actionTokens = Collections.unmodifiableMap(actionTokens); + } + } + // Invalid §V is consumed by legacy parsers before restoration. + // Keep this prefix opaque too; finish restores literal visible + // text or the exact original prefix in an action attribute. + protectedText.append(guard); + continue; + } + } + protectedText.append(source.charAt(index)); + } + return new ProtectedBoundaries(protectedText.toString(), tokens, actionTokens); + } + + private record ProtectedBoundaries(String template, Map tokens, Map actionTokens) { + } + + /** One finite output budget shared by all lines of a rendered recipient batch. */ + public static final class OutputBudget { + private int admittedCharacters; + private int admittedBindingCharacters; + private final int[] treeCounts = new int[] { 0, 0 }; + + /** Count restored values without allocating them or exposing them to a parser. */ + public void admit(Prepared prepared, boolean broadcast) { + String source = prepared.template; + int characters = 0; + int bindingCharacters = 0; + for (int cursor = 0; cursor < source.length();) { + if (source.startsWith("%NewLine%", cursor) + || (broadcast && source.startsWith("%newline%", cursor))) { + cursor += "%NewLine%".length(); + continue; + } + int end = tokenEnd(source, cursor); + if (end >= 0) { + String marker = source.substring(cursor, end); + String visible = prepared.tokens.get(marker); + String action = prepared.actionTokens.get(marker); + int length = end - cursor; + if (visible != null) { + length = visible.length(); + for (int section = visible.indexOf('§'); section >= 0; + section = visible.indexOf('§', section + 1)) length += "(section)".length() - 1; + if (action != null) length = Math.max(length, action.length()); + if (length > MAX_RESULT - admittedBindingCharacters - bindingCharacters) + throw new IllegalArgumentException("literal batch binding output exceeds limits"); + bindingCharacters += length; + } + // Trusted parser syntax has its own finite allowance; it is not client output. + if (length > MAX_RESULT + MAX_TEMPLATE - admittedCharacters - characters) + throw new IllegalArgumentException("literal batch output exceeds limits"); + characters += length; + cursor = end; + } else { + if (characters >= MAX_RESULT + MAX_TEMPLATE - admittedCharacters) + throw new IllegalArgumentException("literal batch output exceeds limits"); + characters++; + cursor++; + } + } + admittedCharacters += characters; + admittedBindingCharacters += bindingCharacters; + } + + /** Parse only while capacity remains; count the actual final trees across the batch. */ + public TextComponent finish(Prepared prepared, Supplier parser) { + if (treeCounts[0] >= MAX_COMPONENTS || treeCounts[1] >= MAX_RESULT) + throw new IllegalArgumentException("literal component batch exceeds limits"); + TextComponent result = prepared.finish(parser.get()); + validateTree(result, 0, treeCounts, new IdentityHashMap<>()); + return result; + } + } + + public static final class Prepared { + private final String template; + private final Map tokens; + private final Map actionTokens; + private final Set forbiddenTokens; + private final int formattedActionCharacters; + private final int bindingCharacters; + private final Object legacyAdmissionIdentity; + private final boolean ordinaryVisibleExpanded; + + /** All maps passed here are immutable and can be shared by lines and recipients. */ + private Prepared(String template, Map tokens, Map actionTokens, + TokenIndex tokenIndex, int formattedActionCharacters, int bindingCharacters, Object legacyAdmissionIdentity, + boolean ordinaryVisibleExpanded) { + tokenIndex.addCorpus(template); + ProtectedBoundaries protectedText = protectSectionTokenBoundaries(template, tokens, actionTokens, tokenIndex); + this.template = protectedText.template(); + this.tokens = protectedText.tokens(); + this.actionTokens = protectedText.actionTokens(); + this.forbiddenTokens = tokenIndex.snapshot(); + this.formattedActionCharacters = formattedActionCharacters; + this.bindingCharacters = bindingCharacters; + this.legacyAdmissionIdentity = legacyAdmissionIdentity; + this.ordinaryVisibleExpanded = ordinaryVisibleExpanded; + } + + /** Publish newly owned maps; no mutable index or map survives into Prepared. */ + private static Prepared create(String template, Map tokens, Map actions, + TokenIndex tokenIndex, int formattedActionCharacters, int bindingCharacters, Object legacyAdmissionIdentity, + boolean ordinaryVisibleExpanded) { + tokens.forEach(actions::putIfAbsent); + return new Prepared(template, Collections.unmodifiableMap(tokens), Collections.unmodifiableMap(actions), + tokenIndex, formattedActionCharacters, bindingCharacters, legacyAdmissionIdentity, ordinaryVisibleExpanded); + } + + public String template() { + return template; + } + + /** Player messages recognize only the canonical authored separator. Labels remain opaque. */ + public List splitLines() { + List lines = new ArrayList<>(); + for (String line : template.split(java.util.regex.Pattern.quote("%NewLine%"))) { + lines.add(new Prepared(line, tokens, actionTokens, new TokenIndex(forbiddenTokens), formattedActionCharacters, bindingCharacters, legacyAdmissionIdentity, ordinaryVisibleExpanded)); + } + return List.copyOf(lines); + } + + /** Broadcast helpers additionally recognize the established lowercase spelling. */ + public List splitBroadcastLines() { + List lines = new ArrayList<>(); + // Preserve both spellings accepted by the established broadcast helpers. + for (String upperLine : template.split(java.util.regex.Pattern.quote("%newline%"))) { + for (String line : upperLine.split(java.util.regex.Pattern.quote("%NewLine%"))) { + lines.add(new Prepared(line, tokens, actionTokens, new TokenIndex(forbiddenTokens), formattedActionCharacters, bindingCharacters, legacyAdmissionIdentity, ordinaryVisibleExpanded)); + } + } + return List.copyOf(lines); + } + + /** Continue trusted formatting while retaining the same literal bindings. */ + public Prepared withTemplate(String formattedTemplate) { + if (formattedTemplate == null || formattedTemplate.length() > MAX_RESULT) { + throw new IllegalArgumentException("formatted template is invalid"); + } + return new Prepared(formattedTemplate, tokens, actionTokens, new TokenIndex(forbiddenTokens), formattedActionCharacters, bindingCharacters, legacyAdmissionIdentity, ordinaryVisibleExpanded); + } + + /** Render authored action values once, then keep their exact output out of the component parser. */ + public Prepared protectFormattedActions(Function renderer) { + Objects.requireNonNull(renderer, "renderer"); + final class FormattedBindings { + private Map bindings = tokens; + private Map actions = actionTokens; + private int characters = formattedActionCharacters; + private boolean changed; + } + FormattedBindings formatted = new FormattedBindings(); + TokenIndex tokenIndex = new TokenIndex(forbiddenTokens); + String protectedTemplate = mapExactAttributes(template, value -> { + String rendered = restore(renderer.apply(value), actionTokens, false); + if (rendered == null || rendered.length() > MAX_RESULT - formatted.characters) + throw new IllegalArgumentException("formatted action exceeds limits"); + formatted.characters += rendered.length(); + if (formatted.bindings.size() >= MAX_VALUES) throw new IllegalArgumentException("too many formatted actions"); + if (!formatted.changed) { + formatted.bindings = new LinkedHashMap<>(tokens); + formatted.actions = new LinkedHashMap<>(actionTokens); + formatted.changed = true; + } + tokenIndex.addCorpus(rendered); + String token = newToken(tokenIndex); + formatted.bindings.put(token, rendered); + formatted.actions.put(token, rendered); + return token; + }); + if (!formatted.changed) return this; + return create(protectedTemplate, formatted.bindings, formatted.actions, tokenIndex, formatted.characters, bindingCharacters, legacyAdmissionIdentity, ordinaryVisibleExpanded); + } + + public TextComponent finish(TextComponent parsedComponent) { + if (parsedComponent != null) validateTree(parsedComponent, 0, new int[] { 0, 0 }, new IdentityHashMap<>()); + TextComponent result = parsedComponent == null ? new TextComponent() : parsedComponent.duplicate(); + rewrite(result, tokens, actionTokens, 0); + validateTree(result, 0, new int[] { 0, 0 }, new IdentityHashMap<>()); + return result; + } + + /** Restores values for a terminal that will not parse the returned text again. */ + public String finishText(String parsedText) { + return restore(parsedText, tokens, true); + } + + /** Restores raw action values only at a terminal that will not interpret them again. */ + public String finishActionText(String parsedText) { + return restore(parsedText, actionTokens, false); + } + } + + /** Validate terminal replay components without interpreting or rewriting their text. */ + public static void validateRestoredComponents(BaseComponent... components) { + if (components == null || components.length > MAX_COMPONENTS) + throw new IllegalArgumentException("literal component batch exceeds limits"); + int[] counts = new int[] { 0, 0 }; + for (BaseComponent component : components) + validateTree(component, 0, counts, new IdentityHashMap<>()); + } + + @SuppressWarnings("deprecation") + private static void validateTree(BaseComponent component, int depth, int[] count, + IdentityHashMap path) { + if (component == null) return; + if (depth > 64 || ++count[0] > MAX_COMPONENTS || path.put(component, Boolean.TRUE) != null) { + throw new IllegalArgumentException("literal component tree exceeds limits"); + } + if (component instanceof TextComponent text) count[1] += text.getText().length(); + if (component.getInsertion() != null) count[1] += component.getInsertion().length(); + if (component.getClickEvent() != null) count[1] += component.getClickEvent().getValue().length(); + if (count[1] > MAX_RESULT) throw new IllegalArgumentException("literal component text exceeds limits"); + if (component.getExtra() != null) { + for (BaseComponent child : component.getExtra()) validateTree(child, depth + 1, count, path); + } + HoverEvent hover = component.getHoverEvent(); + if (hover != null && hover.getAction() == HoverEvent.Action.SHOW_TEXT) { + for (BaseComponent child : hoverText(hover)) validateTree(child, depth + 1, count, path); + } + path.remove(component); + } + + @SuppressWarnings("deprecation") + private static void rewrite(BaseComponent component, Map tokens, Map actionTokens, int depth) { + if (component == null) return; + if (component instanceof TextComponent text) { + // The existing rich-text parser retains legacy colors inside its text node. + // Convert that trusted node before restoring external values, preserving + // the authored click/hover wrapper and existing child order. + if (text.getText().indexOf('§') >= 0) { + List children = new ArrayList<>(); + Collections.addAll(children, TextComponent.fromLegacyText(text.getText(), text.getColor())); + if (text.getExtra() != null) children.addAll(text.getExtra()); + text.setText(""); + text.setExtra(children); + } + text.setText(restore(text.getText(), tokens, true)); + } + component.setInsertion(restore(component.getInsertion(), actionTokens, false)); + if (component.getClickEvent() != null) { + ClickEvent click = component.getClickEvent(); + component.setClickEvent(new ClickEvent(click.getAction(), restore(click.getValue(), actionTokens, false))); + } + HoverEvent hover = component.getHoverEvent(); + if (hover != null && hover.getAction() == HoverEvent.Action.SHOW_TEXT) { + BaseComponent[] original = hoverText(hover); + BaseComponent[] copy = new BaseComponent[original.length]; + for (int index = 0; index < original.length; index++) { + copy[index] = original[index].duplicate(); + rewrite(copy[index], tokens, actionTokens, depth + 1); + } + component.setHoverEvent(new HoverEvent(hover.getAction(), copy)); + } + if (component.getExtra() != null) { + for (BaseComponent child : component.getExtra()) rewrite(child, tokens, actionTokens, depth + 1); + } + } + + /** Avoid linking modern hover-content classes on older supported clients/servers. */ + private static BaseComponent[] hoverText(HoverEvent hover) { + try { + Object legacy; + try { + legacy = hover.getClass().getMethod("getValue").invoke(hover); + if (legacy instanceof BaseComponent[] components) return components; + throw new IllegalArgumentException("unsupported legacy hover text"); + } catch (NoSuchMethodException modern) { + Object contents = hover.getClass().getMethod("getContents").invoke(hover); + if (!(contents instanceof List list) || list.size() > MAX_COMPONENTS) { + throw new IllegalArgumentException("unsupported hover contents"); + } + List components = new ArrayList<>(); + for (Object content : list) { + Object value = content.getClass().getMethod("getValue").invoke(content); + if (value instanceof BaseComponent[] array) Collections.addAll(components, array); + else if (value instanceof String text) { + if (text.length() > MAX_TEMPLATE) throw new IllegalArgumentException("hover text exceeds limits"); + Collections.addAll(components, TextComponent.fromLegacyText(text)); + } else throw new IllegalArgumentException("unsupported hover text value"); + if (components.size() > MAX_COMPONENTS) throw new IllegalArgumentException("hover text exceeds limits"); + } + return components.toArray(new BaseComponent[0]); + } + } catch (ReflectiveOperationException failure) { + throw new IllegalArgumentException("hover text cannot be safely read", failure); + } + } + + private static String restore(String value, Map tokens, boolean visible) { + return restore(value, tokens, visible, false); + } + + private static String restore(String value, Map tokens, boolean visible, boolean legacySink) { + return restoreTokens(value, tokens, visible, legacySink); + } + + /** Scan only the parser's original output, never the literal strings appended to it. */ + private static String restoreTokens(CharSequence value, Map tokens, boolean visible, boolean legacySink) { + return restoreTokens(value, tokens, visible, legacySink, false); + } + + private static String restoreTokens(CharSequence value, Map tokens, boolean visible, + boolean legacySink, boolean guardInteractiveBoundaries) { + if (value == null || value.isEmpty()) return value == null ? null : ""; + StringBuilder result = new StringBuilder(Math.min(value.length(), MAX_RESULT)); + int cursor = 0; + int copied = 0; + while (cursor < value.length()) { + int end = tokenEnd(value, cursor); + if (end < 0) { + cursor++; + continue; + } + String literal = tokens.get(value.subSequence(cursor, end).toString()); + if (literal == null) { + cursor = end; + continue; + } + appendBounded(result, value, copied, cursor); + // A trusted but incomplete section-sign code must not acquire its + // missing character from an external value at a later legacy sink. + if (legacySink && result.length() > 0 && result.charAt(result.length() - 1) == '§') { + result.setLength(result.length() - 1); + appendBounded(result, "(section)", 0, "(section)".length()); + } + boolean guard = guardInteractiveBoundaries && crossesInteractiveBoundary(result, literal, value, end); + if (guard) appendBounded(result, "\u2060", 0, 1); + int start = 0; + if (visible) { + for (int section = literal.indexOf('§'); section >= 0; section = literal.indexOf('§', start)) { + appendBounded(result, literal, start, section); + appendBounded(result, "(section)", 0, "(section)".length()); + start = section + 1; + } + } + appendBounded(result, literal, start, literal.length()); + if (guard) appendBounded(result, "\u2060", 0, 1); + cursor = end; + copied = end; + } + appendBounded(result, value, copied, value.length()); + return result.toString(); + } + + private static String neutralizeLegacyInteractiveSyntax(String value) { + StringBuilder safe = new StringBuilder(value.length()); + for (int cursor = 0; cursor < value.length(); cursor++) { + if (value.regionMatches(true, cursor, "[Text=\"", 0, "[Text=\"".length())) { + safe.append(value, cursor, cursor + "[Text=".length()).append('\u2060').append('"'); + cursor += "[Text=\"".length() - 1; + continue; + } + char character = value.charAt(cursor); + safe.append(character); + if (character == '"' && cursor + 1 < value.length() + && (value.charAt(cursor + 1) == ']' || value.charAt(cursor + 1) == ',')) safe.append('\u2060'); + } + return safe.toString(); + } + + private static boolean crossesInteractiveBoundary(StringBuilder left, String inserted, CharSequence source, int end) { + int leftStart = Math.max(0, left.length() - "[Text=\"".length()); + String prefix = left.substring(leftStart); + String probe = prefix + inserted + source.subSequence(end, Math.min(source.length(), end + "[Text=\"".length())); + for (String pattern : List.of("[Text=\"", "\"]", "\",")) { + for (int cursor = 0; cursor <= probe.length() - pattern.length(); cursor++) { + boolean match = pattern.equals("[Text=\"") + ? probe.regionMatches(true, cursor, pattern, 0, pattern.length()) : probe.startsWith(pattern, cursor); + if (match && cursor < prefix.length() + inserted.length() && cursor + pattern.length() > prefix.length()) + return true; + } + } + return false; + } + + private static void appendBounded(StringBuilder result, CharSequence source, int start, int end) { + if (end - start > MAX_RESULT - result.length()) + throw new IllegalArgumentException("literal text exceeds limits"); + result.append(source, start, end); + } +} 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 d83500f632..f7ffc10688 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 @@ -19,13 +19,16 @@ import java.util.List; import java.util.Map.Entry; import java.util.Set; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.function.Function; +import java.util.function.Consumer; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; import org.bukkit.Bukkit; @@ -139,6 +142,370 @@ public void broadcastPrepared(Function renderer, String consoleM } } + /** Broadcasts final components on each recipient owner; never reparses literal text. */ + public void broadcastPreparedComponents( + Function renderer, + net.md_5.bungee.api.chat.TextComponent[] consoleLines) { + AdvancedCorePlugin owner = plugin; + broadcastPreparedComponentsAsync(renderer, consoleLines).whenComplete((ignored, failure) -> { + if (failure != null) owner.getLogger().warning("Prepared broadcast could not complete"); + }); + } + + /** + * Completes after global console output and all scheduled recipient deliveries. + * A recipient that disconnects before its callback is skipped. Disable, scheduler + * rejection, rendering/transport failure, and a 30-second deadline fail the stage + * and fence queued callbacks, checking again after rendering; values are never reparsed. + */ + public CompletionStage broadcastPreparedComponentsAsync( + Function renderer, + net.md_5.bungee.api.chat.TextComponent[] consoleLines) { + return broadcastPreparedComponentsAsync(renderer, consoleLines, null); + } + + /** Broadcasts player components and one final, unsplit console string; neither is reparsed. */ + public CompletionStage broadcastPreparedMessageAsync( + Function renderer, + java.util.function.Supplier consoleMessage) { + return broadcastPreparedComponentsAsync(renderer, null, consoleMessage, null); + } + + /** Dispatches one already prepared literal line to the original recipient. */ + public CompletionStage> captureOnlinePlayerIdsAsync(AdvancedCorePlugin owner) { + return captureOnlinePlayerIdsWithDeadline(owner, null); + } + + /** Testable/embedding form with an explicit bounded deadline scheduler. */ + public CompletionStage> captureOnlinePlayerIdsAsync(AdvancedCorePlugin owner, + Consumer timeoutScheduler) { + if (timeoutScheduler == null) return CompletableFuture.failedFuture(new IllegalArgumentException("timeout scheduler is required")); + return captureOnlinePlayerIdsWithDeadline(owner, timeoutScheduler); + } + + private CompletionStage> captureOnlinePlayerIdsWithDeadline(AdvancedCorePlugin owner, + Consumer timeoutScheduler) { + if (owner == null) return CompletableFuture.failedFuture(new IllegalArgumentException("plugin is required")); + CompletableFuture> result = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + try { + if (timeoutScheduler == null) armLiteralDeadline(result, claimed); + else timeoutScheduler.accept(() -> { + if (claimed.compareAndSet(false, true)) result.completeExceptionally( + new TimeoutException("Timed out capturing literal broadcast audience")); + }); + owner.getBukkitScheduler().executeOrScheduleSync(owner, () -> { + if (!claimed.compareAndSet(false, true)) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled while capturing broadcast audience"); + ArrayList ids = new ArrayList<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player != null && player.getUniqueId() != null) ids.add(player.getUniqueId()); + if (ids.size() >= 8192) throw new IllegalArgumentException("Literal broadcast audience is too large"); + } + result.complete(List.copyOf(ids)); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }); + } catch (Throwable failure) { if (claimed.compareAndSet(false, true)) result.completeExceptionally(failure); } + return result; + } + + /** Captures recipient-owned rendering before its durable per-fragment delivery plan is admitted. */ + public CompletionStage capturePreparedRecipientAsync(AdvancedCorePlugin owner, UUID recipientId, + Function renderer) { + if (owner == null || recipientId == null || renderer == null) + return CompletableFuture.failedFuture(new IllegalArgumentException("literal rendering arguments are required")); + CompletableFuture result = new CompletableFuture<>(); + AtomicBoolean finished = new AtomicBoolean(); + AtomicBoolean globalClaim = new AtomicBoolean(); + armLiteralDeadline(result, finished); + try { + owner.getBukkitScheduler().executeOrScheduleSync(owner, () -> { + if (!globalClaim.compareAndSet(false, true) || finished.get()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before recipient rendering"); + Player player = Bukkit.getPlayer(recipientId); + if (finished.get()) return; + if (player == null) { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled while skipping absent recipient"); + if (finished.compareAndSet(false, true)) result.complete(PreparedRecipient.absent()); + return; + } + AtomicBoolean claimed = new AtomicBoolean(); + runBroadcastRecipient(owner, player, () -> { + if (!claimed.compareAndSet(false, true) || finished.get()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before recipient rendering"); + if (!player.isOnline()) { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled while skipping offline recipient"); + if (finished.compareAndSet(false, true)) result.complete(PreparedRecipient.absent()); + return; + } + var rendered = new PreparedRecipient(player, renderer.apply(player)); + if (finished.get() || !owner.isEnabled() || !player.isOnline() || Bukkit.getPlayer(recipientId) != player) + throw new IllegalStateException("Literal recipient rendering is no longer active"); + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before recipient capture completion"); + if (finished.compareAndSet(false, true)) result.complete(rendered); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + }, () -> { + if (claimed.compareAndSet(false, true) && finished.compareAndSet(false, true)) { + try { + CompletableFuture.runAsync(() -> { + if (result.isDone()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before retired recipient capture completion"); + result.complete(PreparedRecipient.absent()); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }); + } catch (Throwable failure) { result.completeExceptionally(failure); } + } + }); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + }); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + return result; + } + + /** Ephemeral owner identity and bounded final components; never persisted in the replay journal. */ + public record PreparedRecipient(Player player, net.md_5.bungee.api.chat.TextComponent[] components) { + public PreparedRecipient { + if (components == null || components.length > 4096) + throw new IllegalArgumentException("Literal recipient components exceed limits"); + components = components.clone(); + } + @Override public net.md_5.bungee.api.chat.TextComponent[] components() { return components.clone(); } + private static PreparedRecipient absent() { return new PreparedRecipient(null, new net.md_5.bungee.api.chat.TextComponent[0]); } + } + + public CompletionStage dispatchPreparedComponentLineAsync(AdvancedCorePlugin owner, UUID recipientId, + Function renderer) { + if (owner == null || recipientId == null || renderer == null) + return CompletableFuture.failedFuture(new IllegalArgumentException("literal delivery arguments are required")); + CompletableFuture result = new CompletableFuture<>(); + AtomicBoolean finished = new AtomicBoolean(); + AtomicBoolean globalClaim = new AtomicBoolean(); + armLiteralDeadline(result, finished); + try { + owner.getBukkitScheduler().executeOrScheduleSync(owner, () -> { + if (!globalClaim.compareAndSet(false, true) || finished.get()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before literal recipient lookup"); + Player player = Bukkit.getPlayer(recipientId); + if (finished.get()) return; + if (player == null) { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled while skipping absent recipient"); + if (finished.compareAndSet(false, true)) result.complete(null); + return; + } + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before literal recipient scheduling"); + AtomicBoolean claimed = new AtomicBoolean(); + runBroadcastRecipient(owner, player, () -> { + if (!claimed.compareAndSet(false, true) || finished.get()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled during literal delivery"); + if (player.isOnline()) { + var line = renderer.apply(player); + if (finished.get() || !owner.isEnabled()) throw new IllegalStateException("Literal delivery is no longer active"); + if (line != null) PlayerUtils.getServerHandle().sendMessage(player, line); + } + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before literal delivery completion"); + if (finished.compareAndSet(false, true)) result.complete(null); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + }, () -> { + if (claimed.compareAndSet(false, true) && finished.compareAndSet(false, true)) { + // Native retirement callbacks run inside critical entity-removal code. + // Keep completion continuations off that callback stack. + try { + CompletableFuture.runAsync(() -> { + if (result.isDone()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before retired literal delivery completion"); + result.complete(null); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }); + } catch (Throwable failure) { result.completeExceptionally(failure); } + } + }); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + }); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + return result; + } + + /** Sends one prepared literal line on the global owner. */ + public CompletionStage dispatchPreparedConsoleLineAsync(AdvancedCorePlugin owner, + net.md_5.bungee.api.chat.TextComponent line) { + return dispatchPreparedConsoleLineAsync(owner, () -> line); + } + + /** Sends one legacy-shaped prepared console payload without parsing rich text JSON. */ + public CompletionStage dispatchPreparedConsoleMessageAsync(AdvancedCorePlugin owner, + java.util.function.Supplier renderer) { + if (owner == null || renderer == null) return CompletableFuture.completedFuture(null); + CompletableFuture result = new CompletableFuture<>(); + AtomicBoolean finished = new AtomicBoolean(); + armLiteralDeadline(result, finished); + try { + owner.getBukkitScheduler().executeOrScheduleSync(owner, () -> { + try { + if (finished.get()) return; + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before literal console delivery"); + String message = renderer.get(); + if (finished.get() || !owner.isEnabled()) throw new IllegalStateException("Plugin disabled during literal console delivery"); + if (message != null && !message.isEmpty()) Bukkit.getConsoleSender().sendMessage(message); + if (finished.compareAndSet(false, true)) result.complete(null); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + }); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + return result; + } + + /** Renders and sends one console line on the global owner. */ + public CompletionStage dispatchPreparedConsoleLineAsync(AdvancedCorePlugin owner, + java.util.function.Supplier renderer) { + if (owner == null || renderer == null) return CompletableFuture.completedFuture(null); + CompletableFuture result = new CompletableFuture<>(); + AtomicBoolean finished = new AtomicBoolean(); + armLiteralDeadline(result, finished); + try { + owner.getBukkitScheduler().executeOrScheduleSync(owner, () -> { + try { + if (finished.get()) return; + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before literal console delivery"); + var line = renderer.get(); + if (finished.get() || !owner.isEnabled()) throw new IllegalStateException("Plugin disabled during literal console delivery"); + if (line != null) Bukkit.getConsoleSender().sendMessage(line.toLegacyText()); + if (finished.compareAndSet(false, true)) result.complete(null); + } + catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + }); + } catch (Throwable failure) { if (finished.compareAndSet(false, true)) result.completeExceptionally(failure); } + return result; + } + + private CompletionStage broadcastPreparedComponentsAsync( + Function renderer, + net.md_5.bungee.api.chat.TextComponent[] consoleLines, Consumer timeoutScheduler) { + return broadcastPreparedComponentsAsync(renderer, consoleLines, null, timeoutScheduler); + } + + private CompletionStage broadcastPreparedComponentsAsync( + Function renderer, + net.md_5.bungee.api.chat.TextComponent[] consoleLines, + java.util.function.Supplier consoleMessage, Consumer timeoutScheduler) { + if (renderer == null) throw new IllegalArgumentException("renderer is required"); + AdvancedCorePlugin owner = plugin; + BroadcastCompletion delivery = new BroadcastCompletion(); + AtomicBoolean globalClaim = new AtomicBoolean(); + var consoleSnapshot = consoleLines == null ? null : consoleLines.clone(); + try { + if (timeoutScheduler == null) armLiteralDeadline(delivery.completion, delivery.closed); + else timeoutScheduler.accept(() -> delivery.fail( + new TimeoutException("Timed out waiting for prepared broadcast delivery"))); + owner.getBukkitScheduler().executeOrScheduleSync(owner, () -> { + if (!globalClaim.compareAndSet(false, true) || delivery.closed.get()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before broadcast dispatch"); + for (Player player : new ArrayList<>(Bukkit.getOnlinePlayers())) { + if (delivery.closed.get()) break; + delivery.remaining.incrementAndGet(); + AtomicBoolean recipientClaim = new AtomicBoolean(); + try { + runBroadcastRecipient(owner, player, () -> { + if (!recipientClaim.compareAndSet(false, true) || delivery.closed.get()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before broadcast delivery"); + if (player.isOnline()) { + var lines = renderer.apply(player); + if (lines != null) for (var line : lines) { + if (delivery.closed.get()) break; + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled while rendering broadcast"); + if (line != null) PlayerUtils.getServerHandle().sendMessage(player, line); + } + } + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before broadcast recipient completion"); + } catch (Throwable failure) { delivery.fail(failure); } + finally { delivery.settle(); } + }, () -> { + if (recipientClaim.compareAndSet(false, true) && !delivery.closed.get()) { + // Native retirement runs in critical entity-removal code. + // Complete outside it so reward continuations cannot run there. + try { + CompletableFuture.runAsync(() -> { + if (delivery.closed.get()) return; + try { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before retired broadcast recipient completion"); + delivery.settle(); + } catch (Throwable failure) { delivery.fail(failure); } + }); + } catch (Throwable failure) { delivery.fail(failure); } + } + }); + } catch (Throwable failure) { delivery.fail(failure); delivery.settle(); } + } + if (consoleMessage != null && !delivery.closed.get()) { + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled before broadcast console rendering"); + String message = consoleMessage.get(); + if (!owner.isEnabled()) throw new IllegalStateException("Plugin disabled while rendering broadcast"); + if (!delivery.closed.get() && message != null && !message.isEmpty()) + Bukkit.getConsoleSender().sendMessage(message); + } + if (consoleSnapshot != null) for (var line : consoleSnapshot) { + if (delivery.closed.get()) break; + if (line != null) Bukkit.getConsoleSender().sendMessage(line.toLegacyText()); + } + } catch (Throwable failure) { delivery.fail(failure); } + finally { delivery.settle(); } + }); + } catch (Throwable failure) { delivery.fail(failure); } + return delivery.completion; + } + + private static void runBroadcastRecipient(AdvancedCorePlugin owner, Player player, + Runnable action, Runnable retired) throws ReflectiveOperationException { + var scheduler = owner.getBukkitScheduler(); + var folia = scheduler.getFoliaLib(); + if (folia == null || !folia.isFolia()) { + scheduler.executeOrScheduleSync(owner, action, player); + return; + } + // Use the supported native retirement callback without linking Paper-only + // classes on Spigot. FoliaLib's void wrapper cannot report retired entities. + var getter = player.getClass().getMethod("getScheduler"); + Object entityScheduler = getter.invoke(player); + var execute = getter.getReturnType().getMethod("execute", org.bukkit.plugin.Plugin.class, + Runnable.class, Runnable.class, long.class); + try { + boolean admitted = (Boolean) execute.invoke(entityScheduler, owner, action, retired, 1L); + if (!admitted) retired.run(); + } catch (java.lang.reflect.InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + if (cause instanceof Error error) throw error; + throw failure; + } + } + + /** orTimeout cancels its scheduled deadline when the operation settles, including failure/cancellation. */ + private static void armLiteralDeadline(CompletableFuture completion, AtomicBoolean settled) { + completion.orTimeout(30L, TimeUnit.SECONDS).whenComplete((ignored, failure) -> settled.set(true)); + } + + private static final class BroadcastCompletion { + private final CompletableFuture completion = new CompletableFuture<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicInteger remaining = new AtomicInteger(1); + private BroadcastCompletion() { + completion.whenComplete((ignored, failure) -> { if (completion.isCancelled()) closed.set(true); }); + } + private void fail(Throwable failure) { + if (closed.compareAndSet(false, true)) completion.completeExceptionally(failure); + } + private void settle() { + if (remaining.decrementAndGet() == 0 && closed.compareAndSet(false, true)) completion.complete(null); + } + } + 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/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index d3fa202130..f95b0ef0ad 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java @@ -710,9 +710,40 @@ public static CompletionStage replayNestedRewardSequence(AdvancedCorePlugi HashMap placeholders, String lane, List configuredRewards, ReplayState replayState, String activeKey, BiFunction> dispatch) { + return replayNestedRewardSequence(plugin, placeholders, lane, configuredRewards, replayState, activeKey, + dispatch, null, Integer.MAX_VALUE, Integer.MAX_VALUE, Long.MAX_VALUE); + } + + /** + * Executes a nested sequence with a bounded, validated delivery plan. This is + * the same replay/checkpoint engine as the ordinary nested sequence; the + * validator is applied to both a newly created plan and a restored snapshot + * before the first child is dispatched. + */ + public static CompletionStage replayNestedRewardSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List configuredRewards, + ReplayState replayState, String activeKey, + BiFunction> dispatch, + java.util.function.Predicate entryValidator, int maxEntries, int maxEntryCharacters, + long maxTotalCharacters) { + List admission = configuredRewards == null ? List.of() : configuredRewards; + java.util.HashSet distinctAdmission = entryValidator == null ? null : new java.util.HashSet<>(); + if (admission.size() > maxEntries) return CompletableFuture.failedFuture( + new IllegalArgumentException("Nested replay plan exceeds entry limit")); + long admissionCharacters = 0; + for (String entry : admission) { + if (distinctAdmission != null && !distinctAdmission.add(entry)) return CompletableFuture.failedFuture( + new IllegalArgumentException("Nested replay plan contains a duplicate entry")); + if (entry == null || entry.length() > maxEntryCharacters + || (entryValidator != null && !entryValidator.test(entry))) + return CompletableFuture.failedFuture(new IllegalArgumentException("Nested replay plan contains an invalid entry")); + admissionCharacters += entry.length(); + if (admissionCharacters > maxTotalCharacters) return CompletableFuture.failedFuture( + new IllegalArgumentException("Nested replay plan exceeds character limit")); + } if (replayState == null || activeKey == null) { CompletionStage sequence = CompletableFuture.completedFuture(null); - List rewards = configuredRewards == null ? List.of() : configuredRewards; + List rewards = admission; for (int index = 0; index < rewards.size(); index++) { String rewardName = rewards.get(index); int rewardIndex = index; @@ -722,8 +753,44 @@ public static CompletionStage replayNestedRewardSequence(AdvancedCorePlugi } String stableLane = lane == null ? "nested" : lane; String storageKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, stableLane); + String snapshotKey = storageKey + "_snapshot"; + if (entryValidator != null && replayMetadata(placeholders, replayState, snapshotKey) == null) { + // Admission observes the union, including state-only markers from nested injectors. + // Caller placeholders have the same precedence as replayMetadata(). + HashMap projected = new HashMap<>(); + replayState.mergeReplayMetadataInto(projected); + mergeReplayMetadata(projected, placeholders); + projected.put(snapshotKey, encodeCommandSnapshot(admission)); + projected.put(storageKey, Integer.toString(maxEntries)); + try { + copyWithoutReplayMetadata(projected); + long characters = 0; + for (Entry entry : projected.entrySet()) + characters += entry.getKey().length() + (entry.getValue() == null ? 0L : entry.getValue().length()); + // Leave room for the outer action's completion marker after this plan finishes. + if (characters > 1048576L - 4096L) throw new IllegalArgumentException("Nested replay metadata exceeds limits"); + } catch (IllegalArgumentException failure) { + return CompletableFuture.failedFuture(failure); + } + } return replayNestedRewardSnapshot(plugin, placeholders, stableLane, configuredRewards, replayState, activeKey) .thenCompose(rewards -> { + java.util.HashSet distinctRestored = entryValidator == null ? null : new java.util.HashSet<>(); + if (rewards.size() > maxEntries) return CompletableFuture.failedFuture( + new IllegalArgumentException("Nested replay plan exceeds entry limit")); + long totalCharacters = 0; + for (String reward : rewards) { + if (distinctRestored != null && !distinctRestored.add(reward)) return CompletableFuture.failedFuture( + new IllegalStateException("Nested replay plan contains a duplicate entry")); + if (reward == null || reward.length() > maxEntryCharacters + || (entryValidator != null && !entryValidator.test(reward))) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Nested replay plan contains an invalid entry")); + } + totalCharacters += reward.length(); + if (totalCharacters > maxTotalCharacters) return CompletableFuture.failedFuture( + new IllegalArgumentException("Nested replay plan exceeds character limit")); + } String storedProgress = replayMetadata(placeholders, replayState, storageKey); int completed; try { @@ -753,6 +820,15 @@ public static CompletionStage replayNestedRewardSequence(AdvancedCorePlugi }); } + private static long replayPlaceholderCharacterCount(HashMap placeholders) { + if (placeholders == null) return 0L; + long total = 0; + for (Entry entry : placeholders.entrySet()) if (isReplayMetadataKey(entry.getKey())) + total += (entry.getKey() == null ? 0 : entry.getKey().length()) + + (entry.getValue() == null ? 0 : entry.getValue().length()); + return total; + } + /** * Dispatches one nested child and persists its completion before the parent * injector can complete. A retry consults this marker before resolving the @@ -810,6 +886,27 @@ private static String replaySequenceKey(String prefix, String activeKey, String return prefix + digest(activeKey.length() + ":" + activeKey + lane.length() + ":" + lane); } + /** Read-only capacity projection for plans owned by this replay engine. */ + public record ReplayMetadataSize(long characters, int entries) { } + + public static ReplayMetadataSize replayMetadataSize(HashMap placeholders, ReplayState state) { + HashMap metadata = new HashMap<>(); + if (state != null) state.mergeReplayMetadataInto(metadata); + mergeReplayMetadata(metadata, placeholders); + copyWithoutReplayMetadata(metadata); + long characters = 0; + for (Entry entry : metadata.entrySet()) + characters += entry.getKey().length() + (entry.getValue() == null ? 0L : entry.getValue().length()); + return new ReplayMetadataSize(characters, metadata.size()); + } + + /** Includes snapshot keys and the largest progress value used by a bounded 8192-step plan. */ + public static ReplayMetadataSize nestedRewardPlanMetadataSize(String activeKey, String lane, List plan) { + String key = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, lane); + return new ReplayMetadataSize(2L * key.length() + "_snapshot".length() + + encodeCommandSnapshot(plan).length() + "8192".length(), 2); + } + /** Returns whether the active replay already froze this logical command lane. */ public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane) { ReplayState replayState = currentReplayState(); @@ -864,8 +961,12 @@ public static boolean hasReplayNestedRewardSnapshot(HashMap plac /** Returns whether every child in a frozen nested-reward lane is complete. */ public static boolean hasCompletedNestedRewardSequence(HashMap placeholders, String lane) { - ReplayState replayState = currentReplayState(); - String activeKey = currentReplayKey(); + return hasCompletedNestedRewardSequence(placeholders, lane, currentReplayState(), currentReplayKey()); + } + + /** Consult explicit captured replay ownership when asynchronous continuations have no thread-local lane. */ + public static boolean hasCompletedNestedRewardSequence(HashMap placeholders, String lane, + ReplayState replayState, String activeKey) { if (replayState == null || activeKey == null) return false; String storageKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, lane == null ? "nested" : lane); @@ -966,6 +1067,30 @@ private static String digest(String value) { } } + /** Copies display/action bindings while independently bounding durable replay metadata. */ + public static HashMap copyWithoutReplayMetadata(HashMap source) { + if (source == null) return new HashMap<>(); + if (source.size() > 16384) throw new IllegalArgumentException("Reward binding map exceeds limits"); + int displayEntries = 0, replayEntries = 0; + long displayCharacters = 0, replayCharacters = 0; + for (Entry entry : source.entrySet()) { + long characters = (entry.getKey() == null ? 0L : entry.getKey().length()) + + (entry.getValue() == null ? 0L : entry.getValue().length()); + if (isReplayMetadataKey(entry.getKey())) { + replayCharacters += characters; + if (++replayEntries > 8192 || replayCharacters > 1048576) + throw new IllegalArgumentException("Reward replay metadata exceeds limits"); + } else { + displayCharacters += characters; + if (++displayEntries > 8192 || displayCharacters > 1048576) + throw new IllegalArgumentException("Reward display bindings exceed limits"); + } + } + HashMap result = new HashMap<>(source); + result.keySet().removeIf(Reward::isReplayMetadataKey); + return result; + } + /** Copies only durable replay markers; ordinary reward placeholders stay isolated. */ public static void mergeReplayMetadata(HashMap target, Map source) { if (target == null || source == null) return; @@ -1103,6 +1228,13 @@ public synchronized void mergeReplayMetadataInto(HashMap target) Reward.mergeReplayMetadata(target, replayMetadata); } public synchronized String replayMetadata(String key) { return replayMetadata.get(key); } + private synchronized long replayMetadataCharacterCount() { + long total = 0; + for (Entry entry : replayMetadata.entrySet()) + total += (entry.getKey() == null ? 0 : entry.getKey().length()) + + (entry.getValue() == null ? 0 : entry.getValue().length()); + return total; + } private synchronized boolean hasPersistedCheckpoint() { return legacyCheckpoint || !completed.isEmpty() || !registryFingerprints.isEmpty(); } 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 11583224f5..8cd9ca4561 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 @@ -184,6 +184,12 @@ public RewardBuilder withDisplayPlaceHolder(String toReplace, String replaceWith return this; } + /** Adds a literal display value while preserving a safe fallback for old queues. */ + public RewardBuilder withLiteralDisplayPlaceHolder(String key, String literal, String legacySafeDisplay) { + rewardOptions.addLiteralDisplayPlaceholder(key, literal, legacySafeDisplay); + 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 index c5792e385b..db2cd49d97 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardDisplayPlaceholders.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardDisplayPlaceholders.java @@ -14,6 +14,7 @@ import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue; import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.messages.LiteralMessageTemplate; import com.bencodez.simpleapi.messages.MessageAPI; /** @@ -28,6 +29,10 @@ public final class RewardDisplayPlaceholders { 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 String LITERAL_PREFIX = PREFIX + "literal:"; + private static final int MAX_LITERAL_CHARACTERS = 8192; + // At most three UTF-8 bytes per UTF-16 unit, four Base64 characters per three bytes, plus prefix. + private static final int MAX_ENCODED_LITERAL_CHARACTERS = MAX_LITERAL_CHARACTERS * 4 + 1; private static final Pattern MESSAGE_ATTRIBUTE = Pattern.compile( "(?i)(command|url|suggest_command|hover)\\s*=\\s*\"([^\"]*)\""); @@ -39,9 +44,268 @@ public static void put(HashMap placeholders, String key, String if (placeholders == null || key == null) return; placeholders.put(MANIFEST_KEY, MANIFEST_VALUE); placeholders.keySet().removeIf(existing -> isEncodedOverrideFor(existing, key)); + placeholders.keySet().removeIf(existing -> isEncodedLiteralFor(existing, key)); placeholders.put(VALUE_PREFIX + encode(key), encode(value == null ? "" : value)); } + /** Adds an explicit literal display binding while retaining the v1 fallback value. */ + public static void putLiteral(HashMap placeholders, String key, String literal, + String legacySafeDisplay) { + if (placeholders == null || key == null) return; + if (!key.matches("[A-Za-z_][A-Za-z0-9_]{0,127}") || (literal != null && literal.length() > MAX_LITERAL_CHARACTERS) + || (legacySafeDisplay != null && legacySafeDisplay.length() > 32768)) + throw new IllegalArgumentException("invalid literal binding"); + put(placeholders, key, legacySafeDisplay == null ? "" : legacySafeDisplay); + placeholders.put(LITERAL_PREFIX + encode(key), encode(literal == null ? "" : literal)); + } + + private static boolean isEncodedLiteralFor(String encodedKey, String displayKey) { + if (encodedKey == null || !encodedKey.startsWith(LITERAL_PREFIX)) return false; + try { return decode(encodedKey.substring(LITERAL_PREFIX.length())).equalsIgnoreCase(displayKey); } + catch (IllegalArgumentException ignored) { return false; } + } + + public static boolean hasLiteralValues(HashMap placeholders) { + return hasDisplayValues(placeholders) && placeholders.keySet().stream() + .anyMatch(key -> key != null && key.startsWith(LITERAL_PREFIX)); + } + + /** Parses trusted formatting before literal values enter final components. Caller owns the player. */ + public static net.md_5.bungee.api.chat.TextComponent[] renderLiteralComponents(Player player, String template, + HashMap placeholders) { + LiteralPreparation preparation = prepareLiteral(template, placeholders); + var prepared = preparation.prepared(); + var legacy = preparation.legacy(); + var rendering = legacy.renderer(); + prepared = prepared.withTemplate(renderInteractiveBounded(prepared.template(), + text -> legacy.revealDisplayLineSeparators(text, false), Function.identity())); + prepared = prepared.protectFormattedActions(value -> renderIndexedExact(player, value, rendering)); + String parsed; + if (legacy.hasDisplayValues()) { + // Evaluate visible provider/script output before splitting, as the legacy + // prepared-message path does. Exact attributes and literal values remain opaque. + parsed = renderInteractiveBounded(prepared.template(), + value -> MessageAPI.colorize(renderIndexedVisible(player, value, rendering)), Function.identity()); + } else { + String text = rendering.protectAfterOrdinary(prepared.template(), false, true); + parsed = player == null ? PlaceholderUtils.parseText(text) : PlaceholderUtils.parseText(player, text); + } + prepared = prepared.withTemplate(parsed); + var budget = new LiteralMessageTemplate.OutputBudget(); + budget.admit(prepared, false); + java.util.ArrayList result = new java.util.ArrayList<>(); + for (var line : prepared.splitLines()) { + result.add(budget.finish(line, () -> PlaceholderUtils.parseJson(line.template()))); + } + return result.toArray(new net.md_5.bungee.api.chat.TextComponent[0]); + } + + /** Captures source-player placeholders once on that player's owner before recipient handoffs. */ + public static LiteralBroadcast prepareLiteralBroadcast(Player triggeringPlayer, String template, + HashMap placeholders) { + LiteralPreparation preparation = prepareLiteral(template, placeholders); + var legacy = preparation.legacy(); + var rendering = legacy.renderer(); + var prepared = preparation.prepared(); + prepared = prepared.withTemplate(renderInteractiveBounded(prepared.template(), + text -> legacy.revealDisplayLineSeparators(text, true), Function.identity())); + prepared = prepared.withTemplate(renderInteractiveBounded(prepared.template(), + value -> rendering.protectAfterOrdinary(value, false, false), value -> rendering.protect(value, true, false))); + if (triggeringPlayer != null) prepared = prepared.withTemplate( + PlaceholderUtils.replacePlaceHolders(triggeringPlayer, prepared.template())); + return new LiteralBroadcast(prepared, legacy); + } + + /** Immutable protected template and indexes; retains no player, user, or caller-owned map reference. */ + public static final class LiteralBroadcast { + private final LiteralMessageTemplate.Prepared prepared; + private final java.util.List lines; + private final LiteralMessageTemplate.LegacyBindings legacy; + private LiteralBroadcast(LiteralMessageTemplate.Prepared prepared, LiteralMessageTemplate.LegacyBindings legacy) { + this.prepared = prepared; + this.lines = prepared.splitBroadcastLines(); + this.legacy = legacy; + } + /** Recipient placeholders must run on that recipient's owner; console uses null on the global owner. */ + public net.md_5.bungee.api.chat.TextComponent[] render(Player recipient) { + var budget = new LiteralMessageTemplate.OutputBudget(); + java.util.ArrayList result = new java.util.ArrayList<>(); + for (int index = 0; index < lineCount(); index++) { + for (var fragment : renderRecipientLineFragments(recipient, index, budget)) { + if (result.size() >= 4096) throw new IllegalArgumentException("Literal broadcast has too many rendered lines"); + result.add(fragment); + } + } + return result.toArray(new net.md_5.bungee.api.chat.TextComponent[0]); + } + + public int lineCount() { return lines.size(); } + + /** Recreates the old single console payload; rich JSON syntax remains literal. */ + public String renderConsoleMessage() { + return renderInteractiveBounded(prepared.template(), + value -> prepared.finishText(MessageAPI.colorize(legacy.finish(value, false))), + value -> prepared.finishActionText(MessageAPI.colorize(legacy.finish(value, true)))); + } + + public net.md_5.bungee.api.chat.TextComponent renderLine(Player recipient, int index) { + var line = renderProtectedLine(recipient, index); + return line.finish(PlaceholderUtils.parseJson(line.template())); + } + + /** Expand recipient placeholders before splitting; exact actions and literal values stay protected. */ + public net.md_5.bungee.api.chat.TextComponent[] renderRecipientLineFragments(Player recipient, int index) { + return renderRecipientLineFragments(recipient, index, new LiteralMessageTemplate.OutputBudget()); + } + + private net.md_5.bungee.api.chat.TextComponent[] renderRecipientLineFragments(Player recipient, int index, + LiteralMessageTemplate.OutputBudget budget) { + var preparedLine = renderProtectedLine(recipient, index); + budget.admit(preparedLine, true); + var fragments = preparedLine.splitBroadcastLines(); + if (fragments.size() > 4096) throw new IllegalArgumentException("Literal broadcast has too many rendered lines"); + var result = new net.md_5.bungee.api.chat.TextComponent[fragments.size()]; + for (int fragment = 0; fragment < result.length; fragment++) { + var line = fragments.get(fragment); + result[fragment] = budget.finish(line, () -> PlaceholderUtils.parseJson(line.template())); + } + return result; + } + + private LiteralMessageTemplate.Prepared renderProtectedLine(Player recipient, int index) { + if (index < 0 || index >= lines.size()) throw new IllegalArgumentException("broadcast line index out of range"); + var line = lines.get(index); + String text; + if (legacy.hasDisplayValues()) { + line = line.protectFormattedActions(value -> finishIndexedBroadcast(value, recipient, legacy, true)); + text = renderInteractiveBounded(line.template(), + value -> MessageAPI.colorize(finishIndexedBroadcast(value, recipient, legacy, false)), Function.identity()); + } else { + text = recipient == null ? line.template() : PlaceholderUtils.replacePlaceHolders(recipient, line.template()); + text = MessageAPI.colorize(text); + } + return line.withTemplate(text); + } + } + + private static LiteralPreparation prepareLiteral(String template, HashMap placeholders) { + placeholders = Reward.copyWithoutReplayMetadata(placeholders); + Map literals = literalValues(placeholders); + Map actions = new HashMap<>(); + if (placeholders != null) for (Map.Entry entry : placeholders.entrySet()) { + if (entry.getKey() != null && literals.containsKey(LiteralMessageTemplate.normalizeBindingKey(entry.getKey()))) + actions.put(entry.getKey(), entry.getValue() == null ? "" : entry.getValue()); + } + var legacy = indexedLegacyValues(legacyPlaceholders(placeholders, literals.keySet())); + var prepared = LiteralMessageTemplate.prepareRewardDisplay(template, literals, actions, legacy); + return new LiteralPreparation(prepared, legacy.bind(prepared)); + } + + private record LiteralPreparation(LiteralMessageTemplate.Prepared prepared, + LiteralMessageTemplate.LegacyBindings legacy) { } + + /** Decode and admit the mixed lane once; avoid rebuilding or probing the override table for every segment. */ + private static LiteralMessageTemplate.LegacyValues indexedLegacyValues(HashMap placeholders) { + if (placeholders.size() > 8192) throw new IllegalArgumentException("literal metadata exceeds limits"); + Map display = new java.util.LinkedHashMap<>(); + Map displayKeys = new HashMap<>(); + Map exact = new java.util.LinkedHashMap<>(); + Map ordinary = new HashMap<>(); + int characters = 0; + if (hasDisplayValues(placeholders)) for (Map.Entry entry : placeholders.entrySet()) { + String key = entry.getKey(); + if (key == null || !key.startsWith(VALUE_PREFIX)) continue; + if (key.length() > VALUE_PREFIX.length() + 4097 || entry.getValue() == null + || entry.getValue().length() > 131073) throw new IllegalArgumentException("legacy metadata exceeds limits"); + String decodedKey; + String value; + try { + decodedKey = decode(key.substring(VALUE_PREFIX.length())); + value = decode(entry.getValue()); + } catch (IllegalArgumentException ignored) { + // Malformed v1 metadata keeps its historical ignored behavior. + continue; + } + if (decodedKey.isEmpty() || decodedKey.length() > 1024 || value.length() > 32768) + throw new IllegalArgumentException("legacy metadata exceeds limits"); + characters += decodedKey.length() + value.length(); + if (characters > 1048576) throw new IllegalArgumentException("legacy metadata exceeds limits"); + String normalized = LiteralMessageTemplate.normalizeBindingKey(decodedKey); + if (displayKeys.putIfAbsent(normalized, decodedKey) == null) display.put(decodedKey, value); + } + for (Map.Entry entry : placeholders.entrySet()) { + String key = entry.getKey(); + if (key == null || key.equals(MANIFEST_KEY) || key.startsWith(VALUE_PREFIX) || key.startsWith(LITERAL_PREFIX)) continue; + String value = entry.getValue() == null ? "" : entry.getValue(); + if (key.isEmpty() || key.length() > 1024 || value.length() > 32768) + throw new IllegalArgumentException("legacy bindings exceed limits"); + characters += key.length() + value.length(); + if (characters > 1048576) throw new IllegalArgumentException("legacy bindings exceed limits"); + String normalized = LiteralMessageTemplate.normalizeBindingKey(key); + if (displayKeys.containsKey(normalized)) exact.put(normalized, value); + else ordinary.put(key, value); + } + return new LiteralMessageTemplate.LegacyValues(display, exact, ordinary); + } + + private static String renderIndexedVisible(Player player, String source, LiteralMessageTemplate.LegacyBindings.Renderer values) { + String protectedValue = values.protectAfterOrdinary(source, false, true); + String parsed = player == null ? PlaceholderUtils.parseText(protectedValue) : PlaceholderUtils.parseText(player, protectedValue); + return values.finish(parsed, false); + } + + private static String renderIndexedExact(Player player, String source, LiteralMessageTemplate.LegacyBindings.Renderer values) { + String protectedValue = values.protect(source, true, true); + String parsed = player == null ? PlaceholderUtils.parseText(protectedValue) : PlaceholderUtils.parseText(player, protectedValue); + return values.finish(parsed, true); + } + + private static String finishIndexedBroadcast(String source, Player recipient, LiteralMessageTemplate.LegacyBindings values, + boolean action) { + String parsed = recipient == null ? source : PlaceholderUtils.replacePlaceHolders(recipient, source); + return values.finish(MessageAPI.colorize(parsed), action); + } + + /** Snapshot only the existing legacy lane; explicit literal bindings remain protected separately. */ + private static HashMap legacyPlaceholders(HashMap placeholders, java.util.Set literals) { + HashMap legacy = placeholders == null ? new HashMap<>() : new HashMap<>(placeholders); + HashMap replay = new HashMap<>(); + Reward.mergeReplayMetadata(replay, legacy); + legacy.keySet().removeAll(replay.keySet()); + legacy.keySet().removeIf(key -> { + if (key == null) return false; + if (key.startsWith(LITERAL_PREFIX) || literals.contains(LiteralMessageTemplate.normalizeBindingKey(key))) return true; + if (!key.startsWith(VALUE_PREFIX)) return false; + try { return literals.contains(LiteralMessageTemplate.normalizeBindingKey(decode(key.substring(VALUE_PREFIX.length())))); } + catch (IllegalArgumentException ignored) { return false; } + }); + return legacy; + } + + private static Map literalValues(HashMap placeholders) { + Map values = new HashMap<>(); + int characters = 0; + if (!hasLiteralValues(placeholders)) return values; + if (placeholders.size() > 8192) throw new IllegalArgumentException("literal metadata exceeds limits"); + for (Map.Entry entry : placeholders.entrySet()) { + String key = entry.getKey(); + if (key == null || !key.startsWith(LITERAL_PREFIX)) continue; + if (key.length() > 1024 || entry.getValue() == null || entry.getValue().length() > MAX_ENCODED_LITERAL_CHARACTERS) + throw new IllegalArgumentException("literal metadata exceeds limits"); + String decodedKey = decode(key.substring(LITERAL_PREFIX.length())); + if (!decodedKey.matches("[A-Za-z_][A-Za-z0-9_]{0,127}")) + throw new IllegalArgumentException("invalid literal binding"); + String decoded = LiteralMessageTemplate.normalizeBindingKey(decodedKey); + String literal = decode(entry.getValue()); + characters += decodedKey.length() + literal.length(); + if (characters > 1048576) throw new IllegalArgumentException("literal metadata exceeds limits"); + if (literal.length() > MAX_LITERAL_CHARACTERS) throw new IllegalArgumentException("literal metadata exceeds limits"); + if (values.putIfAbsent(decoded, literal) != null) + throw new IllegalArgumentException("duplicate literal binding"); + } + return values; + } + /** 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)); @@ -59,7 +323,7 @@ public static HashMap forDisplay(HashMap placeho for (Map.Entry entry : placeholders.entrySet()) { String key = entry.getKey(); if (!hasDisplayValues || key == null - || (!key.equals(MANIFEST_KEY) && !key.startsWith(VALUE_PREFIX))) { + || (!key.equals(MANIFEST_KEY) && !key.startsWith(VALUE_PREFIX) && !key.startsWith(LITERAL_PREFIX))) { display.put(key, entry.getValue()); } } @@ -137,6 +401,18 @@ public static String replaceFormattedBroadcast(Player triggeringPlayer, Player r value -> renderBroadcastExact(triggeringPlayer, recipient, value, placeholders)); } + private static String renderInteractiveBounded(String template, Function visibleRenderer, + Function exactRenderer) { + int[] characters = { template.length() }; + Function, Function> bounded = renderer -> source -> { + String rendered = renderer.apply(source); + characters[0] += rendered.length() - source.length(); + if (characters[0] > 262144) throw new IllegalArgumentException("legacy text exceeds limits"); + return rendered; + }; + return renderInteractive(template, bounded.apply(visibleRenderer), bounded.apply(exactRenderer)); + } + private static String renderInteractive(String template, Function visibleRenderer, Function exactRenderer) { StringBuilder rendered = new StringBuilder(template.length()); @@ -292,7 +568,7 @@ private static VisibleValues visibleValues(HashMap 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 (key == null || key.equals(MANIFEST_KEY) || key.startsWith(VALUE_PREFIX) || key.startsWith(LITERAL_PREFIX)) continue; if (overrides.keySet().stream().noneMatch(override -> override.equalsIgnoreCase(key))) { ordinary.put(key, entry.getValue()); } @@ -307,7 +583,7 @@ private static VisibleValues exactValues(HashMap 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 (key == null || key.equals(MANIFEST_KEY) || key.startsWith(VALUE_PREFIX) || key.startsWith(LITERAL_PREFIX)) continue; String overrideKey = display.keySet().stream().filter(candidate -> candidate.equalsIgnoreCase(key)) .findFirst().orElse(null); if (overrideKey == null) ordinary.put(key, entry.getValue()); 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 c174557e8b..0fb6dbd1ef 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 @@ -119,6 +119,12 @@ public RewardOptions addDisplayPlaceholder(String key, String value) { return this; } + /** Adds a literal display value and a safe v1 fallback for older queued readers. */ + public RewardOptions addLiteralDisplayPlaceholder(String key, String literal, String legacySafeDisplay) { + RewardDisplayPlaceholders.putLiteral(getPlaceholders(), key, literal, legacySafeDisplay); + return this; + } + public RewardOptions disableDefaultWorlds() { useDefaultWorlds = false; return this; 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 dfc236bd64..7cb1837c1e 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 @@ -75,7 +75,11 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { - sendPlayerMessage(user, value.get(ThreadLocalRandom.current().nextInt(0, value.size())), placeholders); + if (RewardDisplayPlaceholders.hasLiteralValues(placeholders)) { + user.sendLiteralRandomDisplayMessage(value, placeholders); + } else { + sendPlayerMessage(user, value.get(ThreadLocalRandom.current().nextInt(0, value.size())), placeholders); + } return null; } }.requiresPlayerWhen((data, placeholders) -> !data.getStringList("RandomMessage").isEmpty())); @@ -135,6 +139,10 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection private static void sendPlayerMessage(AdvancedCoreUser user, String message, HashMap placeholders) { + if (RewardDisplayPlaceholders.hasLiteralValues(placeholders)) { + user.sendLiteralDisplayMessage(message, placeholders); + return; + } if (RewardDisplayPlaceholders.hasDisplayValues(placeholders)) { user.sendPreparedMessage( RewardDisplayPlaceholders.replaceFormattedMessage(user.getPlayer(), message, placeholders)); @@ -154,6 +162,10 @@ private static void sendPlayerMessages(AdvancedCoreUser user, ArrayList private static void broadcast(AdvancedCoreUser user, String message, HashMap placeholders) { MiscUtils misc = MiscUtils.getInstance(); + if (RewardDisplayPlaceholders.hasLiteralValues(placeholders)) { + user.sendLiteralDisplayBroadcast(message, placeholders); + return; + } if (RewardDisplayPlaceholders.hasDisplayValues(placeholders)) { misc.broadcastPrepared( player -> RewardDisplayPlaceholders.replaceFormattedBroadcast( 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 40a8f6a43c..80e3c9d05f 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 @@ -12,6 +12,7 @@ import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.UUID; @@ -94,6 +95,8 @@ public static final class AsyncActionCollection { private final String snapshotKey; private final AdvancedCorePlugin plugin; private boolean closed; + private IllegalArgumentException admissionFailure; + private HashMap registrationSnapshot; private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser owner, Reward.ReplayState replayState, HashMap placeholders, String injectionKey, AdvancedCorePlugin plugin) { @@ -107,13 +110,52 @@ private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser o } private synchronized boolean add(Supplier> action, String descriptor) { - if (closed) return false; + return addRegistration(action, descriptor) != null; + } + + private synchronized ReplayRegistration addRegistration(Supplier> action, String descriptor) { + if (closed) return null; + if (actions.size() >= 8192) { + admissionFailure = new IllegalArgumentException("Legacy action collection exceeds limits"); + throw admissionFailure; + } String fingerprint = Reward.legacyActionFingerprint(descriptor); int occurrence = actionOccurrences.getOrDefault(fingerprint, 0); actionOccurrences.put(fingerprint, occurrence + 1); - actions.add(new ReplayAction(fingerprint + "/" + occurrence, fingerprint, action, + String identity = fingerprint + "/" + occurrence; + actions.add(new ReplayAction(identity, fingerprint, action, !descriptor.startsWith("failure:"))); - return true; + boolean previouslyReserved = false; + try { previouslyReserved = registrationSnapshot().containsKey(identity); } + catch (IllegalArgumentException failure) { throw new IllegalStateException("Malformed legacy action snapshot", failure); } + return new ReplayRegistration(identity, replayState, placeholders, plugin, previouslyReserved, checkpointKey); + } + + /** Capture reservation membership once; final admission still validates current persisted state. */ + private HashMap registrationSnapshot() { + if (registrationSnapshot == null) registrationSnapshot = snapshot(readReplayValue(snapshotKey)); + return registrationSnapshot; + } + + /** Recover the configured choice of older literal random-message checkpoints. */ + private String reservedLiteralMessage(List templates) { + HashMap reserved; + HashMap occurrences; + synchronized (this) { + reserved = registrationSnapshot(); + occurrences = new HashMap<>(actionOccurrences); + } + if (reserved.isEmpty()) return null; + for (String template : templates) { + String fingerprint = Reward.legacyActionFingerprint(literalMessageDescriptor(template, false)); + int occurrence = occurrences.getOrDefault(fingerprint, 0); + if (fingerprint.equals(reserved.get(fingerprint + "/" + occurrence))) return template; + } + // A legacy snapshot may belong entirely to another action. It is not + // evidence that this newly admitted random action had a removed choice; + // the replay-selection metadata check below remains authoritative when a + // prior random choice was actually persisted. + return null; } private boolean belongsTo(AdvancedCoreUser user) { @@ -122,6 +164,7 @@ private boolean belongsTo(AdvancedCoreUser user) { private synchronized CompletionStage closeAndAwait() { closed = true; + if (admissionFailure != null) return CompletableFuture.failedFuture(admissionFailure); HashMap currentSnapshot = new HashMap<>(); for (ReplayAction action : actions) { if (action.durable) currentSnapshot.put(action.identity, action.fingerprint); @@ -158,9 +201,23 @@ else if (!persistedSnapshot.equals(currentSnapshot)) { persistedSnapshot.putAll(currentSnapshot); snapshotChanged = true; } + String encodedSnapshot = encodeSnapshot(persistedSnapshot); + if (!persistedSnapshot.isEmpty()) { + // Project the complete next replay metadata state, including the eventual + // completion ledger, before publishing any identity or checkpoint. + HashMap projected = new HashMap<>(); + if (replayState != null) replayState.mergeReplayMetadataInto(projected); + Reward.mergeReplayMetadata(projected, placeholders); + projected.put(snapshotKey, encodedSnapshot); + HashSet projectedCompleted = new HashSet<>(completed); + for (ReplayAction action : actions) if (action.durable) projectedCompleted.add(action.identity); + projected.put(checkpointKey, encodeCompleted(projectedCompleted)); + try { Reward.copyWithoutReplayMetadata(projected); } + catch (IllegalArgumentException failure) { return CompletableFuture.failedFuture(failure); } + } CompletionStage result = CompletableFuture.completedFuture(null); if (snapshotChanged) { - recordReplayValue(snapshotKey, encodeSnapshot(persistedSnapshot)); + recordReplayValue(snapshotKey, encodedSnapshot); result = checkpoint(); } HashMap snapshotLedger = persistedSnapshot; @@ -288,6 +345,28 @@ private ReplayAction(String identity, String fingerprint, Supplier placeholders; + final AdvancedCorePlugin plugin; + final boolean previouslyReserved; + final String replayScopeKey; + + private ReplayRegistration(String identity, Reward.ReplayState replayState, + HashMap placeholders, AdvancedCorePlugin plugin, boolean previouslyReserved, + String replayScopeKey) { + this.identity = identity; + this.replayState = replayState; + this.placeholders = placeholders; + this.plugin = plugin; + this.previouslyReserved = previouslyReserved; + this.replayScopeKey = replayScopeKey; + } + + String nestedActiveKey() { return replayScopeKey + "/" + identity; } + } } /** Signals a replay-aware legacy action that was conclusively never started. */ @@ -418,6 +497,14 @@ private boolean collectAsyncAction(Supplier> action, Strin return false; } + private AsyncActionCollection.ReplayRegistration collectLiteralBroadcastAction( + Supplier> action, String descriptor) { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + if (collection != null && collection.belongsTo(this)) + return collection.addRegistration(action, descriptor); + return null; + } + private boolean hasOwnedAsyncActionCollection() { AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); return collection != null && collection.belongsTo(this); @@ -2840,6 +2927,349 @@ public void sendPreparedMessage(String msg) { } } + /** Schedules literal reward formatting and delivery on the live player's owner. */ + public void sendLiteralDisplayMessage(String template, HashMap placeholders) { + sendLiteralDisplay(template, placeholders, false); + } + + /** Captures source placeholders on its owner before independently scheduled recipients. */ + public void sendLiteralDisplayBroadcast(String template, HashMap placeholders) { + sendLiteralDisplay(template, placeholders, true); + } + + /** Uses the existing replay ledger to retain a configured random message choice. */ + public void sendLiteralRandomDisplayMessage(List templates, HashMap placeholders) { + validateLiteralDisplayBindings(placeholders); + if (templates == null || templates.isEmpty() || templates.size() > 4096) { + throw new IllegalArgumentException("Invalid literal random message choices"); + } + ArrayList choices = new ArrayList<>(templates.size()); + long characters = 0; + for (String template : templates) { + if (choices.size() >= 4096) throw new IllegalArgumentException("Too many literal random message choices"); + if (template == null || template.length() > 65536) { + throw new IllegalArgumentException("Invalid literal random message template"); + } + characters += template.length(); + if (characters > 1048576) throw new IllegalArgumentException("Literal random messages exceed limits"); + choices.add(template); + } + String message = Reward.replaySelection(placeholders, "literal-random-message", () -> { + AsyncActionCollection current = ASYNC_ACTION_COLLECTION.get(); + String reserved = current != null && current.belongsTo(this) + ? current.reservedLiteralMessage(choices) : null; + return reserved != null ? reserved : choices.get( + java.util.concurrent.ThreadLocalRandom.current().nextInt(choices.size())); + }); + // Persisted selection metadata never supplies a new trusted template. + if (!choices.contains(message)) throw new IllegalStateException("The queued random message is no longer configured"); + sendLiteralDisplayMessage(message, placeholders); + } + + private static String literalMessageDescriptor(String template, boolean broadcast) { + return "literal-message:" + broadcast + ":" + canonicalActionDescriptor(template); + } + + private void sendLiteralDisplay(String template, HashMap placeholders, boolean broadcast) { + validateLiteralDisplayBindings(placeholders); + HashMap captured = placeholders == null ? new HashMap<>() : new HashMap<>(placeholders); + AtomicReference registration = new AtomicReference<>(); + Supplier> delivery = () -> { + AtomicBoolean formattingStarted = new AtomicBoolean(); + return Reward.continueOnServerThread(plugin, this, () -> { + var current = registration.get(); + String lane = (broadcast ? "literal-broadcast:" : "literal-player:") + + (current == null ? "" : current.nestedActiveKey()); + if (current != null && Reward.hasReplayNestedRewardSnapshot(current.placeholders, lane, + current.replayState, current.nestedActiveKey())) { + // A reservation with a durable plan owns its exact output. Consult it + // before getPlayer(), source providers, recipient providers or scripts. + formattingStarted.set(true); + boolean completed = Reward.hasCompletedNestedRewardSequence(current.placeholders, lane, + current.replayState, current.nestedActiveKey()); + return Reward.replayNestedRewardSnapshot(current.plugin, current.placeholders, lane, List.of(), + current.replayState, current.nestedActiveKey()).thenCompose(plan -> + replayLiteralPayloadPlan(current, lane, plan, broadcast, completed, null, null)); + } + if (current != null && current.previouslyReserved) { + formattingStarted.set(true); + return CompletableFuture.failedFuture(new IllegalStateException( + "Legacy literal message has no durable payload; refusing ambiguous replay")); + } + Player player = getPlayer(); + if (!broadcast && (player == null || !player.isOnline())) return CompletableFuture.failedFuture( + replayActionNotStarted("Player unavailable before literal message delivery")); + formattingStarted.set(true); + if (broadcast) { + var prepared = com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders + .prepareLiteralBroadcast(player, template, captured); + var misc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(); + if (current == null) return misc.broadcastPreparedMessageAsync(prepared::render, prepared::renderConsoleMessage); + HashMap expectedRecipients = new HashMap<>(); + return misc.captureOnlinePlayerIdsAsync(current.plugin) + .thenCompose(ids -> captureLiteralBroadcastPayload(misc, prepared, ids, expectedRecipients, + current, lane)) + .thenCompose(plan -> replayLiteralPayloadPlan(current, lane, plan, true, false, null, + expectedRecipients)); + } + var rendered = com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders + .renderLiteralComponents(player, template, captured); + if (current == null) { + sendLiteralRewardComponents(player, rendered); + return CompletableFuture.completedFuture(null); + } + if (rendered.length > 4096) return CompletableFuture.failedFuture( + new IllegalArgumentException("Literal player message has too many lines")); + List plan = new ArrayList<>(); + var existingSize = Reward.replayMetadataSize(current.placeholders, current.replayState); + var planSize = Reward.nestedRewardPlanMetadataSize(current.nestedActiveKey(), lane, List.of()); + long characters = existingSize.characters() + planSize.characters(); + if (existingSize.entries() + planSize.entries() > 8192) return CompletableFuture.failedFuture( + new IllegalArgumentException("Literal player replay metadata exceeds limits")); + for (int line = 0; line < rendered.length; line++) { + String entry = LiteralReplayPayload.player(getUUID(), line, rendered[line]); + characters += LiteralReplayPayload.snapshotCharacters(entry); + if (characters > 1048576L - 4096L) return CompletableFuture.failedFuture( + new IllegalArgumentException("Literal player payload exceeds replay limits")); + plan.add(entry); + } + return replayLiteralPayloadPlan(current, lane, plan, false, false, player, null); + }).handle((ignored, failure) -> { + if (failure != null) { + Throwable cause = AsyncActionCollection.unwrapCompletionFailure(failure); + if (!formattingStarted.get()) throw new java.util.concurrent.CompletionException( + replayActionNotStarted("Literal reward message was not dispatched", cause)); + throw new java.util.concurrent.CompletionException(cause); + } + return null; + }); + }; + // The configured template identifies the action. Replay metadata and live + // placeholder values change while its already-completed prefix is resumed. + String descriptor = literalMessageDescriptor(template, broadcast); + var collected = collectLiteralBroadcastAction(delivery, descriptor); + registration.set(collected); + if (collected == null) { + delivery.get().whenComplete((ignored, failure) -> { + if (failure != null) plugin.getLogger().warning("A literal reward message could not be delivered"); + }); + } + } + + /** Freeze terminal output on the existing owners before the first delivery. */ + private CompletionStage> captureLiteralBroadcastPayload( + com.bencodez.advancedcore.api.misc.MiscUtils misc, + com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders.LiteralBroadcast prepared, + List audience, HashMap expectedRecipients, + AsyncActionCollection.ReplayRegistration current, String lane) { + int lines = prepared.lineCount(); + if (lines > 4096 || audience.size() > 8192 || (long) lines * audience.size() + 1 > 8192) + return CompletableFuture.failedFuture(new IllegalArgumentException("Literal broadcast audience exceeds limits")); + HashSet unique = new HashSet<>(); + for (UUID id : audience) if (id == null || !unique.add(id)) return CompletableFuture.failedFuture( + new IllegalArgumentException("Literal broadcast audience is not unique")); + String console = LiteralReplayPayload.console(prepared.renderConsoleMessage()); + var existingSize = Reward.replayMetadataSize(current.placeholders, current.replayState); + var planSize = Reward.nestedRewardPlanMetadataSize(current.nestedActiveKey(), lane, List.of()); + long[] characters = { existingSize.characters() + planSize.characters() + + LiteralReplayPayload.snapshotCharacters(console) }; + long entries = existingSize.entries() + planSize.entries() + 2L * lines * audience.size(); + if (entries > 8192) return CompletableFuture.failedFuture( + new IllegalArgumentException("Literal broadcast replay metadata exceeds limits")); + if (characters[0] > 1048576L - 4096L) return CompletableFuture.failedFuture( + new IllegalArgumentException("Literal broadcast payload exceeds replay limits")); + ArrayList plan = new ArrayList<>(); + CompletionStage capture = CompletableFuture.completedFuture(null); + for (UUID id : audience) for (int line = 0; line < lines; line++) { + int ordinal = line; + capture = capture.thenCompose(ignored -> misc.capturePreparedRecipientAsync(plugin, id, + player -> prepared.renderRecipientLineFragments(player, ordinal))).thenAccept(rendered -> { + if (rendered.player() != null) { + Player previous = expectedRecipients.putIfAbsent(id, rendered.player()); + if (previous != null && previous != rendered.player()) + throw new IllegalStateException("Literal broadcast recipient changed during admission"); + } + var components = rendered.components(); + String entry = LiteralReplayPayload.recipient(id, ordinal, components); + characters[0] += LiteralReplayPayload.snapshotCharacters(entry); + List fragments = new ArrayList<>(); + for (int index = 0; index < components.length; index++) fragments.add("F|" + index); + String fragmentsLane = "literal-broadcast-fragments:" + current.nestedActiveKey() + + ":R|" + id + "|" + ordinal; + characters[0] += Reward.nestedRewardPlanMetadataSize(current.nestedActiveKey(), fragmentsLane, + fragments).characters(); + if (characters[0] > 1048576L - 4096L) + throw new IllegalArgumentException("Literal broadcast payload exceeds replay limits"); + plan.add(entry); + }); + } + return capture.thenApply(ignored -> { plan.add(console); return List.copyOf(plan); }); + } + + private CompletionStage replayLiteralPayloadPlan(AsyncActionCollection.ReplayRegistration current, + String lane, List plan, boolean broadcast, boolean completedOnly, Player expectedPlayer, + HashMap expectedRecipients) { + HashMap decoded = new HashMap<>(); + HashSet identities = new HashSet<>(); + HashMap nextOrdinals = new HashMap<>(); + boolean consoleSeen = false; + for (String entry : plan) { + if (!LiteralReplayPayload.isFrozen(entry)) { + // Numeric legacy plans cannot prove their original unfinished payload. + if (!completedOnly || !validLiteralBroadcastEntry(entry) + || (!broadcast && (!entry.startsWith("P|") + || !entry.split("\\|", -1)[1].equalsIgnoreCase(getUUID())))) + return CompletableFuture.failedFuture(new IllegalStateException( + "Legacy literal plan has no frozen payload; refusing ambiguous replay")); + String[] parts = entry.split("\\|", -1); + int index = literalBroadcastEntryLine(entry); + if (index >= 4096 || (!"C".equals(entry) && !Integer.toString(index).equals(parts[parts.length - 1]))) + return CompletableFuture.failedFuture(new IllegalStateException("Malformed legacy literal plan")); + String identity = "C".equals(parts[0]) ? "C|" + index + : parts[0] + "|" + UUID.fromString(parts[1]) + "|" + index; + if (!identities.add(identity)) return CompletableFuture.failedFuture( + new IllegalStateException("Duplicate literal delivery identity")); + decoded.put(entry, null); + continue; + } + var payload = LiteralReplayPayload.decode(entry); + if ("C2".equals(payload.kind())) { + if (!broadcast || consoleSeen || !entry.equals(plan.get(plan.size() - 1))) + return CompletableFuture.failedFuture(new IllegalStateException("Malformed literal console plan")); + consoleSeen = true; + } else { + if (consoleSeen || (broadcast ? !"R2".equals(payload.kind()) : !"P2".equals(payload.kind())) + || (!broadcast && !payload.owner().toString().equalsIgnoreCase(getUUID()))) + return CompletableFuture.failedFuture(new IllegalStateException("Literal delivery target changed")); + int next = nextOrdinals.getOrDefault(payload.owner(), 0); + if (payload.ordinal() != next) return CompletableFuture.failedFuture( + new IllegalStateException("Noncanonical literal delivery order")); + nextOrdinals.put(payload.owner(), next + 1); + } + String identity = payload.kind() + "|" + payload.owner() + "|" + payload.ordinal(); + if (!identities.add(identity)) return CompletableFuture.failedFuture( + new IllegalStateException("Duplicate literal delivery identity")); + decoded.put(entry, payload); + } + if (broadcast && !completedOnly && !consoleSeen) return CompletableFuture.failedFuture( + new IllegalStateException("Literal broadcast console payload is missing")); + Player activePlayer = broadcast || completedOnly ? null : (expectedPlayer == null ? getPlayer() : expectedPlayer); + if (!broadcast && !completedOnly && activePlayer == null) + return CompletableFuture.failedFuture(new IllegalStateException("Player unavailable during frozen literal delivery")); + return Reward.replayNestedRewardSequence(current.plugin, current.placeholders, lane, plan, + current.replayState, current.nestedActiveKey(), (entry, ignored) -> { + if (completedOnly) return CompletableFuture.failedFuture( + new IllegalStateException("Completed literal plan has unfinished work")); + var payload = decoded.get(entry); + if (!broadcast) return dispatchFrozenLiteralPlayer(activePlayer, payload.components()[0]); + var misc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(); + if ("C2".equals(payload.kind())) + return misc.dispatchPreparedConsoleMessageAsync(plugin, payload::console); + String fragmentsLane = "literal-broadcast-fragments:" + current.nestedActiveKey() + + ":R|" + payload.owner() + "|" + payload.ordinal(); + List fragments = new ArrayList<>(); + for (int index = 0; index < payload.components().length; index++) fragments.add("F|" + index); + return Reward.replayNestedRewardSequence(current.plugin, current.placeholders, fragmentsLane, + fragments, current.replayState, current.nestedActiveKey(), (fragment, unused) -> + misc.dispatchPreparedComponentLineAsync(plugin, payload.owner(), player -> { + if (expectedRecipients != null && expectedRecipients.get(payload.owner()) != player) + throw new IllegalStateException("Literal broadcast recipient changed after admission"); + return payload.components()[Integer.parseInt(fragment.substring(2))]; + }), fragment -> validLiteralBroadcastFragment(fragment) + && Integer.parseInt(fragment.substring(2)) < payload.components().length, + 4096, 16, 1048576L); + }, decoded::containsKey, 8192, LiteralReplayPayload.MAX_CHARACTERS, 1048576L); + } + + private CompletionStage dispatchFrozenLiteralPlayer(Player expectedPlayer, TextComponent component) { + return Reward.continueOnServerThread(plugin, this, () -> { + if (getPlayer() != expectedPlayer || !expectedPlayer.isOnline() || !plugin.isEnabled()) + return CompletableFuture.failedFuture(new IllegalStateException("Player unavailable during literal line delivery")); + if (component != null) PlayerUtils.getServerHandle().sendMessage(expectedPlayer, component); + return CompletableFuture.completedFuture(null); + }); + } + + private static boolean validLiteralBroadcastEntry(String entry) { + if (entry == null || entry.length() > 96) return false; + String[] parts = entry.split("\\|", -1); + if ("C".equals(parts[0])) return (parts.length == 1) + || (parts.length == 2 && parseLiteralLineIndex(parts[1]) != null); + if (parts.length != 3) return false; + if (!"P".equals(parts[0]) && !"R".equals(parts[0])) return false; + try { + UUID id = UUID.fromString(parts[1]); + if (!id.toString().equalsIgnoreCase(parts[1])) return false; + } catch (IllegalArgumentException failure) { return false; } + return parseLiteralLineIndex(parts[2]) != null; + } + + private static int literalBroadcastEntryLine(String entry) { + String[] parts = entry.split("\\|", -1); + if ("C".equals(parts[0]) && parts.length == 1) return 0; + return Integer.parseInt(parts["C".equals(parts[0]) ? 1 : 2]); + } + + private static Integer parseLiteralLineIndex(String value) { + try { + int index = Integer.parseInt(value); + return index >= 0 && index < 8192 ? index : null; + } catch (NumberFormatException failure) { return null; } + } + + private static boolean validLiteralBroadcastFragment(String fragment) { + if (fragment == null || !fragment.startsWith("F|")) return false; + Integer index = parseLiteralLineIndex(fragment.substring(2)); + return index != null && index < 4096 && fragment.equals("F|" + index); + } + + /** Legacy single-line helper; durable literal replay uses frozen payloads above. */ + private CompletionStage dispatchLiteralBroadcastEntry( + com.bencodez.advancedcore.api.misc.MiscUtils misc, + com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders.LiteralBroadcast prepared, + String entry) { + if (!validLiteralBroadcastEntry(entry)) return CompletableFuture.failedFuture( + new IllegalStateException("Malformed literal broadcast delivery plan")); + String[] parts = entry.split("\\|", -1); + if ("C".equals(entry)) return misc.dispatchPreparedConsoleMessageAsync(plugin, prepared::renderConsoleMessage); + int line = literalBroadcastEntryLine(entry); + if (line >= prepared.lineCount()) return CompletableFuture.failedFuture( + new IllegalStateException("Literal broadcast line is no longer available")); + if ("R".equals(parts[0])) return CompletableFuture.failedFuture( + new IllegalStateException("Literal broadcast fragment payload is unavailable")); + if ("C".equals(parts[0])) return misc.dispatchPreparedConsoleLineAsync(plugin, + () -> prepared.renderLine(null, line)); + return misc.dispatchPreparedComponentLineAsync(plugin, UUID.fromString(parts[1]), + player -> prepared.renderLine(player, line)); + } + + private static void validateLiteralDisplayBindings(HashMap placeholders) { + Reward.copyWithoutReplayMetadata(placeholders); + } + + private void sendLiteralRewardComponents(Player expectedPlayer, BaseComponent... components) { + if (components == null) throw new IllegalStateException("Literal reward components unavailable"); + for (BaseComponent line : components) { + if (line == null) continue; + if (getPlayer() != expectedPlayer || !expectedPlayer.isOnline()) { + // Rendering already began and trusted providers/scripts may have run. + // Fail without checkpointing delivery; retain the replay identity rather + // than claiming that the entire action was conclusively never started. + throw new IllegalStateException("Player unavailable after literal reward formatting"); + } + PlayerUtils.getServerHandle().sendMessage(expectedPlayer, line); + } + } + + /** Sends final parsed components without a second formatting pass. */ + public void sendPreparedComponents(BaseComponent... components) { + Player player = getPlayer(); + if (player == null || components == null || components.length == 0) return; + for (BaseComponent line : components) { + if (line != null) PlayerUtils.getServerHandle().sendMessage(player, line); + } + } + /** * Sends a message with placeholders to the player. * diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/LiteralReplayPayload.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/LiteralReplayPayload.java new file mode 100644 index 0000000000..7c492999d1 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/LiteralReplayPayload.java @@ -0,0 +1,222 @@ +package com.bencodez.advancedcore.api.user; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.UUID; + +import com.bencodez.advancedcore.api.messages.LiteralMessageTemplate; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.chat.ComponentSerializer; + +/** Terminal, internal replay payloads. These are never templates or placeholder input. */ +final class LiteralReplayPayload { + static final int MAX_CHARACTERS = 1048576; + private LiteralReplayPayload() { } + + record Entry(String kind, UUID owner, int ordinal, TextComponent[] components, String console) { } + + static String player(String owner, int ordinal, TextComponent component) { + return "P2|" + UUID.fromString(owner) + "|" + ordinal + "|" + componentJson(component); + } + + static String recipient(UUID owner, int ordinal, TextComponent[] components) { + if (components.length > 4096) throw new IllegalArgumentException("Too many literal fragments"); + StringBuilder json = new StringBuilder("["); + for (TextComponent component : components) { + if (json.length() > 1) json.append(','); + json.append(componentJson(component)); + if (json.length() > MAX_CHARACTERS) throw new IllegalArgumentException("Literal payload exceeds limits"); + } + return "R2|" + owner + "|" + ordinal + "|" + json.append(']'); + } + + static String console(String text) { + return "C2|" + (text == null ? "null" : new JsonPrimitive(text).toString()); + } + + private static String componentJson(TextComponent component) { + return component == null ? "null" : ComponentSerializer.toString(component); + } + + static boolean isFrozen(String entry) { + return entry != null && (entry.startsWith("P2|") || entry.startsWith("R2|") || entry.startsWith("C2|")); + } + + static Entry decode(String entry) { + if (entry == null || entry.length() > MAX_CHARACTERS) + throw new IllegalArgumentException("Literal payload exceeds limits"); + if (entry.startsWith("C2|")) { + JsonElement json = strictJson(entry.substring(3)); + if (!json.isJsonNull() && (!json.isJsonPrimitive() || !json.getAsJsonPrimitive().isString())) + throw new IllegalArgumentException("Malformed literal console payload"); + String console = json.isJsonNull() ? null : json.getAsString(); + if (console != null && console.length() > 262144) + throw new IllegalArgumentException("Literal console payload exceeds limits"); + return new Entry("C2", null, 0, new TextComponent[0], console); + } + String[] parts = entry.split("\\|", 4); + if (parts.length != 4 || (!"P2".equals(parts[0]) && !"R2".equals(parts[0]))) + throw new IllegalArgumentException("Malformed literal payload"); + UUID owner = UUID.fromString(parts[1]); + if (!owner.toString().equals(parts[1])) throw new IllegalArgumentException("Noncanonical literal owner"); + int ordinal = Integer.parseInt(parts[2]); + if (ordinal < 0 || ordinal >= 4096 || !Integer.toString(ordinal).equals(parts[2])) + throw new IllegalArgumentException("Noncanonical literal ordinal"); + JsonElement json = strictJson(parts[3]); + TextComponent[] components; + if ("P2".equals(parts[0])) components = new TextComponent[] { decodeComponent(json) }; + else { + if (!json.isJsonArray() || json.getAsJsonArray().size() > 4096) + throw new IllegalArgumentException("Malformed literal fragments"); + components = new TextComponent[json.getAsJsonArray().size()]; + for (int index = 0; index < components.length; index++) + components[index] = decodeComponent(json.getAsJsonArray().get(index)); + } + LiteralMessageTemplate.validateRestoredComponents(components); + return new Entry(parts[0], owner, ordinal, components, null); + } + + private static TextComponent decodeComponent(JsonElement json) { + if (json.isJsonNull()) return null; + if (!json.isJsonObject()) throw new IllegalArgumentException("Malformed literal component"); + BaseComponent[] components = ComponentSerializer.parse(json.toString()); + if (components.length != 1 || !(components[0] instanceof TextComponent text)) + throw new IllegalArgumentException("Malformed literal component root"); + return text; + } + + /** The existing snapshot uses URL Base64; budget its actual encoded UTF-8 size while collecting. */ + static long snapshotCharacters(String entry) { + long bytes = entry.getBytes(StandardCharsets.UTF_8).length; + return (bytes / 3) * 4 + (bytes % 3 == 0 ? 0 : bytes % 3 + 1) + 1; + } + + private static JsonElement strictJson(String json) { + if (json.length() > MAX_CHARACTERS) throw new IllegalArgumentException("Literal JSON exceeds limits"); + validateJsonLexemes(json); + try (JsonReader reader = new JsonReader(new StringReader(json))) { + // Gson is supplied by supported server runtimes. Do not depend on + // the newer Strictness API; the lexical pass excludes its older + // LEGACY_STRICT escape/control-character exceptions. + reader.setLenient(false); + JsonElement result = readJson(reader, 0, new int[1]); + if (reader.peek() != JsonToken.END_DOCUMENT) + throw new IllegalArgumentException("Trailing literal JSON"); + return result; + } catch (IOException | IllegalStateException failure) { + throw new IllegalArgumentException("Malformed literal JSON", failure); + } + } + + /** RFC 8259 tokens; JsonReader below owns the bounded structural grammar. */ + private static void validateJsonLexemes(String json) { + for (int cursor = 0; cursor < json.length();) { + char character = json.charAt(cursor); + if (isJsonDelimiter(character)) { cursor++; continue; } + if (character == '"') { + cursor++; + boolean closed = false; + while (cursor < json.length()) { + char value = json.charAt(cursor++); + if (value == '"') { closed = true; break; } + if (value < 0x20) throw new IllegalArgumentException("Unescaped literal JSON control character"); + if (value != '\\') continue; + if (cursor >= json.length()) throw new IllegalArgumentException("Incomplete literal JSON escape"); + char escaped = json.charAt(cursor++); + if (escaped == 'u') { + for (int digit = 0; digit < 4; digit++) { + if (cursor >= json.length() || !isHexDigit(json.charAt(cursor++))) + throw new IllegalArgumentException("Malformed literal JSON Unicode escape"); + } + } else if ("\"\\/bfnrt".indexOf(escaped) < 0) + throw new IllegalArgumentException("Non-JSON literal escape"); + } + if (!closed) throw new IllegalArgumentException("Unterminated literal JSON string"); + continue; + } + int start = cursor; + if (character == '-' || isDigit(character)) { + if (character == '-') cursor++; + if (cursor >= json.length() || !isDigit(json.charAt(cursor))) + throw new IllegalArgumentException("Malformed literal JSON number"); + if (json.charAt(cursor) == '0') cursor++; + else while (cursor < json.length() && isDigit(json.charAt(cursor))) cursor++; + if (cursor < json.length() && json.charAt(cursor) == '.') { + cursor++; + int decimal = cursor; + while (cursor < json.length() && isDigit(json.charAt(cursor))) cursor++; + if (cursor == decimal) throw new IllegalArgumentException("Malformed literal JSON fraction"); + } + if (cursor < json.length() && (json.charAt(cursor) == 'e' || json.charAt(cursor) == 'E')) { + cursor++; + if (cursor < json.length() && (json.charAt(cursor) == '+' || json.charAt(cursor) == '-')) cursor++; + int exponent = cursor; + while (cursor < json.length() && isDigit(json.charAt(cursor))) cursor++; + if (cursor == exponent) throw new IllegalArgumentException("Malformed literal JSON exponent"); + } + } else if (json.startsWith("true", cursor)) cursor += 4; + else if (json.startsWith("false", cursor)) cursor += 5; + else if (json.startsWith("null", cursor)) cursor += 4; + else throw new IllegalArgumentException("Non-JSON literal token"); + if (cursor == start || (cursor < json.length() && !isJsonDelimiter(json.charAt(cursor)))) + throw new IllegalArgumentException("Malformed literal JSON token boundary"); + } + } + + private static boolean isJsonDelimiter(char value) { + return value == ' ' || value == '\t' || value == '\r' || value == '\n' + || value == '{' || value == '}' || value == '[' || value == ']' || value == ',' || value == ':'; + } + + private static boolean isDigit(char value) { return value >= '0' && value <= '9'; } + private static boolean isHexDigit(char value) { + return isDigit(value) || value >= 'a' && value <= 'f' || value >= 'A' && value <= 'F'; + } + + private static JsonElement readJson(JsonReader reader, int depth, int[] nodes) throws IOException { + // JSON includes arrays/objects around component children. The terminal tree + // independently retains its 64-level, 4096-node, 262144-character limits. + if (depth > 192 || ++nodes[0] > 32768) throw new IllegalArgumentException("Literal JSON exceeds limits"); + switch (reader.peek()) { + case BEGIN_OBJECT: + reader.beginObject(); + JsonObject object = new JsonObject(); + HashSet names = new HashSet<>(); + while (reader.hasNext()) { + String name = reader.nextName(); + if (!names.add(name)) throw new IllegalArgumentException("Duplicate literal JSON field"); + object.add(name, readJson(reader, depth + 1, nodes)); + } + reader.endObject(); + return object; + case BEGIN_ARRAY: + reader.beginArray(); + JsonArray array = new JsonArray(); + while (reader.hasNext()) array.add(readJson(reader, depth + 1, nodes)); + reader.endArray(); + return array; + case STRING: return new JsonPrimitive(reader.nextString()); + case BOOLEAN: return new JsonPrimitive(reader.nextBoolean()); + case NULL: reader.nextNull(); return JsonNull.INSTANCE; + case NUMBER: + String number = reader.nextString(); + if (number.length() > 128) throw new IllegalArgumentException("Literal JSON number exceeds limits"); + var decimal = new java.math.BigDecimal(number); + if (Math.abs((long) decimal.scale()) > 128) + throw new IllegalArgumentException("Literal JSON exponent exceeds limits"); + return new JsonPrimitive(decimal); + default: throw new IllegalArgumentException("Malformed literal JSON token"); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/LiteralReplayPayloadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/LiteralReplayPayloadTest.java new file mode 100644 index 0000000000..21dd6e825d --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/LiteralReplayPayloadTest.java @@ -0,0 +1,83 @@ +package com.bencodez.advancedcore.api.user; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.hover.content.Item; +import net.md_5.bungee.api.chat.hover.content.Text; +import net.md_5.bungee.chat.ComponentSerializer; + +class LiteralReplayPayloadTest { + private static final UUID OWNER = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Test void terminalComponentsPreserveLiteralTextAndExactRichActions() { + var original = new TextComponent("%player_name% &a [Javascript=unexpected()] | 界"); + original.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "inspect %raw_action% &a")); + original.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text("%raw_hover% [Text=\"literal\"]"))); + var restored = LiteralReplayPayload.decode(LiteralReplayPayload.player(OWNER.toString(), 0, original)); + assertEquals(ComponentSerializer.toString(original), ComponentSerializer.toString(restored.components()[0])); + } + + @Test void legitimateNumericItemHoverMetadataSurvivesTerminalDecoding() { + var original = new TextComponent("Stone"); + original.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_ITEM, new Item("minecraft:stone", 2, null))); + var restored = LiteralReplayPayload.decode(LiteralReplayPayload.player(OWNER.toString(), 0, original)); + assertEquals(ComponentSerializer.toString(original), ComponentSerializer.toString(restored.components()[0])); + } + + @Test void nullAndEmptyPayloadsRetainTheirNoOpShape() { + assertNull(LiteralReplayPayload.decode(LiteralReplayPayload.player(OWNER.toString(), 0, null)).components()[0]); + assertEquals(0, LiteralReplayPayload.decode(LiteralReplayPayload.recipient(OWNER, 0, new TextComponent[0])) + .components().length); + assertNull(LiteralReplayPayload.decode(LiteralReplayPayload.console(null)).console()); + assertEquals("", LiteralReplayPayload.decode(LiteralReplayPayload.console("")).console()); + } + + @Test void consolePayloadPreservesColorsSeparatorsAndUninterpretedSyntax() { + String console = "§a界%NewLine%[Text=\"literal\",Command=\"%raw%\"]"; + assertEquals(console, LiteralReplayPayload.decode(LiteralReplayPayload.console(console)).console()); + } + + @Test void componentPayloadRejectsDuplicateFieldsTrailingTokensAndLenientJson() { + for (String json : List.of("{\"text\":\"one\",\"text\":\"two\"}", "{text:'one'}", + "{\"text\":\"one\"} true", "{\"text\":\"one\",}", "{\"text\":\"\\q\"}", + "{\"text\":\"\\'\"}", "{\"text\":\"unescaped\nline\"}", "{\"text\":01}", + "{\"text\":+1}", "{\"text\":1.}", "{\"text\":1e}", "{\"text\":NaN}", + "{\"text\":truefalse}", "{\"text\":\"\\uQQQQ\"}", + "{\"text\":\"item\",\"hoverEvent\":{\"action\":\"show_item\",\"contents\":{\"id\":\"minecraft:stone\",\"count\":1e2000000000}}}", + "{\"text\":\"item\",\"hoverEvent\":{\"action\":\"show_item\",\"contents\":{\"id\":\"minecraft:stone\",\"count\":1e-2000000000}}}")) + assertThrows(RuntimeException.class, () -> LiteralReplayPayload.decode("P2|" + OWNER + "|0|" + json)); + } + + @Test void ordinalsAndOwnersAreCanonical() { + for (String index : List.of("00", "-1", "+1", "4096")) + assertThrows(RuntimeException.class, + () -> LiteralReplayPayload.decode("P2|" + OWNER + "|" + index + "|{\"text\":\"ok\"}")); + assertThrows(RuntimeException.class, + () -> LiteralReplayPayload.decode("P2|0-0-0-0-1|0|{\"text\":\"ok\"}")); + } + + @Test void restoredComponentTextRetainsItsOriginalBoundary() { + var atLimit = new TextComponent("界".repeat(262144)); + assertEquals(262144, LiteralReplayPayload.decode(LiteralReplayPayload.player(OWNER.toString(), 0, atLimit)) + .components()[0].toPlainText().length()); + assertThrows(RuntimeException.class, () -> LiteralReplayPayload.decode( + LiteralReplayPayload.player(OWNER.toString(), 0, new TextComponent("x".repeat(262145))))); + } + + @Test void frozenRecipientBatchRetains4096ComponentBoundary() { + var components = new TextComponent[4096]; + for (int index = 0; index < components.length; index++) components[index] = new TextComponent(""); + assertEquals(4096, LiteralReplayPayload.decode(LiteralReplayPayload.recipient(OWNER, 0, components)) + .components().length); + assertThrows(RuntimeException.class, + () -> LiteralReplayPayload.recipient(OWNER, 0, new TextComponent[4097])); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralActionKeyFoldTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralActionKeyFoldTest.java new file mode 100644 index 0000000000..43d2b56087 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralActionKeyFoldTest.java @@ -0,0 +1,110 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.messages.LiteralMessageTemplate; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; + +class LiteralActionKeyFoldTest { + @Test + void literalActionLookupUsesTheMarkerCodepointFoldForEveryAsciiAlias() { + for (String key : List.of("S\u0130TE", "s\u0131te", "\u017fite", "SITE")) { + var prepared = LiteralMessageTemplate.prepare( + "[Text=\"%\u017f\u0130TE%\",Command=\"/{S\u0131TE}\"]", + Map.of("Site", "visible"), Map.of(key, "raw/" + key)); + TextComponent rendered = prepared.finish(PlaceholderUtils.parseJson(prepared.template())); + assertEquals("visible", rendered.toPlainText(), key); + assertEquals(List.of("/raw/" + key), actions(rendered), key); + } + var kelvin = LiteralMessageTemplate.prepare("[Text=\"{\u212aey}\",Command=\"%KEY%\"]", + Map.of("Key", "visible"), Map.of("\u212aEY", "raw-kelvin")); + assertEquals(List.of("raw-kelvin"), actions(kelvin.finish(PlaceholderUtils.parseJson(kelvin.template())))); + } + + @Test + void arbitraryActionKeysMatchDottedIFinalSigmaAndSupplementaryCasePairs() { + Map actions = new LinkedHashMap<>(); + actions.put("\u0130", "i"); + actions.put("\u017f", "s"); + actions.put("\u212a", "k"); + actions.put("\u03a3", "greek"); + actions.put("\uD801\uDC00", "astral"); + var prepared = LiteralMessageTemplate.prepare( + "[Text=\"open\",Command=\"%i%/{\u0131}/%S%/{K}/%\u03c2%/{\u03c3}/%\uD801\uDC28%\"]", + Map.of(), actions); + assertEquals(List.of("i/i/s/k/greek/greek/astral"), + actions(prepared.finish(PlaceholderUtils.parseJson(prepared.template())))); + } + + @Test + void caseAmbiguousActionKeysAreRejectedBeforeUseInEitherMapOrder() { + for (List pair : List.of(List.of("i", "\u0130"), List.of("i", "\u0131"), + List.of("s", "\u017f"), List.of("k", "\u212a"), List.of("\u03c3", "\u03c2"), + List.of("\uD801\uDC00", "\uD801\uDC28"), List.of("Site", "S\u0130TE"))) { + for (int first = 0; first < 2; first++) { + Map actions = new LinkedHashMap<>(); + actions.put(pair.get(first), "first"); + actions.put(pair.get(1 - first), "second"); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", Map.of(), actions), pair.toString()); + } + } + } + + @Test + void fullStringLowercaseExpansionIsNotTheMarkerFold() { + var prepared = LiteralMessageTemplate.prepare("[Text=\"open\",Command=\"%\u0130%/%i\u0307%\"]", + Map.of(), Map.of("\u0130", "simple-i", "i\u0307", "combining-dot")); + assertEquals(List.of("simple-i/combining-dot"), + actions(prepared.finish(PlaceholderUtils.parseJson(prepared.template())))); + assertEquals("i", LiteralMessageTemplate.normalizeBindingKey("\u0130")); + assertEquals("i\u0307", LiteralMessageTemplate.normalizeBindingKey("i\u0307")); + } + + @Test + void productionSingleAndBroadcastPathsUseUnicodeRawAliasesAndRejectAmbiguousRawKeys() { + HashMap values = new HashMap<>(); + values.put("S\u0130TE", "raw-site"); + values.put("Legacy", "raw-legacy"); + RewardDisplayPlaceholders.putLiteral(values, "Site", "visible-site", "fallback"); + RewardDisplayPlaceholders.put(values, "Legacy", "&cLegacy"); + String source = "[Text=\"%\u017f\u0130TE% {Legacy}\",Command=\"/%Site%/{Legacy}\"]"; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + TextComponent[] single = RewardDisplayPlaceholders.renderLiteralComponents(null, source, values); + TextComponent[] broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, source, values).render(null); + assertEquals("visible-site Legacy", BaseComponent.toPlainText(single)); + assertEquals(BaseComponent.toPlainText(single), BaseComponent.toPlainText(broadcast)); + assertEquals(List.of("/raw-site/raw-legacy"), actions(single[0])); + assertEquals(actions(single[0]), actions(broadcast[0])); + values.put("Site", "ambiguous"); + assertThrows(IllegalArgumentException.class, + () -> RewardDisplayPlaceholders.renderLiteralComponents(null, source, values)); + assertThrows(IllegalArgumentException.class, + () -> RewardDisplayPlaceholders.prepareLiteralBroadcast(null, source, values)); + } + } + + private static List actions(BaseComponent component) { + List result = new ArrayList<>(); + if (component.getClickEvent() != null) result.add(component.getClickEvent().getValue()); + if (component.getExtra() != null) for (BaseComponent child : component.getExtra()) result.addAll(actions(child)); + return result; + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralActionSnapshotBoundsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralActionSnapshotBoundsTest.java new file mode 100644 index 0000000000..36638d6401 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralActionSnapshotBoundsTest.java @@ -0,0 +1,166 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; + +class LiteralActionSnapshotBoundsTest { + @Test + void oversizedRealActionCollectionNeverPublishesOrStartsAnEffect() throws Exception { + var fixture = new Fixture(); + var before = new HashMap<>(fixture.metadata); + var collection = fixture.begin(); + for (int index = 0; index < 6000; index++) fixture.add(collection, index); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join()); + assertEquals(0, fixture.checkpoints.get()); + assertEquals(0, fixture.effects.get()); + assertEquals(before, fixture.metadata, "oversize admission must not poison the durable owner"); + } + + @Test + void unchangedSnapshotReservesCompletionSpaceBeforeRetrying() throws Exception { + var fixture = new Fixture(); + var collection = fixture.begin(); + var snapshot = new HashMap(); + for (int index = 0; index < 4500; index++) { + fixture.add(collection, index); + String fingerprint = Reward.legacyActionFingerprint("literal-message:false:synthetic-line-" + index); + snapshot.put(fingerprint + "/0", fingerprint); + } + var encode = AdvancedCoreUser.AsyncActionCollection.class.getDeclaredMethod("encodeSnapshot", HashMap.class); + encode.setAccessible(true); + fixture.metadata.put(Reward.legacyActionReplayKey("synthetic-message-injector") + "_snapshot", + (String) encode.invoke(null, snapshot)); + assertDoesNotThrow(() -> Reward.copyWithoutReplayMetadata(fixture.metadata), + "the stored snapshot alone fits, but its future completion ledger does not"); + var before = new HashMap<>(fixture.metadata); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join()); + assertEquals(0, fixture.effects.get()); + assertEquals(0, fixture.checkpoints.get()); + assertEquals(before, fixture.metadata); + } + + @Test + void replayRegistrationReadsThePersistedSnapshotOnce() throws Exception { + var fixture = new Fixture(); + var snapshot = new HashMap(); + for (int index = 0; index < 128; index++) { + String fingerprint = Reward.legacyActionFingerprint("literal-message:false:synthetic-line-" + index); + snapshot.put(fingerprint + "/0", fingerprint); + } + var encode = AdvancedCoreUser.AsyncActionCollection.class.getDeclaredMethod("encodeSnapshot", HashMap.class); + encode.setAccessible(true); + fixture.metadata.put(Reward.legacyActionReplayKey("synthetic-message-injector") + "_snapshot", + (String) encode.invoke(null, snapshot)); + var collection = fixture.begin(); + try { + for (int index = 0; index < 128; index++) fixture.add(collection, index); + assertEquals(1, fixture.metadata.snapshotReads.get(), + "registration must not decode the whole persisted snapshot for every action"); + assertEquals(0, fixture.effects.get()); + } finally { fixture.user.restoreAsyncActionCollectionScope(collection); } + fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join(); + assertEquals(128, fixture.effects.get()); + } + + @Test + void normalAndAdditiveCollectionsRetainCompletedActions() throws Exception { + var fixture = new Fixture(); + var initial = fixture.begin(); + for (int index = 0; index < 4; index++) fixture.add(initial, index); + fixture.user.endAsyncActionCollection(initial).toCompletableFuture().join(); + assertEquals(4, fixture.effects.get()); + var restored = fixture.begin(); + for (int index = 0; index < 5; index++) fixture.add(restored, index); + fixture.user.endAsyncActionCollection(restored).toCompletableFuture().join(); + assertEquals(5, fixture.effects.get(), "only the added occurrence may execute"); + assertDoesNotThrow(() -> Reward.copyWithoutReplayMetadata(fixture.metadata)); + } + + @Test + void registrationOverflowCannotExecuteTheCollectedPrefix() throws Exception { + var fixture = new Fixture(); + var before = new HashMap<>(fixture.metadata); + var collection = fixture.begin(); + for (int index = 0; index < 8192; index++) fixture.add(collection, index); + try { + var failure = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> fixture.add(collection, 8192)); + assertInstanceOf(IllegalArgumentException.class, failure.getCause()); + } finally { fixture.user.restoreAsyncActionCollectionScope(collection); } + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join()); + assertEquals(0, fixture.effects.get()); + assertEquals(0, fixture.checkpoints.get()); + assertEquals(before, fixture.metadata); + } + + private static final class SnapshotReadMap extends HashMap { + private static final long serialVersionUID = 1L; + final AtomicInteger snapshotReads = new AtomicInteger(); + @Override public String get(Object key) { + if (key instanceof String name && name.endsWith("_snapshot")) snapshotReads.incrementAndGet(); + return super.get(key); + } + } + + private static final class Fixture { + final AtomicInteger effects = new AtomicInteger(); + final AtomicInteger checkpoints = new AtomicInteger(); + final SnapshotReadMap metadata = new SnapshotReadMap(); + final AdvancedCoreUser user; + final Reward.ReplayState state; + final Method add; + + Fixture() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + var timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }) + .when(timer).execute(any(Runnable.class)); + user = new AdvancedCoreUser(plugin, UUID.randomUUID(), "SyntheticPlayer"); + var options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> checkpoints.incrementAndGet()); + state = Reward.replayStateFor(options); + metadata.put("ordinary", "unchanged"); + add = AdvancedCoreUser.AsyncActionCollection.class.getDeclaredMethod("addRegistration", + Supplier.class, String.class); + add.setAccessible(true); + } + + AdvancedCoreUser.AsyncActionCollection begin() { + return user.beginAsyncActionCollection(state, metadata, "synthetic-message-injector"); + } + + void add(AdvancedCoreUser.AsyncActionCollection collection, int index) throws Exception { + Supplier> action = () -> { + Reward.copyWithoutReplayMetadata(metadata); + effects.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }; + add.invoke(collection, action, "literal-message:false:synthetic-line-" + index); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBatchOutputBudgetTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBatchOutputBudgetTest.java new file mode 100644 index 0000000000..71cbc6fd4b --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBatchOutputBudgetTest.java @@ -0,0 +1,81 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; +import net.md_5.bungee.api.chat.BaseComponent; + +class LiteralBatchOutputBudgetTest { + private HashMap bindings(String text) { + var map = new HashMap(); + map.put("A", "raw-action"); + RewardDisplayPlaceholders.putLiteral(map, "A", text, "safe"); + return map; + } + + @Test void validMultilineBatchRetainsMaximumRestoredTextAndLiteralBoundaries() { + var map = bindings("a".repeat(8192)); + try (var parsing = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + parsing.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + var lines = RewardDisplayPlaceholders.renderLiteralComponents(null, + "%A%%NewLine%".repeat(32), map); + assertEquals(32, lines.length); + assertEquals(262144, BaseComponent.toPlainText(lines).length()); + } + } + + @Test void oversizedPlayerBatchRejectsBeforeAnyComponentParsing() { + var parsed = new AtomicInteger(); + try (var parsing = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + parsing.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + parsing.when(() -> PlaceholderUtils.parseJson(anyString())).thenAnswer(call -> { + parsed.incrementAndGet(); return call.callRealMethod(); + }); + assertThrows(IllegalArgumentException.class, () -> RewardDisplayPlaceholders.renderLiteralComponents(null, + "%A%%NewLine%".repeat(4000), bindings("a".repeat(8192)))); + assertEquals(0, parsed.get(), "reject aggregate literal expansion before allocating its component batch"); + } + } + + @Test void authoredBroadcastLinesShareOneRestoredOutputBudget() { + var parsed = new AtomicInteger(); + try (var parsing = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + parsing.when(() -> PlaceholderUtils.parseJson(anyString())).thenAnswer(call -> { + parsed.incrementAndGet(); return call.callRealMethod(); + }); + var prepared = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, + "%A%%NewLine%".repeat(40), bindings("a".repeat(8192))); + assertThrows(IllegalArgumentException.class, () -> prepared.render(null)); + assertTrue(parsed.get() <= 32, "stop parsing when the recipient's aggregate output budget is exhausted"); + } + } + + @Test void recipientExpandedFragmentsRejectBeforeComponentParsing() { + Player recipient = mock(Player.class); + var parsed = new AtomicInteger(); + try (var parsing = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + parsing.when(() -> PlaceholderUtils.parseJson(anyString())).thenAnswer(call -> { + parsed.incrementAndGet(); return call.callRealMethod(); + }); + parsing.when(() -> PlaceholderUtils.replacePlaceHolders(eq(recipient), anyString())) + .thenAnswer(call -> call.getArgument(1).repeat(100)); + var prepared = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "%A%", bindings("a".repeat(8192))); + assertThrows(IllegalArgumentException.class, () -> prepared.renderRecipientLineFragments(recipient, 0)); + assertEquals(0, parsed.get(), "recipient expansion must be budgeted before rich-text parsing"); + } + } + + @Test void componentTreesAcrossPlayerLinesShareOneNodeBudget() { + try (var parsing = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + parsing.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + assertThrows(IllegalArgumentException.class, () -> RewardDisplayPlaceholders.renderLiteralComponents(null, + "%A%%NewLine%".repeat(4096), bindings("short"))); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBroadcastReplayProgressTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBroadcastReplayProgressTest.java new file mode 100644 index 0000000000..6bcb934e5a --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBroadcastReplayProgressTest.java @@ -0,0 +1,181 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; + +/** Regression coverage for the bounded per-target literal broadcast replay lane. */ +class LiteralBroadcastReplayProgressTest { + private static final String PREFIX = "P|00000000-0000-0000-0000-000000000001|"; + + private static Reward.ReplayState state(AdvancedCorePlugin plugin, HashMap values, + List saved, boolean failPersistence) throws Exception { + var type = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + var constructor = type.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Reward.ReplayState state = (Reward.ReplayState) constructor.newInstance(new HashMap<>(), new HashMap<>(), false); + var setter = type.getDeclaredMethod("setCheckpointConsumer", java.util.function.Consumer.class); + setter.setAccessible(true); + setter.invoke(state, (java.util.function.Consumer) checkpoint -> { + if (failPersistence && !saved.isEmpty()) throw new IllegalStateException("synthetic checkpoint failure"); + saved.add(checkpoint); + values.clear(); + values.putAll(checkpoint.getPlaceholders()); + }); + return state; + } + + private AdvancedCorePlugin plugin() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(invocation -> { invocation.getArgument(0).run(); return null; }) + .when(timer).execute(any(Runnable.class)); + return plugin; + } + + @Test + void failedLaterTargetResumesOnlyTheUncompletedSuffixAfterRestart() throws Exception { + AdvancedCorePlugin plugin = plugin(); + HashMap values = new HashMap<>(); + List saved = new ArrayList<>(); + Reward.ReplayState state = state(plugin, values, saved, false); + List first = new ArrayList<>(); + var attempt = Reward.replayNestedRewardSequence(plugin, values, "literal-broadcast:test", + List.of(PREFIX + "0", PREFIX + "1", PREFIX + "2"), state, "outer/0", + (entry, index) -> { + first.add(entry); + return index == 1 ? CompletableFuture.failedFuture(new IllegalStateException("transport")) + : CompletableFuture.completedFuture(null); + }, entry -> entry.startsWith(PREFIX), 8192, 96, 1048576L); + assertThrows(Exception.class, attempt.toCompletableFuture()::join); + assertEquals(List.of(PREFIX + "0", PREFIX + "1"), first); + assertFalse(saved.isEmpty()); + + values = com.bencodez.simpleapi.array.ArrayUtils.fromString( + com.bencodez.simpleapi.array.ArrayUtils.makeString(values)); + Reward.ReplayState restored = state(plugin, values, new ArrayList<>(), false); + List retry = new ArrayList<>(); + Reward.replayNestedRewardSequence(plugin, values, "literal-broadcast:test", List.of(), restored, "outer/0", + (entry, index) -> { retry.add(entry); return CompletableFuture.completedFuture(null); }, + entry -> entry.startsWith(PREFIX), 8192, 96, 1048576L).toCompletableFuture().join(); + assertEquals(List.of(PREFIX + "1", PREFIX + "2"), retry); + } + + @Test + void checkpointFailurePreventsTheNextTargetFromBeingDispatched() throws Exception { + AdvancedCorePlugin plugin = plugin(); + HashMap values = new HashMap<>(); + Reward.ReplayState state = state(plugin, values, new ArrayList<>(), true); + List dispatched = new ArrayList<>(); + var attempt = Reward.replayNestedRewardSequence(plugin, values, "literal-broadcast:test", + List.of(PREFIX + "0", PREFIX + "1"), state, "outer/0", + (entry, index) -> { dispatched.add(entry); return CompletableFuture.completedFuture(null); }, + entry -> entry.startsWith(PREFIX), 8192, 96, 1048576L); + assertThrows(Exception.class, attempt.toCompletableFuture()::join); + assertEquals(List.of(PREFIX + "0"), dispatched); + } + + @Test + void identicalBroadcastActionsUseIndependentOccurrences() throws Exception { + AdvancedCorePlugin plugin = plugin(); + HashMap firstValues = new HashMap<>(); + HashMap secondValues = new HashMap<>(); + Reward.ReplayState first = state(plugin, firstValues, new ArrayList<>(), false); + Reward.ReplayState second = state(plugin, secondValues, new ArrayList<>(), false); + AtomicInteger dispatches = new AtomicInteger(); + Reward.replayNestedRewardSequence(plugin, firstValues, "literal-broadcast:same", List.of(PREFIX + "0"), + first, "outer/0", (entry, index) -> { dispatches.incrementAndGet(); return CompletableFuture.completedFuture(null); }, + entry -> entry.startsWith(PREFIX), 8192, 96, 1048576L).toCompletableFuture().join(); + Reward.replayNestedRewardSequence(plugin, secondValues, "literal-broadcast:same", List.of(PREFIX + "0"), + second, "outer/1", (entry, index) -> { dispatches.incrementAndGet(); return CompletableFuture.completedFuture(null); }, + entry -> entry.startsWith(PREFIX), 8192, 96, 1048576L).toCompletableFuture().join(); + assertEquals(2, dispatches.get()); + } + + @Test + void aggregateReplayMetadataCapacityRejectsBeforeSavingOrDispatchingAnotherPlan() throws Exception { + var plugin = plugin(); + var values = new HashMap(); + String existing = Reward.legacyActionReplayKey("existing"); + values.put(existing, "x".repeat(1048576 - existing.length() - 16)); + var before = new HashMap<>(values); + var saved = new ArrayList(); + var state = state(plugin, values, saved, false); + var dispatches = new AtomicInteger(); + var attempt = Reward.replayNestedRewardSequence(plugin, values, "literal-broadcast:test", + List.of(PREFIX + "0"), state, "outer/0", (entry, index) -> { + dispatches.incrementAndGet(); return CompletableFuture.completedFuture(null); + }, entry -> entry.startsWith(PREFIX), 8192, 96, 1048576L); + assertThrows(java.util.concurrent.CompletionException.class, attempt.toCompletableFuture()::join); + assertEquals(0, dispatches.get()); + assertTrue(saved.isEmpty()); + assertEquals(before, values); + } + + @Test + void aggregateAdmissionIncludesDisjointStateOnlyAndCallerMetadata() throws Exception { + var plugin = plugin(); + var values = new HashMap(); + values.put(Reward.legacyActionReplayKey("caller"), "x".repeat(600000)); + var saved = new ArrayList(); + var state = state(plugin, values, saved, false); + state.recordReplayMetadata(Reward.legacyActionReplayKey("state"), "x".repeat(600000)); + var dispatches = new AtomicInteger(); + var attempt = Reward.replayNestedRewardSequence(plugin, values, "literal-broadcast:test", + List.of(PREFIX + "0"), state, "outer/0", (entry, index) -> { + dispatches.incrementAndGet(); return CompletableFuture.completedFuture(null); + }, entry -> entry.startsWith(PREFIX), 8192, 96, 1048576L); + assertThrows(java.util.concurrent.CompletionException.class, attempt.toCompletableFuture()::join); + assertEquals(0, dispatches.get()); + assertTrue(saved.isEmpty()); + assertEquals(1, values.size()); + } + + @Test + void ordinaryNestedRewardSequencesRetainTheirExistingMetadataAdmission() throws Exception { + var plugin = plugin(); + var values = new HashMap(); + values.put(Reward.legacyActionReplayKey("existing"), "x".repeat(1048577)); + var state = state(plugin, values, new ArrayList<>(), false); + var dispatches = new AtomicInteger(); + Reward.replayNestedRewardSequence(plugin, values, "ordinary-reward", List.of("synthetic-child"), + state, "ordinary/0", (entry, index) -> { + dispatches.incrementAndGet(); return CompletableFuture.completedFuture(null); + }).toCompletableFuture().join(); + assertEquals(1, dispatches.get()); + } + + @Test + void invalidOrOversizedDeliveryPlansFailBeforeAnyDispatch() throws Exception { + AdvancedCorePlugin plugin = plugin(); + HashMap values = new HashMap<>(); + Reward.ReplayState state = state(plugin, values, new ArrayList<>(), false); + AtomicInteger dispatches = new AtomicInteger(); + var invalid = Reward.replayNestedRewardSequence(plugin, values, "literal-broadcast:test", + List.of("P|not-a-uuid|0"), state, "outer/0", + (entry, index) -> { dispatches.incrementAndGet(); return CompletableFuture.completedFuture(null); }, + entry -> entry.matches("P\\|[0-9a-f-]{36}\\|[0-9]+"), 8192, 96, 1048576L); + assertThrows(Exception.class, invalid.toCompletableFuture()::join); + assertEquals(0, dispatches.get()); + var oversized = Reward.replayNestedRewardSequence(plugin, values, "literal-broadcast:test", + java.util.Collections.nCopies(8193, PREFIX + "0"), state, "outer/1", + (entry, index) -> { dispatches.incrementAndGet(); return CompletableFuture.completedFuture(null); }, + entry -> true, 8192, 96, 1048576L); + assertThrows(Exception.class, oversized.toCompletableFuture()::join); + assertEquals(0, dispatches.get()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBroadcastSchedulingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBroadcastSchedulingTest.java new file mode 100644 index 0000000000..41630322d3 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralBroadcastSchedulingTest.java @@ -0,0 +1,834 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.simpleapi.player.PlayerUtils; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.serverhandle.IServerHandle; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; + +public class LiteralBroadcastSchedulingTest { + /** Test stand-in for the supported native EntityScheduler capability. */ + public interface EntityOwner { OwnerScheduler getScheduler(); } + public interface OwnerScheduler { + boolean execute(org.bukkit.plugin.Plugin plugin, Runnable action, Runnable retired, long delay); + } + + @Test void singleConsolePayloadUsesGlobalOwnerAndDoesNotReparseFinalText() throws Exception { + try (Fixture fixture = new Fixture()) { + String message = "§7R&D &aLiteral%NewLine%[Text=\"literal\",Command=\"run\"]"; + var completion = fixture.misc.dispatchPreparedConsoleMessageAsync(fixture.plugin, () -> { + assertTrue(fixture.globalOwner.get()); + return message; + }); + verifyNoInteractions(fixture.console); + fixture.global.get(0).run(); + fixture.global.get(0).run(); + completion.toCompletableFuture().join(); + verify(fixture.console).sendMessage(message); + verifyNoMoreInteractions(fixture.console); + } + } + + @Test void cancelledConsolePayloadFencesQueuedGlobalCallback() throws Exception { + try (Fixture fixture = new Fixture()) { + var rendered = new AtomicBoolean(); + var completion = fixture.misc.dispatchPreparedConsoleMessageAsync(fixture.plugin, () -> { + rendered.set(true); return "Original"; + }).toCompletableFuture(); + completion.cancel(false); + fixture.global.get(0).run(); + assertFalse(rendered.get()); + verifyNoInteractions(fixture.console); + } + } + + @Test void disabledQueuedConsoleLineFailsBeforeRenderingOrConsoleLookup() throws Exception { + try (Fixture fixture = new Fixture()) { + var rendered = new AtomicBoolean(); + var completion = fixture.misc.dispatchPreparedConsoleLineAsync(fixture.plugin, () -> { + rendered.set(true); + return new TextComponent("Original"); + }).toCompletableFuture(); + assertFalse(completion.isDone()); + assertEquals(1, fixture.global.size()); + when(fixture.plugin.isEnabled()).thenReturn(false); + fixture.global.get(0).run(); + assertInstanceOf(IllegalStateException.class, + assertThrows(CompletionException.class, completion::join).getCause()); + assertFalse(rendered.get()); + fixture.bukkit.verify(Bukkit::getConsoleSender, never()); + verifyNoInteractions(fixture.console, fixture.transport); + } + } + + @Test void enabledConsoleLineRendersOnGlobalOwnerAndDeliversOnce() throws Exception { + try (Fixture fixture = new Fixture()) { + var rendered = new ArrayList(); + TextComponent line = new TextComponent("R&D &aLiteral"); + var completion = fixture.misc.dispatchPreparedConsoleLineAsync(fixture.plugin, () -> { + assertTrue(fixture.globalOwner.get()); + rendered.add(line); + return line; + }).toCompletableFuture(); + assertFalse(completion.isDone()); + assertTrue(rendered.isEmpty()); + verifyNoInteractions(fixture.console); + fixture.global.get(0).run(); + fixture.global.get(0).run(); + completion.join(); + assertEquals(List.of(line), rendered); + verify(fixture.console).sendMessage(line.toLegacyText()); + verifyNoMoreInteractions(fixture.console); + } + } + + @Test void disableDuringConsoleLineRenderingFailsBeforeConsoleLookup() throws Exception { + try (Fixture fixture = new Fixture()) { + var rendered = new AtomicBoolean(); + var completion = fixture.misc.dispatchPreparedConsoleLineAsync(fixture.plugin, () -> { + assertTrue(fixture.globalOwner.get()); + rendered.set(true); + when(fixture.plugin.isEnabled()).thenReturn(false); + return new TextComponent("Late"); + }).toCompletableFuture(); + fixture.global.get(0).run(); + assertInstanceOf(IllegalStateException.class, + assertThrows(CompletionException.class, completion::join).getCause()); + assertTrue(rendered.get()); + fixture.bukkit.verify(Bukkit::getConsoleSender, never()); + verifyNoInteractions(fixture.console, fixture.transport); + } + } + + @Test void disableDuringRecipientAdmissionFencesBroadcastConsoleRenderer() throws Exception { + try (Fixture fixture = new Fixture()) { + var rendered = new AtomicBoolean(); + doAnswer(call -> { + Runnable task = call.getArgument(1); + Player player = call.getArgument(2); + fixture.owners.add(() -> { + fixture.currentOwner.set(player); + try { task.run(); } finally { fixture.currentOwner.set(null); } + }); + when(fixture.plugin.isEnabled()).thenReturn(false); + return null; + }).when(fixture.scheduler).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + var completion = fixture.misc.broadcastPreparedMessageAsync(fixture.renderer, () -> { + rendered.set(true); + return "Late"; + }).toCompletableFuture(); + fixture.global.get(0).run(); + assertInstanceOf(IllegalStateException.class, + assertThrows(CompletionException.class, completion::join).getCause()); + assertFalse(rendered.get()); + fixture.owners.forEach(Runnable::run); + assertTrue(fixture.rendered.isEmpty()); + fixture.bukkit.verify(Bukkit::getConsoleSender, never()); + verifyNoInteractions(fixture.console, fixture.transport); + } + } + + @Test void audienceCaptureDeadlineFencesLateGlobalCallback() throws Exception { + try (Fixture fixture = new Fixture()) { + var capture = fixture.misc.captureOnlinePlayerIdsAsync(fixture.plugin, fixture.deadline::set); + fixture.deadline.get().run(); + assertThrows(CompletionException.class, capture.toCompletableFuture()::join); + fixture.global.get(0).run(); + fixture.bukkit.verify(Bukkit::getOnlinePlayers, never()); + } + } + + @Test void audienceCaptureClaimsDuplicateGlobalCallbacksOnce() throws Exception { + try (Fixture fixture = new Fixture()) { + var capture = fixture.misc.captureOnlinePlayerIdsAsync(fixture.plugin, fixture.deadline::set); + fixture.global.get(0).run(); + fixture.global.get(0).run(); + assertEquals(List.of(fixture.first.getUniqueId(), fixture.second.getUniqueId()), + capture.toCompletableFuture().join()); + } + } + + @Test void disabledPluginRejectsAudienceCaptureBeforeInspectingPlayers() throws Exception { + try (Fixture fixture = new Fixture()) { + when(fixture.plugin.isEnabled()).thenReturn(false); + var capture = fixture.misc.captureOnlinePlayerIdsAsync(fixture.plugin, fixture.deadline::set); + fixture.global.get(0).run(); + assertThrows(CompletionException.class, capture.toCompletableFuture()::join); + fixture.bukkit.verify(Bukkit::getOnlinePlayers, never()); + } + } + + @Test void foliaRetirementSettlesWithoutWaitingForTheDispatchDeadline() throws Exception { + try (Fixture fixture = new Fixture()) { + var nativeScheduler = fixture.nativeSchedulers(); + var completion = fixture.withControlledDeadline(fixture.renderer); + fixture.global.get(0).run(); + nativeScheduler.retired.get(0).run(); + fixture.owners.get(1).run(); + completion.toCompletableFuture().get(2, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(List.of(fixture.second), fixture.rendered); + verify(fixture.transport, never()).sendMessage(eq(fixture.first), any(BaseComponent.class)); + fixture.deadline.get().run(); + assertFalse(completion.toCompletableFuture().isCompletedExceptionally()); + } + } + + @Test void retiredRecipientCompletionContinuationRunsOutsideNativeRetirementCallback() throws Exception { + try (Fixture fixture = new Fixture()) { + var nativeScheduler = fixture.nativeSchedulers(); + var insideRetirement = new AtomicBoolean(); + var completion = fixture.withControlledDeadline(fixture.renderer); + completion.whenComplete((ignored, failure) -> assertFalse(insideRetirement.get())); + fixture.global.get(0).run(); + insideRetirement.set(true); + nativeScheduler.retired.get(0).run(); + insideRetirement.set(false); + fixture.owners.get(1).run(); + completion.toCompletableFuture().get(2, java.util.concurrent.TimeUnit.SECONDS); + } + } + + @Test void alreadyRetiredFoliaEntityIsSkippedAndOtherRecipientStillCompletes() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.nativeSchedulers(); + when(((EntityOwner) fixture.first).getScheduler().execute(any(), any(), any(), anyLong())).thenReturn(false); + var completion = fixture.withControlledDeadline(fixture.renderer); + fixture.global.get(0).run(); + assertFalse(completion.toCompletableFuture().isDone()); + fixture.owners.get(0).run(); + completion.toCompletableFuture().get(2, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(List.of(fixture.second), fixture.rendered); + } + } + + @Test void repeatedRetirementAndLateRunCannotSettleOrDeliverTheRecipientTwice() throws Exception { + try (Fixture fixture = new Fixture()) { + var nativeScheduler = fixture.nativeSchedulers(); + var completion = fixture.withControlledDeadline(fixture.renderer); + fixture.global.get(0).run(); + nativeScheduler.retired.get(0).run(); + nativeScheduler.retired.get(0).run(); + fixture.owners.get(0).run(); + assertFalse(completion.toCompletableFuture().isDone()); + fixture.owners.get(1).run(); + completion.toCompletableFuture().get(2, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(List.of(fixture.second), fixture.rendered); + } + } + + @Test void nativeSchedulingExceptionFailsAndFencesQueuedRecipients() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.nativeSchedulers(); + IllegalStateException failure = new IllegalStateException("native scheduler rejected"); + when(((EntityOwner) fixture.second).getScheduler().execute(any(), any(), any(), anyLong())).thenThrow(failure); + var completion = fixture.withControlledDeadline(fixture.renderer); + fixture.global.get(0).run(); + assertSame(failure, assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + fixture.owners.get(0).run(); + assertTrue(fixture.rendered.isEmpty()); + } + } + + @Test void completionWaitsForGlobalConsoleAndEveryRecipientDelivery() throws Exception { + try (Fixture fixture = new Fixture()) { + CompletionStage completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + assertFalse(completion.toCompletableFuture().isDone()); + verifyNoInteractions(fixture.console, fixture.transport); + fixture.global.get(0).run(); + assertFalse(completion.toCompletableFuture().isDone()); + verify(fixture.console).sendMessage(contains("R&D &aLiteral")); + fixture.owners.get(0).run(); + assertFalse(completion.toCompletableFuture().isDone()); + fixture.owners.get(1).run(); + completion.toCompletableFuture().join(); + assertEquals(List.of(fixture.first, fixture.second), fixture.rendered); + verify(fixture.transport).sendMessage(eq(fixture.first), any(BaseComponent.class)); + verify(fixture.transport).sendMessage(eq(fixture.second), any(BaseComponent.class)); + } + } + + @Test void globalSchedulingFailureIsExceptionalBeforeAnyDelivery() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException failure = new IllegalStateException("global scheduler rejected"); + doThrow(failure).when(fixture.scheduler).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class)); + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + assertSame(failure, assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + assertTrue(fixture.global.isEmpty()); + verifyNoInteractions(fixture.console, fixture.transport); + } + } + + @Test void recipientSchedulingFailureFencesAlreadyQueuedRecipients() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException failure = new IllegalStateException("entity retired"); + doThrow(failure).when(fixture.scheduler).executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class), eq(fixture.second)); + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + fixture.global.get(0).run(); + assertSame(failure, assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + fixture.owners.get(0).run(); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.console, fixture.transport); + } + } + + @Test void renderingFailureIsExceptionalAndRemainingRecipientsCannotDeliverLate() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalArgumentException failure = new IllegalArgumentException("invalid component"); + var completion = fixture.misc.broadcastPreparedComponentsAsync(player -> { throw failure; }, fixture.consoleLines()); + fixture.global.get(0).run(); + fixture.owners.get(0).run(); + assertSame(failure, assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + fixture.owners.get(1).run(); + verifyNoInteractions(fixture.transport); + } + } + + @Test void transportFailureNeverReportsSuccessfulBroadcast() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException failure = new IllegalStateException("transport failed"); + doThrow(failure).when(fixture.transport).sendMessage(eq(fixture.first), any(BaseComponent.class)); + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + fixture.global.get(0).run(); + fixture.owners.get(0).run(); + assertSame(failure, assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + fixture.owners.get(1).run(); + verify(fixture.transport, never()).sendMessage(eq(fixture.second), any(BaseComponent.class)); + } + } + + @Test void disabledGlobalCallbackFailsWithoutInspectingPlayers() throws Exception { + try (Fixture fixture = new Fixture()) { + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + when(fixture.plugin.isEnabled()).thenReturn(false); + fixture.global.get(0).run(); + assertInstanceOf(IllegalStateException.class, + assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + fixture.bukkit.verify(Bukkit::getOnlinePlayers, never()); + verifyNoInteractions(fixture.console, fixture.transport); + } + } + + @Test void disconnectedRecipientIsSkippedAndOtherRecipientsStillGateCompletion() throws Exception { + try (Fixture fixture = new Fixture()) { + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + fixture.global.get(0).run(); + when(fixture.first.isOnline()).thenReturn(false); + fixture.owners.get(0).run(); + assertFalse(completion.toCompletableFuture().isDone()); + fixture.owners.get(1).run(); + completion.toCompletableFuture().join(); + assertEquals(List.of(fixture.second), fixture.rendered); + verify(fixture.transport, never()).sendMessage(eq(fixture.first), any(BaseComponent.class)); + } + } + + @Test void disabledRecipientCallbackFailsAndFencesOtherRecipients() throws Exception { + try (Fixture fixture = new Fixture()) { + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + fixture.global.get(0).run(); + when(fixture.plugin.isEnabled()).thenReturn(false); + fixture.owners.get(0).run(); fixture.owners.get(1).run(); + assertInstanceOf(IllegalStateException.class, + assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void consoleFailureIsExceptionalAndFencesPendingRecipients() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException failure = new IllegalStateException("console unavailable"); + doThrow(failure).when(fixture.console).sendMessage(anyString()); + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + fixture.global.get(0).run(); + assertSame(failure, assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + fixture.owners.forEach(Runnable::run); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void cancellingCompletionFencesQueuedRecipientCallbacks() throws Exception { + try (Fixture fixture = new Fixture()) { + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + fixture.global.get(0).run(); + assertTrue(completion.toCompletableFuture().cancel(false)); + fixture.owners.forEach(Runnable::run); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void deadlineFencesGlobalCallbackBeforeAnyGameStateAccess() throws Exception { + try (Fixture fixture = new Fixture()) { + var completion = fixture.withControlledDeadline(fixture.renderer); + fixture.deadline.get().run(); + assertInstanceOf(TimeoutException.class, + assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + fixture.global.get(0).run(); + fixture.bukkit.verify(Bukkit::getOnlinePlayers, never()); + verifyNoInteractions(fixture.console, fixture.transport); + } + } + + @Test void deadlineFencesRecipientCallbacksAfterGlobalDispatch() throws Exception { + try (Fixture fixture = new Fixture()) { + var completion = fixture.withControlledDeadline(fixture.renderer); + fixture.global.get(0).run(); + assertFalse(completion.toCompletableFuture().isDone()); + fixture.deadline.get().run(); + assertInstanceOf(TimeoutException.class, + assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + fixture.owners.forEach(Runnable::run); + assertTrue(fixture.rendered.isEmpty()); + verify(fixture.first, never()).isOnline(); + verify(fixture.second, never()).isOnline(); + verifyNoInteractions(fixture.transport); + } + } + + @Test void deadlineDuringRenderingPreventsSubsequentNativeSend() throws Exception { + try (Fixture fixture = new Fixture()) { + var completion = fixture.withControlledDeadline(player -> { + fixture.deadline.get().run(); + return new TextComponent[] {new TextComponent("late")}; + }); + fixture.global.get(0).run(); fixture.owners.get(0).run(); + assertInstanceOf(TimeoutException.class, + assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void duplicateSchedulerCallbacksCannotRepeatDelivery() throws Exception { + try (Fixture fixture = new Fixture()) { + var completion = fixture.misc.broadcastPreparedComponentsAsync(fixture.renderer, fixture.consoleLines()); + fixture.global.get(0).run(); fixture.global.get(0).run(); + fixture.owners.forEach(task -> {task.run(); task.run();}); + completion.toCompletableFuture().join(); + assertEquals(List.of(fixture.first, fixture.second), fixture.rendered); + verify(fixture.console, times(1)).sendMessage(anyString()); + verify(fixture.transport, times(1)).sendMessage(eq(fixture.first), any(BaseComponent.class)); + verify(fixture.transport, times(1)).sendMessage(eq(fixture.second), any(BaseComponent.class)); + } + } + + @Test void recipientCaptureUsesItsOwnerBeforeReturningComponentsAndDoesNotSend() throws Exception { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(fixture.first); + var capture = fixture.misc.capturePreparedRecipientAsync(fixture.plugin, id, fixture.renderer); + assertFalse(capture.toCompletableFuture().isDone()); + fixture.global.get(0).run(); fixture.global.get(0).run(); + assertTrue(fixture.rendered.isEmpty()); + fixture.owners.get(0).run(); fixture.owners.get(0).run(); + var prepared = capture.toCompletableFuture().join(); + assertSame(fixture.first, prepared.player()); + assertEquals(List.of(fixture.first), fixture.rendered); + assertEquals("R&D &aLiteral", prepared.components()[0].toPlainText()); + var returned = prepared.components(); returned[0] = null; + assertNotNull(prepared.components()[0], "component-array ownership must not escape"); + verifyNoInteractions(fixture.transport); + } + } + + @Test void cancelledRecipientCaptureFencesQueuedCallbacks() throws Exception { + try (Fixture fixture = new Fixture()) { + var capture = fixture.misc.capturePreparedRecipientAsync(fixture.plugin, + fixture.first.getUniqueId(), fixture.renderer).toCompletableFuture(); + capture.cancel(false); + fixture.global.get(0).run(); + assertTrue(fixture.rendered.isEmpty()); assertTrue(fixture.owners.isEmpty()); + assertTrue(capture.isCancelled()); verifyNoInteractions(fixture.transport); + } + } + + @Test void disabledRecipientCaptureDoesNotInvokeIntegrationRendering() throws Exception { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(fixture.first); + var capture = fixture.misc.capturePreparedRecipientAsync(fixture.plugin, id, fixture.renderer).toCompletableFuture(); + fixture.global.get(0).run(); + when(fixture.plugin.isEnabled()).thenReturn(false); + fixture.owners.get(0).run(); + assertThrows(CompletionException.class, capture::join); + assertTrue(fixture.rendered.isEmpty()); verifyNoInteractions(fixture.transport); + } + } + + @Test void disabledQueuedAbsentRecipientFailsBeforeLookupAndReplayCheckpoints() throws Exception { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + var checkpoints = new ArrayList(); + var completion = fixture.replayLiteralFragment(id, checkpoints).toCompletableFuture(); + assertEquals(2, checkpoints.size(), "outer and fragment snapshots were admitted before dispatch"); + assertFalse(completion.isDone()); + when(fixture.plugin.isEnabled()).thenReturn(false); + assertDoesNotThrow(() -> fixture.global.get(0).run()); + assertInstanceOf(IllegalStateException.class, + assertThrows(CompletionException.class, completion::join).getCause()); + assertEquals(2, checkpoints.size(), "shutdown must not persist fragment or outer completion"); + fixture.bukkit.verify(() -> Bukkit.getPlayer(id), never()); + assertTrue(fixture.owners.isEmpty()); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport, fixture.console); + } + } + + @Test void disabledRetiredRecipientDeliveryFailsWithoutAdvancingReplayCheckpoints() throws Exception { + try (Fixture fixture = new Fixture()) { + var nativeScheduler = fixture.nativeSchedulers(); + var id = fixture.first.getUniqueId(); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(fixture.first); + var checkpoints = new ArrayList(); + var completion = fixture.replayLiteralFragment(id, checkpoints).toCompletableFuture(); + fixture.global.get(0).run(); + assertEquals(2, checkpoints.size()); + when(fixture.plugin.isEnabled()).thenReturn(false); + assertDoesNotThrow(() -> nativeScheduler.retired.get(0).run()); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> completion.get(2, java.util.concurrent.TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, failure.getCause()); + assertEquals(2, checkpoints.size(), "retirement after disable is failure, not completed delivery"); + fixture.owners.get(0).run(); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void disabledRetiredRecipientCaptureFailsInsteadOfPublishingAbsence() throws Exception { + try (Fixture fixture = new Fixture()) { + var nativeScheduler = fixture.nativeSchedulers(); + var id = fixture.first.getUniqueId(); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(fixture.first); + var capture = fixture.misc.capturePreparedRecipientAsync(fixture.plugin, id, fixture.renderer) + .toCompletableFuture(); + fixture.global.get(0).run(); + when(fixture.plugin.isEnabled()).thenReturn(false); + assertDoesNotThrow(() -> nativeScheduler.retired.get(0).run()); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> capture.get(2, java.util.concurrent.TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, failure.getCause()); + fixture.owners.get(0).run(); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void disabledRetiredOrdinaryBroadcastFailsAndFencesLateOwners() throws Exception { + try (Fixture fixture = new Fixture()) { + var nativeScheduler = fixture.nativeSchedulers(); + var completion = fixture.withControlledDeadline(fixture.renderer).toCompletableFuture(); + fixture.global.get(0).run(); + when(fixture.plugin.isEnabled()).thenReturn(false); + nativeScheduler.retired.forEach(Runnable::run); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> completion.get(2, java.util.concurrent.TimeUnit.SECONDS)); + assertInstanceOf(IllegalStateException.class, failure.getCause()); + fixture.owners.forEach(Runnable::run); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void enabledAbsentAndOfflineRecipientsStillAdvanceTheirReplayCheckpointsOnce() throws Exception { + for (boolean absent : List.of(true, false)) { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + if (!absent) { + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(fixture.first); + when(fixture.first.isOnline()).thenReturn(false); + } + var checkpoints = new ArrayList(); + var completion = fixture.replayLiteralFragment(id, checkpoints).toCompletableFuture(); + fixture.global.get(0).run(); fixture.global.get(0).run(); + if (!absent) { assertEquals(1, fixture.owners.size()); fixture.owners.get(0).run(); } + completion.join(); + assertEquals(4, checkpoints.size(), "live skip completes the fragment and its outer recipient once"); + fixture.bukkit.verify(() -> Bukkit.getPlayer(id), times(1)); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + } + + @Test void enabledRetirementStillSkipsRecipientCaptureAndDelivery() throws Exception { + for (boolean capture : List.of(true, false)) { + try (Fixture fixture = new Fixture()) { + var nativeScheduler = fixture.nativeSchedulers(); + var id = fixture.first.getUniqueId(); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(fixture.first); + var checkpoints = new ArrayList(); + var completion = capture ? fixture.misc.capturePreparedRecipientAsync(fixture.plugin, id, fixture.renderer) + : fixture.replayLiteralFragment(id, checkpoints); + fixture.global.get(0).run(); + nativeScheduler.retired.get(0).run(); nativeScheduler.retired.get(0).run(); + Object value = completion.toCompletableFuture().get(2, java.util.concurrent.TimeUnit.SECONDS); + if (capture) assertNull(((MiscUtils.PreparedRecipient) value).player()); + else assertEquals(4, checkpoints.size()); + fixture.owners.get(0).run(); + assertTrue(fixture.rendered.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + } + + @Test void cancelledLiteralDeliveryFencesQueuedGlobalLookup() throws Exception { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + var completion = fixture.misc.dispatchPreparedComponentLineAsync(fixture.plugin, id, + player -> new TextComponent("Late")).toCompletableFuture(); + assertTrue(completion.cancel(false)); + fixture.global.get(0).run(); fixture.global.get(0).run(); + fixture.bukkit.verify(() -> Bukkit.getPlayer(id), never()); + assertTrue(fixture.owners.isEmpty()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void recipientLookupFailureCompletesExceptionallyWithoutEscapingQueuedCallback() throws Exception { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + var failure = new IllegalStateException("synthetic player lookup failure"); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenThrow(failure); + var completion = fixture.misc.dispatchPreparedComponentLineAsync(fixture.plugin, id, + player -> new TextComponent("Late")).toCompletableFuture(); + assertDoesNotThrow(() -> fixture.global.get(0).run()); + assertSame(failure, assertThrows(CompletionException.class, completion::join).getCause()); + assertTrue(fixture.owners.isEmpty()); + } + } + + @Test void disableDuringAbsentLookupFailsBeforeSuccessfulSkip() throws Exception { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenAnswer(call -> { + when(fixture.plugin.isEnabled()).thenReturn(false); return null; + }); + var checkpoints = new ArrayList(); + var completion = fixture.replayLiteralFragment(id, checkpoints).toCompletableFuture(); + fixture.global.get(0).run(); + assertThrows(CompletionException.class, completion::join); + assertEquals(2, checkpoints.size()); + } + } + + @Test void disableDuringOfflineCheckFailsBeforeSuccessfulSkip() throws Exception { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(fixture.first); + when(fixture.first.isOnline()).thenAnswer(call -> { + when(fixture.plugin.isEnabled()).thenReturn(false); return false; + }); + var checkpoints = new ArrayList(); + var completion = fixture.replayLiteralFragment(id, checkpoints).toCompletableFuture(); + fixture.global.get(0).run(); fixture.owners.get(0).run(); + assertThrows(CompletionException.class, completion::join); + assertEquals(2, checkpoints.size()); + verifyNoInteractions(fixture.transport); + } + } + + @Test void disableAfterNativeSendRetainsPendingReplayCheckpoints() throws Exception { + try (Fixture fixture = new Fixture()) { + var id = fixture.first.getUniqueId(); + fixture.bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(fixture.first); + doAnswer(call -> { + assertSame(fixture.first, fixture.currentOwner.get()); + when(fixture.plugin.isEnabled()).thenReturn(false); + return null; + }).when(fixture.transport).sendMessage(eq(fixture.first), any(BaseComponent.class)); + var checkpoints = new ArrayList(); + var completion = fixture.replayLiteralFragment(id, checkpoints).toCompletableFuture(); + fixture.global.get(0).run(); fixture.owners.get(0).run(); + assertThrows(CompletionException.class, completion::join); + assertEquals(2, checkpoints.size(), "the ambiguous native effect must not be acknowledged after disable"); + verify(fixture.transport, times(1)).sendMessage(eq(fixture.first), any(BaseComponent.class)); + } + } + + private static final class Fixture implements AutoCloseable { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final BukkitScheduler scheduler = mock(BukkitScheduler.class); + final Player first = mock(Player.class, withSettings().extraInterfaces(EntityOwner.class)); + final Player second = mock(Player.class, withSettings().extraInterfaces(EntityOwner.class)); + final ConsoleCommandSender console = mock(ConsoleCommandSender.class); + final IServerHandle transport = mock(IServerHandle.class); + final ArrayList global = new ArrayList<>(); + final ArrayList owners = new ArrayList<>(); + final ArrayList rendered = new ArrayList<>(); + final AtomicReference currentOwner = new AtomicReference<>(); + final AtomicBoolean globalOwner = new AtomicBoolean(); + final AtomicReference deadline = new AtomicReference<>(); + final MiscUtils misc = MiscUtils.getInstance(); + final java.lang.reflect.Field pluginField; + final Object previousPlugin; + final org.mockito.MockedStatic bukkit; + final org.mockito.MockedStatic utility; + final Function renderer = player -> { + assertSame(player, currentOwner.get()); rendered.add(player); + return new TextComponent[] {new TextComponent("R&D &aLiteral")}; + }; + + Fixture() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(first.isOnline()).thenReturn(true); when(second.isOnline()).thenReturn(true); + when(first.getUniqueId()).thenReturn(java.util.UUID.fromString("00000000-0000-0000-0000-000000000001")); + when(second.getUniqueId()).thenReturn(java.util.UUID.fromString("00000000-0000-0000-0000-000000000002")); + doAnswer(call -> { + Runnable task = call.getArgument(1); + global.add(() -> {globalOwner.set(true); try {task.run();} finally {globalOwner.set(false);}}); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { + Runnable task = call.getArgument(1); Player player = call.getArgument(2); + owners.add(() -> {currentOwner.set(player); try {task.run();} finally {currentOwner.set(null);}}); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + doAnswer(call -> {assertTrue(globalOwner.get()); return null;}).when(console).sendMessage(anyString()); + doAnswer(call -> {assertSame(call.getArgument(0), currentOwner.get()); return null;}) + .when(transport).sendMessage(any(Player.class), any(BaseComponent.class)); + pluginField = MiscUtils.class.getDeclaredField("plugin"); pluginField.setAccessible(true); + previousPlugin = pluginField.get(misc); pluginField.set(misc, plugin); + bukkit = mockStatic(Bukkit.class); utility = mockStatic(PlayerUtils.class); + bukkit.when(Bukkit::getOnlinePlayers).thenAnswer(call -> {assertTrue(globalOwner.get()); return List.of(first, second);}); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + utility.when(PlayerUtils::getServerHandle).thenReturn(transport); + } + + TextComponent[] consoleLines() { return new TextComponent[] {new TextComponent("R&D &aLiteral")}; } + + CompletionStage replayLiteralFragment(java.util.UUID id, List checkpoints) { + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }) + .when(timer).execute(any(Runnable.class)); + var options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoints::add); + var state = Reward.replayStateFor(options); + var metadata = new java.util.HashMap(); + return Reward.replayNestedRewardSequence(plugin, metadata, "shutdown-outer", List.of("R|" + id + "|0"), + state, "shutdown-occurrence", (entry, ignored) -> Reward.replayNestedRewardSequence(plugin, + metadata, "shutdown-fragments", List.of("F|0"), state, "shutdown-occurrence", + (fragment, unused) -> misc.dispatchPreparedComponentLineAsync(plugin, id, player -> { + assertSame(player, currentOwner.get()); + rendered.add(player); + return new TextComponent("Original"); + }), fragment -> fragment.equals("F|0"), 4096, 16, 1048576L), + entry -> entry.equals("R|" + id + "|0"), 8192, 96, 1048576L); + } + + private static final class NativeCallbacks { + final ArrayList retired = new ArrayList<>(); + } + + NativeCallbacks nativeSchedulers() { + var library = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + when(library.isFolia()).thenReturn(true); + when(scheduler.getFoliaLib()).thenReturn(library); + NativeCallbacks callbacks = new NativeCallbacks(); + for (Player player : List.of(first, second)) { + OwnerScheduler nativeScheduler = mock(OwnerScheduler.class); + when(((EntityOwner) player).getScheduler()).thenReturn(nativeScheduler); + when(nativeScheduler.execute(eq(plugin), any(), any(), eq(1L))).thenAnswer(call -> { + Runnable action = call.getArgument(1); + owners.add(() -> {currentOwner.set(player); try {action.run();} finally {currentOwner.set(null);}}); + callbacks.retired.add(call.getArgument(2)); + return true; + }); + } + return callbacks; + } + + @SuppressWarnings("unchecked") + CompletionStage withControlledDeadline(Function render) throws Exception { + var method = MiscUtils.class.getDeclaredMethod("broadcastPreparedComponentsAsync", Function.class, + TextComponent[].class, Consumer.class); + method.setAccessible(true); + return (CompletionStage) method.invoke(misc, render, consoleLines(), (Consumer) deadline::set); + } + + @Override public void close() throws Exception { + utility.close(); bukkit.close(); pluginField.set(misc, previousPlugin); + } + } + + @Test + void eachRecipientRendersOnItsOwnerAndConsoleNeverReformatsLiteralAmpersands() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + Player first = mock(Player.class); + Player second = mock(Player.class); + when(first.isOnline()).thenReturn(true); + when(second.isOnline()).thenReturn(true); + ArrayList global = new ArrayList<>(); + ArrayList owners = new ArrayList<>(); + doAnswer(call -> { global.add(call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + AtomicBoolean onOwner = new AtomicBoolean(); + doAnswer(call -> { + Runnable work = call.getArgument(1); + owners.add(() -> { onOwner.set(true); try { work.run(); } finally { onOwner.set(false); } }); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + IServerHandle transport = mock(IServerHandle.class); + var field = MiscUtils.class.getDeclaredField("plugin"); + field.setAccessible(true); + MiscUtils misc = MiscUtils.getInstance(); + Object previous = field.get(misc); + field.set(misc, plugin); + ArrayList rendered = new ArrayList<>(); + try (var bukkit = mockStatic(Bukkit.class); var utility = mockStatic(PlayerUtils.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(first, second)); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + utility.when(PlayerUtils::getServerHandle).thenReturn(transport); + misc.broadcastPreparedComponents(player -> { + assertTrue(onOwner.get()); + rendered.add(player); + return new TextComponent[] { new TextComponent("R&D &aLiteral") }; + }, new TextComponent[] { new TextComponent("R&D &aLiteral") }); + assertTrue(rendered.isEmpty()); + assertEquals(1, global.size()); + global.get(0).run(); + assertTrue(rendered.isEmpty()); + verify(console).sendMessage(contains("R&D &aLiteral")); + assertEquals(2, owners.size()); + owners.forEach(Runnable::run); + assertEquals(List.of(first, second), rendered); + verify(transport).sendMessage(eq(first), any(BaseComponent.class)); + verify(transport).sendMessage(eq(second), any(BaseComponent.class)); + } finally { + field.set(misc, previous); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralDeadlineCancellationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralDeadlineCancellationTest.java new file mode 100644 index 0000000000..97df63a4ce --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralDeadlineCancellationTest.java @@ -0,0 +1,86 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class LiteralDeadlineCancellationTest { + @TempDir Path temporary; + + @Test void completedDeliveryPathsRemoveTheirActualScheduledTimeouts() throws Exception { + // Isolate the JVM's timer queue so unrelated tests cannot affect the count. + // Only this test child opens the JDK diagnostic field; production uses public APIs. + Path output = temporary.resolve("deadline-probe.txt"); + String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + Process child = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-XX:+StartAttachListener", "--add-opens=java.base/java.util.concurrent=ALL-UNNAMED", + "-cp", classpath, Probe.class.getName()) + .redirectErrorStream(true).redirectOutput(output.toFile()).start(); + try { + assertTrue(child.waitFor(20, TimeUnit.SECONDS), "deadline probe exceeded its bound"); + assertEquals(0, child.exitValue(), () -> { + try { return Files.readString(output); } catch (Exception failure) { return "probe output unavailable"; } + }); + assertTrue(Files.readString(output).contains("deadline queue empty")); + } finally { if (child.isAlive()) child.destroyForcibly(); } + } + + public static class Probe { + public static void main(String[] args) throws Exception { + var owner = mock(AdvancedCorePlugin.class); + var scheduler = mock(BukkitScheduler.class); + when(owner.getBukkitScheduler()).thenReturn(scheduler); + when(owner.isEnabled()).thenReturn(true); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(owner), any(Runnable.class)); + var misc = MiscUtils.getInstance(); + var plugin = MiscUtils.class.getDeclaredField("plugin"); + plugin.setAccessible(true); plugin.set(misc, owner); + // Force initialization before reading the otherwise private diagnostic queue. + new CompletableFuture<>().orTimeout(1, TimeUnit.DAYS).complete(null); + var timer = Class.forName("java.util.concurrent.CompletableFuture$Delayer").getDeclaredField("delayer"); + timer.setAccessible(true); + var executor = (ScheduledThreadPoolExecutor) timer.get(null); + if (!executor.getQueue().isEmpty()) throw new AssertionError("probe queue was not isolated"); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + for (int iteration = 0; iteration < 64; iteration++) { + misc.dispatchPreparedComponentLineAsync(owner, UUID.randomUUID(), player -> null).toCompletableFuture().join(); + misc.dispatchPreparedConsoleLineAsync(owner, () -> null).toCompletableFuture().join(); + misc.dispatchPreparedConsoleMessageAsync(owner, () -> null).toCompletableFuture().join(); + misc.captureOnlinePlayerIdsAsync(owner).toCompletableFuture().join(); + misc.capturePreparedRecipientAsync(owner, UUID.randomUUID(), player -> null).toCompletableFuture().join(); + misc.broadcastPreparedComponentsAsync(player -> null, null).toCompletableFuture().join(); + misc.broadcastPreparedMessageAsync(player -> null, () -> null).toCompletableFuture().join(); + } + // Successful, failed and cancelled operations all reclaim their deadlines. + try { misc.dispatchPreparedConsoleMessageAsync(owner, () -> { throw new IllegalStateException("synthetic failure"); }) + .toCompletableFuture().join(); throw new AssertionError("failure was not propagated"); } + catch (java.util.concurrent.CompletionException expected) { } + var queued = new java.util.ArrayList(); + doAnswer(call -> { queued.add(call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(owner), any(Runnable.class)); + var cancelled = misc.dispatchPreparedConsoleMessageAsync(owner, () -> { throw new AssertionError("late renderer ran"); }) + .toCompletableFuture(); + cancelled.cancel(false); + queued.forEach(Runnable::run); + if (!executor.getQueue().isEmpty()) throw new AssertionError("settled deliveries retained " + executor.getQueue().size() + " deadlines"); + System.out.println("deadline queue empty"); + } + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralScriptActionBindingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralScriptActionBindingTest.java new file mode 100644 index 0000000000..98819177e8 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralScriptActionBindingTest.java @@ -0,0 +1,60 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.messages.LiteralMessageTemplate; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; + +import net.md_5.bungee.api.chat.BaseComponent; + +/** Regression coverage for protected script fragments used as component actions. */ +class LiteralScriptActionBindingTest { + @Test + void protectedScriptInCommandKeepsRawActionSeparateFromVisibleLiteral() { + BaseComponent[] rendered = render("[Text=\"%SiteName%\",Command=\"/inspect [Javascript=%SiteName%]\"]", + Map.of("SiteName", "Friendly Site"), Map.of("SiteName", "raw-site")); + + assertEquals("/inspect [Javascript=raw-site]", action(rendered[0])); + assertFalse(action(rendered[0]).contains("Friendly Site")); + assertEquals("Friendly Site", BaseComponent.toPlainText(rendered)); + } + + @Test + void protectedScriptInUrlUsesEmptyLiteralWhenRawActionIsMissing() { + BaseComponent[] rendered = render("[Text=\"Open\",URL=\"https://example.com/[Javascript=%SiteName%]\"]", + Map.of("SiteName", "Friendly Site"), Map.of()); + + assertEquals("https://example.com/[Javascript=]", action(rendered[0])); + } + + @Test + void protectedScriptsKeepCaseVariantsAndMultipleLiteralFragmentsIndependent() { + BaseComponent[] rendered = render( + "[Text=\"Open\",Command=\"/inspect [Javascript=%sitename%/%OTHER%/%provider%]\"]", + Map.of("SiteName", "First", "Other", "Second"), + Map.of("sItEnAmE", "raw-first", "oThEr", "raw-second")); + + assertEquals("/inspect [Javascript=raw-first/raw-second/%provider%]", action(rendered[0])); + } + + private String action(BaseComponent component) { + if (component.getClickEvent() != null) return component.getClickEvent().getValue(); + if (component.getExtra() != null) { + for (BaseComponent child : component.getExtra()) { + String found = action(child); + if (found != null) return found; + } + } + return null; + } + + private BaseComponent[] render(String source, Map literals, Map actions) { + LiteralMessageTemplate.Prepared prepared = LiteralMessageTemplate.prepare(source, literals, actions); + return new BaseComponent[] { prepared.finish(PlaceholderUtils.parseJson(prepared.template())) }; + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralTemplateBoundsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralTemplateBoundsTest.java new file mode 100644 index 0000000000..d51ff01546 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralTemplateBoundsTest.java @@ -0,0 +1,242 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.AbstractMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.messages.LiteralMessageTemplate; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; + +class LiteralTemplateBoundsTest { + @Test + void trustedFormattingHasABoundedExpansionBudgetSeparateFromAuthoredInput() { + var prepared = LiteralMessageTemplate.prepare("%Site%", Map.of("Site", "literal"), null); + String expanded = "x".repeat(262144); + assertEquals(expanded, prepared.withTemplate(expanded).finishText(expanded)); + assertThrows(IllegalArgumentException.class, () -> prepared.withTemplate(expanded + "x")); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("x".repeat(65537), Map.of(), null)); + } + + @Test + void acceptsMaximumBindingCountWithOnlyOneReferencedLiteral() { + Map values = summary(8192); + String source = "x".repeat(65524) + "%Site0%"; + var prepared = LiteralMessageTemplate.prepare(source, values, Map.of()); + assertEquals("x".repeat(65524) + "literal0", prepared.finishText(prepared.template())); + } + + @Test + void repeatsAndUnreferencedBindingsDoNotConsumeTheActiveBindingLimit() { + Map values = summary(8192); + String source = "%Site0%{sItE0}".repeat(100); + var prepared = LiteralMessageTemplate.prepare(source, values, Map.of()); + assertEquals("literal0literal0".repeat(100), prepared.finishText(prepared.template())); + assertEquals("plain", LiteralMessageTemplate.prepare("plain", values, null).template()); + } + + @Test + void rejectsOverLimitMapsBeforeRequestingTheirEntries() { + Map oversized = new AbstractMap<>() { + @Override public int size() { return 8193; } + @Override public Set> entrySet() { + throw new AssertionError("oversized input must be rejected before iteration"); + } + }; + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", oversized, Map.of())); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", Map.of(), oversized)); + } + + @Test + void referencedBindingsAcceptTheirLimitAndRejectTheNextDistinctMarker() { + Map values = new LinkedHashMap<>(); + StringBuilder source = new StringBuilder(); + for (int index = 0; index < 4096; index++) { + values.put("K" + index, ""); + source.append("%K").append(index).append('%'); + } + assertTrue(LiteralMessageTemplate.prepare(source.toString(), values, null).template().contains("VCORELITERAL")); + values.put("K4096", ""); + source.append("%K4096%"); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare(source.toString(), values, null)); + } + + @Test + void aggregateCharacterBudgetAppliesToBothMapsAtAndBeyondItsLimit() { + Map literals = fullCharacterBudget(); + assertEquals("plain", LiteralMessageTemplate.prepare("plain", literals, null).template()); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", literals, Map.of("A", ""))); + String last = "K127"; + literals.put(last, literals.get(last) + "x"); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", literals, null)); + } + + @Test + void singleValueAndTemplateCharacterLimitsPreserveUtf16Counterexamples() { + String value = "\uD83D\uDE00".repeat(4096); + var prepared = LiteralMessageTemplate.prepare("%Name%", Map.of("Name", value), null); + assertEquals(value, prepared.finishText(prepared.template())); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", Map.of("Name", value + "x"), null)); + assertEquals(65536, LiteralMessageTemplate.prepare("x".repeat(65536), null, null).template().length()); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("x".repeat(65537), null, null)); + String key = "A".repeat(128); + var longestKey = LiteralMessageTemplate.prepare("{" + key + "}", Map.of(key, "valid"), null); + assertEquals("valid", longestKey.finishText(longestKey.template())); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", Map.of(key + "A", "invalid"), null)); + } + + @Test + void overlappingDelimiterMarkersKeepOriginalLeftToRightReplacement() { + Map values = Map.of("Site", "display", "Next", "next"); + Map expected = Map.of( + "%missing%Site%", "%missingdisplay", + "%Site%Next%", "displayNext%", + "{%Site%}", "{display}", + "{{SITE}}", "{display}", + "%Site%%Next%", "displaynext"); + expected.forEach((source, text) -> { + var prepared = LiteralMessageTemplate.prepare(source, values, null); + assertEquals(text, prepared.finishText(prepared.template()), source); + }); + } + + @Test + void javaCaseFoldingAndInsertedMarkerTextRemainCompatible() { + for (String marker : new String[] { "%\u017fITE%", "{s\u0131te}", "%SITE%" }) { + var prepared = LiteralMessageTemplate.prepare(marker, + Map.of("Site", "%Other%", "Other", "must not be rescanned"), null); + assertEquals("%Other%", prepared.finishText(prepared.template()), marker); + } + var kelvin = LiteralMessageTemplate.prepare("%\u212aEY%", Map.of("Key", "ok"), null); + assertEquals("ok", kelvin.finishText(kelvin.template())); + } + + @Test + void rawActionIndexSupportsNonLiteralKeysAndOriginalMapPriority() { + Map actions = new LinkedHashMap<>(); + actions.put("s", "short"); + actions.put("s%tail", "long"); + actions.put("\u03a3", "greek"); + actions.put("name with space", "space"); + actions.put("\uD801\uDC00", "supplementary"); + String source = "[Text=\"%Site%\",Command=\"/inspect %s%tail%/{\u03c2}/%name with space%/{\uD801\uDC28}/%Site%\"]"; + TextComponent result = render(source, Map.of("Site", "display"), actions); + assertEquals("display", result.toPlainText()); + assertEquals("/inspect shorttail%/greek/space/supplementary/", action(result)); + } + + @Test + void overlappingRawMarkersRetainFirstUseOrderingAcrossAttributes() { + Map actions = new LinkedHashMap<>(); + actions.put("s%tail", "long"); + actions.put("s", "short"); + TextComponent result = render("[Text=\"A\",Command=\"%s%\"][Text=\"B\",Command=\"%s%tail%\"]", + Map.of(), actions); + String json = net.md_5.bungee.chat.ComponentSerializer.toString(result); + assertTrue(json.contains("shorttail%"), json); + assertTrue(!json.contains("\"value\":\"long\""), json); + } + + @Test + void maximumRawActionMapRetainsItsSeparateValue() { + Map actions = summary(8192); + actions.put("Site0", "raw"); + TextComponent result = render("[Text=\"%Site0%\",Command=\"/inspect %Site0%\"]", + Map.of("Site0", "display"), actions); + assertEquals("display", result.toPlainText()); + assertEquals("/inspect raw", action(result)); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", null, Map.of("a".repeat(1025), ""))); + String key = "a".repeat(1024); + TextComponent longestKey = render("[Text=\"ok\",Command=\"{" + key + "}\"]", Map.of(), Map.of(key, "raw")); + assertEquals("raw", action(longestKey)); + } + + @Test + void templateIndexReadsCharactersOnceRegardlessOfUnreferencedMapSize() throws Exception { + Method index = LiteralMessageTemplate.class.getDeclaredMethod("literalMarkerKeys", CharSequence.class); + index.setAccessible(true); + CountingText source = new CountingText("%Absent%".repeat(8192)); + assertEquals(Set.of("absent"), index.invoke(null, source)); + assertTrue(source.reads <= source.length() * 3, "marker admission must not scan by binding"); + var prepared = LiteralMessageTemplate.prepare(source.text, summary(8192), Map.of()); + assertEquals(source.text, prepared.template()); + } + + @Test + void replacementIndexReadsCharactersIndependentlyOfBindingCount() throws Exception { + Class matcher = Class.forName(LiteralMessageTemplate.class.getName() + "$MarkerMatcher"); + Constructor constructor = matcher.getDeclaredConstructor(Map.class); + constructor.setAccessible(true); + Object indexed = constructor.newInstance(summary(8192)); + Method match = matcher.getDeclaredMethod("match", CharSequence.class, int.class, Set.class); + match.setAccessible(true); + CountingText source = new CountingText("%Absent%".repeat(8192)); + for (int cursor = 0; cursor < source.length(); cursor++) match.invoke(indexed, source, cursor, null); + assertTrue(source.reads <= source.length() * 3, "unmatched markers must not try every binding"); + } + + private static Map summary(int count) { + Map values = new LinkedHashMap<>(); + for (int index = 0; index < count; index++) values.put("Site" + index, "literal" + index); + return values; + } + + private static Map fullCharacterBudget() { + Map values = new LinkedHashMap<>(); + int remaining = 1048576; + for (int index = 0; index < 128; index++) remaining -= ("K" + index).length(); + for (int index = 0; index < 128; index++) { + int length = Math.min(8192, remaining); + values.put("K" + index, "x".repeat(length)); + remaining -= length; + } + assertEquals(0, remaining); + return values; + } + + private static TextComponent render(String source, Map values, Map actions) { + var prepared = LiteralMessageTemplate.prepare(source, values, actions); + return prepared.finish(PlaceholderUtils.parseJson(prepared.template())); + } + + private static String action(BaseComponent component) { + if (component.getClickEvent() != null) return component.getClickEvent().getValue(); + if (component.getExtra() != null) { + for (BaseComponent child : component.getExtra()) { + String result = action(child); + if (result != null) return result; + } + } + return null; + } + + private static final class CountingText implements CharSequence { + private final String text; + private int reads; + private CountingText(String text) { this.text = text; } + @Override public int length() { return text.length(); } + @Override public char charAt(int index) { reads++; return text.charAt(index); } + @Override public CharSequence subSequence(int start, int end) { return text.subSequence(start, end); } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralTokenIndexTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralTokenIndexTest.java new file mode 100644 index 0000000000..c86a753c28 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/LiteralTokenIndexTest.java @@ -0,0 +1,514 @@ +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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.mockStatic; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.AbstractMap; +import java.util.AbstractSet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.messages.LiteralMessageTemplate; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; +import com.bencodez.simpleapi.array.ArrayUtils; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; + +class LiteralTokenIndexTest { + private static final String PREFIX = "VCORELITERAL"; + private static final String OLD_TOKEN = PREFIX + "1Q2Z"; + + @Test + void indexesTheExistingTokenGrammarIncludingEmbeddedTokensAndRejectsLookalikes() throws Exception { + String longest = PREFIX + "z".repeat(13) + "Q" + "0".repeat(13) + "Z"; + List invalid = List.of(PREFIX + "Q1Z", PREFIX + "1QZ", PREFIX + "A" + "Q1Z", + PREFIX + "a".repeat(14) + "Q1Z", PREFIX + "1Q" + "a".repeat(14) + "Z", + PREFIX + "1q2Z", PREFIX + "1Q2z", "vcoreliteral1Q2Z", PREFIX + "PROBE", + PREFIX + "1Q2_ Z", PREFIX + "1Q2", PREFIX); + Object index = index(Set.of()); + add(index, "left" + OLD_TOKEN + "suffix/" + longest + "/" + String.join("/", invalid)); + assertEquals(Set.of(OLD_TOKEN, longest), snapshot(index)); + } + + @Test + void indexesOneMiBCorpusOnceAndAllocates4096TokensWithoutReadingItAgain() throws Exception { + Object index = index(Set.of()); + String fragment = PREFIX + "a".repeat(14) + "Q1Z/" + OLD_TOKEN + "/"; + CountingText corpus = new CountingText(fragment.repeat(1048576 / fragment.length() + 1).substring(0, 1048576)); + add(index, corpus); + assertTrue(corpus.reads <= corpus.length() * 3, "indexing must be bounded by corpus characters"); + int indexedReads = corpus.reads; + AtomicInteger next = new AtomicInteger(); + for (int position = 0; position < 4096; position++) { + String expected = token(position + 100); + assertEquals(expected, allocate(index, () -> token(next.getAndIncrement() + 100))); + } + assertEquals(indexedReads, corpus.reads, "allocations must query the index rather than reread input"); + assertEquals(4097, snapshot(index).size()); + } + + @Test + void collisionRetriesRemainBoundedAndGeneratedTokensAreReserved() throws Exception { + Object index = index(Set.of(OLD_TOKEN)); + AtomicInteger attempts = new AtomicInteger(); + assertThrows(IllegalStateException.class, () -> allocate(index, () -> { + attempts.incrementAndGet(); + return OLD_TOKEN; + })); + assertEquals(16, attempts.get()); + assertEquals(token(10), allocate(index, () -> token(10))); + AtomicInteger collisionThenFree = new AtomicInteger(); + assertEquals(token(11), allocate(index, + () -> collisionThenFree.getAndIncrement() == 0 ? token(10) : token(11))); + assertEquals(2, collisionThenFree.get()); + } + + @Test + void retainedCollisionIndexRejectsGrowthAtItsBoundWithoutEvictingExistingTokens() throws Exception { + Set full = new AbstractSet<>() { + @Override public int size() { return 131072; } + @Override public boolean contains(Object value) { return OLD_TOKEN.equals(value); } + @Override public Iterator iterator() { throw new AssertionError("no corpus copy is needed"); } + }; + Object index = index(full); + assertThrows(IllegalArgumentException.class, () -> allocate(index, () -> token(10))); + AtomicInteger attempts = new AtomicInteger(); + assertThrows(IllegalStateException.class, () -> allocate(index, () -> { + attempts.incrementAndGet(); + return OLD_TOKEN; + })); + assertEquals(16, attempts.get()); + assertSame(full, snapshot(index)); + } + + @Test + void preparationIndexesSourceAllInputValuesAndKeysWithoutTreatingOldTokensAsItsOwn() throws Exception { + Map literals = Map.of("Site", "display/" + token(3), "Unused", token(4), token(5), "unused"); + Map actions = Map.of("Site", "raw/" + token(6), token(7), token(8)); + var prepared = LiteralMessageTemplate.prepare(OLD_TOKEN + "/%Site%", literals, actions); + assertTrue(forbidden(prepared).containsAll(Set.of(OLD_TOKEN, token(3), token(4), token(5), token(6), token(7), token(8)))); + assertEquals(OLD_TOKEN + "/display/" + token(3), prepared.finishText(prepared.template())); + assertThrows(UnsupportedOperationException.class, () -> forbidden(prepared).add(token(99))); + } + + @Test + void composedScriptTokensEnterBothCollisionLanesAndRemainLiteral() throws Exception { + var prepared = LiteralMessageTemplate.prepare("[Javascript=%A%%B%]", + Map.of("A", PREFIX, "B", "1Q2Z"), Map.of("A", PREFIX + "3", "B", "Q4Z")); + assertTrue(forbidden(prepared).containsAll(Set.of(OLD_TOKEN, token(3)))); + assertEquals("[Javascript=" + OLD_TOKEN + "]", prepared.finishText(prepared.template())); + var action = LiteralMessageTemplate.prepare("[Text=\"open\",Command=\"[Javascript=%A%%B%]\"]", + Map.of("A", PREFIX, "B", "1Q2Z"), Map.of("A", PREFIX + "3", "B", "Q4Z")); + assertEquals("[Javascript=" + token(3) + "]", actions(render(action)).get(0)); + } + + @Test + void restorationReadsOriginalTextOnceWith4096BindingsAndNeverEnumeratesThem() throws Exception { + Map values = new HashMap<>(); + StringBuilder source = new StringBuilder(); + StringBuilder expected = new StringBuilder(); + for (int position = 0; position < 4096; position++) { + values.put(token(position + 100), "x"); + source.append(token(position + 100)); + expected.append('x'); + if (position % 64 == 0) { + source.append(PREFIX).append("a".repeat(14)).append("Q1Z/"); + expected.append(PREFIX).append("a".repeat(14)).append("Q1Z/"); + } + } + CountingText text = new CountingText(source.toString()); + LookupOnlyMap lookup = new LookupOnlyMap(values); + String restored = restore(text, lookup, false); + assertEquals(expected.toString(), restored); + assertEquals(4096, lookup.lookups); + assertTrue(text.reads <= text.length() * 3, "restoration must not scan once per token or occurrence"); + } + + @Test + void earliestTokenAndSharedNumericPrefixesKeepOrderAndInsertedValuesAreNeverRescanned() throws Exception { + String first = PREFIX + "1QaZ"; + String second = PREFIX + "1aQaZ"; + Map values = new LinkedHashMap<>(); + values.put(second, "second"); + values.put(first, second); + assertEquals("second/" + second + "/second", restore(second + "/" + first + "/" + second, values, false)); + assertEquals("(section)a", restore(first, Map.of(first, "§a"), true)); + assertEquals("§a", restore(first, Map.of(first, "§a"), false)); + assertEquals(OLD_TOKEN + "/" + PREFIX + "PROBE", restore(OLD_TOKEN + "/" + PREFIX + "PROBE", values, true)); + } + + @Test + void all4096ReferencedValuesFitOneMiBBindingCorpusAndExactly262144OutputCharacters() { + Map literals = new LinkedHashMap<>(); + StringBuilder source = new StringBuilder(); + int remaining = 1048576; + for (int position = 0; position < 4096; position++) { + String key = "K" + position; + literals.put(key, "x".repeat(64)); + remaining -= key.length() + 64; + source.append('%').append(key).append('%'); + } + Map actions = new LinkedHashMap<>(); + for (int position = 0; position < 96; position++) remaining -= ("Unused" + position).length(); + for (int position = 0; position < 96; position++) { + int length = Math.min(8192, remaining); + String fragment = OLD_TOKEN + "/"; + actions.put("Unused" + position, fragment.repeat(length / fragment.length() + 1).substring(0, length)); + remaining -= length; + } + assertEquals(0, remaining); + var prepared = LiteralMessageTemplate.prepare(source.toString(), literals, actions); + assertEquals("x".repeat(262144), prepared.finishText(prepared.template())); + assertThrows(IllegalArgumentException.class, () -> prepared.finishText(prepared.template() + "x")); + } + + @Test + void outputBudgetIncludesOrdinarySuffixesAndSectionSignExpansion() { + var plain = LiteralMessageTemplate.prepare("plain", Map.of(), Map.of()); + assertEquals("x".repeat(262144), plain.finishText("x".repeat(262144))); + assertThrows(IllegalArgumentException.class, () -> plain.finishText("x".repeat(262145))); + var sections = LiteralMessageTemplate.prepare("%Site%".repeat(4), Map.of("Site", "§".repeat(8192)), Map.of()); + assertThrows(IllegalArgumentException.class, () -> sections.finishText(sections.template())); + } + + @Test + void visibleHoverAndRawActionsPreserveUnicodeOverlapsAndOldTokenLookingData() { + String literal = "§a[Text=\"unsafe\"]%Other%{Other}/" + OLD_TOKEN; + String raw = "raw/§z/[Javascript=1]/\"/" + token(3); + var prepared = LiteralMessageTemplate.prepare( + "[Text=\"%\u017fITE%\",Hover=\"{s\u0131te}\",Command=\"/inspect %Site%\"]", + Map.of("Site", literal, "Other", "must not be expanded"), Map.of("Site", raw)); + TextComponent rendered = render(prepared); + assertEquals(literal.replace("§", "(section)"), rendered.toPlainText()); + assertEquals(List.of("/inspect " + raw), actions(rendered)); + assertEquals(List.of(literal.replace("§", "(section)")), hovers(rendered)); + var overlap = LiteralMessageTemplate.prepare("%missing%Site%/%Site%Next%/{{SITE}}", + Map.of("Site", "%Next%", "Next", "next"), Map.of()); + assertEquals("%missing%Next%/%Next%Next%/{%Next%}", overlap.finishText(overlap.template())); + } + + @Test + void persistedRewardMetadataAndProductionConsumerPreserveOldValidTokenData() { + HashMap stored = new HashMap<>(); + RewardDisplayPlaceholders.putLiteral(stored, "Site", "visible/" + OLD_TOKEN, "fallback"); + stored.put("Site", "raw/" + token(3)); + HashMap restored = ArrayUtils.fromString(ArrayUtils.makeString(stored)); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())) + .thenAnswer(call -> call.getArgument(0)); + TextComponent[] rendered = RewardDisplayPlaceholders.renderLiteralComponents(null, + "[Text=\"%Site%\",Command=\"/inspect %Site%\",Hover=\"{Site}\"]", restored); + assertEquals("visible/" + OLD_TOKEN, BaseComponent.toPlainText(rendered)); + assertEquals(List.of("/inspect raw/" + token(3)), actions(rendered[0])); + assertEquals(List.of("visible/" + OLD_TOKEN), hovers(rendered[0])); + } + } + + @Test + void productionScriptLaneNeverEvaluatesRestoredDisplayDataOrProtectedRawScriptActions() { + String literal = "data/[Javascript=danger()]%Other%/" + OLD_TOKEN; + HashMap stored = new HashMap<>(); + RewardDisplayPlaceholders.putLiteral(stored, "Site", literal, "fallback"); + stored.put("Site", "raw-site"); + AtomicInteger executions = new AtomicInteger(); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> + JavascriptTextTemplate.parse(call.getArgument(0)).evaluate(java.util.function.Function.identity(), + expression -> { executions.incrementAndGet(); return "executed"; })); + TextComponent[] rendered = RewardDisplayPlaceholders.renderLiteralComponents(null, + "[Text=\"%Site%\",Command=\"[Javascript=%Site%]\"]|[Javascript='%Site%']", stored); + assertEquals(literal + "|[Javascript='" + literal + "']", BaseComponent.toPlainText(rendered)); + assertEquals(List.of("[Javascript=raw-site]"), actions(rendered[0])); + assertEquals(0, executions.get()); + } + } + + @Test + void formattedActionsIndexEachOutputAndPreserveIndependentRawResults() throws Exception { + var prepared = LiteralMessageTemplate.prepare( + "[Text=\"%Site%\",Command=\"one\"][Text=\"open\",Url=\"two\"]", Map.of("Site", "display"), Map.of()); + AtomicInteger calls = new AtomicInteger(); + var formatted = prepared.protectFormattedActions(value -> { + calls.incrementAndGet(); + return value + "/" + (value.equals("one") ? OLD_TOKEN : token(3)) + "/§/\""; + }); + assertEquals(2, calls.get()); + assertTrue(forbidden(formatted).containsAll(Set.of(OLD_TOKEN, token(3)))); + assertFalse(forbidden(prepared).contains(OLD_TOKEN)); + assertEquals(List.of("one/" + OLD_TOKEN + "/§/\"", "two/" + token(3) + "/§/\""), actions(render(formatted))); + assertEquals("displayopen", render(formatted).toPlainText()); + } + + @Test + void cumulativeFormattedActionBudgetIncludesEveryAttribute() { + var prepared = LiteralMessageTemplate.prepare("[Text=\"\",Command=\"one\"][Text=\"\",Url=\"two\"]", Map.of(), Map.of()); + var atLimit = prepared.protectFormattedActions(value -> "x".repeat(131072)); + assertEquals(List.of("x".repeat(131072), "x".repeat(131072)), actions(render(atLimit))); + assertThrows(IllegalArgumentException.class, + () -> prepared.protectFormattedActions(value -> "x".repeat(131073))); + } + + @Test + void retainedFormattedActionBudgetSurvivesRepeatedFormattingTemplatesAndLineSplits() { + var prepared = LiteralMessageTemplate.prepare("[Text=\"\",Command=\"one\"]", Map.of(), Map.of()); + var first = prepared.protectFormattedActions(value -> "x".repeat(131072)); + var second = first.protectFormattedActions(value -> value); + assertEquals(List.of("x".repeat(131072)), actions(render(second))); + assertThrows(IllegalArgumentException.class, () -> second.protectFormattedActions(value -> value)); + assertThrows(IllegalArgumentException.class, + () -> second.withTemplate(second.template()).protectFormattedActions(value -> value)); + assertThrows(IllegalArgumentException.class, + () -> second.splitLines().get(0).protectFormattedActions(value -> value)); + assertEquals(List.of("x".repeat(131072)), actions(render(first))); + } + + @Test + void scriptCorpusExpansionIsBoundedBeforeRetainingCollisionMetadata() { + var atLimit = LiteralMessageTemplate.prepare("[Javascript=" + "%Site%".repeat(31) + "]", + Map.of("Site", "x".repeat(8192)), Map.of("Site", "raw")); + assertEquals("[Javascript=" + "x".repeat(8192 * 31) + "]", atLimit.finishText(atLimit.template())); + assertThrows(IllegalArgumentException.class, () -> LiteralMessageTemplate.prepare( + "[Javascript=" + "%Site%".repeat(32) + "]", Map.of("Site", "x".repeat(8192)), Map.of("Site", "raw"))); + } + + @Test + void manyLinesShareImmutable4096BindingMapsAndCorpusWithoutCopyingThem() throws Exception { + Map values = new LinkedHashMap<>(); + StringBuilder source = new StringBuilder(); + for (int position = 0; position < 4096; position++) { + values.put("K" + position, ""); + source.append("%K").append(position).append('%'); + } + source.append("%NewLine%line".repeat(2048)); + var prepared = LiteralMessageTemplate.prepare(source.toString(), values, Map.of()); + List lines = prepared.splitLines(); + assertEquals(2049, lines.size()); + for (var line : lines) { + assertSame(field(prepared, "tokens"), field(line, "tokens")); + assertSame(field(prepared, "actionTokens"), field(line, "actionTokens")); + assertSame(forbidden(prepared), forbidden(line)); + assertSame(line, line.protectFormattedActions(value -> { + throw new AssertionError("no authored actions exist"); + })); + } + var changed = lines.get(1).withTemplate("changed"); + assertSame(field(prepared, "tokens"), field(changed, "tokens")); + assertSame(forbidden(prepared), forbidden(changed)); + } + + @Test + void lineSplittingOnlyRecognizesTheCanonicalNewLineTokenCase() { + var prepared = LiteralMessageTemplate.prepare("first%NewLine%second%newline%third", Map.of(), Map.of()); + assertEquals(List.of("first", "second%newline%third"), + prepared.splitLines().stream().map(LiteralMessageTemplate.Prepared::template).toList()); + } + + @Test + void recipientActionIndexesAndSectionGuardsCannotMutateTheirSharedPrepared() throws Exception { + var prepared = LiteralMessageTemplate.prepare("[Text=\"%Site%\",Command=\"source\"]", + Map.of("Site", "display"), Map.of("Site", "raw")); + Set initial = forbidden(prepared); + CompletableFuture first = CompletableFuture.supplyAsync( + () -> prepared.protectFormattedActions(value -> "first/" + OLD_TOKEN)); + CompletableFuture second = CompletableFuture.supplyAsync( + () -> prepared.protectFormattedActions(value -> "second/" + token(3))); + var a = first.get(5, TimeUnit.SECONDS); + var b = second.get(5, TimeUnit.SECONDS); + assertEquals(List.of("first/" + OLD_TOKEN), actions(render(a))); + assertEquals(List.of("second/" + token(3)), actions(render(b))); + assertTrue(forbidden(a).contains(OLD_TOKEN)); + assertFalse(forbidden(a).contains(token(3))); + assertTrue(forbidden(b).contains(token(3))); + assertFalse(forbidden(b).contains(OLD_TOKEN)); + assertSame(initial, forbidden(prepared)); + assertFalse(initial.contains(OLD_TOKEN)); + String binding = ((Map) field(prepared, "tokens")).keySet().iterator().next().toString(); + var guarded = prepared.withTemplate("§" + binding); + assertEquals("(section)display", guarded.finishText(guarded.template())); + assertEquals(1, ((Map) field(prepared, "tokens")).size()); + assertEquals(2, ((Map) field(guarded, "tokens")).size()); + assertEquals("display", prepared.finishText(binding)); + } + + @Test + void sectionBoundaryRecognitionUsesKnownExactTokensAndOneLookupPerBoundary() throws Exception { + Map values = new LinkedHashMap<>(); + Map raw = new LinkedHashMap<>(); + for (int position = 0; position < 4096; position++) { + values.put(token(position + 100), position == 0 ? "§" : "x"); + raw.put(token(position + 100), position == 0 ? "§" : "x"); + } + AtomicInteger enumerations = new AtomicInteger(); + AtomicInteger lookups = new AtomicInteger(); + Map counted = new AbstractMap<>() { + @Override public Set> entrySet() { + enumerations.incrementAndGet(); + return values.entrySet(); + } + @Override public boolean isEmpty() { return false; } + @Override public boolean containsKey(Object key) { + lookups.incrementAndGet(); + return values.containsKey(key); + } + }; + String source = ("§" + token(4095 + 100)).repeat(1000); + Object index = index(Set.copyOf(values.keySet())); + Method protect = LiteralMessageTemplate.class.getDeclaredMethod("protectSectionTokenBoundaries", + String.class, Map.class, Map.class, index.getClass()); + protect.setAccessible(true); + Object result = invoke(protect, null, source, counted, raw, index); + Method template = result.getClass().getDeclaredMethod("template"); + template.setAccessible(true); + assertEquals((token(100) + token(4195)).repeat(1000), invoke(template, result)); + assertEquals(1, enumerations.get(), "guard discovery may enumerate once, boundaries must use exact lookup"); + assertEquals(1000, lookups.get()); + var prepared = LiteralMessageTemplate.prepare("%Site%", Map.of("Site", "display"), Map.of()); + String unknown = "§" + OLD_TOKEN + "/§" + PREFIX + "PROBE"; + assertEquals(unknown, prepared.withTemplate(unknown).template()); + assertEquals(unknown, prepared.finishText(unknown)); + } + + @Test + void thousandsOfComponentsRestoreThroughTheProductionTreeWalk() throws Exception { + Map values = new LinkedHashMap<>(); + StringBuilder source = new StringBuilder(); + for (int position = 0; position < 4096; position++) { + values.put("K" + position, "x"); + source.append("%K").append(position).append('%'); + } + var prepared = LiteralMessageTemplate.prepare(source.toString(), values, Map.of()); + Map tokens = (Map) field(prepared, "tokens"); + String last = new ArrayList<>(tokens.keySet()).get(4095).toString(); + TextComponent root = new TextComponent(""); + for (int position = 0; position < 4095; position++) root.addExtra(new TextComponent(last)); + assertEquals("x".repeat(4095), prepared.finish(root).toPlainText()); + } + + private static String token(int value) { + return PREFIX + Integer.toString(value, 36) + "Q4Z"; + } + + private static Object index(Set corpus) throws Exception { + Class type = Class.forName(LiteralMessageTemplate.class.getName() + "$TokenIndex"); + Constructor constructor = type.getDeclaredConstructor(Set.class); + constructor.setAccessible(true); + return constructor.newInstance(corpus); + } + + private static void add(Object index, CharSequence corpus) throws Exception { + Method add = index.getClass().getDeclaredMethod("addCorpus", CharSequence.class); + add.setAccessible(true); + invoke(add, index, corpus); + } + + @SuppressWarnings("unchecked") + private static Set snapshot(Object index) throws Exception { + Method snapshot = index.getClass().getDeclaredMethod("snapshot"); + snapshot.setAccessible(true); + return (Set) invoke(snapshot, index); + } + + private static String allocate(Object index, Supplier candidates) throws Exception { + Method allocate = LiteralMessageTemplate.class.getDeclaredMethod("newToken", index.getClass(), Supplier.class); + allocate.setAccessible(true); + return (String) invoke(allocate, null, index, candidates); + } + + private static String restore(CharSequence text, Map values, boolean visible) throws Exception { + Method restore = LiteralMessageTemplate.class.getDeclaredMethod("restoreTokens", CharSequence.class, + Map.class, boolean.class, boolean.class); + restore.setAccessible(true); + return (String) invoke(restore, null, text, values, visible, false); + } + + private static Object invoke(Method method, Object receiver, Object... arguments) throws Exception { + try { + return method.invoke(receiver, arguments); + } catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception cause) throw cause; + if (failure.getCause() instanceof Error cause) throw cause; + throw failure; + } + } + + private static Object field(LiteralMessageTemplate.Prepared prepared, String name) throws Exception { + Field field = prepared.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(prepared); + } + + @SuppressWarnings("unchecked") + private static Set forbidden(LiteralMessageTemplate.Prepared prepared) throws Exception { + return (Set) field(prepared, "forbiddenTokens"); + } + + private static TextComponent render(LiteralMessageTemplate.Prepared prepared) { + return prepared.finish(PlaceholderUtils.parseJson(prepared.template())); + } + + private static List actions(BaseComponent component) { + List values = new ArrayList<>(); + if (component.getClickEvent() != null) values.add(component.getClickEvent().getValue()); + if (component.getExtra() != null) for (BaseComponent child : component.getExtra()) values.addAll(actions(child)); + return values; + } + + @SuppressWarnings("deprecation") + private static List hovers(BaseComponent component) { + List values = new ArrayList<>(); + if (component.getHoverEvent() != null && component.getHoverEvent().getAction() == HoverEvent.Action.SHOW_TEXT) + for (var content : component.getHoverEvent().getContents()) { + var text = assertInstanceOf(net.md_5.bungee.api.chat.hover.content.Text.class, content); + Object value = text.getValue(); + values.add(value instanceof BaseComponent[] components ? BaseComponent.toPlainText(components) + : assertInstanceOf(String.class, value)); + } + if (component.getExtra() != null) for (BaseComponent child : component.getExtra()) values.addAll(hovers(child)); + return values; + } + + private static final class CountingText implements CharSequence { + private final String text; + private int reads; + private CountingText(String text) { this.text = text; } + @Override public int length() { return text.length(); } + @Override public char charAt(int index) { reads++; return text.charAt(index); } + @Override public CharSequence subSequence(int start, int end) { return text.subSequence(start, end); } + } + + private static final class LookupOnlyMap extends AbstractMap { + private final Map values; + private int lookups; + private LookupOnlyMap(Map values) { this.values = values; } + @Override public String get(Object key) { lookups++; return values.get(key); } + @Override public Set> entrySet() { + throw new AssertionError("restoration must use token lookup, never enumerate bindings"); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/MixedLegacyBindingIndexTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/MixedLegacyBindingIndexTest.java new file mode 100644 index 0000000000..beecb3ce49 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/MixedLegacyBindingIndexTest.java @@ -0,0 +1,525 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; +import com.bencodez.advancedcore.api.messages.LiteralMessageTemplate; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.hover.content.Text; + +class MixedLegacyBindingIndexTest { + private static final String OLD = "VCORELITERAL1Q2Z"; + + @Test + void markerIndexVisitsSixtyFourKiBOnceWith8192Bindings() throws Exception { + Map values = new LinkedHashMap<>(); + for (int index = 0; index < 8192; index++) values.put("K" + index, "x"); + AtomicInteger enumerations = new AtomicInteger(); + Map counted = new AbstractMap<>() { + @Override public Set> entrySet() { + enumerations.incrementAndGet(); + return values.entrySet(); + } + }; + Class type = Class.forName(LiteralMessageTemplate.class.getName() + "$MarkerMatcher"); + Constructor constructor = type.getDeclaredConstructor(Map.class); + constructor.setAccessible(true); + Object index = constructor.newInstance(counted); + assertEquals(1, enumerations.get()); + String fragment = "%K8191%/{k0}/%missing%/"; + String source = fragment.repeat(65536 / fragment.length() + 1).substring(0, 65536); + CountingText input = new CountingText(source); + Method replace = type.getDeclaredMethod("replace", CharSequence.class, int.class); + replace.setAccessible(true); + assertEquals(source.replace("%K8191%", "x").replace("{k0}", "x"), invoke(replace, index, input, 262144)); + assertTrue(input.reads <= input.length() * 8, "source reads must depend on text, not binding count"); + assertEquals(1, enumerations.get(), "replacement must not re-enumerate the bindings"); + } + + @Test + void bothLegacyRestorationLanesUseOneLookupPerTokenWithoutEnumeratingTheTable() throws Exception { + Map values = new LinkedHashMap<>(); + StringBuilder source = new StringBuilder(); + StringBuilder expected = new StringBuilder(); + for (int index = 0; index < 4096; index++) { + String token = "VCORELITERAL" + Integer.toString(index + 10, 36) + "Q4Z"; + String value = index == 0 ? OLD : "&cx"; + values.put(token, value); + source.append(token).append('/'); + expected.append(value).append('/'); + } + Method restore = LiteralMessageTemplate.class.getDeclaredMethod("restoreTokens", CharSequence.class, + Map.class, boolean.class, boolean.class, boolean.class); + restore.setAccessible(true); + for (boolean guard : List.of(false, true)) { + LookupOnlyMap table = new LookupOnlyMap(values); + CountingText input = new CountingText(source.toString()); + assertEquals(expected.toString(), invoke(restore, null, input, table, false, false, guard)); + assertEquals(4096, table.lookups); + assertTrue(input.reads <= input.length() * 4, "restoration must not scan once per binding"); + } + } + + @Test + void legacyCorpusIsIndexedBeforeLiteralAllocationAndNoInsertedOldTokenIsRescanned() throws Exception { + var legacy = new LiteralMessageTemplate.LegacyValues(Map.of("Legacy", "visible/" + OLD), + Map.of("Legacy", "raw/VCORELITERAL3Q4Z"), Map.of("VCORELITERAL5Q6Z", "VCORELITERAL7Q8Z")); + var prepared = LiteralMessageTemplate.prepare("%Site%/{Legacy}", Map.of("Site", "literal"), + Map.of("Site", "raw-site"), legacy); + @SuppressWarnings("unchecked") Set forbidden = (Set) field(prepared, "forbiddenTokens"); + assertTrue(forbidden.containsAll(Set.of(OLD, "VCORELITERAL3Q4Z", "VCORELITERAL5Q6Z", "VCORELITERAL7Q8Z"))); + var indexed = legacy.bind(prepared); + String source = indexed.protect(prepared.template(), false, false); + assertEquals("literal/visible/" + OLD, prepared.finishText(indexed.finish(source, false))); + String raw = indexed.protect("{Legacy}", true, false); + assertEquals("raw/VCORELITERAL3Q4Z", indexed.finish(raw, true)); + @SuppressWarnings("unchecked") Map tokens = (Map) field(indexed, "visibleTokens"); + assertFalse(tokens.containsKey(OLD)); + assertThrows(UnsupportedOperationException.class, () -> tokens.put(OLD, "changed")); + } + + @Test + void legacyBindingRequiresAdmissionBeforeLiteralTokensAreAllocated() { + var separatelyPrepared = LiteralMessageTemplate.prepare("%Site%", Map.of("Site", "literal"), Map.of()); + var late = new LiteralMessageTemplate.LegacyValues(Map.of("Legacy", separatelyPrepared.template()), Map.of(), Map.of()); + assertThrows(IllegalArgumentException.class, () -> late.bind(separatelyPrepared)); + var admitted = LiteralMessageTemplate.prepare("%Site% {Legacy}", Map.of("Site", "literal"), Map.of(), late); + var indexed = late.bind(admitted); + assertEquals("literal " + separatelyPrepared.template(), + admitted.finishText(indexed.finish(indexed.protect(admitted.template(), false, false), false))); + var different = new LiteralMessageTemplate.LegacyValues(Map.of("Legacy", "different"), Map.of(), Map.of()); + assertThrows(IllegalArgumentException.class, () -> different.bind(admitted)); + } + + @Test + void decodedAdmissionCountsAll8192EntriesAndSharesTheOneMiBInputBudget() { + Map ordinary = corpus(8192, 1048576 - (4 + 7) - (4 + 3) - (6 + 7) - (6 + 3)); + var legacy = new LiteralMessageTemplate.LegacyValues(Map.of("Legacy", "visible"), + Map.of("Legacy", "raw"), ordinary); + var prepared = LiteralMessageTemplate.prepare("%Site%/{Legacy}", Map.of("Site", "literal"), + Map.of("Site", "raw"), legacy); + var indexed = legacy.bind(prepared); + assertEquals("literal/visible", prepared.finishText(indexed.finish(indexed.protect(prepared.template(), false, false), false))); + ordinary.put("K8191", ordinary.get("K8191") + "x"); + var larger = new LiteralMessageTemplate.LegacyValues(Map.of("Legacy", "visible"), Map.of("Legacy", "raw"), ordinary); + assertThrows(IllegalArgumentException.class, + () -> LiteralMessageTemplate.prepare("plain", Map.of("Site", "literal"), Map.of("Site", "raw"), larger)); + ordinary.put("Extra", ""); + assertThrows(IllegalArgumentException.class, () -> new LiteralMessageTemplate.LegacyValues(Map.of(), Map.of(), ordinary)); + assertEquals("visible", indexed.finish(indexed.protect("{Legacy}", false, false), false), + "an admitted snapshot must be independent of later caller changes"); + } + + @Test + void guarded32768CharacterLegacyValuesAreAdmittedAndActiveBindingsRemainCombinedAndBounded() { + var legacy = new LiteralMessageTemplate.LegacyValues(Map.of("Legacy", "x".repeat(32768)), + Map.of("Legacy", "raw"), Map.of()); + var prepared = LiteralMessageTemplate.prepare("{Legacy} %Site%", Map.of("Site", "literal"), Map.of(), legacy); + var indexed = legacy.bind(prepared); + assertEquals("x".repeat(32768) + " literal", + prepared.finishText(indexed.finish(indexed.protect(prepared.template(), false, false), false))); + assertThrows(IllegalArgumentException.class, + () -> new LiteralMessageTemplate.LegacyValues(Map.of("Legacy", "x".repeat(32769)), Map.of(), Map.of())); + Map visible = new LinkedHashMap<>(); + StringBuilder template = new StringBuilder("%Site%"); + for (int position = 0; position < 4095; position++) { + String key = "K" + position; + visible.put(key, ""); template.append('{').append(key).append('}'); + } + var full = new LiteralMessageTemplate.LegacyValues(visible, Map.of(), Map.of()); + var fullPrepared = LiteralMessageTemplate.prepare(template.toString(), Map.of("Site", ""), Map.of(), full); + full.bind(fullPrepared); + visible.put("Extra", ""); template.append("{Extra}"); + var extra = new LiteralMessageTemplate.LegacyValues(visible, Map.of(), Map.of()); + var extraPrepared = LiteralMessageTemplate.prepare(template.toString(), Map.of("Site", ""), Map.of(), extra); + assertThrows(IllegalArgumentException.class, () -> extra.bind(extraPrepared)); + } + + @Test + void orderedOrdinaryValuesKeepLaterExpansionBackwardReferencesAndCycles() { + for (Map ordinary : List.of(ordered("A", "%B%", "B", "expanded"), + ordered("B", "expanded", "A", "%B%"), ordered("A", "%B%", "B", "%A%"), + ordered("A", "%A%", "a", "expanded"))) { + var input = new LiteralMessageTemplate.LegacyValues(Map.of(), Map.of(), ordinary); + var prepared = LiteralMessageTemplate.prepare("%A% %Site%", Map.of("Site", "literal"), Map.of(), input); + var indexed = input.bind(prepared); + String expected = PlaceholderUtils.replacePlaceHolder("%A%", new LinkedHashMap<>(ordinary)) + " literal"; + assertEquals(expected, prepared.finishText(indexed.protect(prepared.template(), false, false))); + } + HashMap values = new HashMap<>(); + values.put("A", "%B%"); values.put("B", "expanded"); + RewardDisplayPlaceholders.putLiteral(values, "Site", "literal", "fallback"); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + assertEquals("expanded literal", BaseComponent.toPlainText( + RewardDisplayPlaceholders.renderLiteralComponents(null, "%A% %Site%", values))); + assertEquals("expanded literal", BaseComponent.toPlainText( + RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "%A% %Site%", values).render(null))); + } + } + + @Test + void productionOrdinaryOrderDoesNotDependOnUnusedMetadataCapacity() { + HashMap values = new HashMap<>(); + values.put("P", "%A%"); values.put("A", "expanded"); + RewardDisplayPlaceholders.putLiteral(values, "Site", "literal", "fallback"); + for (int index = 0; index < 5; index++) { + values.put("Unused" + index, "raw"); + RewardDisplayPlaceholders.put(values, "Unused" + index, "visible"); + } + String source = "[Text=\"%P% %Site%\",Command=\"/say %P%\"]"; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())) + .thenAnswer(call -> call.getArgument(0)); + for (TextComponent[] rendered : List.of(RewardDisplayPlaceholders.renderLiteralComponents(null, source, values), + RewardDisplayPlaceholders.prepareLiteralBroadcast(null, source, values).render(null))) { + assertEquals("expanded literal", BaseComponent.toPlainText(rendered)); + assertEquals(List.of("/say expanded"), actions(rendered)); + } + } + } + + @Test + void pendingOrderedExpansionBuffersAreBoundedUnderASmallHeap() throws Exception { + String javaExecutable = java.nio.file.Path.of(System.getProperty("java.home"), "bin", "java").toString(); + String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + Process child = new ProcessBuilder(javaExecutable, "-Xmx64m", "-cp", classpath, + BoundedExpansionProbe.class.getName()).redirectErrorStream(true).start(); + try { + assertTrue(child.waitFor(20, TimeUnit.SECONDS), "bounded expansion must terminate promptly"); + String output = new String(child.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + assertEquals(0, child.exitValue(), output); + } finally { + if (child.isAlive()) { child.destroyForcibly(); child.waitFor(5, TimeUnit.SECONDS); } + } + } + + public static final class BoundedExpansionProbe { + public static void main(String[] arguments) { + Map ordinary = new LinkedHashMap<>(); + for (int index = 0; index < 256; index++) + ordinary.put("K" + index, "%Big% %K" + (index + 1) + "%"); + ordinary.put("K256", "end"); + ordinary.put("Big", String.join(" ", java.util.Collections.nCopies(8, "%Base%"))); + ordinary.put("Base", "x".repeat(32767)); + var input = new LiteralMessageTemplate.LegacyValues(Map.of(), Map.of(), ordinary); + try { + var prepared = LiteralMessageTemplate.prepare("%K0%", Map.of(), Map.of(), input); + input.bind(prepared).protect(prepared.template(), false, false); + } catch (IllegalArgumentException expected) { + if (expected.getMessage().equals("ordered placeholder expansion exceeds limits")) return; + throw expected; + } + throw new AssertionError("oversized retained expansion was admitted"); + } + } + + @Test + void delimiterFreeValuesCanFormALaterMarkerAcrossReplacementBoundaries() { + for (String replacement : List.of("", "C")) { + HashMap values = new HashMap<>(); + values.put("A", replacement); + values.put("B" + replacement, "expanded"); + RewardDisplayPlaceholders.putLiteral(values, "Site", "literal", "fallback"); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())) + .thenAnswer(call -> call.getArgument(0)); + assertEquals("expanded literal", BaseComponent.toPlainText( + RewardDisplayPlaceholders.renderLiteralComponents(null, "%B{A}% %Site%", values))); + assertEquals("expanded literal", BaseComponent.toPlainText( + RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "%B{A}% %Site%", values).render(null))); + } + } + } + + @Test + void sharedPercentEdgesAndMarkersFormedAcrossReplacementsKeepHistoricalGlobalOrder() { + List> maps = List.of(ordered("B", "b", "A", "a"), + ordered("A", "%B%", "D", "d", "B", "b"), ordered("A", "%", "B", "b"), + ordered("A", "{A}x"), ordered("s%tail", "long", "s", "short"), ordered("A", "[{A}]")); + List templates = List.of("%A%B%", "%A%D%", "%A%B%", "%A%", "%s%tail%", "%A%"); + for (int position = 0; position < maps.size(); position++) { + var input = new LiteralMessageTemplate.LegacyValues(Map.of(), Map.of(), maps.get(position)); + var prepared = LiteralMessageTemplate.prepare(templates.get(position), Map.of(), Map.of(), input); + var indexed = input.bind(prepared); + assertEquals(PlaceholderUtils.replacePlaceHolder(templates.get(position), new LinkedHashMap<>(maps.get(position))), + indexed.protect(prepared.template(), false, false), "case " + position); + } + } + + @Test + void longOrderedChainsExpandIterativelyWithoutRescanningTheTemplatePerBinding() { + Map ordinary = new LinkedHashMap<>(); + for (int index = 0; index < 8191; index++) ordinary.put("K" + index, "%K" + (index + 1) + "%"); + ordinary.put("K8191", "expanded"); + var input = new LiteralMessageTemplate.LegacyValues(Map.of(), Map.of(), ordinary); + var prepared = LiteralMessageTemplate.prepare("%K0% %Site%", Map.of("Site", "literal"), Map.of(), input); + var indexed = input.bind(prepared); + assertEquals("expanded literal", prepared.finishText(indexed.protect(prepared.template(), false, false))); + } + + @Test + void exceptionalOrderedCompatibilityWorkHasAFixedBudget() { + Map ordinary = new LinkedHashMap<>(); + StringBuilder source = new StringBuilder("padding".repeat(4500)); + for (int index = 0; index < 300; index++) { + String key = "K " + index; + ordinary.put(key, "x"); source.append('%').append(key).append('%'); + } + var input = new LiteralMessageTemplate.LegacyValues(Map.of(), Map.of(), ordinary); + var prepared = LiteralMessageTemplate.prepare(source.toString(), Map.of(), Map.of(), input); + var indexed = input.bind(prepared); + assertThrows(IllegalArgumentException.class, () -> indexed.protect(prepared.template(), false, false)); + } + + @Test + void ordinaryCompatibilityBudgetAccumulatesAcrossAnOperationsSegments() { + Map ordinary = new LinkedHashMap<>(); + for (int index = 0; index < 99; index++) ordinary.put("K " + index, "%K " + (index + 1) + "%"); + ordinary.put("K 99", "done"); + String prefix = "padding".repeat(5000); + var input = new LiteralMessageTemplate.LegacyValues(Map.of(), Map.of(), ordinary); + var prepared = LiteralMessageTemplate.prepare(prefix + "%K 0%", Map.of(), Map.of(), input); + var indexed = input.bind(prepared); + var rendering = indexed.renderer(); + assertEquals(prefix + "done", rendering.protect(prepared.template(), false, false)); + assertThrows(IllegalArgumentException.class, () -> rendering.protect(prepared.template(), false, false)); + assertEquals(prefix + "done", indexed.renderer().protect(prepared.template(), false, false), + "another rendering owns an independent budget"); + } + + @Test + void productionEightThousandEntryMetadataRendersBothPathsWithColorsRawActionsAndOldTokenData() { + HashMap values = mixed("raw/" + OLD, "&cVisible/" + OLD); + for (int index = 0; index < 4088; index++) { + HashMap entry = new HashMap<>(); + entry.put("Unused" + index, "raw"); RewardDisplayPlaceholders.put(entry, "Unused" + index, "display"); + values.putAll(entry); + } + while (values.size() < 8192) values.put("Ordinary" + values.size(), "unused"); + assertEquals(8192, values.size()); + String source = "[Text=\"{Legacy} %Site%\",Command=\"/{Legacy}/%Site%\",Hover=\"{Legacy}\"]"; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + TextComponent[] single = RewardDisplayPlaceholders.renderLiteralComponents(null, source, values); + var captured = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, source, values); + values.clear(); + TextComponent[] broadcast = captured.render(null); + assertEquals("Visible/" + OLD + " &aLiteral", BaseComponent.toPlainText(single)); + assertEquals(BaseComponent.toPlainText(single), BaseComponent.toPlainText(broadcast)); + assertEquals(List.of("/raw/" + OLD + "/raw-site"), actions(broadcast)); + BaseComponent clickable = clickable(broadcast).get(0); + Text hover = assertInstanceOf(Text.class, clickable.getHoverEvent().getContents().get(0)); + BaseComponent[] content = assertInstanceOf(BaseComponent[].class, hover.getValue()); + assertEquals("Visible/" + OLD, BaseComponent.toPlainText(content)); + assertEquals(ChatColor.RED, content[0].getColor()); + assertFalse(BaseComponent.toLegacyText(broadcast).contains("§aLiteral")); + } + } + + @Test + void productionSourceRecipientAndSinglePlayerProviderPassesNeverSeeLiteralOrLegacyData() { + Player sourcePlayer = mock(Player.class); + Player recipient = mock(Player.class); + HashMap values = mixed("%untrusted_raw%", "%untrusted_visible%"); + values.put("Rank", "%rank_provider%"); + String source = "[Text=\"{Legacy} %Site% %Rank% %recipient%\",Command=\"/{Legacy}/%Site%/%recipient%\"]"; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(eq(recipient), anyString())).thenAnswer(call -> call.getArgument(1)); + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(sourcePlayer), anyString())).thenAnswer(call -> { + String text = call.getArgument(1); + assertFalse(text.contains("%untrusted_raw%")); assertFalse(text.contains("%untrusted_visible%")); + assertFalse(text.contains("&aLiteral")); + return text.replace("%rank_provider%", "SourceRank"); + }); + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(recipient), anyString())).thenAnswer(call -> { + String text = call.getArgument(1); + assertFalse(text.contains("%untrusted_raw%")); assertFalse(text.contains("%untrusted_visible%")); + assertFalse(text.contains("&aLiteral")); + return text.replace("%rank_provider%", "RecipientRank").replace("%recipient%", "Recipient"); + }); + TextComponent[] broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(sourcePlayer, source, values).render(recipient); + assertEquals("%untrusted_visible% &aLiteral SourceRank Recipient", BaseComponent.toPlainText(broadcast)); + assertEquals(List.of("/%untrusted_raw%/raw-site/Recipient"), actions(broadcast)); + TextComponent[] single = RewardDisplayPlaceholders.renderLiteralComponents(recipient, source, values); + assertEquals("%untrusted_visible% &aLiteral RecipientRank Recipient", BaseComponent.toPlainText(single)); + assertEquals(actions(broadcast), actions(single)); + } + } + + @Test + void legacyDisplayFragmentsCannotCreateInteractiveSyntaxAcrossAdjacentBindings() { + HashMap values = mixed("raw", "unused"); + values.put("A", "raw-a"); values.put("B", "raw-b"); + RewardDisplayPlaceholders.put(values, "A", "[Text="); + RewardDisplayPlaceholders.put(values, "B", "\"unsafe\",Command=\"/bad\"]"); + String source = "%A%%B% %Site%"; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + for (TextComponent[] rendered : List.of(RewardDisplayPlaceholders.renderLiteralComponents(null, source, values), + RewardDisplayPlaceholders.prepareLiteralBroadcast(null, source, values).render(null))) { + assertTrue(actions(rendered).isEmpty()); + assertEquals("[Text=\"unsafe\",Command=\"/bad\"] &aLiteral", + BaseComponent.toPlainText(rendered).replace("\u2060", "")); + } + } + } + + @Test + void productionCumulativeRawActionBudgetIncludesEveryAuthoredAttributeInBothLanes() { + HashMap values = mixed("x".repeat(32768), ""); + RewardDisplayPlaceholders.putLiteral(values, "Site", "", "fallback"); + String attribute = "[Text=\"\",Command=\"{Legacy}\"]"; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + assertEquals(262144, actions(RewardDisplayPlaceholders.renderLiteralComponents(null, attribute.repeat(8), values)) + .stream().mapToInt(String::length).sum()); + var captured = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, attribute.repeat(8), values); + assertEquals(262144, actions(captured.render(null)).stream().mapToInt(String::length).sum()); + assertThrows(IllegalArgumentException.class, + () -> RewardDisplayPlaceholders.renderLiteralComponents(null, attribute.repeat(9), values)); + var tooLarge = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, attribute.repeat(9), values); + assertThrows(IllegalArgumentException.class, () -> tooLarge.render(null)); + } + } + + @Test + void indexedLegacyJavascriptKeepsTheHistoricalBoundArgumentAndFinalRawOutput() { + String raw = "raw'$\\value"; + HashMap values = mixed(raw, "&cVisible"); + List expressions = new ArrayList<>(); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> { + String text = call.getArgument(0); + assertFalse(text.contains(raw)); + return JavascriptTextTemplate.parse(text).evaluate(java.util.function.Function.identity(), expression -> { + expressions.add(expression); return "/computed"; + }); + }); + String source = "[Text=\"%Site%\",Command=\"[Javascript='/{Legacy}']\"]"; + HashMap legacy = new HashMap<>(); + legacy.put("Legacy", raw); RewardDisplayPlaceholders.put(legacy, "Legacy", "&cVisible"); + RewardDisplayPlaceholders.replaceFormattedMessage(source, legacy); + TextComponent[] current = RewardDisplayPlaceholders.renderLiteralComponents(null, source, values); + assertEquals(List.of("/computed"), actions(current)); + assertEquals(2, expressions.size()); + assertEquals(expressions.get(0), expressions.get(1)); + } + } + + @Test + void recipientVariantsShareImmutableIndexesAndKeepTheirIndependentOutputs() throws Exception { + HashMap values = mixed("raw", "visible"); + var captured = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, + "[Text=\"{Legacy} %Site%\",Command=\"/{Legacy}\"]", values); + Object index = field(captured, "legacy"); + Object visible = field(index, "visibleTokens"); + CompletableFuture first = CompletableFuture.supplyAsync(() -> captured.render(null)); + CompletableFuture second = CompletableFuture.supplyAsync(() -> captured.render(null)); + assertEquals("visible &aLiteral", BaseComponent.toPlainText(first.get(5, TimeUnit.SECONDS))); + assertEquals(List.of("/raw"), actions(second.get(5, TimeUnit.SECONDS))); + assertSame(index, field(captured, "legacy")); + assertSame(visible, field(index, "visibleTokens")); + } + + private static Map ordered(String... pairs) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < pairs.length; index += 2) result.put(pairs[index], pairs[index + 1]); + return result; + } + + private static Map corpus(int count, int totalCharacters) { + Map result = new LinkedHashMap<>(); + int remaining = totalCharacters; + for (int index = 0; index < count; index++) remaining -= ("K" + index).length(); + for (int index = 0; index < count; index++) { + int length = Math.min(32768, remaining); + result.put("K" + index, "x".repeat(length)); remaining -= length; + } + assertEquals(0, remaining); + return result; + } + + private static HashMap mixed(String raw, String display) { + HashMap values = new HashMap<>(); + values.put("Site", "raw-site"); values.put("Legacy", raw); + RewardDisplayPlaceholders.putLiteral(values, "Site", "&aLiteral", "fallback"); + RewardDisplayPlaceholders.put(values, "Legacy", display); + return values; + } + + private static List actions(BaseComponent[] components) { + List result = new ArrayList<>(); + for (BaseComponent component : clickable(components)) result.add(component.getClickEvent().getValue()); + return result; + } + + private static List clickable(BaseComponent[] components) { + List result = new ArrayList<>(); + for (BaseComponent component : components) { + if (component.getClickEvent() != null) result.add(component); + if (component.getExtra() != null) result.addAll(clickable(component.getExtra().toArray(new BaseComponent[0]))); + } + return result; + } + + private static Object field(Object receiver, String name) throws Exception { + Field field = receiver.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(receiver); + } + + private static Object invoke(Method method, Object receiver, Object... arguments) throws Exception { + try { return method.invoke(receiver, arguments); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception cause) throw cause; + if (failure.getCause() instanceof Error cause) throw cause; + throw failure; + } + } + + private static final class CountingText implements CharSequence { + private final String value; + private int reads; + private CountingText(String value) { this.value = value; } + @Override public int length() { return value.length(); } + @Override public char charAt(int index) { reads++; return value.charAt(index); } + @Override public CharSequence subSequence(int start, int end) { return value.subSequence(start, end); } + } + + private static final class LookupOnlyMap extends AbstractMap { + private final Map values; + private int lookups; + private LookupOnlyMap(Map values) { this.values = values; } + @Override public String get(Object key) { lookups++; return values.get(key); } + @Override public Set> entrySet() { + throw new AssertionError("token restoration must use lookup, not enumerate the table"); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/MixedLiteralActionValuesTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/MixedLiteralActionValuesTest.java new file mode 100644 index 0000000000..a0be9be120 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/MixedLiteralActionValuesTest.java @@ -0,0 +1,295 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.bukkit.entity.Player; +import java.util.concurrent.atomic.AtomicInteger; +import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; + +import com.bencodez.advancedcore.api.messages.LiteralMessageTemplate; +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.hover.content.Text; + +class MixedLiteralActionValuesTest { + @Test void braceAliasesAreLiteralInVisibleHoverAndAllExactActions() { + for (String attribute : List.of("Command", "URL", "Suggest_Command")) { + TextComponent[] rendered = render("[Text=\"before{SITENAME}after {Legacy}\"," + attribute + + "=\"/{SiteName}/{lEgAcY}\",Hover=\"{SiteName} {Legacy}\"]", values("raw-legacy")); + BaseComponent action = clickable(rendered).get(0); + assertEquals("/raw-site/raw-legacy", action.getClickEvent().getValue()); + assertTrue(BaseComponent.toPlainText(rendered).contains("beforeR&D &aLiteralafter")); + var hover = assertInstanceOf(Text.class, action.getHoverEvent().getContents().get(0)); + var hoverComponents = assertInstanceOf(BaseComponent[].class, hover.getValue()); + assertTrue(BaseComponent.toPlainText(hoverComponents).contains("R&D &aLiteral")); + assertFalse(BaseComponent.toLegacyText(rendered).contains("VCORELITERAL")); + } + } + + @Test void braceAndPercentMarkersInsideInsertedLiteralDataAreNeverReplacedAgain() { + HashMap values = values("raw-legacy"); + String literal = "{Legacy}%Legacy%{SiteName}%SiteName%"; + RewardDisplayPlaceholders.putLiteral(values, "SiteName", literal, "old-safe"); + assertEquals(literal + "/" + literal, BaseComponent.toPlainText(render("{SiteName}/%SiteName%", values))); + } + + @Test void braceAliasInsideAuthoredScriptProtectsTheEntireScriptBeforeEvaluation() { + HashMap values = values("raw-legacy"); + String literal = "');[Javascript=danger()]"; + RewardDisplayPlaceholders.putLiteral(values, "SiteName", literal, "old-safe"); + String source = "[Javascript='pre{SiteName}post']"; + AtomicInteger executable = new AtomicInteger(); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> { + String text = call.getArgument(0); + assertFalse(text.contains(literal)); + return JavascriptTextTemplate.parse(text).evaluate(java.util.function.Function.identity(), expression -> { + executable.incrementAndGet(); return "executed"; + }); + }); + TextComponent[] result = RewardDisplayPlaceholders.renderLiteralComponents(null, source, values); + assertEquals(source.replace("{SiteName}", literal), BaseComponent.toPlainText(result)); + assertEquals(0, executable.get()); + assertTrue(clickable(result).isEmpty()); + } + } + + @Test void legacyVisibleAndHoverValuesStayInertWithAnUnrelatedLiteralBinding() { + for (String display : List.of("[Text=\"Click\",Command=\"/unexpected\"]", "shown\"],Command=\"/unexpected", + "$1\\path\\\"quoted\"", "%vault_rank%", "[Javascript=danger()]")) { + HashMap legacy = legacyValues("raw-legacy", display); + HashMap mixed = new HashMap<>(legacy); + RewardDisplayPlaceholders.putLiteral(mixed, "SiteName", "literal", "old-safe"); + for (String source : List.of("prefix{Legacy}suffix", "[Text=\"%Legacy%\",Command=\"/trusted\",Hover=\"{Legacy}\"]")) { + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + BaseComponent[] previous = new BaseComponent[] {PlaceholderUtils.parseJson( + RewardDisplayPlaceholders.replaceFormattedMessage(source, legacy))}; + BaseComponent[] current = RewardDisplayPlaceholders.renderLiteralComponents(null, source, mixed); + assertEquals(BaseComponent.toPlainText(previous), BaseComponent.toPlainText(current), display); + assertEquals(BaseComponent.toLegacyText(previous), BaseComponent.toLegacyText(current), display); + assertEquals(clickable(previous).size(), clickable(current).size(), display); + if (!clickable(current).isEmpty()) assertEquals("/trusted", clickable(current).get(0).getClickEvent().getValue()); + assertFalse(BaseComponent.toLegacyText(current).contains("VCORELITERAL")); + } + } + } + } + + @Test void configuredLegacyColorsRenderInPlainTextAndHoverAlongsideLiteralValues() { + HashMap values = legacyValues("raw-legacy", "&cColored"); + RewardDisplayPlaceholders.putLiteral(values, "SiteName", "&aLiteral", "old-safe"); + TextComponent[] plain = render("{Legacy} | %SiteName%", values); + assertEquals("Colored | &aLiteral", BaseComponent.toPlainText(plain)); + assertTrue(BaseComponent.toLegacyText(plain).contains("§cColored")); + TextComponent[] interactive = render("[Text=\"{Legacy} %SiteName%\",Command=\"/trusted\",Hover=\"{Legacy}\"]", values); + assertEquals("Colored &aLiteral", BaseComponent.toPlainText(interactive)); + BaseComponent action = clickable(interactive).get(0); + assertEquals("/trusted", action.getClickEvent().getValue()); + var hover = assertInstanceOf(Text.class, action.getHoverEvent().getContents().get(0)); + var content = assertInstanceOf(BaseComponent[].class, hover.getValue()); + assertEquals("Colored", BaseComponent.toPlainText(content)); + assertEquals(ChatColor.RED, content[0].getColor()); + } + + @Test void configuredLegacyColorsRenderInBroadcastPlainTextHoverAndExactActions() { + HashMap values = legacyValues("&aRaw", "&cColored"); + RewardDisplayPlaceholders.putLiteral(values, "SiteName", "&aLiteral", "old-safe"); + var captured = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, + "[Text=\"{Legacy} %SiteName%\",Command=\"/trusted {Legacy}\",Hover=\"{Legacy}\"]", values); + TextComponent[] rendered = captured.render(null); + assertEquals("Colored &aLiteral", BaseComponent.toPlainText(rendered)); + BaseComponent action = clickable(rendered).get(0); + assertEquals("/trusted &aRaw", action.getClickEvent().getValue()); + var hover = assertInstanceOf(Text.class, action.getHoverEvent().getContents().get(0)); + var content = assertInstanceOf(BaseComponent[].class, hover.getValue()); + assertEquals("Colored", BaseComponent.toPlainText(content)); + assertEquals(ChatColor.RED, content[0].getColor()); + assertFalse(BaseComponent.toLegacyText(rendered).contains("§aLiteral")); + } + + @Test void legacyOpaqueValueDoesNotEnterProviderPassButOrdinaryPlaceholderStillDoes() { + Player player = mock(Player.class); + HashMap legacy = legacyValues("raw-legacy", "%untrusted_provider%"); + legacy.put("rank", "%vault_rank%"); + HashMap mixed = new HashMap<>(legacy); + RewardDisplayPlaceholders.putLiteral(mixed, "SiteName", "literal", "old-safe"); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(eq(player), anyString())).thenAnswer(call -> call.getArgument(1)); + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(player), anyString())).thenAnswer(call -> { + String text = call.getArgument(1); + assertFalse(text.contains("%untrusted_provider%")); + return text.replace("%vault_rank%", "Admin"); + }); + String source = "%rank% {Legacy}"; + BaseComponent[] old = new BaseComponent[] {PlaceholderUtils.parseJson( + RewardDisplayPlaceholders.replaceFormattedMessage(player, source, legacy))}; + BaseComponent[] current = RewardDisplayPlaceholders.renderLiteralComponents(player, source, mixed); + assertEquals("Admin %untrusted_provider%", BaseComponent.toPlainText(current)); + assertEquals(BaseComponent.toPlainText(old), BaseComponent.toPlainText(current)); + } + } + + @Test void legacyAuthoredJavascriptKeepsItsExistingBoundDataPassWithMixedLiterals() { + String display = "'); danger(); ('"; + HashMap legacy = legacyValues("raw-legacy", display); + HashMap mixed = new HashMap<>(legacy); + RewardDisplayPlaceholders.putLiteral(mixed, "SiteName", "literal", "old-safe"); + ArrayList expressions = new ArrayList<>(); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> { + String text = call.getArgument(0); + assertFalse(text.contains(display)); + return JavascriptTextTemplate.parse(text).evaluate(java.util.function.Function.identity(), expression -> { + assertTrue(expression.contains("%__advancedcore_bound_")); + expressions.add(expression); return "evaluated"; + }); + }); + String source = "[Javascript='{Legacy}']"; + String previous = RewardDisplayPlaceholders.replaceFormattedMessage(source, legacy); + BaseComponent[] current = RewardDisplayPlaceholders.renderLiteralComponents(null, source, mixed); + assertEquals(previous, BaseComponent.toPlainText(current)); + assertEquals(2, expressions.size()); + assertEquals(expressions.get(0), expressions.get(1)); + } + } + + @Test void mixedBroadcastKeepsLegacyValuesOpaqueAcrossSourceAndRecipientPasses() { + Player source = mock(Player.class); + Player recipient = mock(Player.class); + HashMap legacy = legacyValues("%untrusted_raw%", "%untrusted_provider%"); + legacy.put("rank", "%vault_rank%"); + HashMap mixed = new HashMap<>(legacy); + RewardDisplayPlaceholders.putLiteral(mixed, "SiteName", "literal", "old-safe"); + String template = "[Text=\"{Legacy} %rank%\",Command=\"/{Legacy}/%recipient_suffix%\"]"; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(source), anyString())).thenAnswer(call -> { + String text = call.getArgument(1); + assertFalse(text.contains("%untrusted_provider%")); assertFalse(text.contains("%untrusted_raw%")); + return text.replace("%vault_rank%", "SourceRank"); + }); + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(recipient), anyString())).thenAnswer(call -> { + String text = call.getArgument(1); + assertFalse(text.contains("%untrusted_provider%")); assertFalse(text.contains("%untrusted_raw%")); + return text.replace("%recipient_suffix%", "recipient"); + }); + BaseComponent[] previous = new BaseComponent[] {PlaceholderUtils.parseJson( + RewardDisplayPlaceholders.replaceFormattedBroadcast(source, recipient, template, legacy))}; + var captured = RewardDisplayPlaceholders.prepareLiteralBroadcast(source, template, mixed); + mixed.put("Legacy", "changed"); RewardDisplayPlaceholders.put(mixed, "Legacy", "changed"); + BaseComponent[] current = captured.render(recipient); + assertEquals(BaseComponent.toPlainText(previous), BaseComponent.toPlainText(current)); + assertEquals("/%untrusted_raw%/recipient", clickable(current).get(0).getClickEvent().getValue()); + assertEquals(clickable(previous).get(0).getClickEvent().getValue(), clickable(current).get(0).getClickEvent().getValue()); + assertFalse(BaseComponent.toLegacyText(current).contains("VCORELITERAL")); + } + } + + @Test void legacyExactActionJavascriptUsesTheExistingRawBindingAndOnlyItsFinalOutputIsShielded() { + String raw = "raw'$\\value"; + HashMap legacy = legacyValues(raw, "&cDisplayed"); + HashMap mixed = new HashMap<>(legacy); + RewardDisplayPlaceholders.putLiteral(mixed, "SiteName", "literal", "old-safe"); + ArrayList expressions = new ArrayList<>(); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> { + String text = call.getArgument(0); + assertFalse(text.contains(raw)); + return JavascriptTextTemplate.parse(text).evaluate(java.util.function.Function.identity(), expression -> { + assertTrue(expression.contains("%__advancedcore_bound_")); + expressions.add(expression); return "/computed-command"; + }); + }); + String source = "[Text=\"{Legacy}\",Command=\"[Javascript='/cmd/{Legacy}']\"]"; + BaseComponent[] previous = new BaseComponent[] {PlaceholderUtils.parseJson( + RewardDisplayPlaceholders.replaceFormattedMessage(source, legacy))}; + BaseComponent[] current = RewardDisplayPlaceholders.renderLiteralComponents(null, source, mixed); + assertEquals("/computed-command", clickable(current).get(0).getClickEvent().getValue()); + assertEquals(clickable(previous).get(0).getClickEvent().getValue(), clickable(current).get(0).getClickEvent().getValue()); + assertEquals(2, expressions.size()); + assertEquals(expressions.get(0), expressions.get(1)); + } + } + + @Test void escapedBraceMarkersKeepAuthoredDollarAndBackslashCharactersWithoutPadding() { + HashMap values = values("raw-legacy"); + RewardDisplayPlaceholders.putLiteral(values, "SiteName", "$1\\label", "old-safe"); + assertEquals("$\\$1\\label|\\$1\\label", BaseComponent.toPlainText(render("$\\{SiteName}|\\%SiteName%", values))); + } + + private HashMap legacyValues(String raw, String display) { + HashMap values = new HashMap<>(); + values.put("Legacy", raw); + RewardDisplayPlaceholders.put(values, "Legacy", display); + return values; + } + + @Test void legacyDisplayOverrideKeepsRawValueInEveryAuthoredClickAttribute() { + String raw = "&aRaw §b%NewLine% [Text=\"unexpected\",Command=\"/bad\"]"; + for (String attribute : List.of("Command", "URL", "Suggest_Command")) { + HashMap values = values(raw); + TextComponent[] rendered = render("[Text=\"%Legacy% %SiteName%\"," + attribute + + "=\"prefix:%LeGaCy%\",Hover=\"%Legacy%\"]", values); + List clickable = clickable(rendered); + assertEquals(1, clickable.size(), attribute); + assertEquals("prefix:" + raw, clickable.get(0).getClickEvent().getValue(), attribute); + assertTrue(BaseComponent.toLegacyText(rendered).contains("§cFormatted Name")); + assertTrue(BaseComponent.toLegacyText(rendered).contains("R&D &aLiteral")); + var hover = assertInstanceOf(Text.class, clickable.get(0).getHoverEvent().getContents().get(0)); + var hoverComponents = assertInstanceOf(BaseComponent[].class, hover.getValue()); + assertEquals(ChatColor.RED, hoverComponents[0].getColor()); + assertFalse(BaseComponent.toLegacyText(rendered).contains("VCORELITERAL")); + } + } + + @Test void authoredAmpersandAndSectionPrefixesCannotConsumeActionTokens() { + for (String prefix : List.of("&", "§")) { + TextComponent[] rendered = render("[Text=\"%SiteName%\",Command=\"" + prefix + "%Legacy%\"]", values("aRaw")); + assertEquals(prefix + "aRaw", clickable(rendered).get(0).getClickEvent().getValue()); + } + } + + @Test void protectedScriptTextIsNotRetokenizedAsAnAuthoredAction() { + String source = "[Javascript='[Text=\"%SiteName%\",Command=\"%Legacy%\"]']"; + var prepared = LiteralMessageTemplate.prepare(source, java.util.Map.of("sitename", "literal"), + java.util.Map.of("sitename", "raw-site", "legacy", "raw-legacy")); + String restored = prepared.finishText(prepared.template()); + assertEquals(source.replace("%SiteName%", "literal"), restored); + assertFalse(restored.contains("VCORELITERAL")); + } + + private HashMap values(String raw) { + HashMap values = new HashMap<>(); + values.put("SiteName", "raw-site"); + values.put("Legacy", raw); + RewardDisplayPlaceholders.putLiteral(values, "SiteName", "R&D &aLiteral", "old-safe"); + RewardDisplayPlaceholders.put(values, "Legacy", "&cFormatted Name"); + return values; + } + + private TextComponent[] render(String source, HashMap values) { + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + return RewardDisplayPlaceholders.renderLiteralComponents(null, source, values); + } + } + + private List clickable(BaseComponent[] components) { + List result = new ArrayList<>(); + for (BaseComponent component : components) { + if (component.getClickEvent() != null) result.add(component); + if (component.getExtra() != null) result.addAll(clickable(component.getExtra().toArray(new BaseComponent[0]))); + } + return result; + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardLiteralDisplayTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardLiteralDisplayTest.java new file mode 100644 index 0000000000..858e1a3654 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardLiteralDisplayTest.java @@ -0,0 +1,2151 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.HashMap; +import java.util.List; +import java.io.File; +import java.util.ArrayList; +import java.util.UUID; +import java.util.logging.Logger; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.rewards.RewardDisplayPlaceholders; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; +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.player.PlayerUtils; +import com.bencodez.simpleapi.serverhandle.IServerHandle; +import com.bencodez.simpleapi.array.ArrayUtils; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.hover.content.Text; +import net.md_5.bungee.chat.ComponentSerializer; + +class RewardLiteralDisplayTest { + @TempDir File directory; + private HashMap values(String literal) { + HashMap values = new HashMap<>(); + values.put("SiteName", "raw-site"); + RewardDisplayPlaceholders.putLiteral(values, "SiteName", literal, " legacy-safe "); + return values; + } + + private TextComponent[] render(String template, HashMap values) { + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())) + .thenAnswer(call -> call.getArgument(0)); + return RewardDisplayPlaceholders.renderLiteralComponents(null, template, values); + } + } + + @Test + void finalVisibleTextUsesOnlyAuthoredSpacing() { + String[][] examples = { + {"&7Site: &e%SiteName%", "Site: Minecraft Menu"}, + {"[%SiteName%]", "[Minecraft Menu]"}, + {"Vote on %SiteName%!", "Vote on Minecraft Menu!"}, + {"Site:%SiteName%", "Site:Minecraft Menu"}, + {"&a%SiteName%&7 - Vote now", "Minecraft Menu - Vote now"} + }; + for (String[] example : examples) + assertEquals(example[1], BaseComponent.toPlainText(render(example[0], values("Minecraft Menu")))); + } + + @Test + void externalTextIsLiteralThroughFinalComponentSerialization() { + for (String value : List.of("&aGreen", "&kSpoofed", "&rReset", "&#FF0000Red", "%player_name%", + "%vault_eco_balance%", "%javascript_example%", "Javascript=1", "script=1", "Java", + "[Text=\"extra\",Command=\"/unexpected\"]", "R&D", "VPLITERAL0Q")) { + HashMap stored = values(value); + HashMap before = new HashMap<>(stored); + TextComponent[] lines = render("&7[%SiteName%]", stored); + assertEquals("[" + value + "]", BaseComponent.toPlainText(lines)); + assertEquals(before, stored); + assertFalse(ComponentSerializer.toString(lines).contains("clickEvent")); + assertFalse(ComponentSerializer.toString(lines).contains("hoverEvent")); + assertFalse(BaseComponent.toPlainText(lines).contains("\u2060")); + assertFalse(BaseComponent.toPlainText(lines).contains("\uE000")); + } + } + + @Test + void sectionSignCannotActivateFormattingOnLegacyClient() { + assertEquals("(section)aGreen", BaseComponent.toPlainText(render("%SiteName%", values("§aGreen")))); + } + + @Test + void authoredWhitespaceAndRepeatedRenderingAreStable() { + HashMap map = values("Minecraft Menu"); + for (int i = 0; i < 3; i++) { + assertEquals(" [Minecraft Menu] ", BaseComponent.toPlainText(render(" [%SiteName%] ", map))); + } + } + + @Test + void placeholderAndJavascriptTokensCannotFormAcrossBoundaries() { + assertEquals("%player_name%", BaseComponent.toPlainText(render("%SiteName%player_name%", values("%")))); + assertEquals("[Javascript=1]", BaseComponent.toPlainText(render("[Java%SiteName%]", values("script=1")))); + assertEquals("[Javascript=1]", BaseComponent.toPlainText(render("[%SiteName%script=1]", values("Java")))); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> { + String protectedText = call.getArgument(0); + assertFalse(protectedText.contains("[Javascript=")); + return protectedText; + }); + assertEquals("[Javascript=Java]", BaseComponent.toPlainText( + RewardDisplayPlaceholders.renderLiteralComponents(null, "[Javascript=%SiteName%]", values("Java")))); + } + } + + @Test + void incompleteTrustedColorCannotConsumeALiteralToken() { + var lines = render("&%SiteName%", values("aSite")); + assertEquals("&aSite", BaseComponent.toPlainText(lines)); + assertFalse(BaseComponent.toLegacyText(lines).contains("§a")); + assertEquals("(section)aSite", BaseComponent.toPlainText(render("§%SiteName%", values("aSite")))); + } + + @Test + void clickActionsKeepExactRawValuesAndHoverUsesLiteralDisplay() { + var lines = render("[Text=\"%SiteName%\",Command=\"/inspect %SiteName%\",Hover=\"%SiteName%\"]", + values("[Text=\"x\",Command=\"/unexpected\"]")); + assertEquals("[Text=\"x\",Command=\"/unexpected\"]", BaseComponent.toPlainText(lines)); + String json = ComponentSerializer.toString(lines); + assertTrue(json.contains("/inspect raw-site")); + assertFalse(json.contains("/inspect [Text")); + assertTrue(json.contains("hoverEvent")); + assertFalse(json.contains("ACLITERAL")); + } + + @Test + void urlActionsPreserveRawIdentityRatherThanDisplay() { + var lines = render("[Text=\"%SiteName%\",Url=\"https://example.com/%SiteName%\"]", values("Pretty Site")); + assertEquals("Pretty Site", BaseComponent.toPlainText(lines)); + assertTrue(ComponentSerializer.toString(lines).contains("https://example.com/raw-site")); + } + + @Test + void oldDisplayValuesKeepConfiguredColorsAlongsideNewLiteralValues() { + var map = values("Minecraft Menu"); + map.put("Rank", "raw-rank"); + RewardDisplayPlaceholders.put(map, "Rank", "&aAdmin"); + var lines = render("%Rank%: %SiteName%", map); + assertEquals("Admin: Minecraft Menu", BaseComponent.toPlainText(lines)); + assertTrue(BaseComponent.toLegacyText(lines).contains("§a")); + } + + @Test + void literalMetadataSurvivesExistingOfflineSerializationAndHasSafeOldView() { + var initial = values("%player_name% &aSite"); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(initial)); + assertEquals("raw-site", restored.get("SiteName")); + assertEquals(" legacy-safe ", RewardDisplayPlaceholders.forDisplay(restored).get("SiteName")); + assertEquals("%player_name% &aSite", BaseComponent.toPlainText(render("%SiteName%", restored))); + assertEquals(1, RewardDisplayPlaceholders.forDisplay(restored).size()); + } + + @Test + void replacingLiteralWithTrustedDisplayClearsAllCaseVariantMetadata() { + var map = values("old"); + RewardDisplayPlaceholders.put(map, "sitename", "&aNew"); + assertFalse(RewardDisplayPlaceholders.hasLiteralValues(map)); + assertEquals("&aNew", RewardDisplayPlaceholders.forDisplay(map).get("sitename")); + } + + @Test + void authoredLineBreaksSplitBeforeExternalTextIsRestored() { + var lines = render("First:%SiteName%%NewLine%Second:%SiteName%", values("%NewLine%")); + assertEquals(2, lines.length); + assertEquals("First:%NewLine%", lines[0].toPlainText()); + assertEquals("Second:%NewLine%", lines[1].toPlainText()); + } + + @Test + void playerLineBreaksKeepLowercaseAndUppercaseTokensLiteral() { + var lines = render("First:%SiteName%%newline%Second:%SiteName%", values("literal")); + assertEquals(1, lines.length); + assertEquals("First:literal%newline%Second:literal", lines[0].toPlainText()); + assertEquals("First:literal%NEWLINE%Second:literal", BaseComponent.toPlainText( + render("First:%SiteName%%NEWLINE%Second:%SiteName%", values("literal")))); + } + + @Test + void broadcastLineBreaksAcceptBothEstablishedSpellingsAndKeepLiteralValuesOpaque() { + var message = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, + "First:%SiteName%%newline%Second:%SiteName%%NewLine%Third", values("%newline%")); + assertEquals(3, message.lineCount()); + assertEquals("First:%newline%", message.renderLine(null, 0).toPlainText()); + assertEquals("Second:%newline%", message.renderLine(null, 1).toPlainText()); + assertEquals("Third", message.renderLine(null, 2).toPlainText()); + } + + @Test + void trustedDisplayOverrideSplitsPlayerLinesBeforeLiteralRestoration() { + var map = values("%NewLine%external"); + map.put("Rank", "raw-rank"); + RewardDisplayPlaceholders.put(map, "Rank", "&aOne%NewLine%&bTwo"); + var lines = render("%Rank%:%SiteName%", map); + assertEquals(2, lines.length); + assertEquals("One", lines[0].toPlainText()); + assertEquals("Two:%NewLine%external", lines[1].toPlainText()); + assertTrue(lines[0].toLegacyText().contains("§a")); + assertTrue(lines[1].toLegacyText().contains("§b")); + } + + @Test + void visibleJavascriptSeparatorsSplitAfterEvaluationWithMixedDisplayBindings() { + for (boolean mixed : List.of(false, true)) { + var map = values("%NewLine%literal"); + if (mixed) { map.put("Rank", "raw-rank"); RewardDisplayPlaceholders.put(map, "Rank", "&aAdmin"); } + String prefix = mixed ? "%Rank% " : ""; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> + ((String) call.getArgument(0)).replace("[Javascript=synthetic_multiline]", "One%NewLine%Two")); + var rendered = RewardDisplayPlaceholders.renderLiteralComponents(null, + prefix + "[Javascript=synthetic_multiline]:%SiteName%", map); + assertEquals(2, rendered.length, "visible script results retain the canonical player separator"); + assertEquals((mixed ? "Admin " : "") + "One", rendered[0].toPlainText()); + assertEquals("Two:%NewLine%literal", rendered[1].toPlainText()); + } + } + } + + @Test + void visiblePlaceholderSeparatorsSplitAfterEvaluationWithMixedDisplayBindings() { + Player player = mock(Player.class); + for (boolean mixed : List.of(false, true)) { + var map = values("%NewLine%literal"); + if (mixed) { map.put("Rank", "raw-rank"); RewardDisplayPlaceholders.put(map, "Rank", "&aAdmin"); } + String prefix = mixed ? "%Rank% " : ""; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(player), anyString())).thenAnswer(call -> + ((String) call.getArgument(1)).replace("%synthetic_multiline%", "One%NewLine%Two")); + placeholders.when(() -> PlaceholderUtils.replaceJavascript(eq(player), anyString())) + .thenAnswer(call -> call.getArgument(1)); + var rendered = RewardDisplayPlaceholders.renderLiteralComponents(player, + prefix + "%synthetic_multiline%:%SiteName%", map); + assertEquals(2, rendered.length, "visible provider results retain the canonical player separator"); + assertEquals((mixed ? "Admin " : "") + "One", rendered[0].toPlainText()); + assertEquals("Two:%NewLine%literal", rendered[1].toPlainText()); + } + } + } + + @Test + void postEvaluationSeparatorsInExactActionsStayOpaque() { + Player player = mock(Player.class); + var map = values("%NewLine%literal"); + map.put("Rank", "raw-rank"); RewardDisplayPlaceholders.put(map, "Rank", "&aAdmin"); + for (String attribute : List.of("Command", "Url", "Suggest_Command")) { + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(player), anyString())).thenAnswer(call -> + ((String) call.getArgument(1)).replace("%synthetic_multiline%", "One%NewLine%Two")); + placeholders.when(() -> PlaceholderUtils.replaceJavascript(eq(player), anyString())) + .thenAnswer(call -> call.getArgument(1)); + var rendered = RewardDisplayPlaceholders.renderLiteralComponents(player, + "%Rank% [Text=\"%SiteName%\"," + attribute + "=\"%synthetic_multiline%\"]", map); + assertEquals(1, rendered.length, attribute); + assertEquals("Admin %NewLine%literal", rendered[0].toPlainText()); + assertTrue(ComponentSerializer.toString(rendered).contains("One%NewLine%Two"), attribute); + assertFalse(ComponentSerializer.toString(rendered).contains("VCORELITERAL"), attribute); + } + } + } + + @Test + void generatedLowercaseSeparatorRetainsPlayerCompatibility() { + var map = values("literal"); + map.put("Rank", "raw-rank"); RewardDisplayPlaceholders.put(map, "Rank", "Admin"); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> + ((String) call.getArgument(0)).replace("[Javascript=synthetic_multiline]", "One%newline%Two")); + var rendered = RewardDisplayPlaceholders.renderLiteralComponents(null, + "%Rank% [Javascript=synthetic_multiline]:%SiteName%", map); + assertEquals(1, rendered.length); + assertEquals("Admin One%newline%Two:literal", rendered[0].toPlainText()); + } + } + + @Test + void ordinaryTrustedPlaceholderSeparatorsSplitAfterCascadePreparation() { + var map = values("literal"); + map.put("A", "One%NewLine%%B%"); + map.put("B", "Two"); + var lines = render("%A%:%SiteName%", map); + assertEquals(2, lines.length); + assertEquals("One", lines[0].toPlainText()); + assertEquals("Two:literal", lines[1].toPlainText()); + } + + @Test + void ordinarySeparatorLikeUntrustedValuesRemainOpaque() { + var map = values("ordinary%NewLine%value"); + map.put("Prefix", "One"); + var lines = render("%Prefix%:%SiteName%", map); + assertEquals(1, lines.length); + assertEquals("One:ordinary%NewLine%value", lines[0].toPlainText()); + } + + @Test + void ordinaryBackwardReferenceIsExpandedOnlyOnce() { + var map = values("literal"); + map.put("A", "expanded"); + map.put("B", "%A%"); + assertEquals("%A%:literal", BaseComponent.toPlainText(render("%B%:%SiteName%", map))); + } + + @Test + void ordinaryScriptValueRemainsEscapedUntilScriptProtection() { + var map = values("literal"); + map.put("Script", "[Text=\"evil\",Command=\"/run\"]"); + var scriptInput = new java.util.concurrent.atomic.AtomicReference(); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> { + scriptInput.set(call.getArgument(0)); + return "script result"; + }); + var rendered = RewardDisplayPlaceholders.renderLiteralComponents(null, "[Javascript=%Script%]", map); + assertTrue(scriptInput.get().contains("%__advancedcore_bound_")); + assertFalse(scriptInput.get().contains("[Text=\"evil\",Command=\"/run\"]"), + "raw ordinary values must not enter the JavaScript interpreter"); + assertEquals("script result", BaseComponent.toPlainText(rendered)); + assertFalse(ComponentSerializer.toString(rendered).contains("clickEvent")); + } + } + + @Test + void ordinaryActionPlaceholderUsesRawCommandValue() { + var map = values("display"); + map.put("Command", "/inspect synthetic-player"); + var rendered = render("[Text=\"%SiteName%\",Command=\"%Command%\"]", map); + assertTrue(ComponentSerializer.toString(rendered).contains("/inspect synthetic-player")); + } + + @Test + void broadcastOrdinaryActionPlaceholderKeepsItsOwnExpansionPass() { + var map = values("display"); + map.put("Command", "/inspect synthetic-player"); + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, + "[Text=\"%SiteName%\",Command=\"%Command%\"]", map); + assertTrue(ComponentSerializer.toString(broadcast.renderLine(null, 0)).contains("/inspect synthetic-player")); + } + + @Test + void trustedDisplayOverrideSplitsBothBroadcastSpellingsAndKeepsLiteralValuesOpaque() { + var map = values("%newline%external"); + map.put("Rank", "raw-rank"); + RewardDisplayPlaceholders.put(map, "Rank", "One%newline%Two%NewLine%Three"); + var message = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "%Rank%:%SiteName%", map); + assertEquals(3, message.lineCount()); + assertEquals("One", message.renderLine(null, 0).toPlainText()); + assertEquals("Two", message.renderLine(null, 1).toPlainText()); + assertEquals("Three:%newline%external", message.renderLine(null, 2).toPlainText()); + assertEquals("One%newline%Two", render("%Rank%", map)[0].toPlainText()); + } + + @Test + void trustedDisplaySeparatorsNeverSplitExactClickActionValues() { + var map = values("%NewLine%external"); + map.put("Rank", "raw%NewLine%identity"); + RewardDisplayPlaceholders.put(map, "Rank", "One%NewLine%Two"); + String template = "%Rank% [Text=\"%SiteName%\",Command=\"/inspect %Rank%\"]"; + var playerLines = render(template, map); + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, template, map); + assertEquals(2, playerLines.length); + assertEquals(2, broadcast.lineCount()); + for (var last : List.of(playerLines[1], broadcast.renderLine(null, 1))) { + assertEquals("Two %NewLine%external", last.toPlainText()); + assertTrue(ComponentSerializer.toString(last).contains("/inspect raw%NewLine%identity")); + assertFalse(ComponentSerializer.toString(last).contains("VCORELITERAL")); + } + } + + @Test + void referencedDisplaySeparatorsDoNotSplitIntroducedCommandActions() { + assertReferencedDisplaySeparators("Command", ClickEvent.Action.RUN_COMMAND); + } + + @Test + void referencedDisplaySeparatorsDoNotSplitIntroducedUrlActions() { + assertReferencedDisplaySeparators("Url", ClickEvent.Action.OPEN_URL); + } + + @Test + void referencedDisplaySeparatorsDoNotSplitIntroducedSuggestionActions() { + assertReferencedDisplaySeparators("Suggest_Command", ClickEvent.Action.SUGGEST_COMMAND); + } + + private void assertReferencedDisplaySeparators(String attribute, ClickEvent.Action action) { + var map = values("Unrelated"); + map.put("Legacy", "raw-value"); + RewardDisplayPlaceholders.put(map, "Legacy", "One%NewLine%Two"); + map.put("Card", "[Text=\"Run\"," + attribute + "=\"%Legacy%\"]"); + String template = "%Legacy%%NewLine%%Card%:%SiteName%"; + var player = render(template, map); + assertEquals(List.of("One", "Two", "Run:Unrelated"), java.util.Arrays.stream(player).map(component -> component.toPlainText()).toList()); + assertSingleExactAction(player, action, "raw-value"); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, template, map).render(null); + assertEquals(List.of("One", "Two", "Run:Unrelated"), java.util.Arrays.stream(broadcast).map(component -> component.toPlainText()).toList()); + assertSingleExactAction(broadcast, action, "raw-value"); + } + } + + @Test + void ordinaryIntroducedLiteralBindingsRetainVisibleAndExactActionLanes() { + var map = values("R&D &aLiteral%NewLine%external"); + map.put("Card", "[Text=\"%SiteName%\",Command=\"%SiteName%\"]"); + var player = render("%Card%", map); + assertEquals(1, player.length); + assertEquals("R&D &aLiteral%NewLine%external", BaseComponent.toPlainText(player)); + assertSingleExactAction(player, ClickEvent.Action.RUN_COMMAND, "raw-site"); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())).thenAnswer(call -> call.getArgument(0)); + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "%Card%", map).render(null); + assertEquals(1, broadcast.length); + assertEquals("R&D &aLiteral%NewLine%external", BaseComponent.toPlainText(broadcast)); + assertSingleExactAction(broadcast, ClickEvent.Action.RUN_COMMAND, "raw-site"); + } + } + + @Test + void ordinaryIntroducedCommandKeepsDisplaySeparatorsOutOfTheRawAction() { + assertOrdinaryIntroducedAction("Command", ClickEvent.Action.RUN_COMMAND, false); + } + + @Test + void ordinaryIntroducedUrlKeepsDisplaySeparatorsOutOfTheRawAction() { + assertOrdinaryIntroducedAction("Url", ClickEvent.Action.OPEN_URL, false); + } + + @Test + void ordinaryIntroducedSuggestionKeepsDisplaySeparatorsOutOfTheRawAction() { + assertOrdinaryIntroducedAction("Suggest_Command", ClickEvent.Action.SUGGEST_COMMAND, false); + } + + @Test + void broadcastOrdinaryIntroducedCommandKeepsDisplaySeparatorsOutOfTheRawAction() { + assertOrdinaryIntroducedAction("Command", ClickEvent.Action.RUN_COMMAND, true); + } + + @Test + void broadcastOrdinaryIntroducedUrlKeepsDisplaySeparatorsOutOfTheRawAction() { + assertOrdinaryIntroducedAction("Url", ClickEvent.Action.OPEN_URL, true); + } + + @Test + void broadcastOrdinaryIntroducedSuggestionKeepsDisplaySeparatorsOutOfTheRawAction() { + assertOrdinaryIntroducedAction("Suggest_Command", ClickEvent.Action.SUGGEST_COMMAND, true); + } + + private void assertOrdinaryIntroducedAction(String attribute, ClickEvent.Action action, boolean broadcast) { + var map = values("Unrelated"); + map.put("Legacy", "raw-value"); + RewardDisplayPlaceholders.put(map, "Legacy", "One%NewLine%Two"); + map.put("Card", "[Text=\"Run\"," + attribute + "=\"%Legacy%\"]"); + TextComponent[] rendered; + if (broadcast) { + var prepared = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "%Card%:%SiteName%", map); + assertEquals(1, prepared.lineCount(), attribute); + rendered = prepared.render(null); + } else { + rendered = render("%Card%:%SiteName%", map); + } + assertEquals(1, rendered.length, attribute); + assertEquals("Run:Unrelated", BaseComponent.toPlainText(rendered), attribute); + assertSingleExactAction(rendered, action, "raw-value"); + } + + @Test + void directAttributesKeepRawSeparatorsAndExternalLiteralSeparatorsInert() { + String literal = "%NewLine%/%newline%/%NEWLINE%"; + String raw = "raw%NewLine%/%newline%/%NEWLINE%identity"; + for (var attribute : java.util.Map.of("Command", ClickEvent.Action.RUN_COMMAND, + "Url", ClickEvent.Action.OPEN_URL, "Suggest_Command", ClickEvent.Action.SUGGEST_COMMAND).entrySet()) { + var map = values(literal); + map.put("Legacy", raw); + RewardDisplayPlaceholders.put(map, "Legacy", "One%NewLine%Two%newline%Three"); + String template = "[Text=\"Run\"," + attribute.getKey() + "=\"%Legacy%\",Hover=\"%SiteName%\"]:%SiteName%"; + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, template, map); + assertEquals(1, broadcast.lineCount(), attribute.getKey()); + for (var rendered : List.of(render(template, map), broadcast.render(null))) { + assertEquals(1, rendered.length, attribute.getKey()); + assertEquals("Run:" + literal, BaseComponent.toPlainText(rendered), attribute.getKey()); + BaseComponent clickable = assertSingleExactAction(rendered, attribute.getValue(), raw); + Text hover = assertInstanceOf(Text.class, clickable.getHoverEvent().getContents().get(0)); + BaseComponent[] hoverComponents = assertInstanceOf(BaseComponent[].class, hover.getValue()); + assertEquals(literal, BaseComponent.toPlainText(hoverComponents)); + } + } + } + + @Test + void ordinaryIntroducedVisibleAndHoverDisplayStillUseTheirDisplayValues() { + var map = values("%NewLine%external"); + map.put("Legacy", "raw-value"); + RewardDisplayPlaceholders.put(map, "Legacy", "&aShown"); + map.put("Card", "[Text=\"%Legacy%\",Command=\"%Legacy%\",Hover=\"%Legacy%\"]"); + String template = "%Card%:%SiteName%"; + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, template, map); + assertEquals(1, broadcast.lineCount()); + for (var rendered : List.of(render(template, map), broadcast.render(null))) { + assertEquals(1, rendered.length); + assertEquals("Shown:%NewLine%external", BaseComponent.toPlainText(rendered)); + assertTrue(BaseComponent.toLegacyText(rendered).contains("§aShown")); + BaseComponent clickable = assertSingleExactAction(rendered, ClickEvent.Action.RUN_COMMAND, "raw-value"); + Text hover = assertInstanceOf(Text.class, clickable.getHoverEvent().getContents().get(0)); + BaseComponent[] hoverComponents = assertInstanceOf(BaseComponent[].class, hover.getValue()); + assertEquals("Shown", BaseComponent.toPlainText(hoverComponents)); + assertTrue(BaseComponent.toLegacyText(hoverComponents).contains("§aShown")); + } + } + + private BaseComponent assertSingleExactAction(BaseComponent[] rendered, ClickEvent.Action action, String raw) { + ArrayList pending = new ArrayList<>(List.of(rendered)); + ArrayList clickable = new ArrayList<>(); + for (int index = 0; index < pending.size(); index++) { + BaseComponent component = pending.get(index); + if (component.getClickEvent() != null) clickable.add(component); + if (component.getExtra() != null) pending.addAll(component.getExtra()); + } + assertEquals(1, clickable.size()); + BaseComponent component = clickable.get(0); + assertEquals(action, component.getClickEvent().getAction()); + assertEquals(raw, component.getClickEvent().getValue()); + String json = ComponentSerializer.toString(rendered); + for (String marker : List.of("VCORELITERAL", "ACLITERAL", "%__advancedcore_bound_", "\u2060", "\uE000", "\uE001")) + assertFalse(json.contains(marker), json); + return component; + } + + @Test + void broadcastCapturesSourceOnceAndEvaluatesEachRecipientWithoutRestoringEarly() { + Player source = mock(Player.class); + Player recipient = mock(Player.class); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(source), anyString())) + .thenAnswer(call -> call.getArgument(1).replace("%source%", "Source")); + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(recipient), anyString())) + .thenAnswer(call -> { + String protectedText = call.getArgument(1); + assertFalse(protectedText.contains("%player_name%")); + return protectedText.replace("%recipient%", "Recipient"); + }); + var message = RewardDisplayPlaceholders.prepareLiteralBroadcast(source, + "%source% %recipient% %SiteName%", values("%player_name%")); + assertEquals("Source Recipient %player_name%", BaseComponent.toPlainText(message.render(recipient))); + assertEquals("Source %recipient% %player_name%", BaseComponent.toPlainText(message.render(null))); + } + } + + @Test + void guardedLegacyDisplayHasItsOwnBoundWithoutIncreasingTheLiteralBound() { + var map = new HashMap(); + map.put("SiteName", "raw-site"); + String literal = "x".repeat(8192); + String legacy = "y".repeat(32768); + RewardDisplayPlaceholders.putLiteral(map, "SiteName", literal, legacy); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(map)); + assertEquals("raw-site", restored.get("SiteName")); + assertEquals(legacy, RewardDisplayPlaceholders.forDisplay(restored).get("SiteName")); + assertEquals(literal, BaseComponent.toPlainText(render("%SiteName%", restored))); + var before = new HashMap<>(map); + assertThrows(IllegalArgumentException.class, + () -> RewardDisplayPlaceholders.putLiteral(map, "SiteName", literal, "y".repeat(32769))); + assertEquals(before, map); + assertThrows(IllegalArgumentException.class, + () -> RewardDisplayPlaceholders.putLiteral(map, "SiteName", "x".repeat(8193), legacy)); + assertEquals(before, map); + } + + @Test + void missingRawActionNeverUsesTheLiteralDisplayAsACommandOrUrl() { + HashMap stored = new HashMap<>(); + RewardDisplayPlaceholders.putLiteral(stored, "SiteName", "external-display", "legacy-safe"); + TextComponent[] rendered = render( + "[Text=\"%SiteName%\",Command=\"/inspect %SiteName%\"]", stored); + assertEquals("external-display", BaseComponent.toPlainText(rendered)); + String json = ComponentSerializer.toString(rendered); + assertTrue(json.contains("/inspect "), json); + assertFalse(json.contains("/inspect external-display"), json); + assertTrue(ComponentSerializer.toString(render( + "[Text=\"%SiteName%\",Url=\"https://example.com/%SiteName%\"]", stored)) + .contains("https://example.com/\"")); + } + + @Test + void directLiteralPreparationResolvesMixedCaseRawActionKeys() { + for (String marker : List.of("%sItEnAmE%", "{sItEnAmE}")) { + var prepared = com.bencodez.advancedcore.api.messages.LiteralMessageTemplate.prepare( + "[Text=\"" + marker + "\",Command=\"/inspect " + marker + "\"]", + java.util.Map.of("SiteName", "display"), java.util.Map.of("SiTeNaMe", "raw-site")); + TextComponent rendered = prepared.finish(PlaceholderUtils.parseJson(prepared.template())); + assertEquals("display", rendered.toPlainText()); + assertTrue(ComponentSerializer.toString(rendered).contains("/inspect raw-site")); + assertFalse(ComponentSerializer.toString(rendered).contains("/inspect display")); + } + } + + @Test + void directLiteralPreparationRejectsAmbiguousCaseVariantActionKeys() { + HashMap actions = new HashMap<>(); + actions.put("SiteName", "one"); + actions.put("sitename", "two"); + assertThrows(IllegalArgumentException.class, + () -> com.bencodez.advancedcore.api.messages.LiteralMessageTemplate.prepare("%SiteName%", + java.util.Map.of("SiteName", "display"), actions)); + } + + @Test + void rewardMessageAndBroadcastRejectAmbiguousCaseVariantActionKeys() { + var stored = values("display"); + stored.put("SiteName", "one"); + stored.put("sitename", "two"); + for (String attribute : List.of("Command", "Url")) { + String template = "[Text=\"%SiteName%\"," + attribute + "=\"%SiteName%\"]"; + assertThrows(IllegalArgumentException.class, () -> render(template, stored)); + assertThrows(IllegalArgumentException.class, + () -> RewardDisplayPlaceholders.prepareLiteralBroadcast(null, template, stored)); + } + } + + @Test + void nonAsciiLiteralAtTheDeclaredCharacterLimitSurvivesOfflineSerialization() { + for (String literal : List.of("界".repeat(8192), "Ж".repeat(8192), "\uD83D\uDE00".repeat(4096))) { + var restored = ArrayUtils.fromString(ArrayUtils.makeString(values(literal))); + assertEquals("[" + literal + "]", BaseComponent.toPlainText(render("[%SiteName%]", restored))); + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "{%SiteName%}", restored); + assertEquals("{" + literal + "}", BaseComponent.toPlainText(broadcast.render(null))); + } + } + + @Test + void persistedLiteralCannotExceedTheDecodedCharacterLimit() { + var map = values("safe"); + String key = map.keySet().stream().filter(name -> name.contains("literal:")).findFirst().orElseThrow(); + String oversized = "x".repeat(8193); + map.put(key, "b" + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( + oversized.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + assertThrows(IllegalArgumentException.class, () -> render("%SiteName%", map)); + assertThrows(IllegalArgumentException.class, + () -> RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "%SiteName%", map)); + } + + @Test + void providerProducedSectionPrefixIsProtectedBeforeTheFinalMessageParser() { + Player player = mock(Player.class); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(player), anyString())) + .thenAnswer(call -> call.getArgument(1).replace("%prefix%", "§")); + placeholders.when(() -> PlaceholderUtils.replaceJavascript(eq(player), anyString())) + .thenAnswer(call -> call.getArgument(1)); + var rendered = RewardDisplayPlaceholders.renderLiteralComponents(player, "%prefix%%SiteName%", + values("Minecraft Menu")); + assertEquals("(section)Minecraft Menu", BaseComponent.toPlainText(rendered)); + assertFalse(ComponentSerializer.toString(rendered).contains("VCORELITERAL")); + } + } + + @Test + void recipientProviderSectionPrefixIsProtectedBeforeTheBroadcastParser() { + Player recipient = mock(Player.class); + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(recipient), anyString())) + .thenAnswer(call -> call.getArgument(1).replace("%prefix%", "§")); + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, "%prefix%%SiteName%", + values("Minecraft Menu")); + var rendered = broadcast.render(recipient); + assertEquals("(section)Minecraft Menu", BaseComponent.toPlainText(rendered)); + assertFalse(ComponentSerializer.toString(rendered).contains("VCORELITERAL")); + } + } + + @Test + void providerBoundaryTokenKeepsItsExactActionValueSeparateFromALiteralSectionLabel() { + Player recipient = mock(Player.class); + String template = "[Text=\"%SiteName%\",Command=\"/inspect %prefix%%SiteName%\"]"; + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(recipient), anyString())) + .thenAnswer(call -> call.getArgument(1).replace("%prefix%", "§")); + placeholders.when(() -> PlaceholderUtils.replaceJavascript(eq(recipient), anyString())) + .thenAnswer(call -> call.getArgument(1)); + for (boolean hasRaw : List.of(true, false)) { + HashMap stored = new HashMap<>(); + if (hasRaw) stored.put("SiteName", "raw-site"); + RewardDisplayPlaceholders.putLiteral(stored, "SiteName", "§", "legacy-safe"); + String expectedAction = "/inspect §" + (hasRaw ? "raw-site" : ""); + var message = RewardDisplayPlaceholders.renderLiteralComponents(recipient, template, stored); + var broadcast = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, template, stored) + .render(recipient); + for (var rendered : List.of(message, broadcast)) { + assertEquals("(section)", BaseComponent.toPlainText(rendered)); + String json = ComponentSerializer.toString(rendered); + assertTrue(json.contains(expectedAction + "\""), json); + assertFalse(json.contains("VCORELITERAL"), json); + } + } + } + } + + @Test + void invalidBindingDoesNotPartiallyChangeCallerMetadata() { + var map = values("safe"); + var before = new HashMap<>(map); + assertThrows(IllegalArgumentException.class, () -> RewardDisplayPlaceholders.putLiteral(map, "bad key", "value", "safe")); + assertEquals(before, map); + assertThrows(IllegalArgumentException.class, () -> render("x".repeat(65537), map)); + } + + @Test + void realRewardMessageSinkSendsFinalLinesWithoutAnotherParser() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class); + when(options.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(plugin.isEnabled()).thenReturn(true); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + RewardHandler handler = new RewardHandler(plugin); + Player player = mock(Player.class); + when(player.isOnline()).thenReturn(true); + AdvancedCoreUser user = spy(new AdvancedCoreUser(plugin, UUID.randomUUID(), "SyntheticPlayer")); + doReturn(player).when(user).getPlayer(); + IServerHandle transport = mock(IServerHandle.class); + ArrayList delivered = new ArrayList<>(); + doAnswer(call -> { delivered.add(call.getArgument(1).toPlainText()); return null; }) + .when(transport).sendMessage(eq(player), any(BaseComponent.class)); + try (var utility = mockStatic(PlayerUtils.class); + var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + utility.when(PlayerUtils::getServerHandle).thenReturn(transport); + placeholders.when(() -> PlaceholderUtils.parseText(eq(player), anyString())).thenAnswer(call -> + com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1))); + 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(); + message.onRewardRequest(mock(Reward.class), user, + "Site:%SiteName%%NewLine%[%SiteName%]", values("%player_name% &aName")); + assertEquals(List.of("Site:%player_name% &aName", "[%player_name% &aName]"), delivered); + verify(user, never()).sendPreparedMessage(anyString()); + } finally { + handler.getDelayedTimer().shutdownNow(); + } + } + + @Test + void scopedMessageCompletionWaitsForOwnerAndIgnoresRepeatedCallbacks() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class); + when(options.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + ArrayList global = new ArrayList<>(); + ArrayList owners = new ArrayList<>(); + doAnswer(call -> { global.add(call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { owners.add(call.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + Player player = mock(Player.class); + when(player.isOnline()).thenReturn(true); + AdvancedCoreUser user = spy(new AdvancedCoreUser(plugin, UUID.randomUUID(), "SyntheticPlayer")); + doReturn(player).when(user).getPlayer(); + IServerHandle transport = mock(IServerHandle.class); + ArrayList sent = new ArrayList<>(); + doAnswer(call -> { sent.add(call.getArgument(1).toPlainText()); return null; }) + .when(transport).sendMessage(eq(player), any(BaseComponent.class)); + try (var utility = mockStatic(PlayerUtils.class); + var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + utility.when(PlayerUtils::getServerHandle).thenReturn(transport); + placeholders.when(() -> PlaceholderUtils.parseText(eq(player), anyString())).thenAnswer(call -> { + assertTrue(!owners.isEmpty()); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1)); + }); + var collection = user.beginAsyncActionCollection(); + var map = values("Original"); + user.sendLiteralDisplayMessage("Site:%SiteName%", map); + RewardDisplayPlaceholders.putLiteral(map, "SiteName", "Changed", " safe "); + var finished = user.endAsyncActionCollection(collection).toCompletableFuture(); + assertFalse(finished.isDone()); + assertTrue(sent.isEmpty()); + assertEquals(1, global.size()); + global.get(0).run(); + assertFalse(finished.isDone()); + assertTrue(sent.isEmpty()); + assertEquals(1, owners.size()); + owners.get(0).run(); + assertFalse(finished.isDone()); + assertTrue(sent.isEmpty()); + assertEquals(2, global.size()); + global.get(1).run(); + assertEquals(2, owners.size()); + assertFalse(finished.isDone()); + owners.get(1).run(); + finished.join(); + owners.get(0).run(); + owners.get(1).run(); + assertEquals(List.of("Site:Original"), sent); + } + } + + @Test + void completedLiteralActionDoesNotRepeatWhenSerializedReplayMarkersChange() { + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + fixture.dispatch("Site:%SiteName%", map).join(); + assertEquals(List.of("Site:Original"), fixture.sent); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(map)); + restored.put("CurrentDate", "synthetic-later-date"); + fixture.dispatch("Site:%SiteName%", restored).join(); + assertEquals(List.of("Site:Original"), fixture.sent); + } + } + + @Test + void offlinePlayerReleasesAnUnstartedMessageReservationForTheNextSelection() { + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + when(fixture.player.isOnline()).thenReturn(false); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatch("First selection", map).join()); + assertTrue(fixture.sent.isEmpty()); + when(fixture.player.isOnline()).thenReturn(true); + fixture.dispatch("Different selection", ArrayUtils.fromString(ArrayUtils.makeString(map))).join(); + assertEquals(List.of("Different selection"), fixture.sent); + } + } + + @Test + void disconnectDuringRenderingFailsAndRetainsAnUncompletedReplayForRetry() { + try (ReplayFixture fixture = new ReplayFixture()) { + var current = new java.util.concurrent.atomic.AtomicReference(fixture.player); + doAnswer(call -> current.get()).when(fixture.user).getPlayer(); + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> { + current.set(null); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1)); + }); + var map = values("Original"); + var failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatch("Site:%SiteName%", map).join()); + assertFalse(AdvancedCoreUser.isReplayActionNotStarted(failure), + "trusted formatting ran, so its replay reservation remains conservative"); + assertTrue(fixture.sent.isEmpty()); + current.set(fixture.player); + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1))); + fixture.dispatch("Site:%SiteName%", ArrayUtils.fromString(ArrayUtils.makeString(map))).join(); + assertEquals(List.of("Site:Original"), fixture.sent, + "the unavailable delivery must not have been checkpointed as completed"); + } + } + + @Test void completedPlayerPlanSkipsThrowingProviderAfterOuterCheckpointFailure() { + assertCompletedPlayerPlanResumes(true, false); + } + + @Test void completedPlayerPlanSkipsShortenedProviderAfterOuterCheckpointFailure() { + assertCompletedPlayerPlanResumes(false, false); + } + + @Test void completedPlayerPlanCanFinishItsCheckpointAfterDisconnect() { + assertCompletedPlayerPlanResumes(false, true); + } + + private void assertCompletedPlayerPlanResumes(boolean throwing, boolean offline) { + try (ReplayFixture fixture = new ReplayFixture()) { + var changed = new java.util.concurrent.atomic.AtomicBoolean(); + var renderCount = new java.util.concurrent.atomic.AtomicInteger(); + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> { + renderCount.incrementAndGet(); + if (changed.get() && throwing) throw new IllegalStateException("synthetic provider failure"); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1) + .replace("%dynamic%", changed.get() ? "One" : "One%NewLine%Two")); + }); + var map = values("Original"); + String key = Reward.legacyActionReplayKey("synthetic-message-injector"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> { + String ledger = checkpoint.getPlaceholders().get(key); + if (ledger != null && ledger.startsWith("v2:") && ledger.length() > 3) + throw new IllegalStateException("synthetic parent checkpoint rejection"); + fixture.randomCheckpoint.set(new HashMap<>(checkpoint.getPlaceholders())); + }); + var collection = fixture.user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-message-injector"); + fixture.user.sendLiteralDisplayMessage("%dynamic%:%SiteName%", map); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join()); + assertEquals(List.of("One", "Two:Original"), fixture.sent); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + changed.set(true); + if (offline) when(fixture.player.isOnline()).thenReturn(false); + fixture.dispatch("%dynamic%:%SiteName%", restored).join(); + assertEquals(1, renderCount.get(), "completed line progress must be consulted before live rendering"); + assertEquals(List.of("One", "Two:Original"), fixture.sent); + } + } + + @Test void completedBroadcastPlanSkipsSourceProviderAfterOuterCheckpointFailure() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of()); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + var changed = new java.util.concurrent.atomic.AtomicBoolean(); + var renderCount = new java.util.concurrent.atomic.AtomicInteger(); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> { + renderCount.incrementAndGet(); + if (changed.get()) throw new IllegalStateException("synthetic source provider failure"); + return call.getArgument(1); + }); + String key = Reward.legacyActionReplayKey("synthetic-broadcast-injector"); + fixture.broadcastCheckpointObserver = checkpoint -> { + String ledger = checkpoint.getPlaceholders().get(key); + if (ledger != null && ledger.startsWith("v2:") && ledger.length() > 3) + throw new IllegalStateException("synthetic parent checkpoint rejection"); + }; + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchBroadcast("Site:%SiteName%", values("Original")).join()); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + fixture.broadcastCheckpointObserver = checkpoint -> { }; + changed.set(true); + fixture.dispatchBroadcast("Site:%SiteName%", restored).join(); + assertEquals(1, renderCount.get()); + verify(console, times(1)).sendMessage("Site:Original"); + bukkit.verify(org.bukkit.Bukkit::getOnlinePlayers, times(1)); + } + } + + @Test + void playerMultilineRetryResumesAfterDeliveredPrefix() { + HashMap persisted; + try (ReplayFixture fixture = new ReplayFixture()) { + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> + fixture.randomCheckpoint.set(new HashMap<>(checkpoint.getPlaceholders()))); + var map = values("Original"); + var collection = fixture.user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-message-injector"); + doAnswer(call -> { + fixture.sent.add(call.getArgument(1).toPlainText()); + if (fixture.sent.size() == 1) when(fixture.player.isOnline()).thenReturn(false); + return null; + }).when(fixture.transport).sendMessage(eq(fixture.player), any(BaseComponent.class)); + assertThrows(java.util.concurrent.CompletionException.class, + () -> { + fixture.user.sendLiteralDisplayMessage("First:%SiteName%%NewLine%Second:%SiteName%", map); + fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join(); + }); + assertEquals(List.of("First:Original"), fixture.sent); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + } + try (ReplayFixture restored = new ReplayFixture()) { + restored.dispatch("First:%SiteName%%NewLine%Second:%SiteName%", persisted).join(); + assertEquals(List.of("Second:Original"), restored.sent, + "serialized retry must skip the already delivered player line"); + } + } + + @Test + void legacyPendingPlayerActionWithoutLineSnapshotRefusesAmbiguousReplay() { + HashMap persisted; + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + doAnswer(call -> { + fixture.sent.add(call.getArgument(1).toPlainText()); + when(fixture.player.isOnline()).thenReturn(false); + return null; + }).when(fixture.transport).sendMessage(eq(fixture.player), any(BaseComponent.class)); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatch("First:%SiteName%%NewLine%Second:%SiteName%", map).join()); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(map)); + persisted.keySet().removeIf(key -> key.startsWith("__advancedcore_replay_nested_list_") + || key.startsWith("__advancedcore_replay_single_child_")); + } + try (ReplayFixture restored = new ReplayFixture()) { + assertThrows(java.util.concurrent.CompletionException.class, + () -> restored.dispatch("First:%SiteName%%NewLine%Second:%SiteName%", persisted).join()); + assertTrue(restored.sent.isEmpty(), "legacy ambiguous replay must not redeliver a prefix"); + } + } + + @Test void partialPlayerPlanKeepsFrozenPayloadWhenProviderGrowsAfterRestart() { + assertPartialPlayerPlanKeepsFrozenPayload("Extra%NewLine%One%NewLine%Two:", false); + } + + @Test void partialPlayerPlanKeepsFrozenPayloadWhenProviderShrinksAfterRestart() { + assertPartialPlayerPlanKeepsFrozenPayload("One", false); + } + + @Test void partialPlayerPlanKeepsFrozenPayloadWhenProviderThrowsAfterRestart() { + assertPartialPlayerPlanKeepsFrozenPayload(null, true); + } + + private void assertPartialPlayerPlanKeepsFrozenPayload(String changedOutput, boolean throwing) { + String template = "%dynamic%[Text=\"%SiteName%\",Command=\"inspect %SiteName%\"]"; + HashMap persisted; + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1) + .replace("%dynamic%", "One%NewLine%Two:"))); + doAnswer(call -> { + var component = call.getArgument(1); + if (component.toPlainText().equals("Two:Original")) + throw new IllegalStateException("synthetic second-line transport rejection"); + fixture.sent.add(component.toPlainText()); + return null; + }).when(fixture.transport).sendMessage(eq(fixture.player), any(BaseComponent.class)); + var map = values("Original"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> + fixture.randomCheckpoint.set(new HashMap<>(checkpoint.getPlaceholders()))); + var collection = fixture.user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-message-injector"); + fixture.user.sendLiteralDisplayMessage(template, map); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join()); + assertEquals(List.of("One"), fixture.sent, "only the first rendered line committed"); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + } + try (ReplayFixture restarted = new ReplayFixture()) { + var renderCount = new java.util.concurrent.atomic.AtomicInteger(); + restarted.placeholders.when(() -> PlaceholderUtils.parseText(eq(restarted.player), anyString())) + .thenAnswer(call -> { + renderCount.incrementAndGet(); + if (throwing) throw new IllegalStateException("synthetic provider failure after restart"); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1) + .replace("%dynamic%", changedOutput)); + }); + var components = new ArrayList(); + doAnswer(call -> { components.add(call.getArgument(1)); return null; }) + .when(restarted.transport).sendMessage(eq(restarted.player), any(BaseComponent.class)); + restarted.dispatch(template, persisted).join(); + assertEquals(List.of("Two:Original"), components.stream().map(component -> component.toPlainText()).toList(), + "restart must deliver the original unfinished component, not a new provider list position"); + assertSingleExactAction(components.toArray(BaseComponent[]::new), ClickEvent.Action.RUN_COMMAND, + "inspect raw-site"); + assertEquals(0, renderCount.get(), "frozen recovery must bypass live placeholder providers"); + } + } + + @Test + void offlineOrReplacedPlayerAfterFormattingCannotReceiveOrCompleteTheReward() { + for (boolean replaced : List.of(false, true)) { + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> { + if (replaced) doReturn(mock(Player.class)).when(fixture.user).getPlayer(); + else when(fixture.player.isOnline()).thenReturn(false); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1)); + }); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatch("Site:%SiteName%", values("Original")).join()); + assertTrue(fixture.sent.isEmpty()); + } + } + } + + @Test + void displayAndReplayMetadataHaveIndependentBoundedAdmission() { + var original = new HashMap(); + original.put("Display", "x".repeat(1048576 - "Display".length())); + String replayKey = Reward.legacyActionReplayKey("Messages"); + original.put(replayKey, "x".repeat(1048576 - replayKey.length())); + var snapshot = new HashMap<>(original); + assertEquals(1, Reward.copyWithoutReplayMetadata(original).size()); + assertEquals(snapshot, original, "admission must not remove durable state from the owner"); + original.put(replayKey, original.get(replayKey) + "x"); + assertThrows(IllegalArgumentException.class, () -> Reward.copyWithoutReplayMetadata(original)); + original.put(replayKey, snapshot.get(replayKey)); + original.put("Display", original.get("Display") + "x"); + assertThrows(IllegalArgumentException.class, () -> Reward.copyWithoutReplayMetadata(original)); + } + + @Test + void replayMetadataCannotBypassEntryLimits() { + var original = new HashMap(); + for (int index = 0; index < 8192; index++) { + original.put("Display" + index, ""); + original.put(Reward.legacyActionReplayKey("Action" + index), ""); + } + assertEquals(8192, Reward.copyWithoutReplayMetadata(original).size()); + original.remove("Display0"); + original.put(Reward.legacyActionReplayKey("Excess"), ""); + assertThrows(IllegalArgumentException.class, () -> Reward.copyWithoutReplayMetadata(original)); + original.remove(Reward.legacyActionReplayKey("Excess")); + original.put("Display0", ""); + original.remove(Reward.legacyActionReplayKey("Action0")); + original.put("ExcessDisplay", ""); + assertThrows(IllegalArgumentException.class, () -> Reward.copyWithoutReplayMetadata(original)); + } + + @Test + void randomReplayMetadataDoesNotConsumeTheDisplayCharacterAllowance() { + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + for (int index = 0; index < 30; index++) map.put("Unused" + index, "x".repeat(32768)); + String template = "x".repeat(50000) + "%SiteName%"; + fixture.dispatchRandom(new ArrayList<>(List.of(template)), map).join(); + assertEquals(List.of("x".repeat(50000) + "Original"), fixture.sent); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + fixture.dispatchRandom(new ArrayList<>(List.of(template)), restored).join(); + assertEquals(1, fixture.sent.size()); + } + } + + @Test + void randomReplayMetadataDoesNotConsumeTheDisplayEntryAllowance() { + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + while (map.size() < 8192) map.put("Unused" + map.size(), ""); + fixture.dispatchRandom(new ArrayList<>(List.of("%SiteName%")), map).join(); + assertEquals(List.of("Original"), fixture.sent); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + fixture.dispatchRandom(new ArrayList<>(List.of("%SiteName%")), restored).join(); + assertEquals(1, fixture.sent.size()); + } + } + + @Test + void longRandomLiteralMessageKeepsReplayMetadataOutsideDisplayBindings() { + for (int size : List.of(24577, 65520)) { + String template = "x".repeat(size) + "%SiteName%"; + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.dispatchRandom(new ArrayList<>(List.of(template)), values("Original")).join(); + assertEquals(List.of("x".repeat(size) + "Original"), fixture.sent); + var persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + fixture.dispatchRandom(new ArrayList<>(List.of(template)), persisted).join(); + assertEquals(1, fixture.sent.size(), "completed random message must not be redelivered"); + } + } + } + + @Test + void randomLiteralMessageKeepsItsChoiceAcrossFormattingFailureAndRestart() { + var choices = new ArrayList(); + for (int index = 0; index < 128; index++) choices.add("Choice" + index + ":%SiteName%"); + HashMap persisted; + var firstSelection = new java.util.concurrent.atomic.AtomicReference(); + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> { + firstSelection.set(call.getArgument(1).split(":", 2)[0]); + assertTrue(fixture.randomCheckpoint.get().keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_selection_")), + "the choice must be checkpointed before formatting starts"); + when(fixture.player.isOnline()).thenReturn(false); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1)); + }); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchRandom(choices, values("Original")).join()); + assertTrue(fixture.sent.isEmpty()); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + } + try (ReplayFixture restarted = new ReplayFixture()) { + restarted.dispatchRandom(choices, persisted).join(); + assertEquals(List.of(firstSelection.get() + ":Original"), restarted.sent); + } + } + + @Test + void completedRandomLiteralMessageDoesNotRerollOrRedeliverOnReplay() { + var choices = new ArrayList(); + for (int index = 0; index < 128; index++) choices.add("Choice" + index + ":%SiteName%"); + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.dispatchRandom(choices, values("Original")).join(); + var firstDelivery = List.copyOf(fixture.sent); + assertEquals(1, firstDelivery.size()); + var persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + fixture.dispatchRandom(choices, persisted).join(); + assertEquals(firstDelivery, fixture.sent); + fixture.dispatchRandom(choices, values("Independent")).join(); + assertEquals(2, fixture.sent.size()); + assertEquals(firstDelivery.get(0), fixture.sent.get(0)); + assertTrue(fixture.sent.get(1).endsWith(":Independent")); + } + } + + @Test + void randomLiteralReplayReusesReorderedChoiceButRejectsRemovedConfiguredText() { + var choices = new ArrayList<>(List.of("First:%SiteName%")); + HashMap persisted; + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> { + when(fixture.player.isOnline()).thenReturn(false); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1)); + }); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchRandom(choices, values("Original")).join()); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + } + try (ReplayFixture fixture = new ReplayFixture()) { + assertThrows(IllegalStateException.class, () -> fixture.dispatchRandom( + new ArrayList<>(List.of("Second:%SiteName%")), new HashMap<>(persisted))); + assertTrue(fixture.sent.isEmpty()); + fixture.dispatchRandom(new ArrayList<>(List.of("Second:%SiteName%", "First:%SiteName%")), persisted).join(); + assertEquals(List.of("First:Original"), fixture.sent); + } + } + + @Test + void olderRandomMessageReservationsRecoverTheirChoiceWithoutRerolling() { + for (boolean completed : List.of(false, true)) { + HashMap persisted; + try (ReplayFixture fixture = new ReplayFixture()) { + if (!completed) fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> { + when(fixture.player.isOnline()).thenReturn(false); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1)); + }); + var map = values("Original"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> fixture.randomCheckpoint.set( + new HashMap<>(checkpoint.getPlaceholders()))); + var collection = fixture.user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-random-message-injector"); + fixture.user.sendLiteralDisplayMessage("First:%SiteName%", map); + var delivery = fixture.user.endAsyncActionCollection(collection).toCompletableFuture(); + if (completed) delivery.join(); + else assertThrows(java.util.concurrent.CompletionException.class, delivery::join); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + assertFalse(persisted.keySet().stream().anyMatch(key -> key.startsWith("__advancedcore_replay_selection_"))); + } + try (ReplayFixture restarted = new ReplayFixture()) { + restarted.dispatchRandom(new ArrayList<>(List.of("Second:%SiteName%", "First:%SiteName%")), persisted).join(); + assertEquals(completed ? List.of() : List.of("First:Original"), restarted.sent); + } + } + } + + @Test + void unrelatedLegacySnapshotDoesNotBlockNewRandomMessageAdmission() { + HashMap persisted; + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> { + when(fixture.player.isOnline()).thenReturn(false); + return com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1)); + }); + var map = values("Original"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> fixture.randomCheckpoint.set( + new HashMap<>(checkpoint.getPlaceholders()))); + var collection = fixture.user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-unrelated-injector"); + fixture.user.sendLiteralDisplayMessage("Unrelated:%SiteName%", map); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join()); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + } + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.dispatchRandom(new ArrayList<>(List.of("New:%SiteName%")), persisted).join(); + assertEquals(List.of("New:Original"), fixture.sent); + } + } + + @Test + void completedUnrelatedActionInRandomScopeDoesNotBlockNewChoice() { + HashMap persisted; + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> fixture.randomCheckpoint.set( + new HashMap<>(checkpoint.getPlaceholders()))); + var collection = fixture.user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-random-message-injector"); + fixture.user.sendLiteralDisplayMessage("Unrelated:%SiteName%", map); + fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join(); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + assertFalse(persisted.isEmpty(), "restore the actual non-empty same-scope checkpoint"); + } + try (ReplayFixture restored = new ReplayFixture()) { + restored.dispatchRandom(new ArrayList<>(List.of("New:%SiteName%")), persisted).join(); + assertEquals(List.of("New:Original"), restored.sent); + } + } + + @Test + void storedRandomSelectionCannotBecomeAnUnconfiguredTrustedTemplate() { + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + Reward.replaySelection(map, "literal-random-message", () -> "[Javascript=unexpected()]"); + assertThrows(IllegalStateException.class, () -> fixture.dispatchRandom( + new ArrayList<>(List.of("First:%SiteName%")), map)); + assertTrue(fixture.sent.isEmpty()); + fixture.placeholders.verify(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString()), never()); + } + } + + @Test + void literalRandomChoiceBoundsFailBeforeFormatting() { + List> invalid = List.of( + new ArrayList<>(java.util.Collections.nCopies(4097, "")), + new ArrayList<>(List.of("x".repeat(65537))), + new ArrayList<>(java.util.Collections.nCopies(17, "x".repeat(65536)))); + try (ReplayFixture fixture = new ReplayFixture()) { + for (var choices : invalid) assertThrows(IllegalArgumentException.class, + () -> fixture.dispatchRandom(choices, values("Original"))); + assertTrue(fixture.sent.isEmpty()); + fixture.placeholders.verify(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString()), never()); + } + } + + @Test + void legacyRandomMessagesKeepTheirExistingSelectionAndDeliveryBehavior() { + var choices = spy(new ArrayList<>(List.of("First", "Second"))); + doReturn("First", "Second").when(choices).get(anyInt()); + var user = mock(AdvancedCoreUser.class); + var handler = mock(RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + RewardMessages.register(handler, mock(AdvancedCorePlugin.class)); + var inject = injections.stream().filter(i -> i.getPath().equals("RandomMessage")) + .map(com.bencodez.advancedcore.api.rewards.injected.RewardInjectStringList.class::cast) + .findFirst().orElseThrow(); + var map = new HashMap(); + inject.onRewardRequest(mock(Reward.class), user, choices, map); + inject.onRewardRequest(mock(Reward.class), user, choices, map); + verify(user).sendMessage("First", map); + verify(user).sendMessage("Second", map); + verify(user, never()).sendLiteralDisplayMessage(anyString(), any()); + assertTrue(map.isEmpty()); + } + + @Test + void recipientGeneratedBroadcastSeparatorsSplitAfterVisibleEvaluation() { + Player recipient = mock(Player.class); + var map = values("literal%NewLine%external"); + map.put("Rank", "raw-rank"); RewardDisplayPlaceholders.put(map, "Rank", "&aRank"); + for (String separator : List.of("%NewLine%", "%newline%")) { + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(recipient), anyString())).thenAnswer(call -> + ((String) call.getArgument(1)).replace("%recipient_multiline%", "One" + separator + "Two")); + var rendered = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, + "%Rank% %recipient_multiline%:%SiteName%", map).render(recipient); + assertEquals(2, rendered.length, separator); + assertEquals("Rank One", rendered[0].toPlainText()); + assertEquals("Two:literal%NewLine%external", rendered[1].toPlainText()); + } + } + } + + @Test + void recipientGeneratedBroadcastActionSeparatorsRemainExact() { + Player recipient = mock(Player.class); + var map = values("literal%NewLine%external"); + map.put("Rank", "raw-rank"); RewardDisplayPlaceholders.put(map, "Rank", "&aRank"); + for (String attribute : List.of("Command", "Url", "Suggest_Command")) { + try (var placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS)) { + placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(eq(recipient), anyString())).thenAnswer(call -> + ((String) call.getArgument(1)).replace("%recipient_multiline%", "One%NewLine%Two%newline%Three")); + var rendered = RewardDisplayPlaceholders.prepareLiteralBroadcast(null, + "%Rank% [Text=\"%SiteName%\"," + attribute + "=\"%recipient_multiline%\"]", map).render(recipient); + assertEquals(1, rendered.length, attribute); + assertEquals("Rank literal%NewLine%external", rendered[0].toPlainText()); + assertTrue(ComponentSerializer.toString(rendered).contains("One%NewLine%Two%newline%Three")); + } + } + } + + @Test + void actualRecipientGeneratedBroadcastLinesResumeAfterSerializedPartialDelivery() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + Player second = mock(Player.class); + UUID firstId = UUID.randomUUID(), secondId = UUID.randomUUID(); + when(fixture.player.getUniqueId()).thenReturn(firstId); + when(second.getUniqueId()).thenReturn(secondId); + when(second.isOnline()).thenReturn(true); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of(fixture.player, second)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(firstId)).thenReturn(fixture.player); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(secondId)).thenReturn(second); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> { + String value = call.getArgument(1); + return call.getArgument(0) == second ? value.replace("%recipient_multiline%", "One%NewLine%Two") : value; + }); + var successful = new ArrayList(); + var fail = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call -> { + Player recipient = call.getArgument(0); + String text = call.getArgument(1).toPlainText(); + if (recipient == second && text.equals("Two:Original") && fail.getAndSet(false)) + throw new IllegalStateException("synthetic transport rejection"); + successful.add((recipient == fixture.player ? "first:" : "second:") + text); + return null; + }).when(fixture.transport).sendMessage(any(Player.class), any(BaseComponent.class)); + String template = "%recipient_multiline%:%SiteName%"; + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchBroadcast(template, values("Original")).join()); + assertEquals(List.of("first:%recipient_multiline%:Original", "second:One"), successful); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(java.util.Collections.nCopies(8193, second)); + fixture.dispatchBroadcast(template, restored).join(); + assertEquals(List.of("first:%recipient_multiline%:Original", "second:One", "second:Two:Original"), successful, + "serialized retry must not resend a recipient-generated delivered prefix"); + verify(console, times(1)).sendMessage(anyString()); + bukkit.verify(org.bukkit.Bukkit::getOnlinePlayers, times(1)); + } + } + + @Test + void completedRecipientFragmentsSkipFailingRendererAfterOuterCheckpointFailure() { + assertCompletedRecipientFragmentsResume(true, false); + } + + @Test void partialRecipientPlanKeepsFrozenPayloadWhenProviderGrowsAfterRestart() { + assertPartialRecipientPlanKeepsFrozenPayload("Extra%NewLine%One%NewLine%Two:", false); + } + + @Test void partialRecipientPlanKeepsFrozenPayloadWhenProviderShrinksAfterRestart() { + assertPartialRecipientPlanKeepsFrozenPayload("One", false); + } + + @Test void partialRecipientPlanKeepsFrozenPayloadWhenProviderThrowsAfterRestart() { + assertPartialRecipientPlanKeepsFrozenPayload(null, true); + } + + private void assertPartialRecipientPlanKeepsFrozenPayload(String changedOutput, boolean throwing) { + String template = "%recipient_dynamic%[Text=\"%SiteName%\",Command=\"inspect %SiteName%\"]"; + UUID recipientId = UUID.randomUUID(); + HashMap persisted; + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + Player recipient = mock(Player.class); + when(recipient.getUniqueId()).thenReturn(recipientId); + when(recipient.isOnline()).thenReturn(true); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of(recipient)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(recipientId)).thenReturn(recipient); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> { + String value = call.getArgument(1); + return call.getArgument(0) == recipient + ? value.replace("%recipient_dynamic%", "One%NewLine%Two:") : value; + }); + var sent = new ArrayList(); + doAnswer(call -> { + var component = call.getArgument(1); + if (component.toPlainText().equals("Two:Original")) + throw new IllegalStateException("synthetic recipient transport rejection"); + sent.add(component.toPlainText()); + return null; + }).when(fixture.transport).sendMessage(eq(recipient), any(BaseComponent.class)); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchBroadcast(template, values("Original")).join()); + assertEquals(List.of("One"), sent, "only the recipient's first fragment committed"); + verifyNoInteractions(console); + persisted = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + } + try (ReplayFixture restarted = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + Player recipient = mock(Player.class); + when(recipient.getUniqueId()).thenReturn(recipientId); + when(recipient.isOnline()).thenReturn(true); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(recipientId)).thenReturn(recipient); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + var renderCount = new java.util.concurrent.atomic.AtomicInteger(); + restarted.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> { + String value = call.getArgument(1); + if (call.getArgument(0) != recipient) return value; + renderCount.incrementAndGet(); + if (throwing) throw new IllegalStateException("synthetic recipient provider failure after restart"); + return value.replace("%recipient_dynamic%", changedOutput); + }); + var components = new ArrayList(); + doAnswer(call -> { components.add(call.getArgument(1)); return null; }) + .when(restarted.transport).sendMessage(eq(recipient), any(BaseComponent.class)); + restarted.dispatchBroadcast(template, persisted).join(); + assertEquals(List.of("Two:Original"), components.stream().map(component -> component.toPlainText()).toList(), + "restart must finish the originally rendered recipient payload"); + assertSingleExactAction(components.toArray(BaseComponent[]::new), ClickEvent.Action.RUN_COMMAND, + "inspect raw-site"); + assertEquals(0, renderCount.get(), "frozen recipient fragments must bypass live providers"); + verify(console, times(1)).sendMessage(anyString()); + bukkit.verify(org.bukkit.Bukkit::getOnlinePlayers, never()); + } + } + + @Test + void completedRecipientFragmentsSkipShortenedRendererAfterOuterCheckpointFailure() { + assertCompletedRecipientFragmentsResume(false, false); + } + + @Test + void completedRecipientFragmentsRejectMalformedPersistedPlan() { + assertCompletedRecipientFragmentsResume(false, true); + } + + private void assertCompletedRecipientFragmentsResume(boolean rendererThrows, boolean corruptPlan) { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + Player recipient = mock(Player.class); + UUID recipientId = UUID.randomUUID(); + when(recipient.getUniqueId()).thenReturn(recipientId); + when(recipient.isOnline()).thenReturn(true); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of(recipient)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(recipientId)).thenReturn(recipient); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + var changed = new java.util.concurrent.atomic.AtomicBoolean(); + var recipientRenderCount = new java.util.concurrent.atomic.AtomicInteger(); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> { + String value = call.getArgument(1); + if (call.getArgument(0) != recipient) return value; + recipientRenderCount.incrementAndGet(); + if (changed.get() && rendererThrows) throw new IllegalStateException("synthetic provider failure"); + return value.replace("%recipient_multiline%", changed.get() ? "One" : "One%NewLine%Two"); + }); + var sent = new ArrayList(); + doAnswer(call -> { sent.add(call.getArgument(1).toPlainText()); return null; }) + .when(fixture.transport).sendMessage(eq(recipient), any(BaseComponent.class)); + fixture.broadcastCheckpointObserver = checkpoint -> { + var metadata = checkpoint.getPlaceholders(); + for (var entry : metadata.entrySet()) { + if (entry.getKey().startsWith("__advancedcore_replay_nested_list_") + && entry.getKey().endsWith("_snapshot") && entry.getValue().startsWith("v1:") + && decodeLiteralSnapshot(entry.getValue()).stream() + .anyMatch(step -> step.startsWith("R2|" + recipientId + "|0|")) + && "1".equals(metadata.get(entry.getKey().substring(0, entry.getKey().length() - 9)))) + throw new IllegalStateException("synthetic outer recipient checkpoint failure"); + } + }; + String template = "%recipient_multiline%:%SiteName%"; + var failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchBroadcast(template, values("Original")).join()); + assertTrue(failure.getMessage().contains("synthetic outer recipient checkpoint failure")); + assertEquals(List.of("One", "Two:Original"), sent); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + String encodedFirst = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( + "F|0".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var fragmentSnapshot = restored.entrySet().stream() + .filter(entry -> entry.getKey().endsWith("_snapshot") && entry.getValue().startsWith("v1:" + encodedFirst)) + .findFirst().orElseThrow(); + assertEquals("2", restored.get(fragmentSnapshot.getKey().substring(0, fragmentSnapshot.getKey().length() - 9)), + "the last successful checkpoint confirms every fragment but not its enclosing recipient"); + if (corruptPlan) { + String invalid = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( + "F|00".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + restored.put(fragmentSnapshot.getKey(), fragmentSnapshot.getValue().replace(encodedFirst, invalid)); + } + fixture.broadcastCheckpointObserver = checkpoint -> { }; + changed.set(true); + if (corruptPlan) { + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchBroadcast(template, restored).join()); + verifyNoInteractions(console); + } else { + fixture.dispatchBroadcast(template, restored).join(); + verify(console, times(1)).sendMessage(anyString()); + assertEquals(1, recipientRenderCount.get(), "completed fragments must not invoke the recipient provider again"); + } + assertEquals(List.of("One", "Two:Original"), sent, "completed fragment delivery is never repeated"); + bukkit.verify(org.bukkit.Bukkit::getOnlinePlayers, times(1)); + } + } + + @Test + void actualLiteralBroadcastResumesOriginalRecipientLinesAfterSerializedRetry() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + Player second = mock(Player.class); + UUID firstId = UUID.randomUUID(), secondId = UUID.randomUUID(); + when(fixture.player.getUniqueId()).thenReturn(firstId); + when(second.getUniqueId()).thenReturn(secondId); + when(second.isOnline()).thenReturn(true); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of(fixture.player, second)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(firstId)).thenReturn(fixture.player); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(secondId)).thenReturn(second); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.parseText(any(Player.class), anyString())) + .thenAnswer(call -> com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1))); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1)); + var successful = new ArrayList(); + var fail = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call -> { + Player recipient = call.getArgument(0); + String text = call.getArgument(1).toPlainText(); + if (recipient == second && text.equals("Two:Original") && fail.getAndSet(false)) + throw new IllegalStateException("synthetic transport rejection"); + successful.add((recipient == fixture.player ? "first:" : "second:") + text); + return null; + }).when(fixture.transport).sendMessage(any(Player.class), any(BaseComponent.class)); + var failure = assertThrows(java.util.concurrent.CompletionException.class, () -> fixture.dispatchBroadcast( + "One:%SiteName%%NewLine%Two:%SiteName%", values("Original")).join()); + Throwable cause = failure; + while (cause.getCause() != null) cause = cause.getCause(); + assertEquals("synthetic transport rejection", cause.getMessage()); + assertEquals(List.of("first:One:Original", "first:Two:Original", "second:One:Original"), successful); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + // Different current enumeration must not replace the original durable audience. + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers) + .thenReturn(java.util.Collections.nCopies(8193, second)); + fixture.dispatchBroadcast("One:%SiteName%%NewLine%Two:%SiteName%", restored).join(); + assertEquals(List.of("first:One:Original", "first:Two:Original", "second:One:Original", + "second:Two:Original"), successful); + var consoleMessages = org.mockito.ArgumentCaptor.forClass(String.class); + verify(console, times(1)).sendMessage(consoleMessages.capture()); + assertEquals(List.of("One:Original%NewLine%Two:Original"), consoleMessages.getAllValues().stream() + .map(net.md_5.bungee.api.ChatColor::stripColor).toList()); + bukkit.verify(org.bukkit.Bukkit::getOnlinePlayers, times(1)); + } + } + + @Test + void consoleBroadcastKeepsOneRawColoredPayloadWithoutInterpretingLiteralValues() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of()); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1)); + var map = values("&a%player_name%[Javascript=1]%NewLine%"); + RewardDisplayPlaceholders.put(map, "Trusted", "&bConfigured"); + String template = "&7%Trusted%:%SiteName%%newline%[Text=\"%SiteName%\",Command=\"run %SiteName%\"]%NewLine%End"; + fixture.dispatchBroadcast(template, map).join(); + verify(console).sendMessage("§7§bConfigured:&a%player_name%[Javascript=1]%NewLine%%newline%" + + "[Text=\"&a%player_name%[Javascript=1]%NewLine%\",Command=\"run raw-site\"]%NewLine%End"); + verifyNoMoreInteractions(console); + } + } + + @Test + void separatorOnlyBroadcastStillDeliversItsSingleConsolePayload() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of()); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1)); + fixture.dispatchBroadcast("%NewLine%%newline%", values("Original")).join(); + verify(console).sendMessage("%NewLine%%newline%"); + verifyNoMoreInteractions(console); + } + } + + @Test + void completedConsoleCheckpointIsNotRepeatedWhenParentCompletionFails() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of()); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1)); + String outerKey = Reward.legacyActionReplayKey("synthetic-broadcast-injector"); + fixture.broadcastCheckpointObserver = checkpoint -> { + String ledger = checkpoint.getPlaceholders().get(outerKey); + if (ledger != null && ledger.startsWith("v2:") && ledger.length() > 3) + throw new IllegalStateException("synthetic parent checkpoint rejection"); + }; + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchBroadcast("One:%SiteName%%NewLine%Two", values("Original")).join()); + verify(console).sendMessage("One:Original%NewLine%Two"); + var restored = ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + fixture.broadcastCheckpointObserver = checkpoint -> { }; + fixture.dispatchBroadcast("One:%SiteName%%NewLine%Two", restored).join(); + verifyNoMoreInteractions(console); + } + } + + @Test + void persistedSplitConsoleEntriesRemainSupportedWithoutPlanMigration() throws Exception { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1)); + var prepared = RewardDisplayPlaceholders.prepareLiteralBroadcast(fixture.player, + "One:%SiteName%%NewLine%Two:%SiteName%", values("Original")); + var method = AdvancedCoreUser.class.getDeclaredMethod("dispatchLiteralBroadcastEntry", + com.bencodez.advancedcore.api.misc.MiscUtils.class, + RewardDisplayPlaceholders.LiteralBroadcast.class, String.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + var completion = (java.util.concurrent.CompletionStage) method.invoke(fixture.user, + com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(), prepared, "C|1"); + completion.toCompletableFuture().join(); + verify(console).sendMessage(anyString()); + var message = org.mockito.ArgumentCaptor.forClass(String.class); + verify(console).sendMessage(message.capture()); + assertEquals("Two:Original", net.md_5.bungee.api.ChatColor.stripColor(message.getValue())); + verifyNoMoreInteractions(console); + } + } + + @Test + void uncollectedLiteralBroadcastAlsoKeepsLegacyConsolePayload() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of()); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1)); + var misc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(); + try { + var field = misc.getClass().getDeclaredField("plugin"); + field.setAccessible(true); + Object previous = field.get(misc); + field.set(misc, fixture.plugin); + try { fixture.user.sendLiteralDisplayBroadcast("&7%SiteName%%NewLine%[Text=\"Next\",Command=\"run\"]", values("Original")); } + finally { field.set(misc, previous); } + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + verify(console).sendMessage("§7Original%NewLine%[Text=\"Next\",Command=\"run\"]"); + verifyNoMoreInteractions(console); + } + } + + @Test + void identicalBroadcastsInSeparateInjectorCollectionsRemainIndependent() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + UUID id = UUID.randomUUID(); + when(fixture.player.getUniqueId()).thenReturn(id); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of(fixture.player)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(id)).thenReturn(fixture.player); + bukkit.when(org.bukkit.Bukkit::getConsoleSender) + .thenReturn(mock(org.bukkit.command.ConsoleCommandSender.class)); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1)); + var map = values("Original"); + fixture.dispatchBroadcast("Same:%SiteName%", map, "FirstInjector").join(); + fixture.dispatchBroadcast("Same:%SiteName%", map, "SecondInjector").join(); + assertEquals(List.of("Same:Original", "Same:Original"), fixture.sent); + } + } + + @Test + void frozenPlayerPayloadKeepsSerializedExternalTextOpaque() { + String external = "%player_name% &a [Javascript=unexpected()] {\"text\":\"extra\"} | 界"; + var persisted = capturePlayerPayload(false, external); + try (ReplayFixture restarted = new ReplayFixture()) { + restarted.placeholders.when(() -> PlaceholderUtils.parseText(eq(restarted.player), anyString())) + .thenThrow(new IllegalStateException("provider must not run during frozen recovery")); + restarted.placeholders.when(() -> PlaceholderUtils.replaceJavascript(anyString())) + .thenThrow(new IllegalStateException("script must not run during frozen recovery")); + restarted.dispatch("%dynamic%:%SiteName%", persisted).join(); + assertEquals(List.of("Two:" + external), restarted.sent); + } + } + + @Test + void pendingNumericPlayerPlanFailsClosedWithoutReleasingItsReservation() { + var persisted = capturePlayerPayload(false, "Original"); + var snapshot = playerPayloadSnapshot(persisted); + var numeric = decodeLiteralSnapshot(snapshot.getValue()).stream().map(entry -> { + String[] parts = entry.split("\\|", 4); + return "P|" + parts[1] + "|" + parts[2]; + }).toList(); + persisted.put(snapshot.getKey(), encodeLiteralSnapshot(numeric)); + String reservationKey = Reward.legacyActionReplayKey("synthetic-message-injector") + "_snapshot"; + String reservation = persisted.get(reservationKey); + try (ReplayFixture restarted = new ReplayFixture()) { + var providers = new java.util.concurrent.atomic.AtomicInteger(); + restarted.placeholders.when(() -> PlaceholderUtils.parseText(eq(restarted.player), anyString())) + .thenAnswer(call -> { providers.incrementAndGet(); throw new IllegalStateException("unexpected provider"); }); + for (int retry = 0; retry < 2; retry++) { + var failure = assertThrows(RuntimeException.class, + () -> restarted.dispatch("%dynamic%:%SiteName%", persisted).join()); + assertFalse(AdvancedCoreUser.isReplayActionNotStarted(failure)); + assertEquals(reservation, persisted.get(reservationKey)); + assertEquals(encodeLiteralSnapshot(numeric), persisted.get(snapshot.getKey())); + } + assertTrue(restarted.sent.isEmpty()); + assertEquals(0, providers.get()); + } + } + + @Test + void completedNumericPlayerPlanCanFinishOuterCheckpointWithoutRendering() { + var persisted = capturePlayerPayload(true, "Original"); + var snapshot = playerPayloadSnapshot(persisted); + persisted.put(snapshot.getKey(), encodeLiteralSnapshot(decodeLiteralSnapshot(snapshot.getValue()).stream() + .map(entry -> { String[] parts = entry.split("\\|", 4); return "P|" + parts[1] + "|" + parts[2]; }) + .toList())); + try (ReplayFixture restarted = new ReplayFixture()) { + when(restarted.player.isOnline()).thenReturn(false); + restarted.placeholders.when(() -> PlaceholderUtils.parseText(eq(restarted.player), anyString())) + .thenThrow(new IllegalStateException("completed legacy plan must not render")); + restarted.dispatch("%dynamic%:%SiteName%", persisted).join(); + assertTrue(restarted.sent.isEmpty()); + } + } + + @Test + void frozenPlayerPlanRejectsMalformedTerminalJsonBeforeAnyDelivery() { + var original = capturePlayerPayload(false, "Original"); + var snapshot = playerPayloadSnapshot(original); + var entries = decodeLiteralSnapshot(snapshot.getValue()); + String[] second = entries.get(1).split("\\|", 4); + var malformed = List.of("{\"text\":\"Two\",\"text\":\"changed\"}", + "{\"text\":\"Two\"} {\"text\":\"extra\"}", "{text:\"Two\"}", + "{\"text\":\"bad\\q\"}", + "{\"text\":\"" + "x".repeat(262145) + "\"}", + "{\"text\":\"\",\"extra\":[" + String.join(",", java.util.Collections.nCopies(4096, + "{\"text\":\"\"}")) + "]}", + "{\"text\":\"\",\"extra\":[".repeat(194) + "{\"text\":\"\"}" + "]}".repeat(194)); + for (String json : malformed) { + var persisted = ArrayUtils.fromString(ArrayUtils.makeString(original)); + persisted.put(snapshot.getKey(), encodeLiteralSnapshot(List.of(entries.get(0), + "P2|" + second[1] + "|1|" + json))); + persisted.put(snapshot.getKey().substring(0, snapshot.getKey().length() - 9), "0"); + try (ReplayFixture restarted = new ReplayFixture()) { + restarted.placeholders.when(() -> PlaceholderUtils.parseText(eq(restarted.player), anyString())) + .thenThrow(new IllegalStateException("malformed recovery must not invoke providers")); + assertThrows(RuntimeException.class, + () -> restarted.dispatch("%dynamic%:%SiteName%", persisted).join()); + assertTrue(restarted.sent.isEmpty(), "validate every frozen component before sending a prefix"); + } + } + } + + @Test + void completedFrozenPlayerPlanStillRejectsCorruptPayload() { + var persisted = capturePlayerPayload(true, "Original"); + var snapshot = playerPayloadSnapshot(persisted); + var entries = new ArrayList<>(decodeLiteralSnapshot(snapshot.getValue())); + String[] parts = entries.get(1).split("\\|", 4); + entries.set(1, "P2|" + parts[1] + "|1|{\"text\":\"Two\",\"text\":\"changed\"}"); + persisted.put(snapshot.getKey(), encodeLiteralSnapshot(entries)); + try (ReplayFixture restarted = new ReplayFixture()) { + assertThrows(RuntimeException.class, + () -> restarted.dispatch("%dynamic%:%SiteName%", persisted).join()); + assertTrue(restarted.sent.isEmpty()); + } + } + + @Test + void playerPayloadAdmissionPersistenceFailurePreventsFirstSideEffect() { + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> { + if (checkpoint.getPlaceholders().entrySet().stream().anyMatch(entry -> + entry.getKey().startsWith("__advancedcore_replay_nested_list_") + && entry.getKey().endsWith("_snapshot") && entry.getValue().startsWith("v1:") + && decodeLiteralSnapshot(entry.getValue()).stream().anyMatch(step -> step.startsWith("P2|")))) + throw new IllegalStateException("synthetic payload persistence failure"); + fixture.randomCheckpoint.set(new HashMap<>(checkpoint.getPlaceholders())); + }); + var collection = fixture.user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-message-injector"); + fixture.user.sendLiteralDisplayMessage("One:%SiteName%%NewLine%Two:%SiteName%", map); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join()); + assertTrue(fixture.sent.isEmpty()); + assertTrue(fixture.randomCheckpoint.get().containsKey( + Reward.legacyActionReplayKey("synthetic-message-injector") + "_snapshot")); + } + } + + private HashMap capturePlayerPayload(boolean completed, String literal) { + try (ReplayFixture fixture = new ReplayFixture()) { + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1) + .replace("%dynamic%", "One%NewLine%Two"))); + if (!completed) doAnswer(call -> { + String visible = call.getArgument(1).toPlainText(); + if (visible.startsWith("Two:")) throw new IllegalStateException("synthetic transport rejection"); + fixture.sent.add(visible); + return null; + }).when(fixture.transport).sendMessage(eq(fixture.player), any(BaseComponent.class)); + String outer = Reward.legacyActionReplayKey("synthetic-message-injector"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> { + String progress = checkpoint.getPlaceholders().get(outer); + if (completed && progress != null && progress.startsWith("v2:") && progress.length() > 3) + throw new IllegalStateException("synthetic outer checkpoint failure"); + fixture.randomCheckpoint.set(new HashMap<>(checkpoint.getPlaceholders())); + }); + var map = values(literal); + var collection = fixture.user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-message-injector"); + fixture.user.sendLiteralDisplayMessage("%dynamic%:%SiteName%", map); + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.user.endAsyncActionCollection(collection).toCompletableFuture().join()); + assertEquals(completed ? List.of("One", "Two:" + literal) : List.of("One"), fixture.sent); + return ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + } + } + + @Test + void partialBroadcastFreezesSourceOutputAndConsoleAcrossRestart() { + var persisted = captureBroadcastPayload(false); + try (ReplayFixture restarted = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + UUID id = restarted.player.getUniqueId(); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(id)).thenReturn(restarted.player); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + restarted.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenThrow(new IllegalStateException("source and recipient providers changed after restart")); + restarted.dispatchBroadcast("%source_dynamic%:%SiteName%", persisted).join(); + assertEquals(List.of("Two:Original"), restarted.sent); + var message = org.mockito.ArgumentCaptor.forClass(String.class); + verify(console, times(1)).sendMessage(message.capture()); + assertEquals("One%NewLine%Two:Original", net.md_5.bungee.api.ChatColor.stripColor(message.getValue())); + bukkit.verify(org.bukkit.Bukkit::getOnlinePlayers, never()); + } + } + + @Test + void pendingNumericRecipientFragmentsRetainTheirDurableReservation() { + var persisted = captureBroadcastPayload(false); + var snapshot = broadcastPayloadSnapshot(persisted); + var numeric = decodeLiteralSnapshot(snapshot.getValue()).stream().map(entry -> { + if (entry.startsWith("C2|")) return "C"; + String[] parts = entry.split("\\|", 4); + return "R|" + parts[1] + "|" + parts[2]; + }).toList(); + persisted.put(snapshot.getKey(), encodeLiteralSnapshot(numeric)); + String reservationKey = Reward.legacyActionReplayKey("synthetic-broadcast-injector") + "_snapshot"; + String reservation = persisted.get(reservationKey); + try (ReplayFixture restarted = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + var providers = new java.util.concurrent.atomic.AtomicInteger(); + restarted.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> { providers.incrementAndGet(); throw new IllegalStateException("unexpected provider"); }); + for (int retry = 0; retry < 2; retry++) { + var failure = assertThrows(RuntimeException.class, + () -> restarted.dispatchBroadcast("%source_dynamic%:%SiteName%", persisted).join()); + assertFalse(AdvancedCoreUser.isReplayActionNotStarted(failure)); + assertEquals(reservation, persisted.get(reservationKey)); + assertEquals(encodeLiteralSnapshot(numeric), persisted.get(snapshot.getKey())); + } + assertTrue(restarted.sent.isEmpty()); + assertEquals(0, providers.get()); + verifyNoInteractions(console); + } + } + + @Test + void completedNumericBroadcastPlanCanFinishItsCheckpointWithoutRendering() { + var persisted = captureBroadcastPayload(true); + var snapshot = broadcastPayloadSnapshot(persisted); + persisted.put(snapshot.getKey(), encodeLiteralSnapshot(decodeLiteralSnapshot(snapshot.getValue()).stream() + .map(entry -> { if (entry.startsWith("C2|")) return "C"; + String[] parts = entry.split("\\|", 4); return "R|" + parts[1] + "|" + parts[2]; }) + .toList())); + try (ReplayFixture restarted = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + restarted.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenThrow(new IllegalStateException("completed legacy broadcast must not render")); + restarted.dispatchBroadcast("%source_dynamic%:%SiteName%", persisted).join(); + assertTrue(restarted.sent.isEmpty()); + bukkit.verify(org.bukkit.Bukkit::getOnlinePlayers, never()); + bukkit.verify(org.bukkit.Bukkit::getConsoleSender, never()); + } + } + + @Test + void broadcastPayloadPersistenceFailurePreventsRecipientAndConsoleSideEffects() { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + UUID id = fixture.player.getUniqueId(); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of(fixture.player)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(id)).thenReturn(fixture.player); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1)); + fixture.broadcastCheckpointObserver = checkpoint -> { + if (checkpoint.getPlaceholders().entrySet().stream().anyMatch(entry -> + entry.getKey().startsWith("__advancedcore_replay_nested_list_") + && entry.getKey().endsWith("_snapshot") && entry.getValue().startsWith("v1:") + && decodeLiteralSnapshot(entry.getValue()).stream().anyMatch(step -> step.startsWith("R2|")))) + throw new IllegalStateException("synthetic outer payload persistence failure"); + }; + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchBroadcast("Site:%SiteName%", values("Original")).join()); + assertTrue(fixture.sent.isEmpty()); + verifyNoInteractions(console); + } + } + + @Test + void saturatedReplayMetadataRejectsRenderedPlayerPayloadBeforeSending() { + try (ReplayFixture fixture = new ReplayFixture()) { + var map = values("Original"); + String fillKey = "__advancedcore_replay_selection_unrelated_capacity"; + String filler = "s".repeat(1020000); + map.put(fillKey, filler); + fixture.placeholders.when(() -> PlaceholderUtils.parseText(eq(fixture.player), anyString())) + .thenAnswer(call -> call.getArgument(1).replace("%dynamic%", "x".repeat(50000))); + assertThrows(RuntimeException.class, () -> fixture.dispatch("%dynamic%:%SiteName%", map).join()); + assertTrue(fixture.sent.isEmpty()); + assertEquals(filler, map.get(fillKey), "capacity failure must retain unrelated durable evidence"); + } + } + + private HashMap captureBroadcastPayload(boolean completed) { + try (ReplayFixture fixture = new ReplayFixture(); var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + UUID id = fixture.player.getUniqueId(); + bukkit.when(org.bukkit.Bukkit::getOnlinePlayers).thenReturn(List.of(fixture.player)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(id)).thenReturn(fixture.player); + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + fixture.placeholders.when(() -> PlaceholderUtils.replacePlaceHolders(any(Player.class), anyString())) + .thenAnswer(call -> call.getArgument(1).replace("%source_dynamic%", "One%NewLine%Two")); + if (!completed) doAnswer(call -> { + String visible = call.getArgument(1).toPlainText(); + if (visible.equals("Two:Original")) throw new IllegalStateException("synthetic transport rejection"); + fixture.sent.add(visible); + return null; + }).when(fixture.transport).sendMessage(eq(fixture.player), any(BaseComponent.class)); + String outer = Reward.legacyActionReplayKey("synthetic-broadcast-injector"); + fixture.broadcastCheckpointObserver = checkpoint -> { + String progress = checkpoint.getPlaceholders().get(outer); + if (completed && progress != null && progress.startsWith("v2:") && progress.length() > 3) + throw new IllegalStateException("synthetic outer checkpoint failure"); + }; + assertThrows(java.util.concurrent.CompletionException.class, + () -> fixture.dispatchBroadcast("%source_dynamic%:%SiteName%", values("Original")).join()); + assertEquals(completed ? List.of("One", "Two:Original") : List.of("One"), fixture.sent); + return ArrayUtils.fromString(ArrayUtils.makeString(fixture.randomCheckpoint.get())); + } + } + + private static java.util.Map.Entry broadcastPayloadSnapshot(HashMap metadata) { + return metadata.entrySet().stream().filter(entry -> entry.getKey().startsWith("__advancedcore_replay_nested_list_") + && entry.getKey().endsWith("_snapshot") && entry.getValue().startsWith("v1:") + && decodeLiteralSnapshot(entry.getValue()).stream().anyMatch(step -> step.startsWith("R2|"))) + .findFirst().orElseThrow(); + } + + private static java.util.Map.Entry playerPayloadSnapshot(HashMap metadata) { + return metadata.entrySet().stream().filter(entry -> entry.getKey().startsWith("__advancedcore_replay_nested_list_") + && entry.getKey().endsWith("_snapshot") && entry.getValue().startsWith("v1:") + && decodeLiteralSnapshot(entry.getValue()).stream().anyMatch(step -> step.startsWith("P2|"))) + .findFirst().orElseThrow(); + } + + private static List decodeLiteralSnapshot(String snapshot) { + if (!snapshot.startsWith("v1:")) throw new IllegalArgumentException("Unexpected test snapshot"); + if (snapshot.length() == 3) return List.of(); + return java.util.Arrays.stream(snapshot.substring(3).split("\\.", -1)).map(encoded -> + new String(java.util.Base64.getUrlDecoder().decode(encoded), java.nio.charset.StandardCharsets.UTF_8)).toList(); + } + + private static String encodeLiteralSnapshot(List entries) { + return "v1:" + entries.stream().map(entry -> java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(entry.getBytes(java.nio.charset.StandardCharsets.UTF_8))) + .collect(java.util.stream.Collectors.joining(".")); + } + + private static final class ReplayFixture implements AutoCloseable { + private static final UUID REPLAY_PLAYER_ID = UUID.randomUUID(); + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final Player player = mock(Player.class); + final AdvancedCoreUser user; + final IServerHandle transport = mock(IServerHandle.class); + final ArrayList sent = new ArrayList<>(); + final java.util.concurrent.atomic.AtomicReference> randomCheckpoint = + new java.util.concurrent.atomic.AtomicReference<>(new HashMap<>()); + java.util.function.Consumer broadcastCheckpointObserver = checkpoint -> { }; + final org.mockito.MockedStatic utility = mockStatic(PlayerUtils.class); + final org.mockito.MockedStatic placeholders = mockStatic(PlaceholderUtils.class, CALLS_REAL_METHODS); + + ReplayFixture() { + var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class); + when(options.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }).when(timer).execute(any()); + when(player.isOnline()).thenReturn(true); + user = spy(new AdvancedCoreUser(plugin, REPLAY_PLAYER_ID, "SyntheticPlayer")); + doReturn(player).when(user).getPlayer(); + UUID playerId = UUID.fromString(user.getUUID()); + when(player.getUniqueId()).thenReturn(playerId); + utility.when(PlayerUtils::getServerHandle).thenReturn(transport); + doAnswer(call -> { sent.add(call.getArgument(1).toPlainText()); return null; }) + .when(transport).sendMessage(eq(player), any(BaseComponent.class)); + placeholders.when(() -> PlaceholderUtils.parseText(eq(player), anyString())).thenAnswer(call -> + com.bencodez.simpleapi.messages.MessageAPI.colorize(call.getArgument(1))); + } + + java.util.concurrent.CompletableFuture dispatch(String template, HashMap map) { + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> { }); + var state = Reward.replayStateFor(options); + var collection = user.beginAsyncActionCollection(state, map, "synthetic-message-injector"); + user.sendLiteralDisplayMessage(template, map); + return user.endAsyncActionCollection(collection).toCompletableFuture(); + } + + java.util.concurrent.CompletableFuture dispatchBroadcast(String template, HashMap map) { + return dispatchBroadcast(template, map, "synthetic-broadcast-injector"); + } + + java.util.concurrent.CompletableFuture dispatchBroadcast(String template, HashMap map, + String injectionKey) { + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> { + broadcastCheckpointObserver.accept(checkpoint); + randomCheckpoint.set(new HashMap<>(checkpoint.getPlaceholders())); + }); + var collection = user.beginAsyncActionCollection(Reward.replayStateFor(options), map, injectionKey); + user.sendLiteralDisplayBroadcast(template, map); + return user.endAsyncActionCollection(collection).toCompletableFuture(); + } + + java.util.concurrent.CompletableFuture dispatchRandom(ArrayList choices, + HashMap map) { + var handler = mock(RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + RewardMessages.register(handler, plugin); + var inject = injections.stream().filter(i -> i.getPath().equals("RandomMessage")) + .map(com.bencodez.advancedcore.api.rewards.injected.RewardInjectStringList.class::cast) + .findFirst().orElseThrow(); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setAsyncReplayCheckpointConsumer(checkpoint -> + randomCheckpoint.set(new HashMap<>(checkpoint.getPlaceholders()))); + var collection = user.beginAsyncActionCollection(Reward.replayStateFor(options), map, + "synthetic-random-message-injector"); + try { + inject.onRewardRequest(mock(Reward.class), user, choices, map); + } catch (RuntimeException failure) { + user.endAsyncActionCollection(collection); + throw failure; + } + return user.endAsyncActionCollection(collection).toCompletableFuture(); + } + + @Override public void close() { + placeholders.close(); + utility.close(); + } + } +} diff --git a/docs/literal-reward-display.md b/docs/literal-reward-display.md new file mode 100644 index 0000000000..432fa42da0 --- /dev/null +++ b/docs/literal-reward-display.md @@ -0,0 +1,165 @@ +# Literal reward display placeholders + +Existing reward templates, actions, queues and string APIs keep their behavior. +Integrations can opt a lower-trust display value into final component rendering: + +```java +options.addPlaceholder("SiteName", rawIdentifier); +options.addLiteralDisplayPlaceholder("SiteName", literalLabel, safeLegacyDisplay); +``` + +`RewardBuilder.withLiteralDisplayPlaceHolder` and +`RewardDisplayPlaceholders.putLiteral` expose the same three values. The raw +placeholder remains authoritative for commands, URLs and other actions. Supply +an already guarded `safeLegacyDisplay` for older embedded library readers; it +is retained in the existing v1 display metadata. An explicit administrator +label with colors should continue using `addDisplayPlaceholder`. + +The new literal binding travels in the ordinary serialized reward placeholder +map, so existing delayed/offline queues preserve it. No schema migration is +needed. Replacing a literal override with a trusted display override clears the +old literal metadata, including case variants. + +Player messages and reward broadcasts replace literal placeholders with opaque +printable tokens before trusted color, PlaceholderAPI, JavaScript and rich-text +processing. Provider-expanded text is protected again immediately before component parsing. +Visible text and hover text are restored only into final components. +Action binding keys are case-insensitive; conflicting case variants are rejected. +A missing raw binding inserts an empty action value, never the visible label. +Click actions receive exact raw values after parsing; external visible labels +cannot create or modify those actions. A configured JavaScript block containing +literal display data is displayed as text rather than executed with a data +fragment. When that protected block appears inside an authored click attribute, +its placeholders use the raw action bindings (or empty values when absent), +independently of the visible label. Unrelated configured scripts retain their existing handling. + +Spacing comes from the trusted template. No padding or invisible separator is +added by the literal renderer. A literal section sign uses `(section)` because +Minecraft's legacy text handling interprets section-sign color sequences. +Authored `%NewLine%` separators split before restoring external values. +Trusted ordinary visible templates expand once before binding admission so introduced rich components retain separate visible and exact action lanes. Command, URL and suggestion values are shielded before display separators are revealed; a display name's multiline value never supplies the raw action value. + +Trusted legacy display overrides retain their established line separators. Only +those separators are exposed before splitting; their text fragments remain +protected through provider/script evaluation. Raw command and URL values keep +their exact contents, including separator-like text. +Both existing `%SiteName%` and `{SiteName}` placeholder forms are supported, +including case variants and authored hover or click attributes. Existing display +overrides keep their interactive-syntax protection when mixed with literal ones. +Configured legacy colors in those display overrides remain available in visible +text and hover text. Trusted legacy text is converted to styled components before +literal restoration; exact command and URL attributes remain protected throughout. + +Broadcast source placeholders are captured on the source owner, with an +immutable protected template passed to each recipient owner. Console delivery +uses final text without another ampersand-formatting pass. Reload/disable +checks precede scheduled delivery. The new renderer does not introduce a reward +claim path, change vote accounting, or promise atomic/exactly-once execution of +arbitrary external actions. + +The reward action waits for console and recipient delivery, rather than merely +task submission. A disconnected recipient is skipped. Scheduler rejection, +rendering or transport failure, disable, and a 30-second dispatch deadline fail +the action and invalidate pending callbacks. An already delivered message cannot +be rolled back if another recipient fails; interrupted or ambiguous actions keep +the existing conservative replay reservation. Failures proven to occur before +formatting starts release that reservation. Player delivery rechecks the same +player instance and online state after formatting and before each component. +A disconnect or reconnect during formatting fails delivery instead of silently +checkpointing an unsent message. Because trusted providers or scripts may +already have run, this failure retains the existing replay identity; retrying +the same action can deliver its uncompleted message. Replay identity uses the configured +template rather than mutable checkpoint metadata or live placeholder values. + +Replay-aware literal broadcasts freeze the original audience as player UUIDs, each recipient's terminal rendered components, and the exact single console payload before the first delivery. Source placeholders run once on the source owner, and recipient providers run sequentially on their existing owners during this admission phase. This means later recipient providers now run before an earlier recipient's transport can fail. Retries use the original stored payloads and never rerun source/recipient providers, scripts, color handling or rich-template parsing. The existing reward journal checkpoints each successfully completed recipient line before advancing; retry and serialized recovery skip that completed prefix, even if the current audience changes. Each action occurrence has a separate lane, so two identical configured broadcasts remain distinct. The plan allows at most 8192 delivery steps and 4096 authored lines. Recipient lookup uses the global scheduler, rendering and delivery use the recipient owner, and console delivery uses the global owner. First-attempt delivery revalidates the captured player instance; serialized recovery resolves the same UUID on its current owner. Each outstanding delivery has a 30-second deadline. Default deadlines are cancelled when delivery succeeds, fails or is cancelled, so completed lines do not retain timer entries. + +New broadcast plans retain one unsplit console payload, including the configured +`%NewLine%`/`%newline%` markers and rich `[Text=...]` syntax as text. Trusted colors +still apply; literal bindings are restored after color handling and are never +reparsed. This preserves the established console shape while players retain +per-line progress. Fully completed older split-console plans can finish their enclosing checkpoint without rendering or redelivery. Incomplete older numeric plans have no original terminal payload to restore, so they remain held with their existing reservation and progress; recovery does not synthesize new output or erase the reservation. + +Delivery and journal persistence are not one atomic transaction. A crash or failed checkpoint after a transport handoff can still leave an ambiguous step; this does not provide universal exactly-once messaging. Older pending literal broadcast reservations without recipient progress remain pending rather than blindly repeating the whole broadcast. Completed older actions remain completed. Disconnected or retired original recipients are skipped, and newly connected players do not join a replayed audience. No vote accounting or additional reward path is introduced. + +Replay-aware literal player messages also checkpoint each successfully delivered +line in the existing reward journal. Serialized retry resumes after that prefix, +restores each remaining terminal component without live rendering, revalidates the original player identity on its owner scheduler, and fails when +the player disconnects before an outstanding line. Older pending player-message +reservations without frozen component payloads remain pending rather than repeating an ambiguous prefix, even when they contain numeric line progress. Completed older actions remain completed. The same 4096-line +bound applies; transport delivery and checkpoint publication are still not atomic. +Player messages retain the exact `%NewLine%` separator. Broadcasts also accept +the established `%newline%` spelling. Other casing remains available to normal +placeholder processing; separator-like text inside a literal value stays literal. + +With literal metadata, `RandomMessage` records its first configured choice in +existing replay-selection metadata before formatting. Retry and restart retain +that choice, including recovery of older literal action reservations. Reordering +the list does not reroll it. Removing the chosen template leaves the queued action +pending rather than interpreting arbitrary stored text or delivering a new choice. +Restore the original choice before retrying. This opt-in lane permits up to 4096 +choices, 65536 UTF-16 units per template and 1048576 combined template units. +The ordinary non-literal `RandomMessage` path keeps its existing behavior. + +Collected actions are bounded to 8192 registrations. Before reserving a new +action snapshot, admission checks the existing 1 MiB replay-metadata budget for +the snapshot, current replay state and eventual completion ledger together. +Rejected admission publishes no new checkpoint and executes no collected prefix; +existing durable progress remains intact. +A collection decodes its existing reservation snapshot once for registration and +random-choice recovery; final admission separately validates current persisted +state. Large retries do not repeatedly decode the same snapshot for each action. + +On Folia, broadcast recipients use the supported native +[entity scheduler retirement callback](https://jd.papermc.io/paper/1.20.6/io/papermc/paper/threadedregions/scheduler/EntityScheduler.html). +A recipient removed before dispatch settles as skipped; reward continuations run +outside the scheduler's critical retirement callback. Reflection keeps these +optional Paper API types out of Spigot class loading. + +Literal values are limited to 8192 UTF-16 units; serialized UTF-8/Base64 metadata +allows the corresponding worst-case byte expansion and rechecks the decoded limit. +Their guarded legacy display +representations may use up to 32768 characters. Public preparation admits at most 8192 entries in each binding map and +1,048,576 combined key/value UTF-16 units before building marker indexes. Durable replay metadata has its own allowance of 8192 entries and 1,048,576 key/value UTF-16 units. Admitting a display does not count internal action checkpoints or saved random selections against the display allowance, and does not remove them from the durable reward owner. Both allowances are checked before copying or building indexes; excess in either lane is rejected. +Literal keys retain their 128-character limit; raw action keys are bounded to +1024 characters. Unreferenced bindings do not consume the 4096 active-binding +limit. Template and action marker matching uses indexes rather than one +full template scan per binding. Collision detection indexes the existing printable +token grammar once per input corpus; allocations use exact membership checks. +Restoration scans the parsed output once and never reinterprets inserted values. +Line and recipient variants share immutable bindings and collision metadata, +while each recipient owns any newly formatted action bindings. Retained formatted +action text and protected script text each have a 262144-character budget; +collision metadata is capped at 131072 distinct tokens across repeated transformations. +Mixed legacy/literal messages use the same indexed marker and collision authority. +Configured legacy colors and ordered ordinary-placeholder expansion are retained. +Exceptional shared-boundary or arbitrary-key expansion has a 16 MiB scan-work +budget; exceeding it fails rendering instead of monopolizing an owner thread. +Metadata, templates and component trees retain their separate bounds. Invalid literal +metadata fails rendering rather than being parsed as ordinary executable text. +Callers of the public preparation/rendering methods must use the appropriate +player owner for player-aware placeholder providers. The existing reward +pipeline remains responsible for scheduling its injectors. + +Tests cover final component output, raw click actions, metadata serialization, +legacy behavior and mock scheduler ownership. This is not a claim of live +Minecraft client or Folia verification. + +Replay selections and action checkpoints remain internal durable metadata, outside the display-binding index. Their encoded size does not reduce the documented random-message template limit. Ordered ordinary substitution also retains markers formed by adjacent text after an empty or delimiter-free replacement. +Trusted formatted templates may expand to the existing 262144-character result budget, while authored input remains limited to 65536 UTF-16 units. This accommodates opaque token expansion without unbounded rendered text. +Ordered expansion charges pending buffers before descending as well as completed cached text, within the same combined character budget. Ordinary bindings retain the historical fresh-map iteration order independently of unused metadata. + +Player message rendering completes visible PlaceholderAPI and configured JavaScript evaluation before splitting canonical `%NewLine%` separators. Trusted display overrides keep multiline behavior; exact command/URL/suggestion attributes and explicit literal values remain protected through the split. Player messages continue treating the broadcast-only lowercase `%newline%` spelling as text. + +Queued console render suppliers check plugin availability before invoking integration-dependent rendering and again afterward. The same fence applies if broadcast recipient scheduling disables the plugin before console rendering begins. A retired callback fails its completion instead of executing the renderer. + +Recipient-generated broadcast separators are expanded on each recipient owner before final components are split. Versioned `R2` entries persist the terminal fragment array in the existing outer replay snapshot. The existing nested fragment lane stores only canonical `F|index` references into that immutable array: each successfully delivered fragment is checkpointed before the next is sent. Serialized retries keep the original audience, payloads and completed prefixes. Disconnected or retired broadcast recipients retain the established skip behavior. + +New player entries use versioned `P2` components, and console entries use versioned `C2` strings. Fully completed legacy numeric `P`, `R`, `F` and `C|line` plans retain checkpoint-only recovery; incomplete older outer plans cannot safely recreate their original payload and fail closed without rerendering, renumbering or releasing durable reservations. Each recipient fragment list remains limited to 4096 lines. Admission projects the outer payload and every future fragment snapshot/progress against the existing 8192-entry, 1 MiB metadata allowance before any transport side effect. Large broadcasts that cannot retain this recovery evidence are rejected before delivery. Final delivery remains non-atomic with transport and checkpoint persistence. + +A completed recipient fragment plan is validated and acknowledged using its frozen outer payload without consulting current recipient providers. If its enclosing checkpoint failed, a retry can finish that checkpoint without rerendering or resending the completed fragments. Malformed stored plans remain rejected. + +A rendered player/recipient batch shares a 262,144-character restored-binding budget, a 262,144-character final-tree budget and 4,096 final component nodes. Protected source syntax has a separate bounded allowance so markup bytes do not displace otherwise valid action text. Literal expansion is counted without restoration before component parsing; recognized line delimiters do not consume the binding budget. Exact actions, insertion and hover text also count in final-tree validation. Oversized batches fail without sending a partial direct batch. Separate durable recipient fragments remain individually bounded. + +Fully completed player and broadcast delivery plans are also validated before live rendering or player availability checks. An outer checkpoint retry can finish after a provider changes or the player disconnects; it does not resend completed output. This does not bypass plan validation or acknowledge incomplete deliveries. + +Frozen component JSON is internal reserved replay metadata, not an external literal binding or a template. Its decoder rejects duplicate fields, trailing tokens, noncanonical identities and oversized/deep trees before terminal component deserialization. Deserialization preserves click/hover values and literal text without passing them to placeholders, JavaScript or formatting again. Corrupt completed plans are still rejected. Persisting a payload does not authenticate arbitrary externally supplied JSON or create a public component-input API.