package com.xfestudio.xfeservermanager.core.trigger;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Supported built-in event, comparison and action vocabulary. Custom events remain extensible. */
public final class TriggerCatalog {
public static final List<String> EVENTS = List.of(
"schedule.daily", "schedule.interval", "server.started", "server.stopping",
"player.join", "player.leave", "player.chat", "player.command", "player.command_trigger",
"player.death",
"player.respawn", "player.dimension_change", "player.advancement", "player.item_pickup",
"player.item_drop", "player.item_use", "player.item_use_finish", "player.item_craft",
"player.attack", "player.hurt", "player.heal", "player.interact", "player.entity_interact",
"player.sleep", "player.wake", "block.break", "block.place", "block.change", "block.grow",
"block.tool_modify", "entity.spawn", "entity.remove", "entity.death", "world.explosion",
"world.weather_change", "world.load", "world.unload", "chunk.load", "chunk.unload",
"variable.changed", "menu.open", "menu.close", "menu.control",
"economy.balance_changed", "economy.deposit", "economy.withdraw", "economy.set",
"economy.transfer", "protection.item_overflow", "protection.mob_overflow",
"protection.entity_overflow", "protection.mod_entity_overflow", "protection.spawn_burst",
"protection.command_block_rate", "protection.slow_tick",
"protection.loaded_chunk_overflow", "protection.memory_pressure",
"region.enter", "region.leave", "region.stay", "custom");
public static final List<String> OPERATORS = List.of(
"eq", "neq", "contains", "not_contains",
"starts_with", "not_starts_with", "ends_with", "not_ends_with",
"matches", "not_matches", "gt", "gte", "lt", "lte", "between", "not_between",
"in", "not_in", "exists", "not_exists", "empty", "not_empty", "true", "false");
public static final List<String> ACTIONS = List.of(
"send_player", "broadcast", "title", "actionbar", "sound", "server_command",
"player_command", "kick", "teleport", "give_item", "clear_inventory",
"set_gamemode", "add_effect", "remove_effects", "heal", "feed", "set_time",
"set_weather", "whitelist_add", "whitelist_remove", "ban", "pardon", "log",
"variable", "wait", "run_trigger", "open_menu", "close_menu",
"economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
"economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
"economy_transfer_players", "set_health", "set_food", "set_experience",
"set_spawnpoint", "scoreboard_set", "scoreboard_add", "team_join", "team_leave",
"advancement_grant", "advancement_revoke", "spawn_entity", "damage_entity",
"teleport_entity", "remove_entity", "set_entity_attribute", "tag_entity",
"set_block", "fill_blocks", "explosion", "lightning", "set_difficulty",
"set_gamerule", "world_border");
public static final Map<String, List<String>> ACTION_PARAMETERS = Map.ofEntries(
Map.entry("send_player", List.of("message")),
Map.entry("broadcast", List.of("message")),
Map.entry("title", List.of("title", "subtitle", "numberPrecision", "fadeIn", "stay", "fadeOut")),
Map.entry("actionbar", List.of("message")),
Map.entry("sound", List.of("sound", "volume", "pitch")),
Map.entry("server_command", List.of("command", "showFeedback")),
Map.entry("player_command", List.of("command", "showFeedback")),
Map.entry("kick", List.of("message")),
Map.entry("teleport", List.of("destination")),
Map.entry("give_item", List.of("item", "count")),
Map.entry("clear_inventory", List.of("item", "maxCount")),
Map.entry("set_gamemode", List.of("gamemode")),
Map.entry("add_effect", List.of("effect", "duration", "amplifier")),
Map.entry("remove_effects", List.of()),
Map.entry("heal", List.of()),
Map.entry("feed", List.of()),
Map.entry("set_time", List.of("time")),
Map.entry("set_weather", List.of("weather", "duration")),
Map.entry("whitelist_add", List.of()),
Map.entry("whitelist_remove", List.of()),
Map.entry("ban", List.of("reason")),
Map.entry("pardon", List.of()),
Map.entry("log", List.of("message", "level")),
Map.entry("variable", List.of("name", "operation", "value", "extra")),
Map.entry("wait", List.of("mode", "value", "timeout", "pollTicks", "field", "operator", "expected")),
Map.entry("run_trigger", List.of("triggerId", "waitForCompletion")),
Map.entry("open_menu", List.of("menuId")),
Map.entry("close_menu", List.of()),
Map.entry("economy_deposit", List.of("currency", "amount", "reason")),
Map.entry("economy_withdraw", List.of("currency", "amount", "reason")),
Map.entry("economy_set_balance", List.of("currency", "amount", "reason")),
Map.entry("economy_transfer", List.of("currency", "amount", "target", "reason")),
Map.entry("economy_deposit_player", List.of("player", "currency", "amount", "reason")),
Map.entry("economy_withdraw_player", List.of("player", "currency", "amount", "reason")),
Map.entry("economy_set_player_balance", List.of("player", "currency", "amount", "reason")),
Map.entry("economy_transfer_players", List.of("source", "target", "currency", "amount", "reason")),
Map.entry("set_health", List.of("value")),
Map.entry("set_food", List.of("value")),
Map.entry("set_experience", List.of("amount", "mode")),
Map.entry("set_spawnpoint", List.of("position")),
Map.entry("scoreboard_set", List.of("objective", "value")),
Map.entry("scoreboard_add", List.of("objective", "value")),
Map.entry("team_join", List.of("team")),
Map.entry("team_leave", List.of()),
Map.entry("advancement_grant", List.of("advancement")),
Map.entry("advancement_revoke", List.of("advancement")),
Map.entry("spawn_entity", List.of("entityType", "position", "nbt")),
Map.entry("damage_entity", List.of("selector", "amount", "damageType")),
Map.entry("teleport_entity", List.of("selector", "destination")),
Map.entry("remove_entity", List.of("selector")),
Map.entry("set_entity_attribute", List.of("selector", "attribute", "value")),
Map.entry("tag_entity", List.of("selector", "operation", "tag")),
Map.entry("set_block", List.of("dimension", "position", "block", "mode")),
Map.entry("fill_blocks", List.of("dimension", "from", "to", "block", "mode")),
Map.entry("explosion", List.of("dimension", "position", "power", "fire")),
Map.entry("lightning", List.of("dimension", "position")),
Map.entry("set_difficulty", List.of("difficulty")),
Map.entry("set_gamerule", List.of("rule", "value")),
Map.entry("world_border", List.of("operation", "value", "seconds")));
private static final Set<String> EVENT_SET = Set.copyOf(EVENTS);
private static final Set<String> OPERATOR_SET = Set.copyOf(OPERATORS);
private static final Set<String> ACTION_SET = Set.copyOf(ACTIONS);
private static final Set<String> PLAYER_CONTEXT_ACTIONS = Set.of(
"send_player", "title", "actionbar", "sound", "player_command", "kick",
"teleport", "give_item", "clear_inventory", "set_gamemode", "add_effect",
"remove_effects", "heal", "feed", "whitelist_add", "whitelist_remove", "ban", "pardon",
"open_menu", "close_menu", "economy_deposit", "economy_withdraw",
"economy_set_balance", "economy_transfer", "set_health", "set_food",
"set_experience", "set_spawnpoint", "scoreboard_set", "scoreboard_add",
"team_join", "team_leave", "advancement_grant", "advancement_revoke");
private static final Set<String> ONLINE_PLAYER_ACTIONS = Set.of(
"send_player", "title", "actionbar", "sound", "player_command", "kick", "teleport",
"give_item", "clear_inventory", "set_gamemode", "add_effect", "remove_effects", "heal", "feed",
"open_menu", "close_menu", "set_health", "set_food", "set_experience",
"set_spawnpoint", "scoreboard_set", "scoreboard_add", "team_join", "team_leave",
"advancement_grant", "advancement_revoke");
private static final Set<String> RESOURCE_ACTIONS = Set.of("sound", "give_item", "add_effect");
private TriggerCatalog() { }
public static void validate(TriggerDefinition.Program program) {
String event = program.event().type();
boolean builtIn = EVENT_SET.contains(event) && !event.equals("custom");
if (!builtIn && !event.matches("custom\\.[a-z0-9_.-]+")) {
throw new IllegalArgumentException("unsupported trigger event: " + event);
}
validateEventConfiguration(program.event());
program.conditions().forEach(condition -> {
if (!OPERATOR_SET.contains(condition.operator())) {
throw new IllegalArgumentException("unsupported condition operator: " + condition.operator());
}
if (Set.of("matches", "not_matches").contains(condition.operator())) {
TriggerEvaluator.validateRegex(condition.value());
}
if (Set.of("gt", "gte", "lt", "lte").contains(condition.operator())) {
finiteNumber(condition.value(), "numeric comparison value");
}
if (Set.of("between", "not_between").contains(condition.operator())) {
TriggerEvaluator.validateNumberRange(condition.value());
}
});
if (program.actions().isEmpty()) throw new IllegalArgumentException("a trigger needs at least one action");
Set<String> declaredVariables = program.event().variables().stream()
.map(variable -> variable.visibility().equals("global")
? "global." + variable.name() : variable.name())
.collect(java.util.stream.Collectors.toSet());
program.actions().forEach(action -> {
if (!ACTION_SET.contains(action.type())) {
throw new IllegalArgumentException("unsupported trigger action: " + action.type());
}
Set<String> acceptedParameters = Set.copyOf(ACTION_PARAMETERS.get(action.type()));
for (String parameter : action.parameters().keySet()) {
if (!acceptedParameters.contains(parameter)) {
throw new IllegalArgumentException(action.type() + " does not accept parameter " + parameter);
}
}
String primary = switch (action.type()) {
case "server_command", "player_command" -> "command";
case "sound" -> "sound";
case "title" -> "title";
case "teleport" -> "destination";
case "give_item" -> "item";
case "set_gamemode" -> "gamemode";
case "add_effect" -> "effect";
case "set_time" -> "time";
case "set_weather" -> "weather";
case "variable" -> "name";
case "wait" -> "mode";
case "run_trigger" -> "triggerId";
case "open_menu" -> "menuId";
case "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
"economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
"economy_transfer_players" -> "currency";
case "set_health", "set_food" -> "value";
case "set_experience" -> "amount";
case "set_spawnpoint" -> "position";
case "scoreboard_set", "scoreboard_add" -> "objective";
case "team_join" -> "team";
case "team_leave" -> null;
case "advancement_grant", "advancement_revoke" -> "advancement";
case "spawn_entity" -> "entityType";
case "damage_entity", "teleport_entity", "remove_entity",
"set_entity_attribute", "tag_entity" -> "selector";
case "set_block", "fill_blocks" -> "block";
case "explosion", "lightning" -> "position";
case "set_difficulty" -> "difficulty";
case "set_gamerule" -> "rule";
case "world_border" -> "operation";
case "clear_inventory", "remove_effects", "heal", "feed", "whitelist_add",
"whitelist_remove", "ban", "pardon", "close_menu" -> null;
default -> "message";
};
if (primary != null && (!action.parameters().containsKey(primary)
|| action.parameters().get(primary) == null
|| action.parameters().get(primary).isBlank())) {
throw new IllegalArgumentException(action.type() + " requires " + primary);
}
if (PLAYER_CONTEXT_ACTIONS.contains(action.type()) && !providesPlayerContext(event)) {
throw new IllegalArgumentException(action.type()
+ " requires a player-context event; use broadcast/log/server actions instead");
}
if (event.equals("player.leave") && ONLINE_PLAYER_ACTIONS.contains(action.type())) {
throw new IllegalArgumentException(action.type()
+ " cannot run after the player has left; use broadcast/log or identity-list actions instead");
}
action.parameters().values().forEach(TriggerEvaluator::validateTemplateVariables);
validateActionParameters(action);
if (action.type().equals("variable")) {
String name = action.parameters().getOrDefault("name", "").strip();
if (!TriggerEvaluator.hasTemplateVariable(name) && !declaredVariables.contains(name)
&& !name.matches("global\\.[A-Za-z_][A-Za-z0-9_-]{0,47}")) {
throw new IllegalArgumentException("variable action references an undeclared variable: " + name);
}
}
});
}
private static void validateActionParameters(TriggerDefinition.Action action) {
Map<String, String> values = action.parameters();
if (RESOURCE_ACTIONS.contains(action.type())) {
String key = action.type().equals("sound") ? "sound"
: action.type().equals("give_item") ? "item" : "effect";
resourceIdentifier(values.get(key), key);
}
switch (action.type()) {
case "server_command", "player_command" -> {
String command = values.get("command");
if (command.length() > 32_768 || command.indexOf('\n') >= 0 || command.indexOf('\r') >= 0) {
throw new IllegalArgumentException("trigger command must be a single line of at most 32768 characters");
}
enumeration(values.getOrDefault("showFeedback", "false"), "showFeedback",
Set.of("true", "false"));
}
case "sound" -> {
decimalRange(values.getOrDefault("volume", "1"), "volume", 0, 1_000);
decimalRange(values.getOrDefault("pitch", "1"), "pitch", 0, 2);
}
case "title" -> {
integerRange(values.getOrDefault("numberPrecision", "2"),
"numberPrecision", 0, TriggerEvaluator.MAX_MESSAGE_FRACTION_DIGITS);
integerRange(values.getOrDefault("fadeIn", "10"), "fadeIn", 0, 12_000);
integerRange(values.getOrDefault("stay", "70"), "stay", 0, 12_000);
integerRange(values.getOrDefault("fadeOut", "20"), "fadeOut", 0, 12_000);
}
case "teleport" -> {
String destination = values.get("destination").strip();
if (destination.indexOf('\n') >= 0 || destination.indexOf('\r') >= 0
|| destination.length() > 256) {
throw new IllegalArgumentException("teleport destination must be a single line of at most 256 characters");
}
}
case "give_item" -> integerRange(values.getOrDefault("count", "1"), "count", 1, 6_400);
case "clear_inventory" -> {
String item = values.getOrDefault("item", "").strip();
if (!item.isEmpty()) resourceIdentifier(item, "item");
String maximum = values.getOrDefault("maxCount", "").strip();
if (!maximum.isEmpty()) integerRange(maximum, "maxCount", 0, Integer.MAX_VALUE);
}
case "set_gamemode" -> enumeration(values.get("gamemode"), "gamemode",
Set.of("survival", "creative", "adventure", "spectator"));
case "add_effect" -> {
integerRange(values.getOrDefault("duration", "30"), "duration", 1, 1_000_000);
integerRange(values.getOrDefault("amplifier", "0"), "amplifier", 0, 255);
}
case "set_time" -> {
String time = values.get("time").strip().toLowerCase(java.util.Locale.ROOT);
if (!Set.of("day", "night", "noon", "midnight").contains(time)) {
integerRange(time, "time", 0, 24_000);
}
}
case "set_weather" -> {
enumeration(values.get("weather"), "weather", Set.of("clear", "rain", "thunder"));
integerRange(values.getOrDefault("duration", "300"), "duration", 1, 1_000_000);
}
case "log" -> enumeration(values.getOrDefault("level", "info"), "level",
Set.of("debug", "info", "warn", "error"));
case "variable" -> {
String name = values.getOrDefault("name", "").strip();
if (!name.matches("(?:global\\.)?[A-Za-z_][A-Za-z0-9_-]{0,47}")) {
throw new IllegalArgumentException("variable action requires a valid variable name");
}
enumeration(values.getOrDefault("operation", "set"), "variable operation", Set.of(
"set", "parse", "add", "subtract", "multiply", "divide", "modulo",
"increment", "decrement", "append", "prepend", "replace", "regex_replace",
"trim", "upper", "lower", "escape_json", "escape_command", "escape_regex",
"toggle", "list_add", "list_remove", "array_add", "array_insert",
"array_remove", "array_remove_at", "array_set", "dictionary_put",
"dictionary_remove", "dictionary_merge", "clear"));
}
case "wait" -> {
String modeValue = values.getOrDefault("mode", "duration");
enumeration(modeValue, "wait mode", Set.of("duration", "ticks", "game_time", "condition"));
String mode = modeValue.strip().toLowerCase(java.util.Locale.ROOT);
integerRange(values.getOrDefault("timeout", "0"), "wait timeout", 0, 86_400);
integerRange(values.getOrDefault("pollTicks", "20"), "wait pollTicks", 1, 72_000);
if (TriggerEvaluator.hasTemplateVariable(modeValue)) {
// Runtime validation selects the mode after template rendering.
} else if (mode.equals("duration")) {
decimalRange(values.getOrDefault("value", "0"), "wait value", 0, 86_400_000);
} else if (mode.equals("ticks")) {
integerRange(values.getOrDefault("value", "0"), "wait ticks", 0, 86_400_000);
} else if (mode.equals("game_time")) {
integerRange(values.getOrDefault("value", "0"), "wait game time", 0, Integer.MAX_VALUE);
} else {
TriggerDefinition.Condition condition = new TriggerDefinition.Condition(
values.get("field"), values.getOrDefault("operator", "eq"),
values.getOrDefault("expected", ""));
if (!TriggerEvaluator.hasTemplateVariable(condition.operator())
&& !OPERATOR_SET.contains(condition.operator())) {
throw new IllegalArgumentException("unsupported wait operator: " + condition.operator());
}
}
}
case "run_trigger" -> {
if (!TriggerEvaluator.hasTemplateVariable(values.getOrDefault("triggerId", ""))) {
try { java.util.UUID.fromString(values.getOrDefault("triggerId", "").strip()); }
catch (IllegalArgumentException invalid) {
throw new IllegalArgumentException("run_trigger requires a trigger UUID", invalid);
}
}
enumeration(values.getOrDefault("waitForCompletion", "false"), "waitForCompletion",
Set.of("true", "false"));
}
case "open_menu" -> {
if (!TriggerEvaluator.hasTemplateVariable(values.getOrDefault("menuId", ""))) {
try { java.util.UUID.fromString(values.getOrDefault("menuId", "").strip()); }
catch (IllegalArgumentException invalid) {
throw new IllegalArgumentException("open_menu requires a menu UUID", invalid);
}
}
}
case "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
"economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
"economy_transfer_players" -> {
String currency = values.getOrDefault("currency", "");
if (!TriggerEvaluator.hasTemplateVariable(currency)
&& !currency.matches("(?:[a-z][a-z0-9_]{0,31}|[0-9a-fA-F-]{36})")) {
throw new IllegalArgumentException("economy currency must be a code or UUID");
}
String amount = values.getOrDefault("amount", "");
if (!TriggerEvaluator.hasTemplateVariable(amount)) {
try {
java.math.BigDecimal parsed = new java.math.BigDecimal(amount);
if (!Set.of("economy_set_balance", "economy_set_player_balance").contains(action.type())
&& parsed.signum() <= 0) {
throw new NumberFormatException();
}
} catch (RuntimeException invalid) {
throw new IllegalArgumentException("economy amount must be a decimal number", invalid);
}
}
String reason = values.getOrDefault("reason", "Trigger economy operation");
if (reason.isBlank() || reason.length() > 512) {
throw new IllegalArgumentException("economy reason must contain 1-512 characters");
}
if (action.type().equals("economy_transfer")
&& values.getOrDefault("target", "").isBlank()) {
throw new IllegalArgumentException("economy_transfer requires target");
}
if (Set.of("economy_deposit_player", "economy_withdraw_player",
"economy_set_player_balance").contains(action.type())) {
economyIdentity(values.get("player"), "player");
}
if (action.type().equals("economy_transfer_players")) {
economyIdentity(values.get("source"), "source");
economyIdentity(values.get("target"), "target");
}
}
case "set_health" -> decimalRange(values.get("value"), "health", 0, 2_048);
case "set_food" -> integerRange(values.get("value"), "food", 0, 20);
case "set_experience" -> {
integerRange(values.get("amount"), "experience", 0, Integer.MAX_VALUE);
enumeration(values.getOrDefault("mode", "points"), "experience mode",
Set.of("points", "levels"));
}
case "set_spawnpoint" -> coordinateTriple(values.get("position"), "position");
case "scoreboard_set", "scoreboard_add" -> {
safeWord(values.get("objective"), "objective", 64);
integerRange(values.getOrDefault("value", "0"), "score", Integer.MIN_VALUE, Integer.MAX_VALUE);
}
case "team_join" -> safeWord(values.get("team"), "team", 64);
case "advancement_grant", "advancement_revoke" ->
resourceIdentifier(values.get("advancement"), "advancement");
case "spawn_entity" -> {
resourceIdentifier(values.get("entityType"), "entityType");
coordinateTriple(values.getOrDefault("position", "~ ~ ~"), "position");
boundedSingleLine(values.getOrDefault("nbt", ""), "nbt", 16_384, true);
}
case "damage_entity" -> {
selector(values.get("selector"));
decimalRange(values.getOrDefault("amount", "1"), "damage amount", 0, 1_000_000);
String damageType = values.getOrDefault("damageType", "");
if (!damageType.isBlank()) resourceIdentifier(damageType, "damageType");
}
case "teleport_entity" -> {
selector(values.get("selector"));
coordinateTriple(values.get("destination"), "destination");
}
case "remove_entity" -> selector(values.get("selector"));
case "set_entity_attribute" -> {
selector(values.get("selector"));
resourceIdentifier(values.get("attribute"), "attribute");
decimalRange(values.get("value"), "attribute value", -1_000_000, 1_000_000);
}
case "tag_entity" -> {
selector(values.get("selector"));
enumeration(values.getOrDefault("operation", "add"), "tag operation", Set.of("add", "remove"));
safeWord(values.get("tag"), "tag", 128);
}
case "set_block" -> {
resourceIdentifier(values.getOrDefault("dimension", "minecraft:overworld"), "dimension");
coordinateTriple(values.get("position"), "position");
boundedSingleLine(values.get("block"), "block", 1_024, false);
enumeration(values.getOrDefault("mode", "replace"), "set block mode",
Set.of("replace", "destroy", "keep"));
}
case "fill_blocks" -> {
resourceIdentifier(values.getOrDefault("dimension", "minecraft:overworld"), "dimension");
coordinateTriple(values.get("from"), "from");
coordinateTriple(values.get("to"), "to");
boundedSingleLine(values.get("block"), "block", 1_024, false);
enumeration(values.getOrDefault("mode", "replace"), "fill mode",
Set.of("replace", "destroy", "keep", "hollow", "outline"));
}
case "explosion" -> {
resourceIdentifier(values.getOrDefault("dimension", "minecraft:overworld"), "dimension");
coordinateTriple(values.get("position"), "position");
decimalRange(values.getOrDefault("power", "4"), "explosion power", 0, 128);
enumeration(values.getOrDefault("fire", "false"), "explosion fire", Set.of("true", "false"));
}
case "lightning" -> {
resourceIdentifier(values.getOrDefault("dimension", "minecraft:overworld"), "dimension");
coordinateTriple(values.get("position"), "position");
}
case "set_difficulty" -> enumeration(values.get("difficulty"), "difficulty",
Set.of("peaceful", "easy", "normal", "hard"));
case "set_gamerule" -> {
safeWord(values.get("rule"), "gamerule", 128);
boundedSingleLine(values.getOrDefault("value", ""), "gamerule value", 128, false);
}
case "world_border" -> {
enumeration(values.get("operation"), "world border operation",
Set.of("size", "add", "center", "damage_amount", "damage_buffer", "warning_distance", "warning_time"));
boundedSingleLine(values.getOrDefault("value", ""), "world border value", 128, false);
integerRange(values.getOrDefault("seconds", "0"), "world border seconds", 0, 86_400);
}
default -> { }
}
}
private static void resourceIdentifier(String value, String name) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
if (value == null || !value.strip().matches("(?:[a-z0-9_.-]+:)?[a-z0-9_./-]+")) {
throw new IllegalArgumentException(name + " must be a valid resource identifier");
}
}
private static void economyIdentity(String value, String name) {
if (value == null || value.isBlank() || value.length() > 128
|| value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) {
throw new IllegalArgumentException("economy " + name
+ " must be a player name, UUID, or template variable");
}
}
private static void selector(String value) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
boundedSingleLine(value, "selector", 512, false);
String normalized = value.strip();
if (!normalized.matches("(?:@[aep][^\\r\\n ]{0,509}|[A-Za-z0-9_]{1,16}|[0-9a-fA-F-]{36})")) {
throw new IllegalArgumentException("selector must be a player/entity selector, name, or UUID");
}
}
private static void coordinateTriple(String value, String name) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
if (value == null || !value.strip().matches("(?:[~^](?:-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))?|-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))"
+ "(?:\\s+(?:[~^](?:-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))?|-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))){2}")) {
throw new IllegalArgumentException(name + " must contain exactly three coordinates");
}
}
private static void safeWord(String value, String name, int maximum) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
if (value == null || !value.strip().matches("[A-Za-z0-9_.:-]{1," + maximum + "}")) {
throw new IllegalArgumentException(name + " contains unsupported characters");
}
}
private static void boundedSingleLine(String value, String name, int maximum, boolean emptyAllowed) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
String normalized = value == null ? "" : value.strip();
if ((!emptyAllowed && normalized.isEmpty()) || normalized.length() > maximum
|| normalized.indexOf('\r') >= 0 || normalized.indexOf('\n') >= 0) {
throw new IllegalArgumentException(name + " must be a bounded single line");
}
}
private static void enumeration(String value, String name, Set<String> accepted) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
if (value == null || !accepted.contains(value.strip().toLowerCase(java.util.Locale.ROOT))) {
throw new IllegalArgumentException(name + " must be one of " + accepted);
}
}
private static void integerRange(String value, String name, int minimum, int maximum) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
try {
long parsed = Long.parseLong(value.strip());
if (parsed < minimum || parsed > maximum) throw new NumberFormatException();
} catch (RuntimeException exception) {
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum, exception);
}
}
private static void decimalRange(String value, String name, double minimum, double maximum) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
double parsed = finiteNumber(value, name);
if (parsed < minimum || parsed > maximum) {
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum);
}
}
private static double finiteNumber(String value, String name) {
try {
double parsed = Double.parseDouble(value.strip());
if (!Double.isFinite(parsed)) throw new NumberFormatException();
return parsed;
} catch (RuntimeException exception) {
throw new IllegalArgumentException(name + " must be a finite number", exception);
}
}
private static boolean providesPlayerContext(String event) {
return event.startsWith("player.") || event.equals("block.break") || event.equals("block.place")
|| event.equals("block.tool_modify")
|| event.startsWith("menu.")
|| event.startsWith("economy.")
|| event.startsWith("region.")
|| event.startsWith("custom.");
}
private static void validateEventConfiguration(TriggerDefinition.TriggerEvent event) {
if (event.type().equals(TriggerCommandConfiguration.EVENT_TYPE)) {
TriggerCommandConfiguration.parse(event);
return;
}
if (!event.arguments().isEmpty()) {
throw new IllegalArgumentException("command arguments require the player.command_trigger event");
}
if (event.type().equals("schedule.daily")) {
rejectUnknownEventParameters(event, Set.of("time", "timezone"));
String time = event.configuration().getOrDefault("time", "");
try {
java.time.LocalTime.parse(time);
java.time.ZoneId.of(event.configuration().getOrDefault("timezone", "UTC"));
} catch (java.time.DateTimeException exception) {
throw new IllegalArgumentException("daily schedule needs a valid time and timezone", exception);
}
}
if (event.type().equals("schedule.interval")) {
rejectUnknownEventParameters(event, Set.of("seconds"));
TriggerIntervalSchedule.totalSeconds(event.configuration());
return;
}
if (event.type().startsWith("protection.")) {
validateProtectionEvent(event);
return;
}
if (event.type().startsWith("region.")) {
rejectUnknownEventParameters(event, Set.of("regionId"));
String region = event.configuration().getOrDefault("regionId", "");
if (!region.matches("[a-z][a-z0-9_.-]{1,63}")) {
throw new IllegalArgumentException("region event requires a regionId");
}
return;
}
if (!event.type().equals("schedule.daily") && !event.configuration().isEmpty()) {
throw new IllegalArgumentException(event.type() + " does not accept event configuration parameters");
}
}
private static void validateProtectionEvent(TriggerDefinition.TriggerEvent event) {
Set<String> accepted = switch (event.type()) {
case "protection.item_overflow", "protection.mob_overflow", "protection.entity_overflow" ->
Set.of("threshold", "scope", "cooldownSeconds");
case "protection.mod_entity_overflow" ->
Set.of("threshold", "namespace", "cooldownSeconds");
case "protection.slow_tick" ->
Set.of("threshold", "consecutive", "cooldownSeconds");
default -> Set.of("threshold", "cooldownSeconds");
};
rejectUnknownEventParameters(event, accepted);
int maximum = event.type().equals("protection.memory_pressure") ? 99 : Integer.MAX_VALUE;
int minimum = event.type().equals("protection.memory_pressure") ? 50
: event.type().equals("protection.slow_tick") ? 50 : 1;
integerRange(event.configuration().getOrDefault("threshold", ""),
"protection threshold", minimum, maximum);
integerRange(event.configuration().getOrDefault("cooldownSeconds", "60"),
"protection cooldownSeconds", 1, 86_400);
if (Set.of("protection.item_overflow", "protection.mob_overflow",
"protection.entity_overflow").contains(event.type())) {
enumeration(event.configuration().getOrDefault("scope", "dimension"),
"protection scope", Set.of("dimension", "chunk"));
}
if (event.type().equals("protection.mod_entity_overflow")) {
String namespace = event.configuration().getOrDefault("namespace", "").strip();
if (!namespace.matches("[a-z0-9_.-]{1,64}")) {
throw new IllegalArgumentException("mod entity protection requires a namespace");
}
}
if (event.type().equals("protection.slow_tick")) {
integerRange(event.configuration().getOrDefault("consecutive", "1"),
"protection consecutive", 1, 1_200);
}
}
private static void rejectUnknownEventParameters(
TriggerDefinition.TriggerEvent event, Set<String> accepted) {
for (String parameter : event.configuration().keySet()) {
if (!accepted.contains(parameter)) {
throw new IllegalArgumentException(event.type()
+ " does not accept event configuration parameter " + parameter);
}
}
}
}
package com.xfestudio.xfeservermanager.core.trigger;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Supported built-in event, comparison and action vocabulary. Custom events remain extensible. */
public final class TriggerCatalog {
public static final List<String> EVENTS = List.of(
"schedule.daily", "schedule.interval", "server.started", "server.stopping",
"player.join", "player.leave", "player.chat", "player.command", "player.command_trigger",
"player.death",
"player.respawn", "player.dimension_change", "player.advancement", "player.item_pickup",
"player.item_drop", "player.item_use", "player.item_use_finish", "player.item_craft",
"player.attack", "player.hurt", "player.heal", "player.interact", "player.entity_interact",
"player.sleep", "player.wake", "block.break", "block.place", "block.change", "block.grow",
"block.tool_modify", "entity.spawn", "entity.remove", "entity.death", "world.explosion",
"world.weather_change", "world.load", "world.unload", "chunk.load", "chunk.unload",
"variable.changed", "menu.open", "menu.close", "menu.control",
"economy.balance_changed", "economy.deposit", "economy.withdraw", "economy.set",
"economy.transfer", "protection.item_overflow", "protection.mob_overflow",
"protection.entity_overflow", "protection.mod_entity_overflow", "protection.spawn_burst",
"protection.command_block_rate", "protection.slow_tick",
"protection.loaded_chunk_overflow", "protection.memory_pressure",
"region.enter", "region.leave", "region.stay", "custom");
public static final List<String> OPERATORS = List.of(
"eq", "neq", "contains", "not_contains",
"starts_with", "not_starts_with", "ends_with", "not_ends_with",
"matches", "not_matches", "gt", "gte", "lt", "lte", "between", "not_between",
"in", "not_in", "exists", "not_exists", "empty", "not_empty", "true", "false");
public static final List<String> ACTIONS = List.of(
"send_player", "broadcast", "title", "actionbar", "sound", "server_command",
"player_command", "kick", "teleport", "give_item", "clear_inventory",
"set_gamemode", "add_effect", "remove_effects", "heal", "feed", "set_time",
"set_weather", "whitelist_add", "whitelist_remove", "ban", "pardon", "log",
"variable", "wait", "run_trigger", "open_menu", "close_menu",
"economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
"economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
"economy_transfer_players", "set_health", "set_food", "set_experience",
"set_spawnpoint", "scoreboard_set", "scoreboard_add", "team_join", "team_leave",
"advancement_grant", "advancement_revoke", "spawn_entity", "damage_entity",
"teleport_entity", "remove_entity", "set_entity_attribute", "tag_entity",
"set_block", "fill_blocks", "explosion", "lightning", "set_difficulty",
"set_gamerule", "world_border");
public static final Map<String, List<String>> ACTION_PARAMETERS = Map.ofEntries(
Map.entry("send_player", List.of("message")),
Map.entry("broadcast", List.of("message")),
Map.entry("title", List.of("title", "subtitle", "numberPrecision", "fadeIn", "stay", "fadeOut")),
Map.entry("actionbar", List.of("message")),
Map.entry("sound", List.of("sound", "volume", "pitch")),
Map.entry("server_command", List.of("command", "showFeedback")),
Map.entry("player_command", List.of("command", "showFeedback")),
Map.entry("kick", List.of("message")),
Map.entry("teleport", List.of("destination")),
Map.entry("give_item", List.of("item", "count")),
Map.entry("clear_inventory", List.of("item", "maxCount")),
Map.entry("set_gamemode", List.of("gamemode")),
Map.entry("add_effect", List.of("effect", "duration", "amplifier")),
Map.entry("remove_effects", List.of()),
Map.entry("heal", List.of()),
Map.entry("feed", List.of()),
Map.entry("set_time", List.of("time")),
Map.entry("set_weather", List.of("weather", "duration")),
Map.entry("whitelist_add", List.of()),
Map.entry("whitelist_remove", List.of()),
Map.entry("ban", List.of("reason")),
Map.entry("pardon", List.of()),
Map.entry("log", List.of("message", "level")),
Map.entry("variable", List.of("name", "operation", "value", "extra")),
Map.entry("wait", List.of("mode", "value", "timeout", "pollTicks", "field", "operator", "expected")),
Map.entry("run_trigger", List.of("triggerId", "waitForCompletion")),
Map.entry("open_menu", List.of("menuId")),
Map.entry("close_menu", List.of()),
Map.entry("economy_deposit", List.of("currency", "amount", "reason")),
Map.entry("economy_withdraw", List.of("currency", "amount", "reason")),
Map.entry("economy_set_balance", List.of("currency", "amount", "reason")),
Map.entry("economy_transfer", List.of("currency", "amount", "target", "reason")),
Map.entry("economy_deposit_player", List.of("player", "currency", "amount", "reason")),
Map.entry("economy_withdraw_player", List.of("player", "currency", "amount", "reason")),
Map.entry("economy_set_player_balance", List.of("player", "currency", "amount", "reason")),
Map.entry("economy_transfer_players", List.of("source", "target", "currency", "amount", "reason")),
Map.entry("set_health", List.of("value")),
Map.entry("set_food", List.of("value")),
Map.entry("set_experience", List.of("amount", "mode")),
Map.entry("set_spawnpoint", List.of("position")),
Map.entry("scoreboard_set", List.of("objective", "value")),
Map.entry("scoreboard_add", List.of("objective", "value")),
Map.entry("team_join", List.of("team")),
Map.entry("team_leave", List.of()),
Map.entry("advancement_grant", List.of("advancement")),
Map.entry("advancement_revoke", List.of("advancement")),
Map.entry("spawn_entity", List.of("entityType", "position", "nbt")),
Map.entry("damage_entity", List.of("selector", "amount", "damageType")),
Map.entry("teleport_entity", List.of("selector", "destination")),
Map.entry("remove_entity", List.of("selector")),
Map.entry("set_entity_attribute", List.of("selector", "attribute", "value")),
Map.entry("tag_entity", List.of("selector", "operation", "tag")),
Map.entry("set_block", List.of("dimension", "position", "block", "mode")),
Map.entry("fill_blocks", List.of("dimension", "from", "to", "block", "mode")),
Map.entry("explosion", List.of("dimension", "position", "power", "fire")),
Map.entry("lightning", List.of("dimension", "position")),
Map.entry("set_difficulty", List.of("difficulty")),
Map.entry("set_gamerule", List.of("rule", "value")),
Map.entry("world_border", List.of("operation", "value", "seconds")));
private static final Set<String> EVENT_SET = Set.copyOf(EVENTS);
private static final Set<String> OPERATOR_SET = Set.copyOf(OPERATORS);
private static final Set<String> ACTION_SET = Set.copyOf(ACTIONS);
private static final Set<String> PLAYER_CONTEXT_ACTIONS = Set.of(
"send_player", "title", "actionbar", "sound", "player_command", "kick",
"teleport", "give_item", "clear_inventory", "set_gamemode", "add_effect",
"remove_effects", "heal", "feed", "whitelist_add", "whitelist_remove", "ban", "pardon",
"open_menu", "close_menu", "economy_deposit", "economy_withdraw",
"economy_set_balance", "economy_transfer", "set_health", "set_food",
"set_experience", "set_spawnpoint", "scoreboard_set", "scoreboard_add",
"team_join", "team_leave", "advancement_grant", "advancement_revoke");
private static final Set<String> ONLINE_PLAYER_ACTIONS = Set.of(
"send_player", "title", "actionbar", "sound", "player_command", "kick", "teleport",
"give_item", "clear_inventory", "set_gamemode", "add_effect", "remove_effects", "heal", "feed",
"open_menu", "close_menu", "set_health", "set_food", "set_experience",
"set_spawnpoint", "scoreboard_set", "scoreboard_add", "team_join", "team_leave",
"advancement_grant", "advancement_revoke");
private static final Set<String> RESOURCE_ACTIONS = Set.of("sound", "give_item", "add_effect");
private TriggerCatalog() { }
public static void validate(TriggerDefinition.Program program) {
String event = program.event().type();
boolean builtIn = EVENT_SET.contains(event) && !event.equals("custom");
if (!builtIn && !event.matches("custom\\.[a-z0-9_.-]+")) {
throw new IllegalArgumentException("unsupported trigger event: " + event);
}
validateEventConfiguration(program.event());
program.conditions().forEach(condition -> {
if (!OPERATOR_SET.contains(condition.operator())) {
throw new IllegalArgumentException("unsupported condition operator: " + condition.operator());
}
if (Set.of("matches", "not_matches").contains(condition.operator())) {
TriggerEvaluator.validateRegex(condition.value());
}
if (Set.of("gt", "gte", "lt", "lte").contains(condition.operator())) {
finiteNumber(condition.value(), "numeric comparison value");
}
if (Set.of("between", "not_between").contains(condition.operator())) {
TriggerEvaluator.validateNumberRange(condition.value());
}
});
if (program.actions().isEmpty()) throw new IllegalArgumentException("a trigger needs at least one action");
Set<String> declaredVariables = program.event().variables().stream()
.map(variable -> variable.visibility().equals("global")
? "global." + variable.name() : variable.name())
.collect(java.util.stream.Collectors.toSet());
program.actions().forEach(action -> {
if (!ACTION_SET.contains(action.type())) {
throw new IllegalArgumentException("unsupported trigger action: " + action.type());
}
Set<String> acceptedParameters = Set.copyOf(ACTION_PARAMETERS.get(action.type()));
for (String parameter : action.parameters().keySet()) {
if (!acceptedParameters.contains(parameter)) {
throw new IllegalArgumentException(action.type() + " does not accept parameter " + parameter);
}
}
String primary = switch (action.type()) {
case "server_command", "player_command" -> "command";
case "sound" -> "sound";
case "title" -> "title";
case "teleport" -> "destination";
case "give_item" -> "item";
case "set_gamemode" -> "gamemode";
case "add_effect" -> "effect";
case "set_time" -> "time";
case "set_weather" -> "weather";
case "variable" -> "name";
case "wait" -> "mode";
case "run_trigger" -> "triggerId";
case "open_menu" -> "menuId";
case "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
"economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
"economy_transfer_players" -> "currency";
case "set_health", "set_food" -> "value";
case "set_experience" -> "amount";
case "set_spawnpoint" -> "position";
case "scoreboard_set", "scoreboard_add" -> "objective";
case "team_join" -> "team";
case "team_leave" -> null;
case "advancement_grant", "advancement_revoke" -> "advancement";
case "spawn_entity" -> "entityType";
case "damage_entity", "teleport_entity", "remove_entity",
"set_entity_attribute", "tag_entity" -> "selector";
case "set_block", "fill_blocks" -> "block";
case "explosion", "lightning" -> "position";
case "set_difficulty" -> "difficulty";
case "set_gamerule" -> "rule";
case "world_border" -> "operation";
case "clear_inventory", "remove_effects", "heal", "feed", "whitelist_add",
"whitelist_remove", "ban", "pardon", "close_menu" -> null;
default -> "message";
};
if (primary != null && (!action.parameters().containsKey(primary)
|| action.parameters().get(primary) == null
|| action.parameters().get(primary).isBlank())) {
throw new IllegalArgumentException(action.type() + " requires " + primary);
}
if (PLAYER_CONTEXT_ACTIONS.contains(action.type()) && !providesPlayerContext(event)) {
throw new IllegalArgumentException(action.type()
+ " requires a player-context event; use broadcast/log/server actions instead");
}
if (event.equals("player.leave") && ONLINE_PLAYER_ACTIONS.contains(action.type())) {
throw new IllegalArgumentException(action.type()
+ " cannot run after the player has left; use broadcast/log or identity-list actions instead");
}
action.parameters().values().forEach(TriggerEvaluator::validateTemplateVariables);
validateActionParameters(action);
if (action.type().equals("variable")) {
String name = action.parameters().getOrDefault("name", "").strip();
if (!TriggerEvaluator.hasTemplateVariable(name) && !declaredVariables.contains(name)
&& !name.matches("global\\.[A-Za-z_][A-Za-z0-9_-]{0,47}")) {
throw new IllegalArgumentException("variable action references an undeclared variable: " + name);
}
}
});
}
private static void validateActionParameters(TriggerDefinition.Action action) {
Map<String, String> values = action.parameters();
if (RESOURCE_ACTIONS.contains(action.type())) {
String key = action.type().equals("sound") ? "sound"
: action.type().equals("give_item") ? "item" : "effect";
resourceIdentifier(values.get(key), key);
}
switch (action.type()) {
case "server_command", "player_command" -> {
String command = values.get("command");
if (command.length() > 32_768 || command.indexOf('\n') >= 0 || command.indexOf('\r') >= 0) {
throw new IllegalArgumentException("trigger command must be a single line of at most 32768 characters");
}
enumeration(values.getOrDefault("showFeedback", "false"), "showFeedback",
Set.of("true", "false"));
}
case "sound" -> {
decimalRange(values.getOrDefault("volume", "1"), "volume", 0, 1_000);
decimalRange(values.getOrDefault("pitch", "1"), "pitch", 0, 2);
}
case "title" -> {
integerRange(values.getOrDefault("numberPrecision", "2"),
"numberPrecision", 0, TriggerEvaluator.MAX_MESSAGE_FRACTION_DIGITS);
integerRange(values.getOrDefault("fadeIn", "10"), "fadeIn", 0, 12_000);
integerRange(values.getOrDefault("stay", "70"), "stay", 0, 12_000);
integerRange(values.getOrDefault("fadeOut", "20"), "fadeOut", 0, 12_000);
}
case "teleport" -> {
String destination = values.get("destination").strip();
if (destination.indexOf('\n') >= 0 || destination.indexOf('\r') >= 0
|| destination.length() > 256) {
throw new IllegalArgumentException("teleport destination must be a single line of at most 256 characters");
}
}
case "give_item" -> integerRange(values.getOrDefault("count", "1"), "count", 1, 6_400);
case "clear_inventory" -> {
String item = values.getOrDefault("item", "").strip();
if (!item.isEmpty()) resourceIdentifier(item, "item");
String maximum = values.getOrDefault("maxCount", "").strip();
if (!maximum.isEmpty()) integerRange(maximum, "maxCount", 0, Integer.MAX_VALUE);
}
case "set_gamemode" -> enumeration(values.get("gamemode"), "gamemode",
Set.of("survival", "creative", "adventure", "spectator"));
case "add_effect" -> {
integerRange(values.getOrDefault("duration", "30"), "duration", 1, 1_000_000);
integerRange(values.getOrDefault("amplifier", "0"), "amplifier", 0, 255);
}
case "set_time" -> {
String time = values.get("time").strip().toLowerCase(java.util.Locale.ROOT);
if (!Set.of("day", "night", "noon", "midnight").contains(time)) {
integerRange(time, "time", 0, 24_000);
}
}
case "set_weather" -> {
enumeration(values.get("weather"), "weather", Set.of("clear", "rain", "thunder"));
integerRange(values.getOrDefault("duration", "300"), "duration", 1, 1_000_000);
}
case "log" -> enumeration(values.getOrDefault("level", "info"), "level",
Set.of("debug", "info", "warn", "error"));
case "variable" -> {
String name = values.getOrDefault("name", "").strip();
if (!name.matches("(?:global\\.)?[A-Za-z_][A-Za-z0-9_-]{0,47}")) {
throw new IllegalArgumentException("variable action requires a valid variable name");
}
enumeration(values.getOrDefault("operation", "set"), "variable operation", Set.of(
"set", "parse", "add", "subtract", "multiply", "divide", "modulo",
"increment", "decrement", "append", "prepend", "replace", "regex_replace",
"trim", "upper", "lower", "escape_json", "escape_command", "escape_regex",
"toggle", "list_add", "list_remove", "array_add", "array_insert",
"array_remove", "array_remove_at", "array_set", "dictionary_put",
"dictionary_remove", "dictionary_merge", "clear"));
}
case "wait" -> {
String modeValue = values.getOrDefault("mode", "duration");
enumeration(modeValue, "wait mode", Set.of("duration", "ticks", "game_time", "condition"));
String mode = modeValue.strip().toLowerCase(java.util.Locale.ROOT);
integerRange(values.getOrDefault("timeout", "0"), "wait timeout", 0, 86_400);
integerRange(values.getOrDefault("pollTicks", "20"), "wait pollTicks", 1, 72_000);
if (TriggerEvaluator.hasTemplateVariable(modeValue)) {
// Runtime validation selects the mode after template rendering.
} else if (mode.equals("duration")) {
decimalRange(values.getOrDefault("value", "0"), "wait value", 0, 86_400_000);
} else if (mode.equals("ticks")) {
integerRange(values.getOrDefault("value", "0"), "wait ticks", 0, 86_400_000);
} else if (mode.equals("game_time")) {
integerRange(values.getOrDefault("value", "0"), "wait game time", 0, Integer.MAX_VALUE);
} else {
TriggerDefinition.Condition condition = new TriggerDefinition.Condition(
values.get("field"), values.getOrDefault("operator", "eq"),
values.getOrDefault("expected", ""));
if (!TriggerEvaluator.hasTemplateVariable(condition.operator())
&& !OPERATOR_SET.contains(condition.operator())) {
throw new IllegalArgumentException("unsupported wait operator: " + condition.operator());
}
}
}
case "run_trigger" -> {
if (!TriggerEvaluator.hasTemplateVariable(values.getOrDefault("triggerId", ""))) {
try { java.util.UUID.fromString(values.getOrDefault("triggerId", "").strip()); }
catch (IllegalArgumentException invalid) {
throw new IllegalArgumentException("run_trigger requires a trigger UUID", invalid);
}
}
enumeration(values.getOrDefault("waitForCompletion", "false"), "waitForCompletion",
Set.of("true", "false"));
}
case "open_menu" -> {
if (!TriggerEvaluator.hasTemplateVariable(values.getOrDefault("menuId", ""))) {
try { java.util.UUID.fromString(values.getOrDefault("menuId", "").strip()); }
catch (IllegalArgumentException invalid) {
throw new IllegalArgumentException("open_menu requires a menu UUID", invalid);
}
}
}
case "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
"economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
"economy_transfer_players" -> {
String currency = values.getOrDefault("currency", "");
if (!TriggerEvaluator.hasTemplateVariable(currency)
&& !currency.matches("(?:[a-z][a-z0-9_]{0,31}|[0-9a-fA-F-]{36})")) {
throw new IllegalArgumentException("economy currency must be a code or UUID");
}
String amount = values.getOrDefault("amount", "");
if (!TriggerEvaluator.hasTemplateVariable(amount)) {
try {
java.math.BigDecimal parsed = new java.math.BigDecimal(amount);
if (!Set.of("economy_set_balance", "economy_set_player_balance").contains(action.type())
&& parsed.signum() <= 0) {
throw new NumberFormatException();
}
} catch (RuntimeException invalid) {
throw new IllegalArgumentException("economy amount must be a decimal number", invalid);
}
}
String reason = values.getOrDefault("reason", "Trigger economy operation");
if (reason.isBlank() || reason.length() > 512) {
throw new IllegalArgumentException("economy reason must contain 1-512 characters");
}
if (action.type().equals("economy_transfer")
&& values.getOrDefault("target", "").isBlank()) {
throw new IllegalArgumentException("economy_transfer requires target");
}
if (Set.of("economy_deposit_player", "economy_withdraw_player",
"economy_set_player_balance").contains(action.type())) {
economyIdentity(values.get("player"), "player");
}
if (action.type().equals("economy_transfer_players")) {
economyIdentity(values.get("source"), "source");
economyIdentity(values.get("target"), "target");
}
}
case "set_health" -> decimalRange(values.get("value"), "health", 0, 2_048);
case "set_food" -> integerRange(values.get("value"), "food", 0, 20);
case "set_experience" -> {
integerRange(values.get("amount"), "experience", 0, Integer.MAX_VALUE);
enumeration(values.getOrDefault("mode", "points"), "experience mode",
Set.of("points", "levels"));
}
case "set_spawnpoint" -> coordinateTriple(values.get("position"), "position");
case "scoreboard_set", "scoreboard_add" -> {
safeWord(values.get("objective"), "objective", 64);
integerRange(values.getOrDefault("value", "0"), "score", Integer.MIN_VALUE, Integer.MAX_VALUE);
}
case "team_join" -> safeWord(values.get("team"), "team", 64);
case "advancement_grant", "advancement_revoke" ->
resourceIdentifier(values.get("advancement"), "advancement");
case "spawn_entity" -> {
resourceIdentifier(values.get("entityType"), "entityType");
coordinateTriple(values.getOrDefault("position", "~ ~ ~"), "position");
boundedSingleLine(values.getOrDefault("nbt", ""), "nbt", 16_384, true);
}
case "damage_entity" -> {
selector(values.get("selector"));
decimalRange(values.getOrDefault("amount", "1"), "damage amount", 0, 1_000_000);
String damageType = values.getOrDefault("damageType", "");
if (!damageType.isBlank()) resourceIdentifier(damageType, "damageType");
}
case "teleport_entity" -> {
selector(values.get("selector"));
coordinateTriple(values.get("destination"), "destination");
}
case "remove_entity" -> selector(values.get("selector"));
case "set_entity_attribute" -> {
selector(values.get("selector"));
resourceIdentifier(values.get("attribute"), "attribute");
decimalRange(values.get("value"), "attribute value", -1_000_000, 1_000_000);
}
case "tag_entity" -> {
selector(values.get("selector"));
enumeration(values.getOrDefault("operation", "add"), "tag operation", Set.of("add", "remove"));
safeWord(values.get("tag"), "tag", 128);
}
case "set_block" -> {
resourceIdentifier(values.getOrDefault("dimension", "minecraft:overworld"), "dimension");
coordinateTriple(values.get("position"), "position");
boundedSingleLine(values.get("block"), "block", 1_024, false);
enumeration(values.getOrDefault("mode", "replace"), "set block mode",
Set.of("replace", "destroy", "keep"));
}
case "fill_blocks" -> {
resourceIdentifier(values.getOrDefault("dimension", "minecraft:overworld"), "dimension");
coordinateTriple(values.get("from"), "from");
coordinateTriple(values.get("to"), "to");
boundedSingleLine(values.get("block"), "block", 1_024, false);
enumeration(values.getOrDefault("mode", "replace"), "fill mode",
Set.of("replace", "destroy", "keep", "hollow", "outline"));
}
case "explosion" -> {
resourceIdentifier(values.getOrDefault("dimension", "minecraft:overworld"), "dimension");
coordinateTriple(values.get("position"), "position");
decimalRange(values.getOrDefault("power", "4"), "explosion power", 0, 128);
enumeration(values.getOrDefault("fire", "false"), "explosion fire", Set.of("true", "false"));
}
case "lightning" -> {
resourceIdentifier(values.getOrDefault("dimension", "minecraft:overworld"), "dimension");
coordinateTriple(values.get("position"), "position");
}
case "set_difficulty" -> enumeration(values.get("difficulty"), "difficulty",
Set.of("peaceful", "easy", "normal", "hard"));
case "set_gamerule" -> {
safeWord(values.get("rule"), "gamerule", 128);
boundedSingleLine(values.getOrDefault("value", ""), "gamerule value", 128, false);
}
case "world_border" -> {
enumeration(values.get("operation"), "world border operation",
Set.of("size", "add", "center", "damage_amount", "damage_buffer", "warning_distance", "warning_time"));
boundedSingleLine(values.getOrDefault("value", ""), "world border value", 128, false);
integerRange(values.getOrDefault("seconds", "0"), "world border seconds", 0, 86_400);
}
default -> { }
}
}
private static void resourceIdentifier(String value, String name) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
if (value == null || !value.strip().matches("(?:[a-z0-9_.-]+:)?[a-z0-9_./-]+")) {
throw new IllegalArgumentException(name + " must be a valid resource identifier");
}
}
private static void economyIdentity(String value, String name) {
if (value == null || value.isBlank() || value.length() > 128
|| value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) {
throw new IllegalArgumentException("economy " + name
+ " must be a player name, UUID, or template variable");
}
}
private static void selector(String value) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
boundedSingleLine(value, "selector", 512, false);
String normalized = value.strip();
if (!normalized.matches("(?:@[aep][^\\r\\n ]{0,509}|[A-Za-z0-9_]{1,16}|[0-9a-fA-F-]{36})")) {
throw new IllegalArgumentException("selector must be a player/entity selector, name, or UUID");
}
}
private static void coordinateTriple(String value, String name) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
if (value == null || !value.strip().matches("(?:[~^](?:-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))?|-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))"
+ "(?:\\s+(?:[~^](?:-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))?|-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))){2}")) {
throw new IllegalArgumentException(name + " must contain exactly three coordinates");
}
}
private static void safeWord(String value, String name, int maximum) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
if (value == null || !value.strip().matches("[A-Za-z0-9_.:-]{1," + maximum + "}")) {
throw new IllegalArgumentException(name + " contains unsupported characters");
}
}
private static void boundedSingleLine(String value, String name, int maximum, boolean emptyAllowed) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
String normalized = value == null ? "" : value.strip();
if ((!emptyAllowed && normalized.isEmpty()) || normalized.length() > maximum
|| normalized.indexOf('\r') >= 0 || normalized.indexOf('\n') >= 0) {
throw new IllegalArgumentException(name + " must be a bounded single line");
}
}
private static void enumeration(String value, String name, Set<String> accepted) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
if (value == null || !accepted.contains(value.strip().toLowerCase(java.util.Locale.ROOT))) {
throw new IllegalArgumentException(name + " must be one of " + accepted);
}
}
private static void integerRange(String value, String name, int minimum, int maximum) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
try {
long parsed = Long.parseLong(value.strip());
if (parsed < minimum || parsed > maximum) throw new NumberFormatException();
} catch (RuntimeException exception) {
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum, exception);
}
}
private static void decimalRange(String value, String name, double minimum, double maximum) {
if (TriggerEvaluator.hasTemplateVariable(value)) return;
double parsed = finiteNumber(value, name);
if (parsed < minimum || parsed > maximum) {
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum);
}
}
private static double finiteNumber(String value, String name) {
try {
double parsed = Double.parseDouble(value.strip());
if (!Double.isFinite(parsed)) throw new NumberFormatException();
return parsed;
} catch (RuntimeException exception) {
throw new IllegalArgumentException(name + " must be a finite number", exception);
}
}
private static boolean providesPlayerContext(String event) {
return event.startsWith("player.") || event.equals("block.break") || event.equals("block.place")
|| event.equals("block.tool_modify")
|| event.startsWith("menu.")
|| event.startsWith("economy.")
|| event.startsWith("region.")
|| event.startsWith("custom.");
}
private static void validateEventConfiguration(TriggerDefinition.TriggerEvent event) {
if (event.type().equals(TriggerCommandConfiguration.EVENT_TYPE)) {
TriggerCommandConfiguration.parse(event);
return;
}
if (!event.arguments().isEmpty()) {
throw new IllegalArgumentException("command arguments require the player.command_trigger event");
}
if (event.type().equals("schedule.daily")) {
rejectUnknownEventParameters(event, Set.of("time", "timezone"));
String time = event.configuration().getOrDefault("time", "");
try {
java.time.LocalTime.parse(time);
java.time.ZoneId.of(event.configuration().getOrDefault("timezone", "UTC"));
} catch (java.time.DateTimeException exception) {
throw new IllegalArgumentException("daily schedule needs a valid time and timezone", exception);
}
}
if (event.type().equals("schedule.interval")) {
rejectUnknownEventParameters(event, Set.of("seconds"));
TriggerIntervalSchedule.totalSeconds(event.configuration());
return;
}
if (event.type().startsWith("protection.")) {
validateProtectionEvent(event);
return;
}
if (event.type().startsWith("region.")) {
rejectUnknownEventParameters(event, Set.of("regionId"));
String region = event.configuration().getOrDefault("regionId", "");
if (!region.matches("[a-z][a-z0-9_.-]{1,63}")) {
throw new IllegalArgumentException("region event requires a regionId");
}
return;
}
if (!event.type().equals("schedule.daily") && !event.configuration().isEmpty()) {
throw new IllegalArgumentException(event.type() + " does not accept event configuration parameters");
}
}
private static void validateProtectionEvent(TriggerDefinition.TriggerEvent event) {
Set<String> accepted = switch (event.type()) {
case "protection.item_overflow", "protection.mob_overflow", "protection.entity_overflow" ->
Set.of("threshold", "scope", "cooldownSeconds");
case "protection.mod_entity_overflow" ->
Set.of("threshold", "namespace", "cooldownSeconds");
case "protection.slow_tick" ->
Set.of("threshold", "consecutive", "cooldownSeconds");
default -> Set.of("threshold", "cooldownSeconds");
};
rejectUnknownEventParameters(event, accepted);
int maximum = event.type().equals("protection.memory_pressure") ? 99 : Integer.MAX_VALUE;
int minimum = event.type().equals("protection.memory_pressure") ? 50
: event.type().equals("protection.slow_tick") ? 50 : 1;
integerRange(event.configuration().getOrDefault("threshold", ""),
"protection threshold", minimum, maximum);
integerRange(event.configuration().getOrDefault("cooldownSeconds", "60"),
"protection cooldownSeconds", 1, 86_400);
if (Set.of("protection.item_overflow", "protection.mob_overflow",
"protection.entity_overflow").contains(event.type())) {
enumeration(event.configuration().getOrDefault("scope", "dimension"),
"protection scope", Set.of("dimension", "chunk"));
}
if (event.type().equals("protection.mod_entity_overflow")) {
String namespace = event.configuration().getOrDefault("namespace", "").strip();
if (!namespace.matches("[a-z0-9_.-]{1,64}")) {
throw new IllegalArgumentException("mod entity protection requires a namespace");
}
}
if (event.type().equals("protection.slow_tick")) {
integerRange(event.configuration().getOrDefault("consecutive", "1"),
"protection consecutive", 1, 1_200);
}
}
private static void rejectUnknownEventParameters(
TriggerDefinition.TriggerEvent event, Set<String> accepted) {
for (String parameter : event.configuration().keySet()) {
if (!accepted.contains(parameter)) {
throw new IllegalArgumentException(event.type()
+ " does not accept event configuration parameter " + parameter);
}
}
}
}