XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
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;
        }
    }
}