package com.xfestudio.xfeservermanager.core.trigger;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.ZoneId;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
/** Evaluates visual/script conditions against a bounded event context. */
public final class TriggerEvaluator {
public static final int DEFAULT_MESSAGE_FRACTION_DIGITS = 2;
public static final int MAX_MESSAGE_FRACTION_DIGITS = 10;
private static final int REGEX_CACHE_SIZE = 256;
private static final Pattern TEMPLATE_VARIABLE = Pattern.compile(
"\\{([A-Za-z0-9_.-]+)(?::([^{}\\r\\n]{1,64}))?}");
private static final Map<String, Pattern> REGEX_CACHE = java.util.Collections.synchronizedMap(
new java.util.LinkedHashMap<>(REGEX_CACHE_SIZE, 0.75f, true) {
private static final long serialVersionUID = 1L;
@Override
protected boolean removeEldestEntry(Map.Entry<String, Pattern> eldest) {
return size() > REGEX_CACHE_SIZE;
}
});
private TriggerEvaluator() { }
public static boolean matches(TriggerDefinition.Program program, Map<String, ?> context) {
if (program.conditions().isEmpty()) return true;
return program.conditionMode() == TriggerDefinition.MatchMode.ALL
? program.conditions().stream().allMatch(value -> matches(value, context))
: program.conditions().stream().anyMatch(value -> matches(value, context));
}
/** Evaluates one condition, including conditions embedded in an action tree. */
public static boolean matches(TriggerDefinition.Condition condition, Map<String, ?> context) {
Objects.requireNonNull(condition, "condition");
context = context == null ? Map.of() : context;
Object actual = resolve(context, condition.field());
String expected = condition.value();
String operator = condition.operator();
if (operator.equals("exists")) return actual != null;
if (operator.equals("not_exists")) return actual == null;
// Preserve legacy negative-operator behavior for persisted documents. New negations
// deliberately require a present value, so a missing event field cannot turn a typo into
// a match; authors can explicitly use not_exists when absence is intended.
if (actual == null) return operator.equals("neq") || operator.equals("not_contains")
|| operator.equals("not_in");
String text = conditionText(actual);
return switch (operator) {
case "eq" -> scalarEquals(actual, expected);
case "neq" -> !scalarEquals(actual, expected);
case "contains" -> text.contains(expected);
case "not_contains" -> !text.contains(expected);
case "starts_with" -> text.startsWith(expected);
case "not_starts_with" -> !text.startsWith(expected);
case "ends_with" -> text.endsWith(expected);
case "not_ends_with" -> !text.endsWith(expected);
case "matches" -> regex(expected, text);
case "not_matches" -> notRegex(expected, text);
case "gt", "gte", "lt", "lte" -> compareNumber(text, expected, operator);
case "between", "not_between" -> between(text, expected, operator.equals("not_between"));
case "in" -> csv(expected).contains(text);
case "not_in" -> !csv(expected).contains(text);
case "empty" -> Boolean.TRUE.equals(empty(actual));
case "not_empty" -> Boolean.FALSE.equals(empty(actual));
case "true" -> booleanValue(actual, true);
case "false" -> booleanValue(actual, false);
default -> false;
};
}
private static Object resolve(Map<String, ?> context, String path) {
if (context.containsKey(path)) return context.get(path);
Object current = context;
for (String part : path.split("\\.")) {
if (!(current instanceof Map<?, ?> values)) return null;
current = values.get(part);
}
return current;
}
private static boolean scalarEquals(Object actual, String expected) {
if (actual instanceof Boolean) return actual.toString().equalsIgnoreCase(expected);
if (actual instanceof Number) {
try {
double actualNumber = ((Number) actual).doubleValue();
double expectedNumber = Double.parseDouble(expected);
return Double.isFinite(actualNumber) && Double.isFinite(expectedNumber)
&& Double.compare(actualNumber, expectedNumber) == 0;
}
catch (NumberFormatException ignored) { return false; }
}
return conditionText(actual).equalsIgnoreCase(expected);
}
private static String conditionText(Object actual) {
if (actual instanceof Collection<?> || actual instanceof Map<?, ?>) {
return TriggerValueTypes.renderVariable(actual);
}
return Objects.toString(actual, "");
}
private static boolean compareNumber(String actual, String expected, String operator) {
try {
double actualNumber = Double.parseDouble(actual);
double expectedNumber = Double.parseDouble(expected);
if (!Double.isFinite(actualNumber) || !Double.isFinite(expectedNumber)) return false;
int compared = Double.compare(actualNumber, expectedNumber);
return switch (operator) {
case "gt" -> compared > 0;
case "gte" -> compared >= 0;
case "lt" -> compared < 0;
case "lte" -> compared <= 0;
default -> false;
};
} catch (NumberFormatException ignored) {
return false;
}
}
private static boolean between(String actual, String expected, boolean negated) {
try {
double actualNumber = Double.parseDouble(actual);
if (!Double.isFinite(actualNumber)) return false;
double[] bounds = numberRange(expected);
boolean result = actualNumber >= bounds[0] && actualNumber <= bounds[1];
return negated ? !result : result;
} catch (IllegalArgumentException exception) {
// A type error is not evidence that a value is outside the requested range. This is
// intentionally false for both between and not_between.
return false;
}
}
/** Validates the inclusive two-number range used by between and not_between. */
static void validateNumberRange(String expression) {
numberRange(expression);
}
private static double[] numberRange(String expression) {
String[] values = Objects.toString(expression, "").split(",", -1);
if (values.length != 2 || values[0].isBlank() || values[1].isBlank()) {
throw new IllegalArgumentException("numeric range must contain lower,upper bounds");
}
try {
double lower = Double.parseDouble(values[0].strip());
double upper = Double.parseDouble(values[1].strip());
if (!Double.isFinite(lower) || !Double.isFinite(upper) || lower > upper) {
throw new NumberFormatException();
}
return new double[] { lower, upper };
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"numeric range must contain two finite ascending bounds", exception);
}
}
/** Returns null for scalar types that have no meaningful empty state. */
private static Boolean empty(Object actual) {
if (actual instanceof CharSequence text) return text.isEmpty();
if (actual instanceof Collection<?> values) return values.isEmpty();
if (actual instanceof Map<?, ?> values) return values.isEmpty();
if (actual.getClass().isArray()) return java.lang.reflect.Array.getLength(actual) == 0;
return null;
}
private static boolean booleanValue(Object actual, boolean expected) {
if (actual instanceof Boolean value) return value == expected;
if (actual instanceof CharSequence value) {
String normalized = value.toString().strip();
if (!normalized.equalsIgnoreCase("true") && !normalized.equalsIgnoreCase("false")) return false;
return Boolean.parseBoolean(normalized) == expected;
}
return false;
}
private static Collection<String> csv(String value) {
return java.util.Arrays.stream(value.split(","))
.map(String::strip).filter(item -> !item.isEmpty()).toList();
}
private static boolean regex(String expression, String actual) {
if (actual.length() > 1_024) return false;
try {
validateRegex(expression);
Pattern pattern;
synchronized (REGEX_CACHE) {
pattern = REGEX_CACHE.computeIfAbsent(expression, value -> Pattern.compile(
value, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE));
}
return pattern.matcher(actual).find();
}
catch (IllegalArgumentException ignored) { return false; }
}
private static boolean notRegex(String expression, String actual) {
if (actual.length() > 1_024) return false;
try {
validateRegex(expression);
Pattern pattern;
synchronized (REGEX_CACHE) {
pattern = REGEX_CACHE.computeIfAbsent(expression, value -> Pattern.compile(
value, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE));
}
return !pattern.matcher(actual).find();
}
catch (IllegalArgumentException ignored) { return false; }
}
/**
* Validates the deliberately conservative regular-expression subset accepted by triggers.
* Java's backtracking engine has no deadline, so repeated groups, lookarounds, backreferences
* and excessive variable repetitions are rejected before a player-controlled value can reach it.
*/
public static void validateRegex(String expression) {
if (expression == null || expression.length() > 256) {
throw new IllegalArgumentException("regular expression cannot exceed 256 characters");
}
int variableRepetitions = 0;
int repetitionBudget = 1;
boolean escaped = false;
boolean characterClass = false;
boolean previousWasGroup = false;
for (int index = 0; index < expression.length(); index++) {
char value = expression.charAt(index);
if (escaped) {
if (!characterClass && Character.isDigit(value)) {
throw new IllegalArgumentException("regular-expression backreferences are not supported");
}
escaped = false;
previousWasGroup = false;
continue;
}
if (value == '\\') {
escaped = true;
continue;
}
if (value == '[' && !characterClass) {
characterClass = true;
previousWasGroup = false;
continue;
}
if (value == ']' && characterClass) {
characterClass = false;
continue;
}
if (characterClass) continue;
if (value == '(' && index + 1 < expression.length() && expression.charAt(index + 1) == '?') {
throw new IllegalArgumentException("regular-expression lookarounds and special groups are not supported");
}
if (value == ')') {
previousWasGroup = true;
continue;
}
if (value == '*' || value == '+' || value == '?') {
if (previousWasGroup) {
throw new IllegalArgumentException("repeated regular-expression groups are not supported");
}
variableRepetitions++;
repetitionBudget = multiplyBudget(repetitionBudget, value == '?' ? 2 : 1_025);
previousWasGroup = false;
continue;
}
if (value == '{') {
int close = expression.indexOf('}', index + 1);
if (close < 0) break; // Pattern.compile below reports the precise syntax error.
if (previousWasGroup) {
throw new IllegalArgumentException("repeated regular-expression groups are not supported");
}
String[] bounds = expression.substring(index + 1, close).split(",", -1);
if (bounds.length <= 2 && bounds[0].matches("[0-9]+")
&& (bounds.length == 1 || bounds[1].isEmpty() || bounds[1].matches("[0-9]+"))) {
int minimum = parseRepeatBound(bounds[0]);
int maximum = bounds.length == 1 ? minimum
: bounds[1].isEmpty() ? 1_024 : parseRepeatBound(bounds[1]);
if (maximum < minimum || maximum > 1_024) {
throw new IllegalArgumentException("regular-expression repeat bound must be between 0 and 1024");
}
if (maximum != minimum) {
variableRepetitions++;
repetitionBudget = multiplyBudget(repetitionBudget, maximum - minimum + 1);
}
index = close;
previousWasGroup = false;
continue;
}
}
previousWasGroup = false;
}
if (variableRepetitions > 8 || repetitionBudget > 1_100_000) {
throw new IllegalArgumentException("regular expression has excessive variable repetition");
}
try {
Pattern.compile(expression, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
} catch (PatternSyntaxException exception) {
throw new IllegalArgumentException("invalid regular expression: " + exception.getDescription(), exception);
}
}
private static int parseRepeatBound(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("regular-expression repeat bound is too large", exception);
}
}
private static int multiplyBudget(int current, int factor) {
if (factor == 0) return current;
if (current > 1_100_000 / factor) return 1_100_001;
return current * factor;
}
/** Expands both legacy message variables and the dotted trigger context vocabulary. */
public static String render(String template, Map<String, ?> context) {
return render(template, context, null);
}
/**
* Expands a player-facing message while rendering floating-point values at a stable precision.
* Operational parameters continue to use {@link #render(String, Map)} so presentation rounding
* can never alter a teleport destination, economy amount, or command argument.
*/
public static String renderMessage(String template, Map<String, ?> context, int fractionDigits) {
if (fractionDigits < 0 || fractionDigits > MAX_MESSAGE_FRACTION_DIGITS) {
throw new IllegalArgumentException("message fraction digits must be between 0 and "
+ MAX_MESSAGE_FRACTION_DIGITS);
}
return render(template, context, fractionDigits);
}
private static String render(String template, Map<String, ?> context, Integer fractionDigits) {
Map<String, ?> safeContext = context == null ? Map.of() : context;
Matcher matcher = TEMPLATE_VARIABLE.matcher(Objects.toString(template, ""));
StringBuffer rendered = new StringBuffer();
while (matcher.find()) {
String variable = matcher.group(1);
String format = matcher.group(2);
String path = switch (variable) {
case "player" -> "player.name";
case "online" -> "server.online";
case "maxPlayers" -> "server.maxPlayers";
default -> variable;
};
if (format != null && !path.equals("server.time") && !path.equals("event.time")) {
matcher.appendReplacement(rendered, Matcher.quoteReplacement(matcher.group()));
continue;
}
Object value = resolve(safeContext, path);
if (format != null && (path.equals("server.time") || path.equals("event.time"))) {
value = formattedTime(path, format, safeContext);
}
boolean legacyVariable = !path.equals(variable) || variable.equals("date");
boolean stateVariable = path.startsWith("var.") || path.startsWith("global.");
if ((value == null || value instanceof Map<?, ?> && !stateVariable) && !legacyVariable) {
matcher.appendReplacement(rendered, Matcher.quoteReplacement(matcher.group()));
} else {
String replacement = stateVariable
? fractionDigits == null ? TriggerValueTypes.renderVariable(value)
: TriggerValueTypes.renderVariable(value, fractionDigits)
: displayValue(value, fractionDigits);
matcher.appendReplacement(rendered, Matcher.quoteReplacement(replacement));
}
}
matcher.appendTail(rendered);
return rendered.toString();
}
private static String displayValue(Object value, Integer fractionDigits) {
if (fractionDigits == null || !(value instanceof Number number) || integral(number)) {
return Objects.toString(value, "");
}
double finiteCheck = number.doubleValue();
if (!Double.isFinite(finiteCheck)) return Objects.toString(value, "");
BigDecimal decimal = number instanceof BigDecimal exact
? exact : BigDecimal.valueOf(finiteCheck);
return decimal.setScale(fractionDigits, RoundingMode.HALF_UP).toPlainString();
}
private static boolean integral(Number number) {
return number instanceof Byte || number instanceof Short || number instanceof Integer
|| number instanceof Long || number instanceof BigInteger
|| number instanceof java.util.concurrent.atomic.AtomicInteger
|| number instanceof java.util.concurrent.atomic.AtomicLong;
}
/**
* Returns whether a value contains a placeholder that this renderer can actually expand.
* Formatted placeholders are supported only for server/event time; braces used by command
* JSON, SNBT, or an unsupported formatted variable remain ordinary literal text.
*/
public static boolean hasTemplateVariable(String template) {
Matcher matcher = TEMPLATE_VARIABLE.matcher(Objects.toString(template, ""));
while (matcher.find()) {
if (matcher.group(2) == null
|| matcher.group(1).equals("server.time")
|| matcher.group(1).equals("event.time")) {
return true;
}
}
return false;
}
/** Validates formatted variables without rejecting extensible ordinary context keys. */
public static void validateTemplateVariables(String template) {
String source = Objects.toString(template, "");
for (String prefix : java.util.List.of("{server.time:", "{event.time:")) {
int offset = 0;
while (true) {
int start = source.indexOf(prefix, offset);
if (start < 0) break;
int close = source.indexOf('}', start + prefix.length());
if (close < 0) {
throw new IllegalArgumentException("formatted time variable is missing its closing brace");
}
TriggerTimeContext.validatePattern(source.substring(start + prefix.length(), close));
offset = close + 1;
}
}
}
private static Object formattedTime(String path, String format, Map<String, ?> context) {
try {
Object source = path.equals("server.time")
? context.get("server.time.epochMilli") : context.get("event.time");
Instant instant;
if (source instanceof Number number) {
instant = Instant.ofEpochMilli(number.longValue());
} else {
instant = Instant.parse(Objects.toString(source, ""));
}
ZoneId zone = ZoneId.of(Objects.toString(context.get("server.timezone"),
ZoneId.systemDefault().getId()));
return TriggerTimeContext.format(instant, zone, format);
} catch (DateTimeException | IllegalArgumentException exception) {
return null;
}
}
}
package com.xfestudio.xfeservermanager.core.trigger;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.ZoneId;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
/** Evaluates visual/script conditions against a bounded event context. */
public final class TriggerEvaluator {
public static final int DEFAULT_MESSAGE_FRACTION_DIGITS = 2;
public static final int MAX_MESSAGE_FRACTION_DIGITS = 10;
private static final int REGEX_CACHE_SIZE = 256;
private static final Pattern TEMPLATE_VARIABLE = Pattern.compile(
"\\{([A-Za-z0-9_.-]+)(?::([^{}\\r\\n]{1,64}))?}");
private static final Map<String, Pattern> REGEX_CACHE = java.util.Collections.synchronizedMap(
new java.util.LinkedHashMap<>(REGEX_CACHE_SIZE, 0.75f, true) {
private static final long serialVersionUID = 1L;
@Override
protected boolean removeEldestEntry(Map.Entry<String, Pattern> eldest) {
return size() > REGEX_CACHE_SIZE;
}
});
private TriggerEvaluator() { }
public static boolean matches(TriggerDefinition.Program program, Map<String, ?> context) {
if (program.conditions().isEmpty()) return true;
return program.conditionMode() == TriggerDefinition.MatchMode.ALL
? program.conditions().stream().allMatch(value -> matches(value, context))
: program.conditions().stream().anyMatch(value -> matches(value, context));
}
/** Evaluates one condition, including conditions embedded in an action tree. */
public static boolean matches(TriggerDefinition.Condition condition, Map<String, ?> context) {
Objects.requireNonNull(condition, "condition");
context = context == null ? Map.of() : context;
Object actual = resolve(context, condition.field());
String expected = condition.value();
String operator = condition.operator();
if (operator.equals("exists")) return actual != null;
if (operator.equals("not_exists")) return actual == null;
// Preserve legacy negative-operator behavior for persisted documents. New negations
// deliberately require a present value, so a missing event field cannot turn a typo into
// a match; authors can explicitly use not_exists when absence is intended.
if (actual == null) return operator.equals("neq") || operator.equals("not_contains")
|| operator.equals("not_in");
String text = conditionText(actual);
return switch (operator) {
case "eq" -> scalarEquals(actual, expected);
case "neq" -> !scalarEquals(actual, expected);
case "contains" -> text.contains(expected);
case "not_contains" -> !text.contains(expected);
case "starts_with" -> text.startsWith(expected);
case "not_starts_with" -> !text.startsWith(expected);
case "ends_with" -> text.endsWith(expected);
case "not_ends_with" -> !text.endsWith(expected);
case "matches" -> regex(expected, text);
case "not_matches" -> notRegex(expected, text);
case "gt", "gte", "lt", "lte" -> compareNumber(text, expected, operator);
case "between", "not_between" -> between(text, expected, operator.equals("not_between"));
case "in" -> csv(expected).contains(text);
case "not_in" -> !csv(expected).contains(text);
case "empty" -> Boolean.TRUE.equals(empty(actual));
case "not_empty" -> Boolean.FALSE.equals(empty(actual));
case "true" -> booleanValue(actual, true);
case "false" -> booleanValue(actual, false);
default -> false;
};
}
private static Object resolve(Map<String, ?> context, String path) {
if (context.containsKey(path)) return context.get(path);
Object current = context;
for (String part : path.split("\\.")) {
if (!(current instanceof Map<?, ?> values)) return null;
current = values.get(part);
}
return current;
}
private static boolean scalarEquals(Object actual, String expected) {
if (actual instanceof Boolean) return actual.toString().equalsIgnoreCase(expected);
if (actual instanceof Number) {
try {
double actualNumber = ((Number) actual).doubleValue();
double expectedNumber = Double.parseDouble(expected);
return Double.isFinite(actualNumber) && Double.isFinite(expectedNumber)
&& Double.compare(actualNumber, expectedNumber) == 0;
}
catch (NumberFormatException ignored) { return false; }
}
return conditionText(actual).equalsIgnoreCase(expected);
}
private static String conditionText(Object actual) {
if (actual instanceof Collection<?> || actual instanceof Map<?, ?>) {
return TriggerValueTypes.renderVariable(actual);
}
return Objects.toString(actual, "");
}
private static boolean compareNumber(String actual, String expected, String operator) {
try {
double actualNumber = Double.parseDouble(actual);
double expectedNumber = Double.parseDouble(expected);
if (!Double.isFinite(actualNumber) || !Double.isFinite(expectedNumber)) return false;
int compared = Double.compare(actualNumber, expectedNumber);
return switch (operator) {
case "gt" -> compared > 0;
case "gte" -> compared >= 0;
case "lt" -> compared < 0;
case "lte" -> compared <= 0;
default -> false;
};
} catch (NumberFormatException ignored) {
return false;
}
}
private static boolean between(String actual, String expected, boolean negated) {
try {
double actualNumber = Double.parseDouble(actual);
if (!Double.isFinite(actualNumber)) return false;
double[] bounds = numberRange(expected);
boolean result = actualNumber >= bounds[0] && actualNumber <= bounds[1];
return negated ? !result : result;
} catch (IllegalArgumentException exception) {
// A type error is not evidence that a value is outside the requested range. This is
// intentionally false for both between and not_between.
return false;
}
}
/** Validates the inclusive two-number range used by between and not_between. */
static void validateNumberRange(String expression) {
numberRange(expression);
}
private static double[] numberRange(String expression) {
String[] values = Objects.toString(expression, "").split(",", -1);
if (values.length != 2 || values[0].isBlank() || values[1].isBlank()) {
throw new IllegalArgumentException("numeric range must contain lower,upper bounds");
}
try {
double lower = Double.parseDouble(values[0].strip());
double upper = Double.parseDouble(values[1].strip());
if (!Double.isFinite(lower) || !Double.isFinite(upper) || lower > upper) {
throw new NumberFormatException();
}
return new double[] { lower, upper };
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"numeric range must contain two finite ascending bounds", exception);
}
}
/** Returns null for scalar types that have no meaningful empty state. */
private static Boolean empty(Object actual) {
if (actual instanceof CharSequence text) return text.isEmpty();
if (actual instanceof Collection<?> values) return values.isEmpty();
if (actual instanceof Map<?, ?> values) return values.isEmpty();
if (actual.getClass().isArray()) return java.lang.reflect.Array.getLength(actual) == 0;
return null;
}
private static boolean booleanValue(Object actual, boolean expected) {
if (actual instanceof Boolean value) return value == expected;
if (actual instanceof CharSequence value) {
String normalized = value.toString().strip();
if (!normalized.equalsIgnoreCase("true") && !normalized.equalsIgnoreCase("false")) return false;
return Boolean.parseBoolean(normalized) == expected;
}
return false;
}
private static Collection<String> csv(String value) {
return java.util.Arrays.stream(value.split(","))
.map(String::strip).filter(item -> !item.isEmpty()).toList();
}
private static boolean regex(String expression, String actual) {
if (actual.length() > 1_024) return false;
try {
validateRegex(expression);
Pattern pattern;
synchronized (REGEX_CACHE) {
pattern = REGEX_CACHE.computeIfAbsent(expression, value -> Pattern.compile(
value, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE));
}
return pattern.matcher(actual).find();
}
catch (IllegalArgumentException ignored) { return false; }
}
private static boolean notRegex(String expression, String actual) {
if (actual.length() > 1_024) return false;
try {
validateRegex(expression);
Pattern pattern;
synchronized (REGEX_CACHE) {
pattern = REGEX_CACHE.computeIfAbsent(expression, value -> Pattern.compile(
value, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE));
}
return !pattern.matcher(actual).find();
}
catch (IllegalArgumentException ignored) { return false; }
}
/**
* Validates the deliberately conservative regular-expression subset accepted by triggers.
* Java's backtracking engine has no deadline, so repeated groups, lookarounds, backreferences
* and excessive variable repetitions are rejected before a player-controlled value can reach it.
*/
public static void validateRegex(String expression) {
if (expression == null || expression.length() > 256) {
throw new IllegalArgumentException("regular expression cannot exceed 256 characters");
}
int variableRepetitions = 0;
int repetitionBudget = 1;
boolean escaped = false;
boolean characterClass = false;
boolean previousWasGroup = false;
for (int index = 0; index < expression.length(); index++) {
char value = expression.charAt(index);
if (escaped) {
if (!characterClass && Character.isDigit(value)) {
throw new IllegalArgumentException("regular-expression backreferences are not supported");
}
escaped = false;
previousWasGroup = false;
continue;
}
if (value == '\\') {
escaped = true;
continue;
}
if (value == '[' && !characterClass) {
characterClass = true;
previousWasGroup = false;
continue;
}
if (value == ']' && characterClass) {
characterClass = false;
continue;
}
if (characterClass) continue;
if (value == '(' && index + 1 < expression.length() && expression.charAt(index + 1) == '?') {
throw new IllegalArgumentException("regular-expression lookarounds and special groups are not supported");
}
if (value == ')') {
previousWasGroup = true;
continue;
}
if (value == '*' || value == '+' || value == '?') {
if (previousWasGroup) {
throw new IllegalArgumentException("repeated regular-expression groups are not supported");
}
variableRepetitions++;
repetitionBudget = multiplyBudget(repetitionBudget, value == '?' ? 2 : 1_025);
previousWasGroup = false;
continue;
}
if (value == '{') {
int close = expression.indexOf('}', index + 1);
if (close < 0) break; // Pattern.compile below reports the precise syntax error.
if (previousWasGroup) {
throw new IllegalArgumentException("repeated regular-expression groups are not supported");
}
String[] bounds = expression.substring(index + 1, close).split(",", -1);
if (bounds.length <= 2 && bounds[0].matches("[0-9]+")
&& (bounds.length == 1 || bounds[1].isEmpty() || bounds[1].matches("[0-9]+"))) {
int minimum = parseRepeatBound(bounds[0]);
int maximum = bounds.length == 1 ? minimum
: bounds[1].isEmpty() ? 1_024 : parseRepeatBound(bounds[1]);
if (maximum < minimum || maximum > 1_024) {
throw new IllegalArgumentException("regular-expression repeat bound must be between 0 and 1024");
}
if (maximum != minimum) {
variableRepetitions++;
repetitionBudget = multiplyBudget(repetitionBudget, maximum - minimum + 1);
}
index = close;
previousWasGroup = false;
continue;
}
}
previousWasGroup = false;
}
if (variableRepetitions > 8 || repetitionBudget > 1_100_000) {
throw new IllegalArgumentException("regular expression has excessive variable repetition");
}
try {
Pattern.compile(expression, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
} catch (PatternSyntaxException exception) {
throw new IllegalArgumentException("invalid regular expression: " + exception.getDescription(), exception);
}
}
private static int parseRepeatBound(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("regular-expression repeat bound is too large", exception);
}
}
private static int multiplyBudget(int current, int factor) {
if (factor == 0) return current;
if (current > 1_100_000 / factor) return 1_100_001;
return current * factor;
}
/** Expands both legacy message variables and the dotted trigger context vocabulary. */
public static String render(String template, Map<String, ?> context) {
return render(template, context, null);
}
/**
* Expands a player-facing message while rendering floating-point values at a stable precision.
* Operational parameters continue to use {@link #render(String, Map)} so presentation rounding
* can never alter a teleport destination, economy amount, or command argument.
*/
public static String renderMessage(String template, Map<String, ?> context, int fractionDigits) {
if (fractionDigits < 0 || fractionDigits > MAX_MESSAGE_FRACTION_DIGITS) {
throw new IllegalArgumentException("message fraction digits must be between 0 and "
+ MAX_MESSAGE_FRACTION_DIGITS);
}
return render(template, context, fractionDigits);
}
private static String render(String template, Map<String, ?> context, Integer fractionDigits) {
Map<String, ?> safeContext = context == null ? Map.of() : context;
Matcher matcher = TEMPLATE_VARIABLE.matcher(Objects.toString(template, ""));
StringBuffer rendered = new StringBuffer();
while (matcher.find()) {
String variable = matcher.group(1);
String format = matcher.group(2);
String path = switch (variable) {
case "player" -> "player.name";
case "online" -> "server.online";
case "maxPlayers" -> "server.maxPlayers";
default -> variable;
};
if (format != null && !path.equals("server.time") && !path.equals("event.time")) {
matcher.appendReplacement(rendered, Matcher.quoteReplacement(matcher.group()));
continue;
}
Object value = resolve(safeContext, path);
if (format != null && (path.equals("server.time") || path.equals("event.time"))) {
value = formattedTime(path, format, safeContext);
}
boolean legacyVariable = !path.equals(variable) || variable.equals("date");
boolean stateVariable = path.startsWith("var.") || path.startsWith("global.");
if ((value == null || value instanceof Map<?, ?> && !stateVariable) && !legacyVariable) {
matcher.appendReplacement(rendered, Matcher.quoteReplacement(matcher.group()));
} else {
String replacement = stateVariable
? fractionDigits == null ? TriggerValueTypes.renderVariable(value)
: TriggerValueTypes.renderVariable(value, fractionDigits)
: displayValue(value, fractionDigits);
matcher.appendReplacement(rendered, Matcher.quoteReplacement(replacement));
}
}
matcher.appendTail(rendered);
return rendered.toString();
}
private static String displayValue(Object value, Integer fractionDigits) {
if (fractionDigits == null || !(value instanceof Number number) || integral(number)) {
return Objects.toString(value, "");
}
double finiteCheck = number.doubleValue();
if (!Double.isFinite(finiteCheck)) return Objects.toString(value, "");
BigDecimal decimal = number instanceof BigDecimal exact
? exact : BigDecimal.valueOf(finiteCheck);
return decimal.setScale(fractionDigits, RoundingMode.HALF_UP).toPlainString();
}
private static boolean integral(Number number) {
return number instanceof Byte || number instanceof Short || number instanceof Integer
|| number instanceof Long || number instanceof BigInteger
|| number instanceof java.util.concurrent.atomic.AtomicInteger
|| number instanceof java.util.concurrent.atomic.AtomicLong;
}
/**
* Returns whether a value contains a placeholder that this renderer can actually expand.
* Formatted placeholders are supported only for server/event time; braces used by command
* JSON, SNBT, or an unsupported formatted variable remain ordinary literal text.
*/
public static boolean hasTemplateVariable(String template) {
Matcher matcher = TEMPLATE_VARIABLE.matcher(Objects.toString(template, ""));
while (matcher.find()) {
if (matcher.group(2) == null
|| matcher.group(1).equals("server.time")
|| matcher.group(1).equals("event.time")) {
return true;
}
}
return false;
}
/** Validates formatted variables without rejecting extensible ordinary context keys. */
public static void validateTemplateVariables(String template) {
String source = Objects.toString(template, "");
for (String prefix : java.util.List.of("{server.time:", "{event.time:")) {
int offset = 0;
while (true) {
int start = source.indexOf(prefix, offset);
if (start < 0) break;
int close = source.indexOf('}', start + prefix.length());
if (close < 0) {
throw new IllegalArgumentException("formatted time variable is missing its closing brace");
}
TriggerTimeContext.validatePattern(source.substring(start + prefix.length(), close));
offset = close + 1;
}
}
}
private static Object formattedTime(String path, String format, Map<String, ?> context) {
try {
Object source = path.equals("server.time")
? context.get("server.time.epochMilli") : context.get("event.time");
Instant instant;
if (source instanceof Number number) {
instant = Instant.ofEpochMilli(number.longValue());
} else {
instant = Instant.parse(Objects.toString(source, ""));
}
ZoneId zone = ZoneId.of(Objects.toString(context.get("server.timezone"),
ZoneId.systemDefault().getId()));
return TriggerTimeContext.format(instant, zone, format);
} catch (DateTimeException | IllegalArgumentException exception) {
return null;
}
}
}