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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;

class TriggerEngineTest {
    @Test
    void compilesScriptAndEvaluatesAllConditions() {
        var program = TriggerScriptCompiler.compile("""
                # XFE Script v1
                on player.join
                match all
                when player.firstJoin eq true
                when server.online gte 2
                do send_player message="Welcome, {player.name}!"
                do server_command command="say {player.name} joined"
                """);

        assertEquals("player.join", program.event().type());
        assertEquals(2, program.actions().size());
        assertTrue(TriggerEvaluator.matches(program, Map.of(
                "player.firstJoin", true, "server.online", 2)));
        assertFalse(TriggerEvaluator.matches(program, Map.of(
                "player.firstJoin", true, "server.online", "not-a-number")));
        assertFalse(TriggerEvaluator.matches(program, Map.of(
                "player.firstJoin", true, "server.online", Double.NaN)));
        assertFalse(TriggerEvaluator.matches(
                new TriggerDefinition.Program(
                        new TriggerDefinition.TriggerEvent("player.join", Map.of()),
                        TriggerDefinition.MatchMode.ALL,
                        List.of(new TriggerDefinition.Condition("server.online", "eq", "NaN")),
                        List.of(new TriggerDefinition.Action("log", Map.of("message", "ignored")))),
                Map.of("server.online", Double.NaN)));
        assertEquals("Hi Alex (2/20)", TriggerEvaluator.render(
                "Hi {player.name} ({online}/{maxPlayers})", Map.of(
                        "player.name", "Alex", "server.online", 2, "server.maxPlayers", 20)));
    }

    @Test
    void rendersTemplatesOnceWithoutReinterpretingInsertedPlayerInput() {
        Map<String, Object> context = new java.util.LinkedHashMap<>();
        context.put("chat.message", "literal {player.name} and $1 \\ value");
        context.put("player.name", "Alex");
        context.put("player", Map.of("dimension", "minecraft:overworld"));

        assertEquals("Alex said literal {player.name} and $1 \\ value in minecraft:overworld",
                TriggerEvaluator.render(
                        "{player.name} said {chat.message} in {player.dimension}", context));
        assertEquals("unknown {custom.missing}",
                TriggerEvaluator.render("unknown {custom.missing}", context));
    }

    @Test
    void formatsOnlyPlayerFacingFloatingPointValuesAtTheRequestedPrecision() {
        Map<String, Object> context = Map.of(
                "player.x", 592.5000000001D,
                "player.level", 30,
                "var.metrics", List.of(1.234D, 2L),
                "economy.balance", new BigDecimal("25.5"));

        assertEquals("592.50 / 30 / [1.23,2] / 25.50", TriggerEvaluator.renderMessage(
                "{player.x} / {player.level} / {var.metrics} / {economy.balance}", context, 2));
        assertEquals("592.5000", TriggerEvaluator.renderMessage("{player.x}", context, 4));
        assertEquals("592.5000000001", TriggerEvaluator.render("{player.x}", context));
        assertThrows(IllegalArgumentException.class,
                () -> TriggerEvaluator.renderMessage("{player.x}", context, 11));
    }

    @Test
    void rendersAndComparesNestedStateVariablesAsCompactJson() {
        Map<String, Object> context = Map.of(
                "var.names", List.of("甲", "line\nbreak"),
                "global.rewards", Map.of("coins", List.of(1L, 2L)));

        assertEquals("[\"甲\",\"line\\nbreak\"] / {\"coins\":[1,2]}",
                TriggerEvaluator.render("{var.names} / {global.rewards}", context));
        assertTrue(conditionMatches("global.rewards", "contains", "\"coins\":[1,2]", context));
        assertTrue(conditionMatches("var.names", "eq", "[\"甲\",\"line\\nbreak\"]", context));
    }

