+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft;
+
+import me.lucko.luckperms.common.calculator.CalculatorFactory;
+import me.lucko.luckperms.common.locale.TranslationManager;
+import me.lucko.luckperms.common.minecraft.calculator.MinecraftCalculatorFactory;
+import me.lucko.luckperms.common.minecraft.context.MinecraftContextManager;
+import me.lucko.luckperms.common.model.User;
+import me.lucko.luckperms.common.model.manager.group.StandardGroupManager;
+import me.lucko.luckperms.common.model.manager.track.StandardTrackManager;
+import me.lucko.luckperms.common.model.manager.user.StandardUserManager;
+import me.lucko.luckperms.common.plugin.AbstractLuckPermsPlugin;
+import me.lucko.luckperms.common.sender.DummyConsoleSender;
+import me.lucko.luckperms.common.sender.Sender;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import net.luckperms.api.query.QueryOptions;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.players.PlayerList;
+
+import java.util.Optional;
+import java.util.stream.Stream;
+
+public abstract class MinecraftLuckPermsPlugin, B extends MinecraftLuckPermsBootstrap> extends AbstractLuckPermsPlugin {
+ protected final B bootstrap;
+
+ private StandardUserManager userManager;
+ private StandardGroupManager groupManager;
+ private StandardTrackManager trackManager;
+
+ protected MinecraftLuckPermsPlugin(B bootstrap) {
+ this.bootstrap = bootstrap;
+ }
+
+ @Override
+ public final B getBootstrap() {
+ return this.bootstrap;
+ }
+
+ @Override
+ public abstract MinecraftContextManager getContextManager();
+
+ public abstract MinecraftSenderFactory
getSenderFactory();
+
+ @Override
+ protected void setupManagers() {
+ this.userManager = new StandardUserManager(this);
+ this.groupManager = new StandardGroupManager(this);
+ this.trackManager = new StandardTrackManager(this);
+ }
+
+ @Override
+ protected CalculatorFactory provideCalculatorFactory() {
+ return new MinecraftCalculatorFactory(this);
+ }
+
+ @Override
+ public final Optional getQueryOptionsForUser(User user) {
+ return this.bootstrap.getPlayer(user.getUniqueId()).map(player -> getContextManager().getQueryOptions(player));
+ }
+
+ @Override
+ public final Stream getOnlineSenders() {
+ return Stream.concat(
+ Stream.of(getConsoleSender()),
+ this.bootstrap.getServer()
+ .map(MinecraftServer::getPlayerList)
+ .map(PlayerList::getPlayers)
+ .stream()
+ .flatMap(players -> players.stream()
+ .map(player -> getSenderFactory().wrap(player.createCommandSourceStack()))
+ )
+ );
+ }
+
+ @Override
+ public final Sender getConsoleSender() {
+ return this.bootstrap.getServer()
+ .map(server -> getSenderFactory().wrap(server.createCommandSourceStack()))
+ .orElseGet(() -> new DummyConsoleSender(this) {
+ @Override
+ public void sendMessage(Component message) {
+ MinecraftLuckPermsPlugin.this.bootstrap.getPluginLogger().info(PlainTextComponentSerializer.plainText().serialize(TranslationManager.render(message)));
+ }
+ });
+ }
+
+ @Override
+ public StandardUserManager getUserManager() {
+ return this.userManager;
+ }
+
+ @Override
+ public StandardGroupManager getGroupManager() {
+ return this.groupManager;
+ }
+
+ @Override
+ public StandardTrackManager getTrackManager() {
+ return this.trackManager;
+ }
+
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/MinecraftSchedulerAdapter.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/MinecraftSchedulerAdapter.java
new file mode 100644
index 000000000..7082ca457
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/MinecraftSchedulerAdapter.java
@@ -0,0 +1,53 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft;
+
+import me.lucko.luckperms.common.plugin.scheduler.JavaSchedulerAdapter;
+import me.lucko.luckperms.common.sender.Sender;
+
+import java.util.concurrent.Executor;
+
+public class MinecraftSchedulerAdapter extends JavaSchedulerAdapter {
+ private final Executor syncExecutor;
+
+ public MinecraftSchedulerAdapter(MinecraftLuckPermsBootstrap bootstrap) {
+ super(bootstrap);
+ this.syncExecutor = r -> bootstrap.getServer().orElseThrow(() -> new IllegalStateException("Server not ready")).execute(r);
+ }
+
+ public Executor sync() {
+ return this.syncExecutor;
+ }
+
+ public void executeSync(Runnable task) {
+ this.syncExecutor.execute(task);
+ }
+
+ @Override
+ public void executeSync(Sender ctx, Runnable task) {
+ this.syncExecutor.execute(task);
+ }
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/MinecraftSenderFactory.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/MinecraftSenderFactory.java
new file mode 100644
index 000000000..f98fbe2c5
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/MinecraftSenderFactory.java
@@ -0,0 +1,109 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft;
+
+import com.mojang.serialization.JsonOps;
+import me.lucko.luckperms.common.locale.TranslationManager;
+import me.lucko.luckperms.common.plugin.LuckPermsPlugin;
+import me.lucko.luckperms.common.sender.Sender;
+import me.lucko.luckperms.common.sender.SenderFactory;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
+import net.minecraft.commands.CommandSource;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.network.chat.ComponentSerialization;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.rcon.RconConsoleSource;
+
+import java.util.Locale;
+import java.util.UUID;
+
+public abstract class MinecraftSenderFactory extends SenderFactory
{
+ private final P plugin;
+
+ public MinecraftSenderFactory(P plugin) {
+ super(plugin);
+ this.plugin = plugin;
+ }
+
+ @Override
+ protected P getPlugin() {
+ return this.plugin;
+ }
+
+ protected abstract CommandSource getSource(CommandSourceStack sender);
+
+ @Override
+ protected UUID getUniqueId(CommandSourceStack commandSource) {
+ if (commandSource.getEntity() != null) {
+ return commandSource.getEntity().getUUID();
+ }
+ return Sender.CONSOLE_UUID;
+ }
+
+ @Override
+ protected String getName(CommandSourceStack commandSource) {
+ String name = commandSource.getTextName();
+ if (commandSource.getEntity() != null && name.equals("Server")) {
+ return Sender.CONSOLE_NAME;
+ }
+ return name;
+ }
+
+ @Override
+ protected void sendMessage(CommandSourceStack sender, Component message) {
+ Locale locale = sender.getEntity() instanceof ServerPlayer player
+ ? TranslationManager.parseLocale(player.clientInformation().language())
+ : null;
+ sender.sendSuccess(() -> toNativeText(TranslationManager.render(message, locale)), false);
+ }
+
+ @Override
+ protected boolean hasPermission(CommandSourceStack commandSource, String node) {
+ return getPermissionValue(commandSource, node).asBoolean();
+ }
+
+ @Override
+ protected void performCommand(CommandSourceStack sender, String command) {
+ sender.getServer().getCommands().performPrefixedCommand(sender, command);
+ }
+
+ @Override
+ protected boolean isConsole(CommandSourceStack sender) {
+ CommandSource output = getSource(sender);
+ return output == sender.getServer() || // Console
+ output.getClass() == RconConsoleSource.class || // Rcon
+ (output == CommandSource.NULL && sender.getTextName().equals("")); // Functions
+ }
+
+ public static net.minecraft.network.chat.Component toNativeText(Component component) {
+ return ComponentSerialization.CODEC.decode(
+ RegistryAccess.EMPTY.createSerializationContext(JsonOps.INSTANCE),
+ GsonComponentSerializer.gson().serializeToTree(component)
+ ).getOrThrow(IllegalArgumentException::new).getFirst();
+ }
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/calculator/MinecraftCalculatorFactory.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/calculator/MinecraftCalculatorFactory.java
new file mode 100644
index 000000000..dff9507d0
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/calculator/MinecraftCalculatorFactory.java
@@ -0,0 +1,79 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.calculator;
+
+import me.lucko.luckperms.common.cacheddata.CacheMetadata;
+import me.lucko.luckperms.common.calculator.CalculatorFactory;
+import me.lucko.luckperms.common.calculator.PermissionCalculator;
+import me.lucko.luckperms.common.calculator.PermissionCalculatorMonitored;
+import me.lucko.luckperms.common.calculator.processor.DirectProcessor;
+import me.lucko.luckperms.common.calculator.processor.PermissionProcessor;
+import me.lucko.luckperms.common.calculator.processor.RegexProcessor;
+import me.lucko.luckperms.common.calculator.processor.SpongeWildcardProcessor;
+import me.lucko.luckperms.common.calculator.processor.WildcardProcessor;
+import me.lucko.luckperms.common.config.ConfigKeys;
+import me.lucko.luckperms.common.minecraft.context.MinecraftContextManager;
+import me.lucko.luckperms.common.plugin.LuckPermsPlugin;
+import net.luckperms.api.node.Node;
+import net.luckperms.api.query.QueryOptions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class MinecraftCalculatorFactory implements CalculatorFactory {
+ private final LuckPermsPlugin plugin;
+
+ public MinecraftCalculatorFactory(LuckPermsPlugin plugin) {
+ this.plugin = plugin;
+ }
+
+ @Override
+ public PermissionCalculator build(QueryOptions queryOptions, Map sourceMap, CacheMetadata metadata) {
+ List processors = new ArrayList<>(5);
+
+ processors.add(new DirectProcessor(sourceMap));
+
+ if (this.plugin.getConfiguration().get(ConfigKeys.APPLYING_REGEX)) {
+ processors.add(new RegexProcessor(sourceMap));
+ }
+
+ if (this.plugin.getConfiguration().get(ConfigKeys.APPLYING_WILDCARDS)) {
+ processors.add(new WildcardProcessor(sourceMap));
+ }
+
+ if (this.plugin.getConfiguration().get(ConfigKeys.APPLYING_WILDCARDS_SPONGE)) {
+ processors.add(new SpongeWildcardProcessor(sourceMap));
+ }
+
+ boolean integratedOwner = queryOptions.option(MinecraftContextManager.INTEGRATED_SERVER_OWNER).orElse(false);
+ if (integratedOwner && this.plugin.getConfiguration().get(ConfigKeys.INTEGRATED_SERVER_OWNER_BYPASSES_CHECKS)) {
+ processors.add(ServerOwnerProcessor.INSTANCE);
+ }
+
+ return new PermissionCalculatorMonitored(this.plugin, metadata, processors);
+ }
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/calculator/ServerOwnerProcessor.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/calculator/ServerOwnerProcessor.java
new file mode 100644
index 000000000..c6acd386c
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/calculator/ServerOwnerProcessor.java
@@ -0,0 +1,49 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.calculator;
+
+import me.lucko.luckperms.common.cacheddata.result.TristateResult;
+import me.lucko.luckperms.common.calculator.processor.AbstractPermissionProcessor;
+import me.lucko.luckperms.common.calculator.processor.PermissionProcessor;
+import net.luckperms.api.util.Tristate;
+
+/**
+ * Permission processor which is added to the owner of an integrated server to simply return true if no other processors match.
+ */
+public class ServerOwnerProcessor extends AbstractPermissionProcessor implements PermissionProcessor {
+ private static final TristateResult TRUE_RESULT = new TristateResult.Factory(ServerOwnerProcessor.class).result(Tristate.TRUE);
+
+ public static final ServerOwnerProcessor INSTANCE = new ServerOwnerProcessor();
+
+ private ServerOwnerProcessor() {
+
+ }
+
+ @Override
+ public TristateResult hasPermission(String permission) {
+ return TRUE_RESULT;
+ }
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/command/BrigadierInjector.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/command/BrigadierInjector.java
new file mode 100644
index 000000000..45fcb48cb
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/command/BrigadierInjector.java
@@ -0,0 +1,191 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.command;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.tree.CommandNode;
+import com.mojang.brigadier.tree.LiteralCommandNode;
+import me.lucko.luckperms.common.graph.Graph;
+import me.lucko.luckperms.common.graph.TraversalAlgorithm;
+import me.lucko.luckperms.common.minecraft.MinecraftLuckPermsPlugin;
+import me.lucko.luckperms.common.model.User;
+import net.luckperms.api.query.QueryOptions;
+import net.luckperms.api.util.Tristate;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.permissions.LevelBasedPermissionSet;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.function.Predicate;
+
+/**
+ * Utility for injecting permission requirements into a Brigadier command tree.
+ */
+public final class BrigadierInjector {
+ private BrigadierInjector() {}
+
+ private static final Field REQUIREMENT_FIELD;
+
+ static {
+ Field requirementField;
+ try {
+ requirementField = CommandNode.class.getDeclaredField("requirement");
+ requirementField.setAccessible(true);
+ } catch (NoSuchFieldException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ REQUIREMENT_FIELD = requirementField;
+ }
+
+ /**
+ * Inject permission requirements into the commands in the given dispatcher.
+ *
+ * @param plugin the plugin
+ * @param dispatcher the command dispatcher
+ */
+ public static void inject(MinecraftLuckPermsPlugin, ?> plugin, CommandDispatcher dispatcher) {
+ Iterable tree = CommandNodeGraph.INSTANCE.traverse(
+ TraversalAlgorithm.DEPTH_FIRST_PRE_ORDER,
+ new CommandNodeWithParent(null, dispatcher.getRoot())
+ );
+
+ for (CommandNodeWithParent node : tree) {
+ Predicate requirement = node.node.getRequirement();
+
+ // already injected - skip
+ if (requirement instanceof InjectedPermissionRequirement) {
+ continue;
+ }
+
+ String permission = buildPermissionNode(node);
+ if (permission == null) {
+ continue;
+ }
+
+ plugin.getPermissionRegistry().insert(permission);
+
+ InjectedPermissionRequirement newRequirement = new InjectedPermissionRequirement(plugin, permission, requirement);
+ try {
+ REQUIREMENT_FIELD.set(node.node, newRequirement);
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ private static String buildPermissionNode(CommandNodeWithParent node) {
+ StringBuilder builder = new StringBuilder();
+
+ while (node != null) {
+ if (node.node instanceof LiteralCommandNode) {
+ if (!builder.isEmpty()) {
+ builder.insert(0, '.');
+ }
+
+ String name = node.node.getName().toLowerCase(Locale.ROOT);
+ builder.insert(0, name);
+ }
+
+ node = node.parent;
+ }
+
+ if (builder.isEmpty()) {
+ return null;
+ }
+
+ builder.insert(0, "command.");
+ return builder.toString();
+ }
+
+ /**
+ * Injected {@link CommandNode#getRequirement() requirement} that checks for a permission, before
+ * delegating to the existing requirement.
+ */
+ private static final class InjectedPermissionRequirement implements Predicate {
+ private final MinecraftLuckPermsPlugin, ?> plugin;
+ private final String permission;
+ private final Predicate delegate;
+
+ private InjectedPermissionRequirement(MinecraftLuckPermsPlugin, ?> plugin, String permission, Predicate delegate) {
+ this.plugin = plugin;
+ this.permission = permission;
+ this.delegate = delegate;
+ }
+
+ @Override
+ public boolean test(CommandSourceStack source) {
+ if (source.getEntity() instanceof ServerPlayer player) {
+
+ User user = this.plugin.getUserManager().getIfLoaded(player.getUUID());
+ if (user == null) {
+ return false;
+ }
+
+ QueryOptions queryOptions = this.plugin.getContextManager().getQueryOptions(player);
+ Tristate state = user.getCachedData().getPermissionData(queryOptions).checkPermission(this.permission);
+
+ if (state != Tristate.UNDEFINED) {
+ return state.asBoolean() && this.delegate.test(source.withPermission(LevelBasedPermissionSet.OWNER));
+ }
+ }
+
+ return this.delegate.test(source);
+ }
+ }
+
+ /**
+ * A {@link Graph} to represent the brigadier command node tree.
+ */
+ private enum CommandNodeGraph implements Graph {
+ INSTANCE;
+
+ @Override
+ public Iterable extends CommandNodeWithParent> successors(CommandNodeWithParent ctx) {
+ CommandNode node = ctx.node;
+ Collection successors = new ArrayList<>();
+
+ for (CommandNode child : node.getChildren()) {
+ successors.add(new CommandNodeWithParent(ctx, child));
+ }
+
+ return successors;
+ }
+ }
+
+ private static final class CommandNodeWithParent {
+ private final CommandNodeWithParent parent;
+ private final CommandNode node;
+
+ private CommandNodeWithParent(CommandNodeWithParent parent, CommandNode node) {
+ this.parent = parent;
+ this.node = node;
+ }
+ }
+
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/command/MinecraftCommandExecutor.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/command/MinecraftCommandExecutor.java
new file mode 100644
index 000000000..137bef8ff
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/command/MinecraftCommandExecutor.java
@@ -0,0 +1,178 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.command;
+
+import com.mojang.brigadier.Command;
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.suggestion.SuggestionProvider;
+import com.mojang.brigadier.suggestion.Suggestions;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+import com.mojang.brigadier.tree.ArgumentCommandNode;
+import com.mojang.brigadier.tree.LiteralCommandNode;
+import me.lucko.luckperms.common.command.CommandManager;
+import me.lucko.luckperms.common.command.utils.ArgumentTokenizer;
+import me.lucko.luckperms.common.config.ConfigKeys;
+import me.lucko.luckperms.common.minecraft.MinecraftLuckPermsPlugin;
+import me.lucko.luckperms.common.sender.Sender;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.commands.arguments.EntityArgument;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.permissions.LevelBasedPermissionSet;
+import net.minecraft.server.permissions.Permission;
+import net.minecraft.server.permissions.PermissionLevel;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.concurrent.CompletableFuture;
+
+public class MinecraftCommandExecutor extends CommandManager implements Command, SuggestionProvider {
+ private static final String[] COMMAND_ALIASES = new String[]{"luckperms", "lp", "perm", "perms", "permission", "permissions"};
+
+ private final MinecraftLuckPermsPlugin, ?> plugin;
+
+ protected MinecraftCommandExecutor(MinecraftLuckPermsPlugin, ?> plugin) {
+ super(plugin);
+ this.plugin = plugin;
+ }
+
+ public void register(CommandDispatcher dispatcher) {
+ for (String alias : COMMAND_ALIASES) {
+ LiteralCommandNode command = Commands.literal(alias).executes(this).build();
+ ArgumentCommandNode argument = Commands.argument("args", StringArgumentType.greedyString())
+ .suggests(this)
+ .executes(this)
+ .build();
+
+ command.addChild(argument);
+ dispatcher.getRoot().addChild(command);
+ }
+ }
+
+ @Override
+ public int run(CommandContext context) throws CommandSyntaxException {
+ CommandSourceStack source = context.getSource();
+ Sender sender = this.plugin.getSenderFactory().wrap(source);
+
+ int start = context.getRange().getStart();
+ String buffer = context.getInput().substring(start);
+
+ List arguments;
+ if (this.plugin.getConfiguration().get(ConfigKeys.RESOLVE_COMMAND_SELECTORS)) {
+ arguments = resolveSelectors(source, ArgumentTokenizer.EXECUTE.tokenizeInput(buffer));
+ } else {
+ arguments = ArgumentTokenizer.EXECUTE.tokenizeInput(buffer);
+ }
+
+ String label = arguments.remove(0);
+ if (label.startsWith("/")) {
+ label = label.substring(1);
+ }
+
+ executeCommand(sender, label, arguments);
+ return Command.SINGLE_SUCCESS;
+ }
+
+ @Override
+ public CompletableFuture getSuggestions(CommandContext context, SuggestionsBuilder builder) throws CommandSyntaxException {
+ CommandSourceStack source = context.getSource();
+ Sender sender = this.plugin.getSenderFactory().wrap(source);
+
+ int idx = builder.getStart();
+
+ String buffer = builder.getInput().substring(idx);
+ idx += buffer.length();
+
+ List arguments = ArgumentTokenizer.TAB_COMPLETE.tokenizeInput(buffer);
+ List resolvedArguments;
+ if (this.plugin.getConfiguration().get(ConfigKeys.RESOLVE_COMMAND_SELECTORS)) {
+ resolvedArguments = resolveSelectors(source, new ArrayList<>(arguments));
+ } else {
+ resolvedArguments = arguments;
+ }
+
+ if (!arguments.isEmpty() && !resolvedArguments.isEmpty()) {
+ idx -= arguments.get(arguments.size() - 1).length();
+ }
+
+ List completions = tabCompleteCommand(sender, resolvedArguments);
+
+ // Offset the builder from the current string range so suggestions are placed in the right spot
+ builder = builder.createOffset(idx);
+ for (String completion : completions) {
+ builder.suggest(completion);
+ }
+ return builder.buildFuture();
+ }
+
+ private List resolveSelectors(CommandSourceStack source, List args) {
+ // usage of @ selectors requires at least level 2 permission
+
+ CommandSourceStack atAllowedSource = ensureSourceCanUseSelectors(source);
+ for (ListIterator it = args.listIterator(); it.hasNext(); ) {
+ String arg = it.next();
+ if (arg.isEmpty() || arg.charAt(0) != '@') {
+ continue;
+ }
+
+ List matchedPlayers;
+ try {
+ matchedPlayers = EntityArgument.entities().parse(new StringReader(arg)).findPlayers(atAllowedSource);
+ } catch (CommandSyntaxException e) {
+ this.plugin.getLogger().warn("Error parsing selector '" + arg + "' for " + source + " executing " + args, e);
+ continue;
+ }
+
+ if (matchedPlayers.isEmpty()) {
+ continue;
+ }
+
+ if (matchedPlayers.size() > 1) {
+ this.plugin.getLogger().warn("Error parsing selector '" + arg + "' for " + source + " executing " + args +
+ ": ambiguous result (more than one player matched) - " + matchedPlayers);
+ continue;
+ }
+
+ ServerPlayer player = matchedPlayers.get(0);
+ it.set(player.getStringUUID());
+ }
+
+ return args;
+ }
+
+ private static CommandSourceStack ensureSourceCanUseSelectors(CommandSourceStack source) {
+ if (source.permissions().hasPermission(new Permission.HasCommandLevel(PermissionLevel.GAMEMASTERS))) {
+ return source;
+ }
+ return source.withMaximumPermission(LevelBasedPermissionSet.GAMEMASTER);
+ }
+
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/context/MinecraftContextManager.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/context/MinecraftContextManager.java
new file mode 100644
index 000000000..28356de28
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/context/MinecraftContextManager.java
@@ -0,0 +1,36 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.context;
+
+import me.lucko.luckperms.common.context.manager.ContextManager;
+import net.luckperms.api.query.OptionKey;
+import net.minecraft.server.level.ServerPlayer;
+
+public interface MinecraftContextManager extends ContextManager {
+
+ OptionKey INTEGRATED_SERVER_OWNER = OptionKey.of("integrated_server_owner", Boolean.class);
+
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/context/MinecraftPlayerCalculator.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/context/MinecraftPlayerCalculator.java
new file mode 100644
index 000000000..6a0ad1718
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/context/MinecraftPlayerCalculator.java
@@ -0,0 +1,125 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.context;
+
+import me.lucko.luckperms.common.config.ConfigKeys;
+import me.lucko.luckperms.common.context.ImmutableContextSetImpl;
+import me.lucko.luckperms.common.minecraft.MinecraftLuckPermsPlugin;
+import net.luckperms.api.context.Context;
+import net.luckperms.api.context.ContextCalculator;
+import net.luckperms.api.context.ContextConsumer;
+import net.luckperms.api.context.ContextSet;
+import net.luckperms.api.context.DefaultContextKeys;
+import net.luckperms.api.context.ImmutableContextSet;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.Identifier;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.level.GameType;
+import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+import java.util.Set;
+
+public class MinecraftPlayerCalculator implements ContextCalculator {
+ protected final MinecraftLuckPermsPlugin, ?> plugin;
+
+ protected final boolean gamemode;
+ protected final boolean world;
+ protected final boolean dimensionType;
+
+ public MinecraftPlayerCalculator(MinecraftLuckPermsPlugin, ?> plugin, Set disabled) {
+ this.plugin = plugin;
+ this.gamemode = !disabled.contains(DefaultContextKeys.GAMEMODE_KEY);
+ this.world = !disabled.contains(DefaultContextKeys.WORLD_KEY);
+ this.dimensionType = !disabled.contains(DefaultContextKeys.DIMENSION_TYPE_KEY);
+ }
+
+ @Override
+ public void calculate(@NonNull ServerPlayer target, @NonNull ContextConsumer consumer) {
+ if (this.gamemode) {
+ GameType gameMode = target.gameMode.getGameModeForPlayer();
+ consumer.accept(DefaultContextKeys.GAMEMODE_KEY, gameMode.getName());
+ }
+
+ ServerLevel level = target.level();
+ if (this.dimensionType) {
+ consumer.accept(DefaultContextKeys.DIMENSION_TYPE_KEY, getContextKey(level.dimensionTypeRegistration().unwrapKey().orElse(BuiltinDimensionTypes.OVERWORLD).identifier()));
+ }
+
+ if (this.world) {
+ this.plugin.getConfiguration().get(ConfigKeys.WORLD_REWRITES).rewriteAndSubmit(getContextKey(level.dimension().identifier()), consumer);
+ }
+ }
+
+ @Override
+ public @NonNull ContextSet estimatePotentialContexts() {
+ ImmutableContextSet.Builder builder = new ImmutableContextSetImpl.BuilderImpl();
+
+ if (this.gamemode) {
+ for (GameType gameType : GameType.values()) {
+ builder.add(DefaultContextKeys.GAMEMODE_KEY, gameType.getName());
+ }
+ }
+
+ MinecraftServer server = this.plugin.getBootstrap().getServer().orElse(null);
+ if (this.dimensionType && server != null) {
+ try {
+ server.registryAccess().lookup(Registries.DIMENSION_TYPE).ifPresent(registry -> {
+ for (Identifier id : registry.keySet()) {
+ builder.add(DefaultContextKeys.DIMENSION_TYPE_KEY, getContextKey(id));
+ }
+ });
+ } catch (ClassCastException e) {
+ // Some mod loaders (observed on NeoForge 1.21.1) can return a registry
+ // implementation from #lookup(...) that isn't safely castable to Registry
+ // at this call site, e.g. a MappedRegistry$1 wrapper produced by their mixins.
+ // Rather than letting this crash the whole potential-context calculation
+ // (which is used by things like the web editor), skip the dimension-type
+ // contexts for this estimate.
+ // See: https://github.com/LuckPerms/LuckPerms/issues/4211
+ }
+ }
+
+ if (this.world && server != null) {
+ for (ServerLevel level : server.getAllLevels()) {
+ if (Context.isValidValue(level.dimension().identifier().toString())) {
+ builder.add(DefaultContextKeys.WORLD_KEY, level.dimension().identifier().toString());
+ }
+ }
+ }
+
+ return builder.build();
+ }
+
+ private static String getContextKey(Identifier key) {
+ if (key.getNamespace().equals("minecraft")) {
+ return key.getPath();
+ }
+ return key.toString();
+ }
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/listeners/MinecraftAutoOpListener.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/listeners/MinecraftAutoOpListener.java
new file mode 100644
index 000000000..37041da7b
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/listeners/MinecraftAutoOpListener.java
@@ -0,0 +1,68 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.listeners;
+
+import me.lucko.luckperms.common.event.listeners.AbstractAutoOpListener;
+import me.lucko.luckperms.common.minecraft.MinecraftLuckPermsPlugin;
+import net.minecraft.server.level.ServerPlayer;
+
+import java.util.UUID;
+
+public class MinecraftAutoOpListener extends AbstractAutoOpListener, ServerPlayer> {
+ public MinecraftAutoOpListener(MinecraftLuckPermsPlugin, ?> plugin) {
+ super(plugin, plugin.getContextManager(), ServerPlayer.class);
+ }
+
+ @Override
+ protected boolean isServerAvailable() {
+ return this.plugin.getBootstrap().getServer().isPresent();
+ }
+
+ @Override
+ protected UUID getUniqueId(ServerPlayer player) {
+ return player.getUUID();
+ }
+
+ @Override
+ protected void setOp(ServerPlayer player, boolean value, boolean callerIsSync) {
+ if (callerIsSync) {
+ setOp(player, value);
+ } else {
+ this.plugin.getBootstrap().getScheduler().executeSync(() -> setOp(player, value));
+ }
+ }
+
+ private void setOp(ServerPlayer player, boolean value) {
+ this.plugin.getBootstrap().getServer().ifPresent(server -> {
+ if (value) {
+ server.getPlayerList().op(player.nameAndId());
+ } else {
+ server.getPlayerList().deop(player.nameAndId());
+ }
+ });
+ }
+
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/listeners/MinecraftCommandListUpdater.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/listeners/MinecraftCommandListUpdater.java
new file mode 100644
index 000000000..b1f7c2dfd
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/listeners/MinecraftCommandListUpdater.java
@@ -0,0 +1,65 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.listeners;
+
+import me.lucko.luckperms.common.event.listeners.AbstractCommandListUpdater;
+import me.lucko.luckperms.common.minecraft.MinecraftLuckPermsPlugin;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.players.PlayerList;
+
+import java.util.UUID;
+
+/**
+ * Calls {@link PlayerList#sendPlayerPermissionLevel(ServerPlayer)} when a players permissions change.
+ */
+public class MinecraftCommandListUpdater extends AbstractCommandListUpdater, ServerPlayer> {
+ public MinecraftCommandListUpdater(MinecraftLuckPermsPlugin, ?> plugin) {
+ super(plugin, ServerPlayer.class);
+ }
+
+ @Override
+ protected boolean isServerAvailable() {
+ return this.plugin.getBootstrap().getServer().isPresent();
+ }
+
+ @Override
+ protected UUID getUniqueId(ServerPlayer player) {
+ return player.getUUID();
+ }
+
+ @Override
+ protected void sendCommandListUpdate(UUID uniqueId) {
+ this.plugin.getBootstrap().getScheduler().executeSync(() -> {
+ ServerPlayer player = this.plugin.getBootstrap().getPlayer(uniqueId).orElse(null);
+ if (player != null) {
+ MinecraftServer server = player.level().getServer();
+ server.getPlayerList().sendPlayerPermissionLevel(player);
+ }
+ });
+ }
+
+}
diff --git a/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/util/AbstractAsyncConfigurationTask.java b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/util/AbstractAsyncConfigurationTask.java
new file mode 100644
index 000000000..94d77162a
--- /dev/null
+++ b/common/minecraft/src/main/java/me/lucko/luckperms/common/minecraft/util/AbstractAsyncConfigurationTask.java
@@ -0,0 +1,59 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.minecraft.util;
+
+import me.lucko.luckperms.common.minecraft.MinecraftLuckPermsPlugin;
+import net.minecraft.server.network.ConfigurationTask;
+
+import java.util.concurrent.CompletableFuture;
+
+public abstract class AbstractAsyncConfigurationTask implements ConfigurationTask {
+ private final MinecraftLuckPermsPlugin, ?> plugin;
+ private final Type type;
+ private final Runnable task;
+
+ public AbstractAsyncConfigurationTask(MinecraftLuckPermsPlugin, ?> plugin, Type type, Runnable task) {
+ this.plugin = plugin;
+ this.type = type;
+ this.task = task;
+ }
+
+ protected CompletableFuture start(Runnable completeCallback) {
+ CompletableFuture future = CompletableFuture.runAsync(this.task, this.plugin.getBootstrap().getScheduler().async());
+ future.whenCompleteAsync((o, e) -> {
+ if (e != null) {
+ this.plugin.getLogger().warn("Configuration task threw an exception", e);
+ }
+ completeCallback.run();
+ }, this.plugin.getBootstrap().getScheduler().sync());
+ return future;
+ }
+
+ @Override
+ public Type type() {
+ return this.type;
+ }
+}
\ No newline at end of file
diff --git a/common/placeholders/build.gradle b/common/placeholders/build.gradle
new file mode 100644
index 000000000..155198238
--- /dev/null
+++ b/common/placeholders/build.gradle
@@ -0,0 +1,45 @@
+plugins {
+ id("java-library")
+ id("jacoco")
+}
+
+test {
+ useJUnitPlatform()
+}
+
+jacocoTestReport {
+ dependsOn test
+}
+
+dependencies {
+ api project(':api')
+
+ compileOnly 'org.checkerframework:checker-qual:3.49.3'
+ compileOnly 'org.jetbrains:annotations:26.0.2'
+
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.13.0'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ testImplementation 'org.testcontainers:testcontainers-junit-jupiter:2.0.2'
+ testImplementation 'org.mockito:mockito-core:5.18.0'
+ testImplementation 'org.mockito:mockito-junit-jupiter:5.18.0'
+}
+
+publishing {
+ //repositories {
+ // maven {
+ // url = 'https://nexus.lucko.me/repository/maven-snapshots/'
+ // credentials {
+ // username = luckoNexusUsername
+ // password = luckoNexusPassword
+ // }
+ // }
+ //}
+ publications {
+ mavenJava(MavenPublication) {
+ from components.java
+ artifactId = 'common-placeholders'
+ version = "${project.ext.fullVersion}-SNAPSHOT"
+ }
+ }
+}
\ No newline at end of file
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholder.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholder.java
new file mode 100644
index 000000000..dbb7e6faf
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholder.java
@@ -0,0 +1,117 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A placeholder definition.
+ */
+public interface Placeholder {
+
+ /**
+ * Get the id of the placeholder.
+ *
+ * @return the id
+ */
+ @NonNull String id();
+
+ /**
+ * A placeholder function that uses the standard {@link PlaceholderContext}
+ */
+ interface BasicPlaceholderFunction {
+ /**
+ * Resolve the value of this placeholder with some given context.
+ *
+ * @param ctx the context
+ * @return the resolved value
+ */
+ @NonNull String resolve(@NonNull PlaceholderContext ctx);
+ }
+
+ /**
+ * A placeholder function that uses the extended {@link PlaceholderContext.WithArgument}
+ */
+ interface UsingArgumentPlaceholderFunction {
+ /**
+ * Resolve the value of this placeholder with some given context.
+ *
+ * @param ctx the context
+ * @return the resolved value
+ */
+ @NonNull String resolve(PlaceholderContext.@NonNull WithArgument ctx);
+ }
+
+ /** A basic placeholder */
+ interface Basic extends Placeholder, BasicPlaceholderFunction {}
+
+ /** A placeholder that uses an argument */
+ interface UsingArgument extends Placeholder, UsingArgumentPlaceholderFunction {}
+
+ /**
+ * Create a standard placeholder using the given resolver function.
+ *
+ * @param id the id
+ * @param resolver the resolver function
+ * @return the placeholder
+ */
+ static Basic basic(@NonNull String id, Placeholder.@NonNull BasicPlaceholderFunction resolver) {
+ return new Basic() {
+ @Override
+ public @NonNull String id() {
+ return id;
+ }
+
+ @Override
+ public @NotNull String resolve(@NotNull PlaceholderContext ctx) {
+ return resolver.resolve(ctx);
+ }
+ };
+ }
+
+ /**
+ * Create a dynamic placeholder using the given resolver function.
+ *
+ * @param id the id
+ * @param resolver the resolver function
+ * @return the placeholder
+ */
+ static UsingArgument usingArgument(@NonNull String id, Placeholder.@NonNull UsingArgumentPlaceholderFunction resolver) {
+ return new UsingArgument() {
+ @Override
+ public @NonNull String id() {
+ return id;
+ }
+
+ @Override
+ public @NotNull String resolve(PlaceholderContext.@NotNull WithArgument ctx) {
+ return resolver.resolve(ctx);
+ }
+ };
+ }
+
+}
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderContext.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderContext.java
new file mode 100644
index 000000000..d1b2bd75d
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderContext.java
@@ -0,0 +1,110 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.cacheddata.CachedDataManager;
+import net.luckperms.api.cacheddata.CachedMetaData;
+import net.luckperms.api.cacheddata.CachedPermissionData;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/**
+ * The context passed to a {@link Placeholder} resolve request.
+ */
+public class PlaceholderContext {
+
+ /** The LuckPerms API instance */
+ private final @NonNull LuckPerms api;
+ /** The user for the player the placeholder is being resolved for */
+ private final @NonNull User user;
+ /** The query options for the player the placeholder is being resolved for */
+ private final @NonNull QueryOptions queryOptions;
+
+ public PlaceholderContext(@NonNull LuckPerms api, @NonNull User user, @NonNull QueryOptions queryOptions) {
+ this.api = api;
+ this.user = user;
+ this.queryOptions = queryOptions;
+ }
+
+ public @NonNull LuckPerms api() {
+ return this.api;
+ }
+
+ public @NonNull User user() {
+ return this.user;
+ }
+
+ public @NonNull QueryOptions queryOptions() {
+ return this.queryOptions;
+ }
+
+ public @NonNull CachedDataManager userData() {
+ return this.user.getCachedData();
+ }
+
+ public @NonNull CachedPermissionData permissionData() {
+ return this.user.getCachedData().getPermissionData(this.queryOptions);
+ }
+
+ public @NonNull CachedMetaData metaData() {
+ return this.user.getCachedData().getMetaData(this.queryOptions);
+ }
+
+ /**
+ * Create a copy of this placeholder context, additionally including an argument.
+ *
+ * @param argument the argument
+ * @return the new context
+ */
+ public WithArgument withArgument(@NonNull String argument) {
+ return new WithArgument(this.api, this.user, this.queryOptions, argument);
+ }
+
+ /**
+ * Extension of {@link PlaceholderContext} with an extra dynamic argument provided by the requester.
+ */
+ public static class WithArgument extends PlaceholderContext {
+
+ /** An additional argument passed to the placeholder resolve request */
+ private final @NonNull String argument;
+
+ public WithArgument(@NonNull LuckPerms api, @NonNull User user, @NonNull QueryOptions queryOptions, @NonNull String argument) {
+ super(api, user, queryOptions);
+ this.argument = argument;
+ }
+
+ /**
+ * Gets the argument.
+ *
+ * @return the argument
+ */
+ public @NonNull String argument() {
+ return this.argument;
+ }
+ }
+}
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistry.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistry.java
new file mode 100644
index 000000000..bde519859
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistry.java
@@ -0,0 +1,100 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * A registry of standard/built-in {@link Placeholder}s.
+ */
+public class PlaceholderRegistry {
+
+ private static final List PLACEHOLDERS = Collections.unmodifiableList(Arrays.asList(
+ Placeholders.PREFIX,
+ Placeholders.SUFFIX,
+ Placeholders.ALL_META,
+ Placeholders.META,
+ Placeholders.PREFIX_ELEMENT,
+ Placeholders.SUFFIX_ELEMENT,
+ Placeholders.ALL_CONTEXT,
+ Placeholders.CONTEXT,
+ Placeholders.GROUPS,
+ Placeholders.INHERITED_GROUPS,
+ Placeholders.PRIMARY_GROUP_NAME,
+ Placeholders.HAS_PERMISSION,
+ Placeholders.INHERITS_PERMISSION,
+ Placeholders.CHECK_PERMISSION,
+ Placeholders.IN_GROUP,
+ Placeholders.INHERITS_GROUP,
+ Placeholders.ON_TRACK,
+ Placeholders.HAS_GROUPS_ON_TRACK,
+ Placeholders.HIGHEST_GROUP_BY_WEIGHT,
+ Placeholders.LOWEST_GROUP_BY_WEIGHT,
+ Placeholders.HIGHEST_INHERITED_GROUP_BY_WEIGHT,
+ Placeholders.LOWEST_INHERITED_GROUP_BY_WEIGHT,
+ Placeholders.HIGHEST_GROUP_WEIGHT,
+ Placeholders.CURRENT_GROUP_ON_TRACK,
+ Placeholders.NEXT_GROUP_ON_TRACK,
+ Placeholders.PREVIOUS_GROUP_ON_TRACK,
+ Placeholders.FIRST_GROUP_ON_TRACKS,
+ Placeholders.LAST_GROUP_ON_TRACKS,
+ Placeholders.EXPIRY_TIME,
+ Placeholders.INHERITED_EXPIRY_TIME,
+ Placeholders.GROUP_EXPIRY_TIME,
+ Placeholders.INHERITED_GROUP_EXPIRY_TIME
+ ));
+
+ private static final Map PLACEHOLDER_MAP = PLACEHOLDERS.stream()
+ .collect(Collectors.toMap(Placeholder::id, Function.identity()));
+
+ /**
+ * Get a list of all placeholders.
+ *
+ * @return a list of placeholders
+ */
+ public static @NonNull List getAll() {
+ return PLACEHOLDERS;
+ }
+
+ /**
+ * Lookup a placeholder by id.
+ *
+ * @param id the id to lookup
+ * @return the placeholder, if found
+ */
+ public static @Nullable Placeholder lookup(@NonNull String id) {
+ return PLACEHOLDER_MAP.get(id);
+ }
+
+}
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderResolver.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderResolver.java
new file mode 100644
index 000000000..5eed8ad0e
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/PlaceholderResolver.java
@@ -0,0 +1,106 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import java.util.Collection;
+import java.util.Locale;
+
+/**
+ * Resolves placeholders using a simple string format:
+ *
+ *
+ *
+ * - placeholder
+ * - placeholder_argument
+ *
+ *
+ *
+ * Note: this resolver does not parse placeholders mid-string, it expects to receive the parsed
+ * placeholder string as input.
+ */
+public class PlaceholderResolver {
+
+ /** The placeholders used by this resolver */
+ private final Collection placeholders;
+
+ /**
+ * Create a resolver using the built-in placeholders registered in {@link PlaceholderRegistry}.
+ */
+ public PlaceholderResolver() {
+ this(PlaceholderRegistry.getAll());
+ }
+
+ /**
+ * Create a resolver using a custom list of placeholders.
+ *
+ * @param placeholders the placeholders
+ */
+ public PlaceholderResolver(Collection placeholders) {
+ this.placeholders = placeholders;
+ }
+
+ /**
+ * Resolve the placeholder value of a given input string
+ *
+ * @param input the input string
+ * @return the resolved value, or null if no placeholder matched
+ */
+ public String resolve(PlaceholderContext ctx, String input) {
+ input = input.toLowerCase(Locale.ROOT);
+ for (Placeholder placeholder : this.placeholders) {
+ String result = attemptResolve(ctx, input, placeholder);
+ if (result != null) {
+ return result;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Attempt to resolve the placeholder value of a given input string for a specific placeholder.
+ *
+ * @param ctx the placeholder context
+ * @param input the input string
+ * @param placeholder the placeholder to attempt to resolve with
+ * @return the resolved value if the placeholder matches the input, or null if it does not match
+ */
+ protected String attemptResolve(PlaceholderContext ctx, String input, Placeholder placeholder) {
+ String id = placeholder.id();
+ if (placeholder instanceof Placeholder.Basic) {
+ if (input.equals(id)) {
+ return ((Placeholder.Basic) placeholder).resolve(ctx);
+ }
+ } else if (placeholder instanceof Placeholder.UsingArgument) {
+ if (input.startsWith(id + "_") && input.length() > (id.length() + 1)) {
+ String argument = input.substring(id.length() + 1);
+ return ((Placeholder.UsingArgument) placeholder).resolve(ctx.withArgument(argument));
+ }
+ } else {
+ throw new IllegalArgumentException("Unknown placeholder type: " + placeholder.getClass());
+ }
+ return null;
+ }
+}
diff --git a/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholders.java b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholders.java
new file mode 100644
index 000000000..0979aed19
--- /dev/null
+++ b/common/placeholders/src/main/java/me/lucko/luckperms/common/placeholders/Placeholders.java
@@ -0,0 +1,461 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.metastacking.DuplicateRemovalFunction;
+import net.luckperms.api.metastacking.MetaStackDefinition;
+import net.luckperms.api.metastacking.MetaStackElement;
+import net.luckperms.api.model.group.Group;
+import net.luckperms.api.node.Node;
+import net.luckperms.api.node.NodeType;
+import net.luckperms.api.node.types.InheritanceNode;
+import net.luckperms.api.query.QueryOptions;
+import net.luckperms.api.track.Track;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalInt;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * A class containing the standard, built-in placeholders.
+ */
+public final class Placeholders {
+ private Placeholders() {
+ throw new AssertionError();
+ }
+
+ // Basic meta placeholders
+
+ /** Outputs the user's prefix. */
+ public static final Placeholder.Basic PREFIX = Placeholder.basic("prefix", (ctx) -> stringNullToEmpty(ctx.metaData().getPrefix()));
+
+ /** Outputs the user's suffix. */
+ public static final Placeholder.Basic SUFFIX = Placeholder.basic("suffix", (ctx) -> stringNullToEmpty(ctx.metaData().getSuffix()));
+
+ /** Outputs all meta values for a given key, separated by commas. */
+ public static final Placeholder.UsingArgument ALL_META = Placeholder.usingArgument("all_meta", (ctx) -> {
+ List values = ctx.metaData().getMeta().getOrDefault(ctx.argument(), Collections.emptyList());
+ return values.isEmpty() ? "" : String.join(", ", values);
+ });
+
+ /** Outputs a specific meta value for a given key. */
+ public static final Placeholder.UsingArgument META = Placeholder.usingArgument("meta", (ctx) -> stringNullToEmpty(ctx.metaData().getMetaValue(ctx.argument())));
+
+ // Meta stack element placeholders
+ /** Outputs the user's prefix from a specific meta stack element. */
+ public static final Placeholder.UsingArgument PREFIX_ELEMENT = Placeholder.usingArgument("prefix_element", (ctx) -> {
+ MetaStackElement stackElement = ctx.api().getMetaStackFactory().fromString(ctx.argument()).orElse(null);
+ if (stackElement == null) {
+ throw new IllegalArgumentException("Invalid meta stack element: " + ctx.argument());
+ }
+
+ MetaStackDefinition stackDefinition = ctx.api().getMetaStackFactory().createDefinition(
+ Collections.singletonList(stackElement), DuplicateRemovalFunction.RETAIN_ALL, "", "", "");
+ QueryOptions newOptions = ctx.queryOptions().toBuilder()
+ .option(MetaStackDefinition.PREFIX_STACK_KEY, stackDefinition)
+ .option(MetaStackDefinition.SUFFIX_STACK_KEY, stackDefinition)
+ .build();
+
+ return stringNullToEmpty(ctx.userData().getMetaData(newOptions).getPrefix());
+ });
+
+ /** Outputs the user's suffix from a specific meta stack element. */
+ public static final Placeholder.UsingArgument SUFFIX_ELEMENT = Placeholder.usingArgument("suffix_element", (ctx) -> {
+ MetaStackElement stackElement = ctx.api().getMetaStackFactory().fromString(ctx.argument()).orElse(null);
+ if (stackElement == null) {
+ throw new IllegalArgumentException("Invalid meta stack element: " + ctx.argument());
+ }
+
+ MetaStackDefinition stackDefinition = ctx.api().getMetaStackFactory().createDefinition(
+ Collections.singletonList(stackElement), DuplicateRemovalFunction.RETAIN_ALL, "", "", "");
+ QueryOptions newOptions = ctx.queryOptions().toBuilder()
+ .option(MetaStackDefinition.PREFIX_STACK_KEY, stackDefinition)
+ .option(MetaStackDefinition.SUFFIX_STACK_KEY, stackDefinition)
+ .build();
+
+ return Objects.toString(ctx.userData().getMetaData(newOptions).getSuffix(), "");
+ });
+
+ // Context placeholders
+
+ /** Outputs all context key-value pairs, separated by commas. */
+ public static final Placeholder.Basic ALL_CONTEXT = Placeholder.basic("all_context", (ctx) ->
+ ctx.queryOptions().context().toSet().stream()
+ .map(c -> c.getKey() + "=" + c.getValue())
+ .collect(Collectors.joining(", "))
+ );
+
+ /** Outputs all values for a specific context key, separated by commas. */
+ public static final Placeholder.UsingArgument CONTEXT = Placeholder.usingArgument("context", (ctx) -> String.join(", ", ctx.queryOptions().context().getValues(ctx.argument())));
+
+ // Group placeholders
+
+ /** Outputs the user's directly assigned groups, separated by commas. */
+ public static final Placeholder.Basic GROUPS = Placeholder.basic("groups", (ctx) ->
+ ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .collect(Collectors.joining(", "))
+ );
+
+ /** Outputs all groups the user inherits from, separated by commas. */
+ public static final Placeholder.Basic INHERITED_GROUPS = Placeholder.basic("inherited_groups", (ctx) ->
+ ctx.user().getInheritedGroups(ctx.queryOptions()).stream()
+ .map(Group::getFriendlyName)
+ .collect(Collectors.joining(", "))
+ );
+
+ /** Outputs the user's primary group name. */
+ public static final Placeholder.Basic PRIMARY_GROUP_NAME = Placeholder.basic("primary_group_name", (ctx) ->
+ convertGroupDisplayName(ctx, ctx.user().getPrimaryGroup())
+ );
+
+ // Permission check placeholders
+ /** Checks if the user has a specific permission node directly assigned (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument HAS_PERMISSION = Placeholder.usingArgument("has_permission", (ctx) ->
+ String.valueOf(ctx.user().getNodes().stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .anyMatch(n -> n.getKey().equals(ctx.argument())))
+ );
+
+ /** Checks if the user inherits a specific permission node (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument INHERITS_PERMISSION = Placeholder.usingArgument("inherits_permission", (ctx) ->
+ String.valueOf(ctx.user().resolveInheritedNodes(ctx.queryOptions()).stream()
+ .filter(n -> n.getContexts().isSatisfiedBy(ctx.queryOptions().context()))
+ .anyMatch(n -> n.getKey().equals(ctx.argument())))
+ );
+
+ /** Checks the result of a permission check (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument CHECK_PERMISSION = Placeholder.usingArgument("check_permission", (ctx) ->
+ String.valueOf(ctx.permissionData().checkPermission(ctx.argument()).asBoolean())
+ );
+
+ /** Checks if the user is directly in a specific group (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument IN_GROUP = Placeholder.usingArgument("in_group", (ctx) ->
+ String.valueOf(ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .anyMatch(s -> s.equalsIgnoreCase(ctx.argument())))
+ );
+
+ /** Checks if the user inherits from a specific group (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument INHERITS_GROUP = Placeholder.usingArgument("inherits_group", (ctx) ->
+ String.valueOf(ctx.user().getInheritedGroups(ctx.queryOptions()).stream()
+ .anyMatch(g -> g.getName().equalsIgnoreCase(ctx.argument())))
+ );
+
+ // Track placeholders
+ /** Checks if the user's primary group is on a specific track (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument ON_TRACK = Placeholder.usingArgument("on_track", (ctx) ->
+ String.valueOf(Optional.ofNullable(ctx.api().getTrackManager().getTrack(ctx.argument()))
+ .map(t -> t.containsGroup(ctx.user().getPrimaryGroup()))
+ .orElse(false))
+ );
+
+ /** Checks if the user has any groups on a specific track (outputs "true" or "false"). */
+ public static final Placeholder.UsingArgument HAS_GROUPS_ON_TRACK = Placeholder.usingArgument("has_groups_on_track", (ctx) ->
+ String.valueOf(Optional.ofNullable(ctx.api().getTrackManager().getTrack(ctx.argument()))
+ .map(t -> ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .map(InheritanceNode::getGroupName)
+ .anyMatch(t::containsGroup)
+ )
+ .orElse(false))
+ );
+
+ // Group weight placeholders
+
+ /** Outputs the name of the user's highest weighted directly assigned group. */
+ public static final Placeholder.Basic HIGHEST_GROUP_BY_WEIGHT = Placeholder.basic("highest_group_by_weight", (ctx) ->
+ ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .map(n -> ctx.api().getGroupManager().getGroup(n))
+ .filter(Objects::nonNull)
+ .max(Comparator.comparingInt(g -> g.getWeight().orElse(0)))
+ .map(Group::getName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("")
+ );
+
+ /** Outputs the name of the user's lowest weighted directly assigned group. */
+ public static final Placeholder.Basic LOWEST_GROUP_BY_WEIGHT = Placeholder.basic("lowest_group_by_weight", (ctx) ->
+ ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .map(n -> ctx.api().getGroupManager().getGroup(n))
+ .filter(Objects::nonNull)
+ .min(Comparator.comparingInt(g -> g.getWeight().orElse(0)))
+ .map(Group::getName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("")
+ );
+
+ /** Outputs the name of the user's highest weighted inherited group. */
+ public static final Placeholder.Basic HIGHEST_INHERITED_GROUP_BY_WEIGHT = Placeholder.basic("highest_inherited_group_by_weight", (ctx) ->
+ ctx.user().getInheritedGroups(ctx.queryOptions()).stream()
+ .max(Comparator.comparingInt(g -> g.getWeight().orElse(0)))
+ .map(Group::getName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("")
+ );
+
+ /** Outputs the name of the user's lowest weighted inherited group. */
+ public static final Placeholder.Basic LOWEST_INHERITED_GROUP_BY_WEIGHT = Placeholder.basic("lowest_inherited_group_by_weight", (ctx) ->
+ ctx.user().getInheritedGroups(ctx.queryOptions()).stream()
+ .min(Comparator.comparingInt(g -> g.getWeight().orElse(0)))
+ .map(Group::getName)
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("")
+ );
+
+ /** Outputs the weight value of the user's highest weighted directly assigned group. */
+ public static final Placeholder.Basic HIGHEST_GROUP_WEIGHT = Placeholder.basic("highest_group_weight", (ctx) ->
+ String.valueOf(ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(InheritanceNode::getGroupName)
+ .map(n -> ctx.api().getGroupManager().getGroup(n))
+ .filter(Objects::nonNull)
+ .map(Group::getWeight)
+ .filter(OptionalInt::isPresent)
+ .mapToInt(OptionalInt::getAsInt)
+ .max()
+ .orElse(0))
+ );
+
+ // Track position placeholders
+
+ /** Outputs the user's current group on a specific track. */
+ public static final Placeholder.UsingArgument CURRENT_GROUP_ON_TRACK = Placeholder.usingArgument("current_group_on_track", (ctx) -> {
+ Track track = ctx.api().getTrackManager().getTrack(ctx.argument());
+ if (track == null) {
+ return "";
+ }
+
+ List groups = ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> track.containsGroup(n.getGroupName()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .distinct()
+ .map(n -> ctx.api().getGroupManager().getGroup(n.getGroupName()))
+ .collect(Collectors.toList());
+
+ if (groups.size() != 1) {
+ return "";
+ }
+
+ return groups.get(0).getFriendlyName();
+ });
+
+ /** Outputs the next group on a specific track. */
+ public static final Placeholder.UsingArgument NEXT_GROUP_ON_TRACK = Placeholder.usingArgument("next_group_on_track", (ctx) -> {
+ Track track = ctx.api().getTrackManager().getTrack(ctx.argument());
+ if (track == null || track.getGroups().size() <= 1) {
+ return "";
+ }
+
+ List groups = ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> track.containsGroup(n.getGroupName()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .distinct()
+ .map(n -> ctx.api().getGroupManager().getGroup(n.getGroupName()))
+ .collect(Collectors.toList());
+
+ if (groups.size() != 1) {
+ return "";
+ }
+
+ return Objects.toString(convertGroupDisplayName(ctx, track.getNext(groups.get(0))), "");
+ });
+
+ /** Outputs the previous group on a specific track. */
+ public static final Placeholder.UsingArgument PREVIOUS_GROUP_ON_TRACK = Placeholder.usingArgument("previous_group_on_track", (ctx) -> {
+ Track track = ctx.api().getTrackManager().getTrack(ctx.argument());
+ if (track == null || track.getGroups().size() <= 1) {
+ return "";
+ }
+
+ List groups = ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(n -> track.containsGroup(n.getGroupName()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .distinct()
+ .map(n -> ctx.api().getGroupManager().getGroup(n.getGroupName()))
+ .collect(Collectors.toList());
+
+ if (groups.size() != 1) {
+ return "";
+ }
+
+ return Objects.toString(convertGroupDisplayName(ctx, track.getPrevious(groups.get(0))), "");
+ });
+
+ /** Outputs the first group the user has on a comma-separated list of tracks. */
+ public static final Placeholder.UsingArgument FIRST_GROUP_ON_TRACKS = Placeholder.usingArgument("first_group_on_tracks", (ctx) -> {
+ List tracks = Arrays.stream(ctx.argument().split(",")).map(String::trim).collect(Collectors.toList());
+ Set groups = ctx.user().getInheritedGroups(ctx.queryOptions()).stream().map(Group::getName).collect(Collectors.toSet());
+
+ return tracks.stream()
+ .map(n -> ctx.api().getTrackManager().getTrack(n))
+ .filter(Objects::nonNull)
+ .map(Track::getGroups)
+ .map(trackGroups -> trackGroups.stream().filter(groups::contains).findFirst())
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .findFirst()
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("");
+ });
+
+ /** Outputs the last group the user has on a comma-separated list of tracks. */
+ public static final Placeholder.UsingArgument LAST_GROUP_ON_TRACKS = Placeholder.usingArgument("last_group_on_tracks", (ctx) -> {
+ List tracks = Arrays.stream(ctx.argument().split(",")).map(String::trim).collect(Collectors.toList());
+ Set groups = ctx.user().getInheritedGroups(ctx.queryOptions()).stream().map(Group::getName).collect(Collectors.toSet());
+
+ return tracks.stream()
+ .map(n -> ctx.api().getTrackManager().getTrack(n))
+ .filter(Objects::nonNull)
+ .map(Track::getGroups)
+ .map(list -> {
+ List copy = new ArrayList<>(list);
+ Collections.reverse(copy);
+ return copy;
+ })
+ .map(trackGroups -> trackGroups.stream().filter(groups::contains).findFirst())
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .findFirst()
+ .map(name -> convertGroupDisplayName(ctx, name))
+ .orElse("");
+ });
+
+ // Expiry time placeholders
+
+ /** Outputs the expiry time remaining for a specific permission node. */
+ public static final Placeholder.UsingArgument EXPIRY_TIME = Placeholder.usingArgument("expiry_time", (ctx) ->
+ ctx.user().getNodes().stream()
+ .filter(Node::hasExpiry)
+ .filter(n -> n.getKey().equals(ctx.argument()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(Node::getExpiryDuration)
+ .filter(Objects::nonNull)
+ .filter(d -> !d.isNegative())
+ .findFirst()
+ .map(Placeholders::formatDuration)
+ .orElse("")
+ );
+
+ /** Outputs the expiry time remaining for a specific inherited permission node. */
+ public static final Placeholder.UsingArgument INHERITED_EXPIRY_TIME = Placeholder.usingArgument("inherited_expiry_time", (ctx) ->
+ ctx.user().resolveInheritedNodes(ctx.queryOptions()).stream()
+ .filter(Node::hasExpiry)
+ .filter(n -> n.getKey().equals(ctx.argument()))
+ .map(Node::getExpiryDuration)
+ .filter(Objects::nonNull)
+ .filter(d -> !d.isNegative())
+ .findFirst()
+ .map(Placeholders::formatDuration)
+ .orElse("")
+ );
+
+ /** Outputs the expiry time remaining for a specific group membership. */
+ public static final Placeholder.UsingArgument GROUP_EXPIRY_TIME = Placeholder.usingArgument("group_expiry_time", (ctx) ->
+ ctx.user().getNodes(NodeType.INHERITANCE).stream()
+ .filter(Node::hasExpiry)
+ .filter(n -> n.getGroupName().equals(ctx.argument()))
+ .filter(n -> ctx.queryOptions().satisfies(n.getContexts()))
+ .map(Node::getExpiryDuration)
+ .filter(Objects::nonNull)
+ .filter(d -> !d.isNegative())
+ .findFirst()
+ .map(Placeholders::formatDuration)
+ .orElse("")
+ );
+
+ /** Outputs the expiry time remaining for a specific inherited group membership. */
+ public static final Placeholder.UsingArgument INHERITED_GROUP_EXPIRY_TIME = Placeholder.usingArgument("inherited_group_expiry_time", (ctx) ->
+ ctx.user().resolveInheritedNodes(ctx.queryOptions()).stream()
+ .filter(Node::hasExpiry)
+ .filter(NodeType.INHERITANCE::matches)
+ .map(NodeType.INHERITANCE::cast)
+ .filter(n -> n.getGroupName().equals(ctx.argument()))
+ .map(Node::getExpiryDuration)
+ .filter(Objects::nonNull)
+ .filter(d -> !d.isNegative())
+ .findFirst()
+ .map(Placeholders::formatDuration)
+ .orElse("")
+ );
+
+ private static String stringNullToEmpty(String string) {
+ return string == null ? "" : string;
+ }
+
+ private static String convertGroupDisplayName(PlaceholderContext ctx, String groupName) {
+ Group group = ctx.api().getGroupManager().getGroup(groupName);
+ return group != null ? group.getFriendlyName() : groupName;
+ }
+
+ // simple version of me.lucko.luckperms.common.util.DurationFormatter
+ @VisibleForTesting
+ static String formatDuration(Duration duration) {
+ if (duration == null || duration.isNegative()) {
+ return "";
+ }
+
+ long seconds = duration.getSeconds();
+ StringBuilder builder = new StringBuilder();
+
+ ChronoUnit[] units = {ChronoUnit.YEARS, ChronoUnit.MONTHS, ChronoUnit.WEEKS,
+ ChronoUnit.DAYS, ChronoUnit.HOURS, ChronoUnit.MINUTES, ChronoUnit.SECONDS};
+ String[] labels = {"y", "mo", "w", "d", "h", "m", "s"};
+
+ for (int i = 0; i < units.length; i++) {
+ long unitSeconds = units[i].getDuration().getSeconds();
+ long n = seconds / unitSeconds;
+ if (n > 0) {
+ seconds -= unitSeconds * n;
+ if (builder.length() > 0) {
+ builder.append(" ");
+ }
+ builder.append(n).append(labels[i]);
+ }
+ if (seconds <= 0) {
+ break;
+ }
+ }
+
+ return builder.length() == 0 ? "0s" : builder.toString();
+ }
+}
diff --git a/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistryTest.java b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistryTest.java
new file mode 100644
index 000000000..acce7a732
--- /dev/null
+++ b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderRegistryTest.java
@@ -0,0 +1,88 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+public class PlaceholderRegistryTest {
+
+ @Test
+ public void testAllPlaceholdersAreRegistered() {
+ List returnedByAllMethod = PlaceholderRegistry.getAll();
+ Map inClass = Arrays.stream(Placeholders.class.getDeclaredFields())
+ .filter(f -> Placeholder.class.isAssignableFrom(f.getType()))
+ .collect(Collectors.toMap(Field::getName, f -> {
+ try {
+ return (Placeholder) f.get(null);
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+ }, (a, b) -> { throw new UnsupportedOperationException(); }, LinkedHashMap::new));
+
+ assertEquals(new ArrayList<>(inClass.values()), returnedByAllMethod);
+ inClass.forEach((fieldName, placeholder) ->
+ assertEquals(fieldName.toLowerCase(Locale.ROOT), placeholder.id(), "Placeholder " + fieldName + " has an id that doesn't match its field name")
+ );
+ }
+
+ @Test
+ public void testPlaceholdersDontOverlap() {
+ for (Placeholder placeholder : PlaceholderRegistry.getAll()) {
+ for (Placeholder other : PlaceholderRegistry.getAll()) {
+ if (placeholder == other) {
+ continue;
+ }
+
+ assertNotEquals(placeholder.id(), other.id(), "Placeholder " + placeholder + " has the same id as " + other);
+ assertFalse(other instanceof Placeholder.UsingArgument && placeholder.id().startsWith(other.id()), "Placeholder " + placeholder.id() + " has an id that overlaps with " + other.id());
+ }
+ }
+ }
+
+ @Test
+ public void testRegistryLookup() {
+ Placeholder value = PlaceholderRegistry.lookup("prefix_element");
+ assertSame(Placeholders.PREFIX_ELEMENT, value);
+
+ assertNull(PlaceholderRegistry.lookup("non_existent"));
+ }
+
+}
diff --git a/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderResolverTest.java b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderResolverTest.java
new file mode 100644
index 000000000..cdfe998a4
--- /dev/null
+++ b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderResolverTest.java
@@ -0,0 +1,69 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+
+public class PlaceholderResolverTest {
+
+ private final List placeholders = Arrays.asList(
+ Placeholder.basic("test_simple", ctx -> "hello world"),
+ Placeholder.usingArgument("test_arg", ctx -> "hello " + ctx.argument())
+ );
+ private final PlaceholderContext ctx = new PlaceholderContext(mock(LuckPerms.class), mock(User.class), mock(QueryOptions.class));
+ private final PlaceholderResolver resolver = new PlaceholderResolver(this.placeholders);
+
+ @Test
+ public void testNullResolve() {
+ assertNull(this.resolver.resolve(this.ctx, "non_existent"));
+ assertNull(this.resolver.resolve(this.ctx, ""));
+
+ assertNull(this.resolver.resolve(this.ctx, "test_simple_hello")); // reject basic with arg provided
+ assertNull(this.resolver.resolve(this.ctx, "test_arg")); // reject usingArgument without arg provided
+ assertNull(this.resolver.resolve(this.ctx, "test_arg_")); // reject usingArgument without arg provided
+ }
+
+ @Test
+ public void testBasicResolve() {
+ assertEquals("hello world", this.resolver.resolve(this.ctx, "test_simple"));
+ }
+
+ @Test
+ public void testUsingArgumentResolve() {
+ assertEquals("hello there", this.resolver.resolve(this.ctx, "test_arg_there"));
+ }
+
+}
diff --git a/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderTest.java b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderTest.java
new file mode 100644
index 000000000..362e492f8
--- /dev/null
+++ b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholderTest.java
@@ -0,0 +1,56 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+
+public class PlaceholderTest {
+
+ private final PlaceholderContext ctx = new PlaceholderContext(mock(LuckPerms.class), mock(User.class), mock(QueryOptions.class));
+
+ @Test
+ public void testBasic() {
+ Placeholder.Basic placeholder = Placeholder.basic("test", ctx -> "hello");
+
+ assertEquals("test", placeholder.id());
+ assertEquals("hello", placeholder.resolve(this.ctx));
+ }
+
+ @Test
+ public void testUsingArgument() {
+ Placeholder.UsingArgument placeholder = Placeholder.usingArgument("test", ctx -> "hello " + ctx.argument());
+
+ assertEquals("test", placeholder.id());
+ assertEquals("hello world", placeholder.resolve(this.ctx.withArgument("world")));
+ }
+
+}
diff --git a/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholdersTest.java b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholdersTest.java
new file mode 100644
index 000000000..d1ca22a1b
--- /dev/null
+++ b/common/placeholders/src/test/java/me/lucko/luckperms/common/placeholders/PlaceholdersTest.java
@@ -0,0 +1,105 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.placeholders;
+
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.cacheddata.CachedDataManager;
+import net.luckperms.api.cacheddata.CachedMetaData;
+import net.luckperms.api.cacheddata.CachedPermissionData;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class PlaceholdersTest {
+
+ @Mock private LuckPerms api;
+ @Mock private User user;
+ @Mock private QueryOptions queryOptions;
+
+ @Mock private CachedDataManager cachedDataManager;
+ @Mock private CachedPermissionData cachedPermissionData;
+ @Mock private CachedMetaData cachedMetaData;
+
+ private PlaceholderContext ctx;
+
+ @BeforeEach
+ public void setupMocks() {
+ lenient().when(this.user.getCachedData()).thenReturn(this.cachedDataManager);
+ lenient().when(this.cachedDataManager.getPermissionData(this.queryOptions)).thenReturn(this.cachedPermissionData);
+ lenient().when(this.cachedDataManager.getMetaData(this.queryOptions)).thenReturn(this.cachedMetaData);
+
+ this.ctx = new PlaceholderContext(this.api, this.user, this.queryOptions);
+ }
+
+ // test some of the basic / simple / most used placeholders - the others are too difficult to test
+ // using mocks only.
+
+ @Test
+ public void testPrefix() {
+ when(this.cachedMetaData.getPrefix()).thenReturn("test prefix");
+ assertEquals("test prefix", Placeholders.PREFIX.resolve(this.ctx));
+ }
+
+ @Test
+ public void testSuffix() {
+ when(this.cachedMetaData.getSuffix()).thenReturn("test suffix");
+ assertEquals("test suffix", Placeholders.SUFFIX.resolve(this.ctx));
+ }
+
+ @Test
+ public void testMeta() {
+ when(this.cachedMetaData.getMetaValue("test_key")).thenReturn("hello");
+ assertEquals("hello", Placeholders.META.resolve(this.ctx.withArgument("test_key")));
+ }
+
+ @Test
+ public void testFormatDuration() {
+ Duration duration = ChronoUnit.YEARS.getDuration().multipliedBy(5)
+ .plus(ChronoUnit.MONTHS.getDuration().multipliedBy(4))
+ .plus(ChronoUnit.WEEKS.getDuration().multipliedBy(3))
+ .plusDays(2)
+ .plusHours(1)
+ .plusMinutes(6)
+ .plusSeconds(7);
+
+ assertEquals("5y 4mo 3w 2d 1h 6m 7s", Placeholders.formatDuration(duration));
+ assertEquals("1m 10s", Placeholders.formatDuration(Duration.ofMinutes(1).plusSeconds(10)));
+ assertEquals("0s", Placeholders.formatDuration(Duration.ZERO));
+ }
+
+}
diff --git a/common/placeholders/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/common/placeholders/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 000000000..ca6ee9cea
--- /dev/null
+++ b/common/placeholders/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
\ No newline at end of file
diff --git a/common/src/main/java-templates/me/lucko/luckperms/common/util/BuildInfo.java.peb b/common/src/main/java-templates/me/lucko/luckperms/common/util/BuildInfo.java.peb
new file mode 100644
index 000000000..1d9b0c9cc
--- /dev/null
+++ b/common/src/main/java-templates/me/lucko/luckperms/common/util/BuildInfo.java.peb
@@ -0,0 +1,33 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.util;
+
+public final class BuildInfo {
+ private BuildInfo() {}
+
+ public static final String VERSION = "{{ version }}";
+
+}
diff --git a/common/src/main/java/me/lucko/luckperms/common/actionlog/ActionJsonSerializer.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/ActionJsonSerializer.java
index 967219cb3..5874444be 100644
--- a/common/src/main/java/me/lucko/luckperms/common/actionlog/ActionJsonSerializer.java
+++ b/common/src/main/java/me/lucko/luckperms/common/actionlog/ActionJsonSerializer.java
@@ -29,9 +29,7 @@
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
-
import me.lucko.luckperms.common.util.gson.JObject;
-
import net.luckperms.api.actionlog.Action;
import java.time.Instant;
diff --git a/common/src/main/java/me/lucko/luckperms/common/actionlog/Log.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/Log.java
deleted file mode 100644
index e72df5d4c..000000000
--- a/common/src/main/java/me/lucko/luckperms/common/actionlog/Log.java
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * This file is part of LuckPerms, licensed under the MIT License.
- *
- * Copyright (c) lucko (Luck)
- * Copyright (c) contributors
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
-
-package me.lucko.luckperms.common.actionlog;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSortedSet;
-
-import me.lucko.luckperms.common.util.ImmutableCollectors;
-
-import net.luckperms.api.actionlog.Action;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.SortedSet;
-import java.util.UUID;
-
-public class Log {
- private static final Log EMPTY = new Log(ImmutableList.of());
-
- public static Builder builder() {
- return new Builder();
- }
-
- public static Log empty() {
- return EMPTY;
- }
-
- private final SortedSet content;
-
- Log(List content) {
- this.content = ImmutableSortedSet.copyOf(content);
- }
-
- public SortedSet getContent() {
- return this.content;
- }
-
- public SortedSet getContent(UUID actor) {
- return this.content.stream()
- .filter(e -> e.getSource().getUniqueId().equals(actor))
- .collect(ImmutableCollectors.toSortedSet());
- }
-
- public SortedSet getUserHistory(UUID uniqueId) {
- return this.content.stream()
- .filter(e -> e.getTarget().getType() == Action.Target.Type.USER)
- .filter(e -> e.getTarget().getUniqueId().isPresent() && e.getTarget().getUniqueId().get().equals(uniqueId))
- .collect(ImmutableCollectors.toSortedSet());
- }
-
- public SortedSet getGroupHistory(String name) {
- return this.content.stream()
- .filter(e -> e.getTarget().getType() == Action.Target.Type.GROUP)
- .filter(e -> e.getTarget().getName().equals(name))
- .collect(ImmutableCollectors.toSortedSet());
- }
-
- public SortedSet getTrackHistory(String name) {
- return this.content.stream()
- .filter(e -> e.getTarget().getType() == Action.Target.Type.TRACK)
- .filter(e -> e.getTarget().getName().equals(name))
- .collect(ImmutableCollectors.toSortedSet());
- }
-
- public SortedSet getSearch(String query) {
- return this.content.stream()
- .filter(e -> e.matchesSearch(query))
- .collect(ImmutableCollectors.toSortedSet());
- }
-
- public static class Builder {
- private final List content = new ArrayList<>();
-
- public Builder add(LoggedAction e) {
- this.content.add(e);
- return this;
- }
-
- public Log build() {
- if (this.content.isEmpty()) {
- return EMPTY;
- }
- return new Log(this.content);
- }
- }
-
-}
diff --git a/common/src/main/java/me/lucko/luckperms/common/actionlog/LogDispatcher.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/LogDispatcher.java
index dc2c95ac2..befa7ab2a 100644
--- a/common/src/main/java/me/lucko/luckperms/common/actionlog/LogDispatcher.java
+++ b/common/src/main/java/me/lucko/luckperms/common/actionlog/LogDispatcher.java
@@ -29,13 +29,14 @@
import me.lucko.luckperms.common.commands.log.LogNotify;
import me.lucko.luckperms.common.config.ConfigKeys;
import me.lucko.luckperms.common.locale.Message;
+import me.lucko.luckperms.common.messaging.InternalMessagingService;
import me.lucko.luckperms.common.plugin.LuckPermsPlugin;
import me.lucko.luckperms.common.sender.Sender;
-
import net.luckperms.api.event.log.LogBroadcastEvent;
import net.luckperms.api.event.log.LogNotifyEvent;
import java.util.Collection;
+import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;
public class LogDispatcher {
@@ -65,7 +66,12 @@ private boolean shouldBroadcast(LoggedAction entry, LogBroadcastEvent.Origin ori
return !this.plugin.getEventDispatcher().dispatchLogBroadcast(cancelled, entry, origin);
}
- private void broadcast(LoggedAction entry, LogNotifyEvent.Origin origin, Sender sender) {
+ // broadcast the entry to online players
+ private void broadcast(LoggedAction entry, LogBroadcastEvent.Origin broadcastOrigin, LogNotifyEvent.Origin origin, Sender sender) {
+ if (!shouldBroadcast(entry, broadcastOrigin)) {
+ return;
+ }
+
this.plugin.getOnlineSenders()
.filter(CommandPermission.LOG_NOTIFY::isAuthorized)
.filter(s -> {
@@ -75,41 +81,46 @@ private void broadcast(LoggedAction entry, LogNotifyEvent.Origin origin, Sender
.forEach(s -> Message.LOG.send(s, entry));
}
- public void dispatch(LoggedAction entry, Sender sender) {
+ // log the entry to storage
+ public CompletableFuture logToStorage(LoggedAction entry) {
if (!this.plugin.getEventDispatcher().dispatchLogPublish(false, entry)) {
- this.plugin.getStorage().logAction(entry);
+ return this.plugin.getStorage().logAction(entry);
+ } else {
+ return CompletableFuture.completedFuture(null);
}
+ }
- this.plugin.getMessagingService().ifPresent(service -> service.pushLog(entry));
-
- if (shouldBroadcast(entry, LogBroadcastEvent.Origin.LOCAL)) {
- broadcast(entry, LogNotifyEvent.Origin.LOCAL, sender);
+ // log the entry to messaging
+ public CompletableFuture logToMessaging(LoggedAction entry) {
+ InternalMessagingService messagingService = this.plugin.getMessagingService().orElse(null);
+ if (messagingService != null) {
+ return messagingService.pushLog(entry);
+ } else {
+ return CompletableFuture.completedFuture(null);
}
}
- public void broadcastFromApi(LoggedAction entry) {
- this.plugin.getMessagingService().ifPresent(extendedMessagingService -> extendedMessagingService.pushLog(entry));
+ // log the entry to storage and messaging, and broadcast it to online players
+ private CompletableFuture dispatch(LoggedAction entry, Sender sender, LogBroadcastEvent.Origin broadcastOrigin, LogNotifyEvent.Origin origin) {
+ CompletableFuture storageFuture = logToStorage(entry);
+ CompletableFuture messagingFuture = logToMessaging(entry);
+ broadcast(entry, broadcastOrigin, origin, sender);
+ return CompletableFuture.allOf(storageFuture, messagingFuture);
+ }
- if (shouldBroadcast(entry, LogBroadcastEvent.Origin.LOCAL_API)) {
- broadcast(entry, LogNotifyEvent.Origin.LOCAL_API, null);
- }
+ public CompletableFuture dispatch(LoggedAction entry, Sender sender) {
+ return dispatch(entry, sender, LogBroadcastEvent.Origin.LOCAL, LogNotifyEvent.Origin.LOCAL);
}
- public void dispatchFromApi(LoggedAction entry) {
- if (!this.plugin.getEventDispatcher().dispatchLogPublish(false, entry)) {
- try {
- this.plugin.getStorage().logAction(entry).get();
- } catch (Exception e) {
- this.plugin.getLogger().warn("Error whilst storing action", e);
- }
- }
+ public CompletableFuture dispatchFromApi(LoggedAction entry) {
+ return dispatch(entry, null, LogBroadcastEvent.Origin.LOCAL_API, LogNotifyEvent.Origin.LOCAL_API);
+ }
- broadcastFromApi(entry);
+ public void broadcastFromApi(LoggedAction entry) {
+ broadcast(entry, LogBroadcastEvent.Origin.LOCAL_API, LogNotifyEvent.Origin.LOCAL_API, null);
}
- public void dispatchFromRemote(LoggedAction entry) {
- if (shouldBroadcast(entry, LogBroadcastEvent.Origin.REMOTE)) {
- broadcast(entry, LogNotifyEvent.Origin.REMOTE, null);
- }
+ public void broadcastFromRemote(LoggedAction entry) {
+ broadcast(entry, LogBroadcastEvent.Origin.REMOTE, LogNotifyEvent.Origin.REMOTE, null);
}
}
diff --git a/common/src/main/java/me/lucko/luckperms/common/util/Paginated.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/LogPage.java
similarity index 54%
rename from common/src/main/java/me/lucko/luckperms/common/util/Paginated.java
rename to common/src/main/java/me/lucko/luckperms/common/actionlog/LogPage.java
index dd4dcf81a..b9ea7c008 100644
--- a/common/src/main/java/me/lucko/luckperms/common/util/Paginated.java
+++ b/common/src/main/java/me/lucko/luckperms/common/actionlog/LogPage.java
@@ -23,53 +23,50 @@
* SOFTWARE.
*/
-package me.lucko.luckperms.common.util;
+package me.lucko.luckperms.common.actionlog;
import com.google.common.collect.ImmutableList;
+import me.lucko.luckperms.common.filter.PageParameters;
+import org.checkerframework.checker.nullness.qual.Nullable;
import java.util.ArrayList;
-import java.util.Collection;
import java.util.List;
+import java.util.Objects;
-/**
- * A simple pagination utility
- *
- * @param the element type
- */
-public class Paginated {
- private final List content;
+public class LogPage {
+ public static LogPage of(List content, @Nullable PageParameters params, int totalEntries) {
+ return new LogPage(content, params, totalEntries);
+ }
- public Paginated(Collection content) {
+ private final List content;
+ private final @Nullable PageParameters params;
+ private final int totalEntries;
+
+ LogPage(List content, @Nullable PageParameters params, int totalEntries) {
this.content = ImmutableList.copyOf(content);
+ this.params = params;
+ this.totalEntries = totalEntries;
}
- public List getContent() {
+ public List getContent() {
return this.content;
}
- public int getMaxPages(int entriesPerPage) {
- return (int) Math.ceil((double) this.content.size() / (double) entriesPerPage);
- }
+ public List> getNumberedContent() {
+ int startIndex = this.params != null
+ ? this.params.pageSize() * (this.params.pageNumber() - 1)
+ : 0;
- public List> getPage(int pageNo, int pageSize) {
- if (pageNo < 1) {
- throw new IllegalArgumentException("pageNo cannot be less than 1: " + pageNo);
- }
-
- int first = (pageNo - 1) * pageSize;
- if (this.content.size() <= first) {
- throw new IllegalStateException("Content does not contain that many elements. (requested page: " + pageNo +
- ", page size: " + pageSize + ", page first index: " + first + ", content size: " + this.content.size() + ")");
- }
-
- int last = first + pageSize - 1;
- List> out = new ArrayList<>(pageSize);
-
- for (int i = first; i <= last && i < this.content.size(); i++) {
- out.add(new Entry<>(i + 1, this.content.get(i)));
+ List> numberedContent = new ArrayList<>();
+ for (int i = 0; i < this.content.size(); i++) {
+ int index = startIndex + i + 1;
+ numberedContent.add(new Entry<>(index, this.content.get(i)));
}
+ return numberedContent;
+ }
- return out;
+ public int getTotalEntries() {
+ return this.totalEntries;
}
public static final class Entry {
@@ -93,6 +90,19 @@ public T value() {
public String toString() {
return this.position + ": " + this.value;
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof Entry)) return false;
+ Entry> entry = (Entry>) o;
+ return this.position == entry.position && Objects.equals(this.value, entry.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(this.position, this.value);
+ }
}
}
diff --git a/common/src/main/java/me/lucko/luckperms/common/actionlog/LoggedAction.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/LoggedAction.java
index 5c46fe900..00ce2c64d 100644
--- a/common/src/main/java/me/lucko/luckperms/common/actionlog/LoggedAction.java
+++ b/common/src/main/java/me/lucko/luckperms/common/actionlog/LoggedAction.java
@@ -26,7 +26,7 @@
package me.lucko.luckperms.common.actionlog;
import com.google.common.base.Strings;
-
+import me.lucko.luckperms.common.config.ConfigKeys;
import me.lucko.luckperms.common.model.Group;
import me.lucko.luckperms.common.model.HolderType;
import me.lucko.luckperms.common.model.PermissionHolder;
@@ -35,12 +35,10 @@
import me.lucko.luckperms.common.plugin.LuckPermsPlugin;
import me.lucko.luckperms.common.sender.Sender;
import me.lucko.luckperms.common.util.DurationFormatter;
-
import net.luckperms.api.actionlog.Action;
import net.luckperms.api.context.Context;
import net.luckperms.api.context.ContextSet;
import net.luckperms.api.context.DefaultContextKeys;
-
import org.checkerframework.checker.nullness.qual.NonNull;
import java.time.Duration;
@@ -50,6 +48,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
/**
* An implementation of {@link Action} and {@link Action.Builder},
@@ -125,15 +124,11 @@ public int compareTo(@NonNull Action other) {
return ActionComparator.INSTANCE.compare(this, other);
}
- public boolean matchesSearch(String query) {
- query = Objects.requireNonNull(query, "query").toLowerCase();
- return this.source.name.toLowerCase().contains(query) ||
- this.target.name.toLowerCase().contains(query) ||
- this.description.toLowerCase().contains(query);
- }
-
public void submit(LuckPermsPlugin plugin, Sender sender) {
- plugin.getLogDispatcher().dispatch(this, sender);
+ CompletableFuture future = plugin.getLogDispatcher().dispatch(this, sender);
+ if (plugin.getConfiguration().get(ConfigKeys.LOG_SYNCHRONOUSLY_IN_COMMANDS)) {
+ future.join();
+ }
}
@Override
@@ -395,14 +390,14 @@ public Builder description(Object... args) {
}
}
- public static char getTypeCharacter(Target.Type type) {
+ public static String getTypeString(Target.Type type) {
switch (type) {
case USER:
- return 'U';
+ return "U";
case GROUP:
- return 'G';
+ return "G";
case TRACK:
- return 'T';
+ return "T";
default:
throw new AssertionError();
}
diff --git a/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFields.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFields.java
new file mode 100644
index 000000000..b4887e701
--- /dev/null
+++ b/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFields.java
@@ -0,0 +1,60 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.actionlog.filter;
+
+import me.lucko.luckperms.common.filter.FilterField;
+import net.luckperms.api.actionlog.Action;
+
+import java.util.UUID;
+
+public final class ActionFields {
+
+ public static final FilterField SOURCE_UNIQUE_ID = FilterField.named(
+ "SOURCE_UNIQUE_ID",
+ action -> action.getSource().getUniqueId()
+ );
+ public static final FilterField SOURCE_NAME = FilterField.named(
+ "SOURCE_NAME",
+ action -> action.getSource().getName()
+ );
+ public static final FilterField TARGET_TYPE = FilterField.named(
+ "TARGET_TYPE",
+ action -> action.getTarget().getType()
+ );
+ public static final FilterField TARGET_UNIQUE_ID = FilterField.named(
+ "TARGET_UNIQUE_ID",
+ action -> action.getTarget().getUniqueId().orElse(null)
+ );
+ public static final FilterField TARGET_NAME = FilterField.named(
+ "TARGET_NAME",
+ action -> action.getTarget().getName()
+ );
+ public static final FilterField DESCRIPTION = FilterField.named(
+ "DESCRIPTION",
+ action -> action.getDescription()
+ );
+
+}
\ No newline at end of file
diff --git a/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilterMongoBuilder.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilterMongoBuilder.java
new file mode 100644
index 000000000..d82ec017e
--- /dev/null
+++ b/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilterMongoBuilder.java
@@ -0,0 +1,70 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.actionlog.filter;
+
+import me.lucko.luckperms.common.filter.FilterField;
+import me.lucko.luckperms.common.filter.mongo.FilterMongoBuilder;
+import net.luckperms.api.actionlog.Action;
+
+import java.util.UUID;
+
+public final class ActionFilterMongoBuilder extends FilterMongoBuilder {
+ public static final ActionFilterMongoBuilder INSTANCE = new ActionFilterMongoBuilder();
+
+ private ActionFilterMongoBuilder() {
+
+ }
+
+ @Override
+ public String mapFieldName(FilterField field) {
+ if (field == ActionFields.SOURCE_UNIQUE_ID) {
+ return "source.uniqueId";
+ } else if (field == ActionFields.SOURCE_NAME) {
+ return "source.name";
+ } else if (field == ActionFields.TARGET_TYPE) {
+ return "target.type";
+ } else if (field == ActionFields.TARGET_UNIQUE_ID) {
+ return "target.uniqueId";
+ } else if (field == ActionFields.TARGET_NAME) {
+ return "target.name";
+ } else if (field == ActionFields.DESCRIPTION) {
+ return "description";
+ }
+ throw new AssertionError(field);
+ }
+
+ @Override
+ public Object mapConstraintValue(Object value) {
+ if (value instanceof String | value instanceof UUID) {
+ return value;
+ } else if (value instanceof Action.Target.Type) {
+ return ((Action.Target.Type) value).name();
+ } else {
+ throw new IllegalArgumentException("Don't know how to map value with type: " + value.getClass().getName());
+ }
+ }
+
+}
diff --git a/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilterSqlBuilder.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilterSqlBuilder.java
new file mode 100644
index 000000000..e7a71ce9a
--- /dev/null
+++ b/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilterSqlBuilder.java
@@ -0,0 +1,69 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.actionlog.filter;
+
+import me.lucko.luckperms.common.actionlog.LoggedAction;
+import me.lucko.luckperms.common.filter.FilterField;
+import me.lucko.luckperms.common.filter.sql.FilterSqlBuilder;
+import net.luckperms.api.actionlog.Action;
+
+import java.util.UUID;
+
+public class ActionFilterSqlBuilder extends FilterSqlBuilder {
+
+ @Override
+ public void visitFieldName(FilterField field) {
+ if (field == ActionFields.SOURCE_UNIQUE_ID) {
+ this.builder.append("actor_uuid");
+ } else if (field == ActionFields.SOURCE_NAME) {
+ this.builder.append("actor_name");
+ } else if (field == ActionFields.TARGET_TYPE) {
+ this.builder.append("type");
+ } else if (field == ActionFields.TARGET_UNIQUE_ID) {
+ this.builder.append("acted_uuid");
+ } else if (field == ActionFields.TARGET_NAME) {
+ this.builder.append("acted_name");
+ } else if (field == ActionFields.DESCRIPTION) {
+ this.builder.append("action");
+ } else {
+ throw new AssertionError(field);
+ }
+ }
+
+ @Override
+ public void visitConstraintValue(Object value) {
+ if (value instanceof String) {
+ this.builder.variable(((String) value));
+ } else if (value instanceof UUID) {
+ this.builder.variable(value.toString());
+ } else if (value instanceof Action.Target.Type) {
+ this.builder.variable(LoggedAction.getTypeString((Action.Target.Type) value));
+ } else {
+ throw new IllegalArgumentException("Don't know how to write value with type: " + value.getClass().getName());
+ }
+ }
+
+}
diff --git a/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilters.java b/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilters.java
new file mode 100644
index 000000000..f1513a2ca
--- /dev/null
+++ b/common/src/main/java/me/lucko/luckperms/common/actionlog/filter/ActionFilters.java
@@ -0,0 +1,110 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)
+ * Copyright (c) contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package me.lucko.luckperms.common.actionlog.filter;
+
+import me.lucko.luckperms.common.filter.Comparison;
+import me.lucko.luckperms.common.filter.ConstraintFactory;
+import me.lucko.luckperms.common.filter.FilterList;
+import net.luckperms.api.actionlog.Action;
+import net.luckperms.api.actionlog.Action.Target;
+
+import java.util.UUID;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+public final class ActionFilters {
+ private ActionFilters() {}
+
+ // all actions
+ public static FilterList all() {
+ return FilterList.empty();
+ }
+
+ // all actions performed by a given source (actor)
+ public static FilterList source(UUID uniqueId) {
+ return FilterList.and(
+ ActionFields.SOURCE_UNIQUE_ID.isEqualTo(uniqueId, ConstraintFactory.UUIDS)
+ );
+ }
+
+ // all actions affecting a given user
+ public static FilterList user(UUID uniqueId) {
+ return FilterList.and(
+ ActionFields.TARGET_TYPE.isEqualTo(Target.Type.USER, TARGET_TYPE_CONSTRAINT_FACTORY),
+ ActionFields.TARGET_UNIQUE_ID.isEqualTo(uniqueId, ConstraintFactory.UUIDS)
+ );
+ }
+
+ // all actions affecting a given group
+ public static FilterList group(String name) {
+ return FilterList.and(
+ ActionFields.TARGET_TYPE.isEqualTo(Target.Type.GROUP, TARGET_TYPE_CONSTRAINT_FACTORY),
+ ActionFields.TARGET_NAME.isEqualTo(name, ConstraintFactory.STRINGS)
+ );
+ }
+
+ // all actions affecting a given track
+ public static FilterList track(String name) {
+ return FilterList.and(
+ ActionFields.TARGET_TYPE.isEqualTo(Target.Type.TRACK, TARGET_TYPE_CONSTRAINT_FACTORY),
+ ActionFields.TARGET_NAME.isEqualTo(name, ConstraintFactory.STRINGS)
+ );
+ }
+
+ // all actions matching the given search query
+ public static FilterList search(String query) {
+ return FilterList.or(
+ ActionFields.SOURCE_NAME.isSimilarTo("%" + query + "%", ConstraintFactory.STRINGS),
+ ActionFields.TARGET_NAME.isSimilarTo("%" + query + "%", ConstraintFactory.STRINGS),
+ ActionFields.DESCRIPTION.isSimilarTo("%" + query + "%", ConstraintFactory.STRINGS)
+ );
+ }
+
+ private static final ConstraintFactory TARGET_TYPE_CONSTRAINT_FACTORY = new ConstraintFactory() {
+ @Override
+ public Predicate equal(Target.Type value) {
+ return value::equals;
+ }
+
+ @Override
+ public Predicate notEqual(Target.Type value) {
+ return string -> !value.equals(string);
+ }
+
+ @Override
+ public Predicate similar(Target.Type value) {
+ Pattern pattern = Comparison.compilePatternForLikeSyntax(value.toString());
+ return type -> pattern.matcher(type.toString()).matches();
+ }
+
+ @Override
+ public Predicate notSimilar(Target.Type value) {
+ Pattern pattern = Comparison.compilePatternForLikeSyntax(value.toString());
+ return type -> !pattern.matcher(type.toString()).matches();
+ }
+ };
+
+}
diff --git a/common/src/main/java/me/lucko/luckperms/common/api/ApiUtils.java b/common/src/main/java/me/lucko/luckperms/common/api/ApiUtils.java
index 049606c79..a15bcfaa8 100644
--- a/common/src/main/java/me/lucko/luckperms/common/api/ApiUtils.java
+++ b/common/src/main/java/me/lucko/luckperms/common/api/ApiUtils.java
@@ -26,11 +26,11 @@
package me.lucko.luckperms.common.api;
import com.google.common.base.Preconditions;
-
import me.lucko.luckperms.common.config.ConfigKeys;
import me.lucko.luckperms.common.plugin.LuckPermsPlugin;
import me.lucko.luckperms.common.storage.misc.DataConstraints;
+import java.util.Locale;
import java.util.function.Predicate;
public final class ApiUtils {
@@ -55,7 +55,7 @@ public static String checkName(String s) {
}
Preconditions.checkArgument(DataConstraints.GROUP_NAME_TEST.test(s), "Invalid name entry: " + s);
- return s.toLowerCase();
+ return s.toLowerCase(Locale.ROOT);
}
}
diff --git a/common/src/main/java/me/lucko/luckperms/common/api/LuckPermsApiProvider.java b/common/src/main/java/me/lucko/luckperms/common/api/LuckPermsApiProvider.java
index 77aecbc4f..a68ef671a 100644
--- a/common/src/main/java/me/lucko/luckperms/common/api/LuckPermsApiProvider.java
+++ b/common/src/main/java/me/lucko/luckperms/common/api/LuckPermsApiProvider.java
@@ -25,6 +25,7 @@
package me.lucko.luckperms.common.api;
+import me.lucko.luckperms.common.api.implementation.ApiActionFilterFactory;
import me.lucko.luckperms.common.api.implementation.ApiActionLogger;
import me.lucko.luckperms.common.api.implementation.ApiContextManager;
import me.lucko.luckperms.common.api.implementation.ApiGroupManager;
@@ -41,9 +42,13 @@
import me.lucko.luckperms.common.event.AbstractEventBus;
import me.lucko.luckperms.common.messaging.LuckPermsMessagingService;
import me.lucko.luckperms.common.plugin.LuckPermsPlugin;
-
+import me.lucko.luckperms.common.plugin.bootstrap.BootstrappedWithLoader;
+import me.lucko.luckperms.common.plugin.bootstrap.LuckPermsBootstrap;
+import me.lucko.luckperms.common.plugin.logging.PluginLogger;
import net.luckperms.api.LuckPerms;
+import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.actionlog.ActionLogger;
+import net.luckperms.api.actionlog.filter.ActionFilterFactory;
import net.luckperms.api.context.ContextManager;
import net.luckperms.api.messaging.MessagingService;
import net.luckperms.api.messenger.MessengerProvider;
@@ -52,12 +57,12 @@
import net.luckperms.api.model.user.UserManager;
import net.luckperms.api.node.NodeBuilderRegistry;
import net.luckperms.api.node.matcher.NodeMatcherFactory;
+import net.luckperms.api.platform.Health;
import net.luckperms.api.platform.Platform;
import net.luckperms.api.platform.PlayerAdapter;
import net.luckperms.api.platform.PluginMetadata;
import net.luckperms.api.query.QueryOptionsRegistry;
import net.luckperms.api.track.TrackManager;
-
import org.checkerframework.checker.nullness.qual.NonNull;
import java.util.Objects;
@@ -93,6 +98,37 @@ public LuckPermsApiProvider(LuckPermsPlugin plugin) {
this.metaStackFactory = new ApiMetaStackFactory(plugin);
}
+ public void ensureApiWasLoadedByPlugin() {
+ LuckPermsBootstrap bootstrap = this.plugin.getBootstrap();
+ ClassLoader pluginClassLoader;
+ if (bootstrap instanceof BootstrappedWithLoader) {
+ pluginClassLoader = ((BootstrappedWithLoader) bootstrap).getLoader().getClass().getClassLoader();
+ } else {
+ pluginClassLoader = bootstrap.getClass().getClassLoader();
+ }
+
+ for (Class> apiClass : new Class[]{LuckPerms.class, LuckPermsProvider.class}) {
+ ClassLoader apiClassLoader = apiClass.getClassLoader();
+
+ if (!apiClassLoader.equals(pluginClassLoader)) {
+ String guilty = "unknown";
+ try {
+ guilty = bootstrap.identifyClassLoader(apiClassLoader);
+ } catch (Exception e) {
+ // ignore
+ }
+
+ PluginLogger logger = this.plugin.getLogger();
+ logger.warn("It seems that the LuckPerms API has been (class)loaded by a plugin other than LuckPerms!");
+ logger.warn("The API was loaded by " + apiClassLoader + " (" + guilty + ") and the " +
+ "LuckPerms plugin was loaded by " + pluginClassLoader.toString() + ".");
+ logger.warn("This indicates that the other plugin has incorrectly \"shaded\" the " +
+ "LuckPerms API into its jar file. This can cause errors at runtime and should be fixed.");
+ return;
+ }
+ }
+ }
+
@Override
public @NonNull String getServerName() {
return this.plugin.getConfiguration().get(ConfigKeys.SERVER);
@@ -139,6 +175,11 @@ public LuckPermsApiProvider(LuckPermsPlugin plugin) {
return this.plugin.getSyncTaskBuffer().request();
}
+ @Override
+ public @NonNull Health runHealthCheck() {
+ return this.plugin.runHealthCheck();
+ }
+
@Override
public @NonNull AbstractEventBus> getEventBus() {
return this.plugin.getEventDispatcher().getEventBus();
@@ -186,4 +227,8 @@ public void registerMessengerProvider(@NonNull MessengerProvider messengerProvid
return ApiNodeMatcherFactory.INSTANCE;
}
+ @Override
+ public @NonNull ActionFilterFactory getActionFilterFactory() {
+ return ApiActionFilterFactory.INSTANCE;
+ }
}
diff --git a/common/src/main/java/me/lucko/luckperms/common/api/implementation/ApiActionFilter.java b/common/src/main/java/me/lucko/luckperms/common/api/implementation/ApiActionFilter.java
new file mode 100644
index 000000000..65a4be9ea
--- /dev/null
+++ b/common/src/main/java/me/lucko/luckperms/common/api/implementation/ApiActionFilter.java
@@ -0,0 +1,47 @@
+/*
+ * This file is part of LuckPerms, licensed under the MIT License.
+ *
+ * Copyright (c) lucko (Luck)