    @Test
    void validatesSchedulesAndRejectsUnknownActions() {
        assertEquals("08:30", TriggerScriptCompiler.compile("""
                on schedule.daily
                set time="08:30"
                set timezone="Asia/Shanghai"
                do broadcast message="Good morning"
                """).event().configuration().get("time"));

        IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
                () -> TriggerScriptCompiler.compile("""
                on player.join
                do arbitrary_jvm_code value="no"
                """));
        assertTrue(failure.getMessage().contains("unsupported trigger action"));
    }

    @Test
    void decodesScriptEscapesInOnePassWithoutCorruptingLiteralBackslashes() {
        String script = "on player.join\n"
                + "do send_player message=\"C:\\\\new\\\\folder and literal \\\\n\"\n";

        String message = TriggerScriptCompiler.compile(script).actions().get(0).parameters().get("message");

        assertEquals("C:\\new\\folder and literal \\n", message);
    }

    @Test
    void acceptsNamespacedCustomEventsAndRejectsAnUnboundPlaceholder() {
        assertEquals("custom.example.boss_spawned", TriggerScriptCompiler.compile("""
                on custom.example.boss_spawned
                do log message="Boss spawned"
                """).event().type());
        assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
                on custom
                do log message="never"
                """));
    }

    @Test
    void rejectsPlayerOnlyActionsForSchedulesWithoutPlayerContext() {
        IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
                () -> TriggerScriptCompiler.compile("""
                on schedule.daily
                set time="08:00"
                set timezone="Asia/Shanghai"
                do send_player message="Hello"
                """));
        assertTrue(failure.getMessage().contains("player-context event"));
    }

    @Test
    void rejectsUnsafeOrInvalidRegularExpressionsBeforeExecution() {
        IllegalArgumentException nested = assertThrows(IllegalArgumentException.class,
                () -> TriggerScriptCompiler.compile("""
                on player.chat
                when chat.message matches "(a+)+$"
                do log message="matched"
                """));
        assertTrue(nested.getMessage().contains("repeated regular-expression groups"));

        IllegalArgumentException invalid = assertThrows(IllegalArgumentException.class,
                () -> TriggerScriptCompiler.compile("""
                on player.chat
                when chat.message matches "[unterminated"
                do log message="matched"
                """));
        assertTrue(invalid.getMessage().contains("regular expression"));
    }

    @Test
    void evaluatesSymmetricStringOperatorsAndRequiresPresentFieldsForNewNegations() {
        Map<String, Object> context = Map.of("text", "Alpha-Beta");

        assertTrue(conditionMatches("text", "starts_with", "Alpha", context));
        assertTrue(conditionMatches("text", "not_starts_with", "Beta", context));
        assertTrue(conditionMatches("text", "ends_with", "Beta", context));
        assertTrue(conditionMatches("text", "not_ends_with", "Alpha", context));
        assertTrue(conditionMatches("text", "matches", "^alpha-", context));
        assertTrue(conditionMatches("text", "not_matches", "gamma", context));
        assertFalse(conditionMatches("missing", "not_starts_with", "Alpha", context));
        assertFalse(conditionMatches("missing", "not_ends_with", "Beta", context));
        assertFalse(conditionMatches("missing", "not_matches", ".*", context));

        // These three absence semantics predate the symmetric operators and remain compatible
        // with already-persisted conditions.
        assertTrue(conditionMatches("missing", "neq", "anything", context));
        assertTrue(conditionMatches("missing", "not_contains", "anything", context));
        assertTrue(conditionMatches("missing", "not_in", "a,b", context));
    }

    @Test
    void evaluatesInclusiveRangesWithoutTreatingTypeErrorsAsNotBetween() {
        assertTrue(conditionMatches("score", "between", "10, 20", Map.of("score", 10)));
        assertTrue(conditionMatches("score", "between", "10, 20", Map.of("score", 20.0)));
        assertFalse(conditionMatches("score", "between", "10, 20", Map.of("score", 21)));
        assertTrue(conditionMatches("score", "not_between", "10, 20", Map.of("score", -1)));
        assertFalse(conditionMatches("score", "not_between", "10, 20", Map.of("score", "unknown")));
        assertFalse(conditionMatches("missing", "not_between", "10, 20", Map.of()));

        assertThrows(IllegalArgumentException.class,
                () -> validateCondition("between", "20,10"));
        assertThrows(IllegalArgumentException.class,
                () -> validateCondition("not_between", "0,NaN"));
        assertThrows(IllegalArgumentException.class,
                () -> validateCondition("between", "1,2,3"));
    }

    @Test
    void evaluatesBooleanEmptyMembershipAndExistenceConditionsByType() {
        Map<String, Object> context = new java.util.LinkedHashMap<>();
        context.put("emptyText", "");
        context.put("blankText", "   ");
        context.put("emptyList", List.of());
        context.put("emptyMap", Map.of());
        context.put("emptyArray", new int[0]);
        context.put("values", List.of("one"));
        context.put("enabled", true);
        context.put("disabledText", " FALSE ");
        context.put("number", 1);

        assertTrue(conditionMatches("emptyText", "empty", "ignored", context));
        assertFalse(conditionMatches("blankText", "empty", "ignored", context));
        assertTrue(conditionMatches("emptyList", "empty", "ignored", context));
        assertTrue(conditionMatches("emptyMap", "empty", "ignored", context));
        assertTrue(conditionMatches("emptyArray", "empty", "ignored", context));
        assertTrue(conditionMatches("values", "not_empty", "ignored", context));
        assertFalse(conditionMatches("missing", "empty", "ignored", context));
        assertFalse(conditionMatches("missing", "not_empty", "ignored", context));
        assertFalse(conditionMatches("number", "empty", "ignored", context));
        assertFalse(conditionMatches("number", "not_empty", "ignored", context));

        assertTrue(conditionMatches("enabled", "true", "ignored", context));
        assertTrue(conditionMatches("disabledText", "false", "ignored", context));
        assertFalse(conditionMatches("number", "true", "ignored", context));
        assertTrue(conditionMatches("enabled", "in", "false, true", context));
        assertTrue(conditionMatches("number", "not_in", "2,3", context));
        assertTrue(conditionMatches("enabled", "exists", "ignored", context));
        assertTrue(conditionMatches("missing", "not_exists", "ignored", context));
    }

    @Test
    void compilesNoValueOperatorsAndIgnoresLegacyValues() {
        TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
                on custom.example.condition
                match all
                when payload empty
                when enabled true ignored-by-unary-operator
                when disabled false
                when absent not_exists legacy-value
                do log message="matched"
                """);

        assertEquals("", program.conditions().get(0).value());
        assertEquals("ignored-by-unary-operator", program.conditions().get(1).value());
        assertEquals("legacy-value", program.conditions().get(3).value());
        assertTrue(TriggerEvaluator.matches(program, Map.of(
                "payload", List.of(), "enabled", true, "disabled", "false")));
    }

    @Test
    void validatesEveryAdvertisedOperator() {
        for (String operator : TriggerCatalog.OPERATORS) {
            String value = switch (operator) {
                case "matches", "not_matches" -> "^value$";
                case "gt", "gte", "lt", "lte" -> "1";
                case "between", "not_between" -> "1,2";
                case "exists", "not_exists", "empty", "not_empty", "true", "false" -> "";
                default -> "value";
            };
            TriggerCatalog.validate(programWithCondition(operator, value));
        }
    }

    @Test
    void enforcesActionParametersAndLeaveEventAvailability() {
        assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
                on player.join
                do server_command command="say first\nsay second"
                """));
        assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
                on player.join
                do set_gamemode gamemode=operator
                """));
        assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
                on player.leave
                do title title="Too late"
                """));
        assertThrows(IllegalArgumentException.class, () -> TriggerScriptCompiler.compile("""
                on player.join
                do server_command command="say hello" showFeedback=yes
                """));
        assertDoesNotThrow(() -> TriggerScriptCompiler.compile("""
                on player.join
                do server_command command="say hello" showFeedback=false
                """));
    }

    @Test
    void defersTypedActionValidationForSupportedTemplateVariablesOnly() {
        TriggerDefinition.Program templated = new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("player.join", Map.of()),
                TriggerDefinition.MatchMode.ALL,
                List.of(),
                List.of(
                        new TriggerDefinition.Action("sound", Map.of(
                                "sound", "{args.sound}", "volume", "{args.volume}",
                                "pitch", "{args.pitch}")),
                        new TriggerDefinition.Action("give_item", Map.of(
                                "item", "minecraft:{args.item}", "count", "{args.count}")),
                        new TriggerDefinition.Action("clear_inventory", Map.of(
                                "item", "{args.item}", "maxCount", "{args.maximum}")),
                        new TriggerDefinition.Action("set_gamemode", Map.of(
                                "gamemode", "{args.mode}")),
                        new TriggerDefinition.Action("add_effect", Map.of(
                                "effect", "{args.effect}", "duration", "{args.duration}",
                                "amplifier", "{args.amplifier}")),
                        new TriggerDefinition.Action("set_time", Map.of(
                                "time", "{args.time}")),
                        new TriggerDefinition.Action("set_weather", Map.of(
                                "weather", "{args.weather}", "duration", "{args.duration}")),
                        new TriggerDefinition.Action("title", Map.of(
                                "title", "Hello", "fadeIn", "{args.fadeIn}",
                                "stay", "{args.stay}", "fadeOut", "{args.fadeOut}",
                                "numberPrecision", "{args.precision}")),
                        new TriggerDefinition.Action("log", Map.of(
                                "message", "changed", "level", "{args.level}"))));

        assertDoesNotThrow(() -> TriggerCatalog.validate(templated));
        assertTrue(TriggerEvaluator.hasTemplateVariable("minecraft:{args.item}"));
        assertTrue(TriggerEvaluator.hasTemplateVariable("{server.time:uuuu-MM-dd HH:mm:ss}"));
        assertFalse(TriggerEvaluator.hasTemplateVariable("stone{display:{Name:'value'}}"));
        assertFalse(TriggerEvaluator.hasTemplateVariable("{player.name:unsupported-format}"));
        assertFalse(TriggerEvaluator.hasTemplateVariable("{args.count"));

        assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
                "give_item", Map.of("item", "NOT A RESOURCE", "count", "1"))));
        assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
                "give_item", Map.of("item", "minecraft:stone", "count", "zero"))));
        assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
                "give_item", Map.of("item", "minecraft:stone", "count", "{args.count"))));
        assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
                "title", Map.of("title", "X={player.x}", "numberPrecision", "11"))));
        assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(programWithAction(
                "set_gamemode", Map.of("gamemode", "operator"))));
    }

    @Test
    void validatesEveryAdvertisedBuiltInEvent() {
        for (String event : TriggerCatalog.EVENTS) {
            if (event.equals("custom")) continue;
            Map<String, String> configuration = switch (event) {
                case "schedule.daily" -> Map.of("time", "08:00", "timezone", "Asia/Shanghai");
                case "schedule.interval" -> Map.of("seconds", "60");
                case TriggerCommandConfiguration.EVENT_TYPE -> Map.of("command", "example");
                case "region.enter", "region.leave", "region.stay" ->
                        Map.of("regionId", "example.region");
                case "protection.item_overflow", "protection.mob_overflow",
                        "protection.entity_overflow" -> Map.of(
                        "threshold", "100", "scope", "dimension", "cooldownSeconds", "60");
                case "protection.mod_entity_overflow" -> Map.of(
                        "threshold", "100", "namespace", "create", "cooldownSeconds", "60");
                case "protection.slow_tick" -> Map.of(
                        "threshold", "200", "consecutive", "3", "cooldownSeconds", "60");
                case "protection.memory_pressure" -> Map.of(
                        "threshold", "90", "cooldownSeconds", "60");
                case "protection.spawn_burst", "protection.command_block_rate",
                        "protection.loaded_chunk_overflow" -> Map.of(
                        "threshold", "100", "cooldownSeconds", "60");
                default -> Map.of();
            };
            TriggerCatalog.validate(new TriggerDefinition.Program(
                    new TriggerDefinition.TriggerEvent(event, configuration),
                    TriggerDefinition.MatchMode.ALL,
                    List.of(),
                    List.of(new TriggerDefinition.Action("log", Map.of("message", event)))));
        }
    }

    @Test
    void validatesCrashProtectionThresholdScopeNamespaceAndCooldown() {
        assertDoesNotThrow(() -> TriggerCatalog.validate(new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("protection.item_overflow", Map.of(
                        "threshold", "1500", "scope", "chunk", "cooldownSeconds", "30")),
                TriggerDefinition.MatchMode.ALL, List.of(),
                List.of(new TriggerDefinition.Action("broadcast", Map.of(
                        "message", "Items: {protection.count}"))))));
        assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("protection.item_overflow", Map.of(
                        "threshold", "0", "scope", "region")),
                TriggerDefinition.MatchMode.ALL, List.of(),
                List.of(new TriggerDefinition.Action("log", Map.of("message", "bad"))))));
        assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("protection.mod_entity_overflow", Map.of(
                        "threshold", "100", "namespace", "Create Mod")),
                TriggerDefinition.MatchMode.ALL, List.of(),
                List.of(new TriggerDefinition.Action("log", Map.of("message", "bad"))))));
    }

    @Test
    void validatesVariableDrivenEconomyActionsForOfflinePlayerIdentityEvents() {
        TriggerDefinition.Program program = new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("player.leave", Map.of()),
                TriggerDefinition.MatchMode.ALL,
                List.of(new TriggerDefinition.Condition("economy.primary.balance", "gte", "0")),
                List.of(new TriggerDefinition.Action("economy_deposit", Map.of(
                        "currency", "{economy.primary.code}",
                        "amount", "{economy.primary.balance}",
                        "reason", "离线结算 {player.name}"))));

        assertDoesNotThrow(() -> TriggerCatalog.validate(program));
    }

    @Test
    void validatesEventPlayerAndArbitraryPlayerEconomyActions() {
        TriggerDefinition.Program eventPlayer = new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("player.join", Map.of()),
                TriggerDefinition.MatchMode.ALL,
                List.of(new TriggerDefinition.Condition("player.dailyDue", "eq", "true")),
                List.of(new TriggerDefinition.Action("economy_deposit", Map.of(
                        "currency", "coins", "amount", "500",
                        "reason", "Trigger economy operation"))));
        TriggerDefinition.Program arbitraryPlayer = new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("schedule.interval", Map.of("seconds", "900")),
                TriggerDefinition.MatchMode.ALL,
                List.of(),
                List.of(new TriggerDefinition.Action("economy_deposit_player", Map.of(
                        "player", "Alex", "currency", "coins", "amount", "500",
                        "reason", "Scheduled reward"))));

        assertDoesNotThrow(() -> TriggerCatalog.validate(eventPlayer));
        assertDoesNotThrow(() -> TriggerCatalog.validate(arbitraryPlayer));
        assertThrows(IllegalArgumentException.class, () -> TriggerCatalog.validate(
                programWithAction("economy_deposit_player", Map.of(
                        "player", "", "currency", "coins", "amount", "500",
                        "reason", "Missing player"))));
    }

    @Test
    void reportsMalformedDocumentValuesAsValidationFailures() {
        assertThrows(IllegalArgumentException.class,
                () -> new TriggerDefinition.TriggerEvent(null, Map.of()));
        assertThrows(IllegalArgumentException.class,
                () -> new TriggerDefinition.Action("log", java.util.Collections.singletonMap("message", null)));
        assertThrows(IllegalArgumentException.class,
                () -> new TriggerDefinition.Program(
                        new TriggerDefinition.TriggerEvent("player.join", Map.of()),
                        TriggerDefinition.MatchMode.ALL, java.util.Collections.singletonList(null), List.of()));
    }

    @Test
    void canonicalizesCodeDocumentsOnceFromTheirScript() {
        Instant now = Instant.parse("2026-09-04T00:00:00Z");
        TriggerDefinition definition = new TriggerDefinition(
                UUID.randomUUID(), UUID.randomUUID(), "Scripted", "", true,
                TriggerDefinition.Mode.CODE,
                new TriggerDefinition.TriggerEvent("custom.stale", Map.of()),
                TriggerDefinition.MatchMode.ANY, List.of(),
                List.of(new TriggerDefinition.Action("log", Map.of("message", "stale"))),
                "on player.chat\nmatch all\ndo broadcast message=hello",
                0, "owner", now, now, false);

        assertEquals("player.chat", definition.event().type());
        assertEquals(TriggerDefinition.MatchMode.ALL, definition.conditionMode());
        assertEquals("broadcast", definition.actions().get(0).type());
        assertEquals(definition.program(), definition.executableProgram());
    }

    @Test
    void compilesAndSelectsNestedConditionalActionsInDocumentOrder() {
        TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
                on player.join
                if player.firstJoin true
                  do send_player message="Welcome"
                  if server.online gte 10
                    do server_command command="say Busy server"
                  end
                end
                do log message="joined"
                """);

        TriggerDefinition.Action outer = program.actions().get(0);
        assertEquals(TriggerActionTree.CONDITION_TYPE, outer.type());
        assertEquals(Map.of(
                "field", "player.firstJoin", "operator", "true", "value", ""),
                outer.parameters());
        assertEquals(2, outer.children().size());
        assertEquals(TriggerActionTree.CONDITION_TYPE, outer.children().get(1).type());
        assertEquals(List.of("send_player", "server_command", "log"),
                TriggerActionTree.leafActions(program.actions()).stream()
                        .map(TriggerDefinition.Action::type).toList());

        assertEquals(List.of("send_player", "log"),
                TriggerActionTree.matchingActions(program.actions(), Map.of(
                                "player.firstJoin", true, "server.online", 4)).stream()
                        .map(TriggerDefinition.Action::type).toList());
        assertEquals(List.of("send_player", "server_command", "log"),
                TriggerActionTree.matchingActions(program.actions(), Map.of(
                                "player.firstJoin", true, "server.online", 10)).stream()
                        .map(TriggerDefinition.Action::type).toList());
        assertEquals(List.of("log"),
                TriggerActionTree.matchingActions(program.actions(), Map.of(
                                "player.firstJoin", false, "server.online", 10)).stream()
                        .map(TriggerDefinition.Action::type).toList());
    }

    @Test
    void reportsExtraUnclosedAndEmptyConditionalBlocksAtTheirSourceLines() {
        IllegalArgumentException extraEnd = assertThrows(IllegalArgumentException.class,
                () -> TriggerScriptCompiler.compile("""
                        on custom.example.block
                        end
                        do log message="never"
                        """));
        assertTrue(extraEnd.getMessage().contains("script line 2: end has no matching if"));

        IllegalArgumentException unclosed = assertThrows(IllegalArgumentException.class,
                () -> TriggerScriptCompiler.compile("""
                        on custom.example.block
                        if value exists
                          do log message="inside"
                        """));
        assertTrue(unclosed.getMessage().contains(
                "script line 2: if block is not closed; expected end"));

        IllegalArgumentException empty = assertThrows(IllegalArgumentException.class,
                () -> TriggerScriptCompiler.compile("""
                        on custom.example.block
                        if value exists
                        end
                        """));
        assertTrue(empty.getMessage().contains(
                "script line 3: if block from line 2 needs at least one action"));
    }

    @Test
    void compilesEveryNoValueOperatorInsideConditionalActionBlocks() {
        for (String operator : List.of(
                "exists", "not_exists", "empty", "not_empty", "true", "false")) {
            TriggerDefinition.Program program = TriggerScriptCompiler.compile("""
                    on custom.example.unary
                    if payload %s
                      do log message="matched"
                    end
                    """.formatted(operator));

            assertEquals(operator, program.actions().get(0).parameters().get("operator"));
            assertEquals("", program.actions().get(0).parameters().get("value"));
        }
    }

    @Test
    void acceptsActionTreesBeyondTheFormerNodeAndNestingLimits() {
        assertEquals(1, TriggerScriptCompiler.compile(nestedScript(12)).actions().size());

        StringBuilder manyNodes = new StringBuilder(
                "on custom.example.limit\nif enabled true\n");
        for (int index = 0; index < 40; index++) {
            manyNodes.append("do log message=action-").append(index).append('\n');
        }
        manyNodes.append("end\n");
        TriggerDefinition.Program accepted = TriggerScriptCompiler.compile(manyNodes.toString());
        assertEquals(41, 1 + TriggerActionTree.leafActions(accepted.actions()).size());

        List<TriggerDefinition.Action> nested = List.of(new TriggerDefinition.Action(
                "log", Map.of("message", "deep")));
        for (int index = 0; index < 12; index++) {
            nested = List.of(conditionAction(nested));
        }
        assertEquals("log", TriggerActionTree.leafActions(nested).get(0).type());
        List<TriggerDefinition.Action> manyModelNodes = java.util.stream.IntStream.range(0, 40)
                .mapToObj(index -> new TriggerDefinition.Action(
                        "log", Map.of("message", "action-" + index))).toList();
        TriggerDefinition.Program model = new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("custom.example.limit", Map.of()),
                TriggerDefinition.MatchMode.ALL,
                java.util.stream.IntStream.range(0, 40)
                        .mapToObj(index -> new TriggerDefinition.Condition("value" + index, "exists", "")).toList(),
                manyModelNodes);
        assertEquals(40, model.conditions().size());
        assertEquals(40, TriggerActionTree.leafActions(model.actions()).size());
    }

    @Test
    void rejectsChildrenOnOrdinaryActionsAndInvalidConditionShapes() {
        TriggerDefinition.Action leaf = new TriggerDefinition.Action(
                "log", Map.of("message", "inside"));
        assertThrows(IllegalArgumentException.class,
                () -> new TriggerDefinition.Action(
                        "log", Map.of("message", "outside"), List.of(leaf)));
        assertThrows(IllegalArgumentException.class,
                () -> new TriggerDefinition.Action(
                        TriggerActionTree.CONDITION_TYPE,
                        Map.of("field", "enabled", "operator", "true", "unexpected", "value"),
                        List.of(leaf)));

        TriggerDefinition.Action emptyCondition = new TriggerDefinition.Action(
                TriggerActionTree.CONDITION_TYPE,
                Map.of("field", "enabled", "operator", "true"), List.of());
        IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
                () -> new TriggerDefinition.Program(
                        new TriggerDefinition.TriggerEvent("custom.example.empty", Map.of()),
                        TriggerDefinition.MatchMode.ALL, List.of(), List.of(emptyCondition)));
        assertTrue(failure.getMessage().contains("needs at least one child action"));

        IllegalArgumentException noActions = assertThrows(IllegalArgumentException.class,
                () -> TriggerScriptCompiler.compile("on custom.example.empty\n"));
        assertTrue(noActions.getMessage().contains("needs at least one executable action"));
    }

    private static boolean conditionMatches(
            String field, String operator, String value, Map<String, ?> context) {
        return TriggerEvaluator.matches(programWithCondition(field, operator, value), context);
    }

    private static void validateCondition(String operator, String value) {
        TriggerCatalog.validate(programWithCondition(operator, value));
    }

    private static TriggerDefinition.Program programWithCondition(String operator, String value) {
        return programWithCondition("value", operator, value);
    }

    private static TriggerDefinition.Program programWithCondition(String field, String operator, String value) {
        return new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("custom.example.condition", Map.of()),
                TriggerDefinition.MatchMode.ALL,
                List.of(new TriggerDefinition.Condition(field, operator, value)),
                List.of(new TriggerDefinition.Action("log", Map.of("message", "matched"))));
    }

    private static TriggerDefinition.Program programWithAction(String type, Map<String, String> parameters) {
        return new TriggerDefinition.Program(
                new TriggerDefinition.TriggerEvent("player.join", Map.of()),
                TriggerDefinition.MatchMode.ALL,
                List.of(),
                List.of(new TriggerDefinition.Action(type, parameters)));
    }

    private static String nestedScript(int depth) {
        StringBuilder script = new StringBuilder("on custom.example.depth\n");
        for (int index = 0; index < depth; index++) script.append("if enabled true\n");
        script.append("do log message=deep\n");
        for (int index = 0; index < depth; index++) script.append("end\n");
        return script.toString();
    }

    private static TriggerDefinition.Action conditionAction(List<TriggerDefinition.Action> children) {
        return new TriggerDefinition.Action(
                TriggerActionTree.CONDITION_TYPE,
                Map.of("field", "enabled", "operator", "true"), children);
    }
}