├── api ├── settings.gradle ├── build.gradle └── src │ └── main │ ├── java │ └── dev │ │ └── gegy │ │ └── roles │ │ └── api │ │ ├── override │ │ ├── RoleChangeListener.java │ │ ├── RoleOverrideResult.java │ │ ├── RoleOverrideType.java │ │ └── RoleOverrideReader.java │ │ ├── RoleOwner.java │ │ ├── Role.java │ │ ├── PlayerRolesApi.java │ │ ├── RoleProvider.java │ │ ├── RoleLookup.java │ │ ├── RoleReader.java │ │ ├── VirtualServerCommandSource.java │ │ └── util │ │ └── TinyRegistry.java │ └── resources │ └── fabric.mod.json ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── settings.gradle ├── src ├── main │ ├── resources │ │ ├── data │ │ │ └── player-roles │ │ │ │ └── default_roles.json │ │ ├── fabric.mod.json │ │ └── roles.mixins.json │ └── java │ │ └── dev │ │ └── gegy │ │ └── roles │ │ ├── IdentifiableCommandSource.java │ │ ├── mixin │ │ ├── TeamAccessor.java │ │ ├── ServerCommandSourceMixin.java │ │ ├── command │ │ │ ├── CommandManagerMixin.java │ │ │ ├── ServerCommandSourceMixin.java │ │ │ └── CommandDispatcherMixin.java │ │ ├── mute │ │ │ ├── MessageCommandMixin.java │ │ │ └── TeamMsgCommandMixin.java │ │ ├── entity_selectors │ │ │ ├── EntitySelectorMixin.java │ │ │ └── EntitySelectorReaderMixin.java │ │ ├── permission_level │ │ │ └── MinecraftServerMixin.java │ │ ├── PlayerManagerMixin.java │ │ ├── ServerPlayerEntityMixin.java │ │ ├── CommandFunctionMixin.java │ │ ├── CommandBlockExecutorMixin.java │ │ ├── chat_type │ │ │ └── ServerPlayNetworkHandlerMixin.java │ │ ├── bypass_limit │ │ │ └── DedicatedPlayerManagerMixin.java │ │ └── name_decoration │ │ │ └── ServerPlayerEntityMixin.java │ │ ├── override │ │ ├── command │ │ │ ├── CommandTestContext.java │ │ │ ├── MatchableCommand.java │ │ │ ├── CommandOverride.java │ │ │ ├── CommandOverrideRules.java │ │ │ └── CommandRequirementHooks.java │ │ ├── ChatTypeOverride.java │ │ ├── permission │ │ │ ├── PermissionKeyOverride.java │ │ │ └── PermissionKeyRules.java │ │ ├── RoleOverrideMap.java │ │ └── NameDecorationOverride.java │ │ ├── config │ │ ├── ConfigErrorConsumer.java │ │ ├── RoleApplyConfig.java │ │ ├── RoleConfig.java │ │ ├── RoleConfigMap.java │ │ └── PlayerRolesConfig.java │ │ ├── store │ │ ├── ServerRoleSet.java │ │ ├── db │ │ │ ├── PlayerRoleDatabase.java │ │ │ └── Uuid2BinaryDatabase.java │ │ ├── PlayerRoleSet.java │ │ └── PlayerRoleManager.java │ │ ├── command │ │ ├── PlayerRolesEntitySelectorOptions.java │ │ └── RoleCommand.java │ │ ├── SimpleRole.java │ │ └── PlayerRoles.java └── test │ └── java │ └── dev │ └── gegy │ └── roles │ ├── CommandMatchingTests.java │ ├── PermissionRulesTests.java │ ├── CommandRulesTests.java │ └── RoleDatabaseTests.java ├── .gitignore ├── gradle.properties ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── LICENSE ├── gradlew.bat ├── README.md └── gradlew /api/settings.gradle: -------------------------------------------------------------------------------- 1 | project.name = project.archives_base_name + '-api' 2 | -------------------------------------------------------------------------------- /api/build.gradle: -------------------------------------------------------------------------------- 1 | base { 2 | archivesName = project.archives_base_name + '-api' 3 | } 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NucleoidMC/player-roles/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | maven { url 'https://maven.fabricmc.net/' } 4 | gradlePluginPortal() 5 | } 6 | } 7 | 8 | rootProject.name = 'player-roles' 9 | include ':api' 10 | -------------------------------------------------------------------------------- /src/main/resources/data/player-roles/default_roles.json: -------------------------------------------------------------------------------- 1 | { 2 | "everyone": { 3 | "overrides": { 4 | "commands": { 5 | "help": "allow", 6 | "role": "deny" 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/override/RoleChangeListener.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api.override; 2 | 3 | import net.minecraft.server.network.ServerPlayerEntity; 4 | 5 | public interface RoleChangeListener { 6 | void onRoleChange(ServerPlayerEntity player); 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # gradle 2 | 3 | .gradle/ 4 | build/ 5 | out/ 6 | classes/ 7 | 8 | # eclipse 9 | 10 | *.launch 11 | 12 | # idea 13 | 14 | .idea/ 15 | *.iml 16 | *.ipr 17 | *.iws 18 | 19 | # vscode 20 | 21 | .settings/ 22 | .vscode/ 23 | bin/ 24 | .classpath 25 | .project 26 | 27 | # fabric 28 | 29 | run/ 30 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/IdentifiableCommandSource.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles; 2 | 3 | public interface IdentifiableCommandSource { 4 | void player_roles$setIdentityType(Type type); 5 | 6 | Type player_roles$getIdentityType(); 7 | 8 | enum Type { 9 | UNKNOWN, 10 | COMMAND_BLOCK, 11 | FUNCTION 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Done to increase the memory available to gradle. 2 | org.gradle.jvmargs=-Xmx1G 3 | 4 | # Fabric Properties 5 | minecraft_version=1.21.7 6 | yarn_mappings=1.21.7+build.2 7 | loader_version=0.16.14 8 | 9 | # Fabric api 10 | fabric_version=0.128.1+1.21.7 11 | 12 | # Mod Properties 13 | mod_version=1.6.15 14 | maven_group=dev.gegy 15 | archives_base_name=player-roles 16 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/TeamAccessor.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin; 2 | 3 | import net.minecraft.scoreboard.Team; 4 | import net.minecraft.util.Formatting; 5 | import org.spongepowered.asm.mixin.Mixin; 6 | import org.spongepowered.asm.mixin.gen.Accessor; 7 | 8 | @Mixin(Team.class) 9 | public interface TeamAccessor { 10 | @Accessor("color") 11 | Formatting getFormattingColor(); 12 | } 13 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/RoleOwner.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api; 2 | 3 | /** 4 | * Can be implemented on custom {@link net.minecraft.entity.Entity Entities} or 5 | * {@link net.minecraft.server.command.ServerCommandSource ServerCommandSources} 6 | * to allow overriding the set of roles that the entity/source is assumed to have. 7 | * 8 | * @see VirtualServerCommandSource 9 | */ 10 | public interface RoleOwner { 11 | RoleReader getRoles(); 12 | } 13 | -------------------------------------------------------------------------------- /api/src/main/resources/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "player_roles_api", 4 | "version": "${version}", 5 | "name": "Player Roles API", 6 | "description": "API for interacting with Player Roles", 7 | "authors": ["Gegy"], 8 | "license": "MIT", 9 | "environment": "*", 10 | "custom": { 11 | "modmenu": { 12 | "badges": ["library"] 13 | } 14 | }, 15 | "depends": { 16 | "fabricloader": ">=0.11", 17 | "java": ">=17" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/command/CommandTestContext.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override.command; 2 | 3 | public final class CommandTestContext { 4 | private static final ThreadLocal SUGGESTING = new ThreadLocal<>(); 5 | 6 | public static void startSuggesting() { 7 | SUGGESTING.set(Boolean.TRUE); 8 | } 9 | 10 | public static void stopSuggesting() { 11 | SUGGESTING.remove(); 12 | } 13 | 14 | public static boolean isSuggesting() { 15 | return SUGGESTING.get() == Boolean.TRUE; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "player_roles", 4 | "version": "${version}", 5 | "name": "Player Roles", 6 | "description": "Role management & permissions for Fabric servers via a JSON file", 7 | "authors": ["Gegy"], 8 | "license": "MIT", 9 | "environment": "*", 10 | "entrypoints": { 11 | "main": ["dev.gegy.roles.PlayerRoles"] 12 | }, 13 | "mixins": ["roles.mixins.json"], 14 | "depends": { 15 | "fabricloader": ">=0.15", 16 | "fabric-api": "*", 17 | "minecraft": ">=1.21.6", 18 | "java": ">=21" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/Role.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api; 2 | 3 | import dev.gegy.roles.api.override.RoleOverrideReader; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | public interface Role extends Comparable { 7 | String getId(); 8 | 9 | int getIndex(); 10 | 11 | RoleOverrideReader getOverrides(); 12 | 13 | @Override 14 | default int compareTo(@NotNull Role role) { 15 | int compareIndex = Integer.compare(role.getIndex(), this.getIndex()); 16 | return compareIndex != 0 ? compareIndex : this.getId().compareTo(role.getId()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/ChatTypeOverride.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override; 2 | 3 | import com.mojang.serialization.Codec; 4 | import net.minecraft.network.message.MessageType; 5 | import net.minecraft.registry.RegistryKey; 6 | import net.minecraft.registry.RegistryKeys; 7 | 8 | // We'd rather be name consistent with Vanilla registries than Yarn, especially since it's exposed to datapacks. 9 | public record ChatTypeOverride(RegistryKey chatType) { 10 | public static final Codec CODEC = RegistryKey.createCodec(RegistryKeys.MESSAGE_TYPE).xmap(ChatTypeOverride::new, ChatTypeOverride::chatType); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/config/ConfigErrorConsumer.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.config; 2 | 3 | import com.mojang.serialization.DataResult; 4 | 5 | public interface ConfigErrorConsumer { 6 | void report(String message); 7 | 8 | default void report(String message, DataResult.Error error) { 9 | this.report(message + ": " + error.message()); 10 | } 11 | 12 | default void report(String message, Throwable throwable) { 13 | var throwableMessage = throwable.getMessage(); 14 | if (throwableMessage != null) { 15 | this.report(message + ": " + throwableMessage); 16 | } else { 17 | this.report(message); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/config/RoleApplyConfig.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.config; 2 | 3 | import com.mojang.serialization.Codec; 4 | import com.mojang.serialization.codecs.RecordCodecBuilder; 5 | 6 | public record RoleApplyConfig(boolean commandBlock, boolean functions) { 7 | public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( 8 | Codec.BOOL.optionalFieldOf("command_block", false).forGetter(c -> c.commandBlock), 9 | Codec.BOOL.optionalFieldOf("functions", false).forGetter(c -> c.functions) 10 | ).apply(i, RoleApplyConfig::new)); 11 | 12 | public static final RoleApplyConfig DEFAULT = new RoleApplyConfig(false, false); 13 | } 14 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/PlayerRolesApi.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api; 2 | 3 | public final class PlayerRolesApi { 4 | public static final String ID = "player_roles"; 5 | 6 | private static RoleProvider provider = RoleProvider.EMPTY; 7 | private static RoleLookup lookup = RoleLookup.EMPTY; 8 | 9 | public static void setRoleProvider(RoleProvider provider) { 10 | PlayerRolesApi.provider = provider; 11 | } 12 | 13 | public static void setRoleLookup(RoleLookup lookup) { 14 | PlayerRolesApi.lookup = lookup; 15 | } 16 | 17 | public static RoleLookup lookup() { 18 | return PlayerRolesApi.lookup; 19 | } 20 | 21 | public static RoleProvider provider() { 22 | return PlayerRolesApi.provider; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/ServerCommandSourceMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin; 2 | 3 | import dev.gegy.roles.IdentifiableCommandSource; 4 | import net.minecraft.server.command.ServerCommandSource; 5 | import org.spongepowered.asm.mixin.Mixin; 6 | import org.spongepowered.asm.mixin.Unique; 7 | 8 | @Mixin(ServerCommandSource.class) 9 | public class ServerCommandSourceMixin implements IdentifiableCommandSource { 10 | @Unique 11 | private Type player_roles$identityType = Type.UNKNOWN; 12 | 13 | @Override 14 | public void player_roles$setIdentityType(Type type) { 15 | this.player_roles$identityType = type; 16 | } 17 | 18 | @Override 19 | public Type player_roles$getIdentityType() { 20 | return this.player_roles$identityType; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/cache@v4 11 | with: 12 | path: | 13 | ~/.gradle/loom-cache 14 | ~/.gradle/caches 15 | ~/.gradle/wrapper 16 | key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 17 | restore-keys: | 18 | gradle- 19 | 20 | - uses: actions/checkout@v4 21 | - name: Set up JDK 22 | uses: actions/setup-java@v1 23 | with: 24 | java-version: 21 25 | 26 | - name: Grant execute permission for gradlew 27 | run: chmod +x gradlew 28 | 29 | - name: Build with Gradle 30 | run: ./gradlew clean build 31 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/RoleProvider.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | import java.util.Collections; 7 | import java.util.Iterator; 8 | import java.util.stream.Stream; 9 | import java.util.stream.StreamSupport; 10 | 11 | public interface RoleProvider extends Iterable { 12 | RoleProvider EMPTY = new RoleProvider() { 13 | @Override 14 | @Nullable 15 | public Role get(String id) { 16 | return null; 17 | } 18 | 19 | @NotNull 20 | @Override 21 | public Iterator iterator() { 22 | return Collections.emptyIterator(); 23 | } 24 | }; 25 | 26 | @Nullable 27 | Role get(String id); 28 | 29 | default Stream stream() { 30 | return StreamSupport.stream(this.spliterator(), false); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/resources/roles.mixins.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "minVersion": "0.8", 4 | "package": "dev.gegy.roles.mixin", 5 | "compatibilityLevel": "JAVA_17", 6 | "mixins": [ 7 | "CommandBlockExecutorMixin", 8 | "CommandFunctionMixin", 9 | "PlayerManagerMixin", 10 | "ServerCommandSourceMixin", 11 | "ServerPlayerEntityMixin", 12 | "TeamAccessor", 13 | "bypass_limit.DedicatedPlayerManagerMixin", 14 | "chat_type.ServerPlayNetworkHandlerMixin", 15 | "command.CommandDispatcherMixin", 16 | "command.CommandManagerMixin", 17 | "command.ServerCommandSourceMixin", 18 | "entity_selectors.EntitySelectorReaderMixin", 19 | "entity_selectors.EntitySelectorMixin", 20 | "mute.MessageCommandMixin", 21 | "mute.TeamMsgCommandMixin", 22 | "name_decoration.ServerPlayerEntityMixin", 23 | "permission_level.MinecraftServerMixin" 24 | ], 25 | "injectors": { 26 | "defaultRequire": 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/RoleLookup.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api; 2 | 3 | import net.minecraft.entity.Entity; 4 | import net.minecraft.entity.player.PlayerEntity; 5 | import net.minecraft.server.command.ServerCommandSource; 6 | import org.jetbrains.annotations.NotNull; 7 | 8 | public interface RoleLookup { 9 | RoleLookup EMPTY = new RoleLookup() { 10 | @Override 11 | @NotNull 12 | public RoleReader byEntity(Entity entity) { 13 | return RoleReader.EMPTY; 14 | } 15 | 16 | @Override 17 | @NotNull 18 | public RoleReader bySource(ServerCommandSource source) { 19 | return RoleReader.EMPTY; 20 | } 21 | }; 22 | 23 | @NotNull 24 | default RoleReader byPlayer(PlayerEntity player) { 25 | return this.byEntity(player); 26 | } 27 | 28 | @NotNull 29 | RoleReader byEntity(Entity entity); 30 | 31 | @NotNull 32 | RoleReader bySource(ServerCommandSource source); 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/command/CommandManagerMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.command; 2 | 3 | import dev.gegy.roles.override.command.CommandTestContext; 4 | import net.minecraft.server.command.CommandManager; 5 | import net.minecraft.server.network.ServerPlayerEntity; 6 | import org.spongepowered.asm.mixin.Mixin; 7 | import org.spongepowered.asm.mixin.injection.At; 8 | import org.spongepowered.asm.mixin.injection.Inject; 9 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 10 | 11 | @Mixin(CommandManager.class) 12 | public class CommandManagerMixin { 13 | @Inject(method = "sendCommandTree", at = @At("HEAD")) 14 | private void beforeSendCommandTree(ServerPlayerEntity player, CallbackInfo ci) { 15 | CommandTestContext.startSuggesting(); 16 | } 17 | 18 | @Inject(method = "sendCommandTree", at = @At("RETURN")) 19 | private void afterSendCommandTree(ServerPlayerEntity player, CallbackInfo ci) { 20 | CommandTestContext.stopSuggesting(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/mute/MessageCommandMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.mute; 2 | 3 | import dev.gegy.roles.PlayerRoles; 4 | import net.minecraft.network.message.SignedMessage; 5 | import net.minecraft.server.command.MessageCommand; 6 | import net.minecraft.server.command.ServerCommandSource; 7 | import net.minecraft.server.network.ServerPlayerEntity; 8 | import org.spongepowered.asm.mixin.Mixin; 9 | import org.spongepowered.asm.mixin.injection.At; 10 | import org.spongepowered.asm.mixin.injection.Inject; 11 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 12 | 13 | import java.util.Collection; 14 | 15 | @Mixin(MessageCommand.class) 16 | public class MessageCommandMixin { 17 | @Inject(method = "execute", at = @At("HEAD"), cancellable = true) 18 | private static void execute(ServerCommandSource source, Collection targets, SignedMessage message, CallbackInfo ci) { 19 | if (!PlayerRoles.trySendChat(source)) { 20 | ci.cancel(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/entity_selectors/EntitySelectorMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.entity_selectors; 2 | 3 | import dev.gegy.roles.PlayerRoles; 4 | import dev.gegy.roles.api.PlayerRolesApi; 5 | import net.minecraft.command.EntitySelector; 6 | import net.minecraft.server.command.ServerCommandSource; 7 | import org.spongepowered.asm.mixin.Mixin; 8 | import org.spongepowered.asm.mixin.injection.At; 9 | import org.spongepowered.asm.mixin.injection.Redirect; 10 | 11 | @Mixin(EntitySelector.class) 12 | public class EntitySelectorMixin { 13 | @Redirect(method = "checkSourcePermission", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/command/ServerCommandSource;hasElevatedPermissions()Z")) 14 | private boolean hasPermissionLevel(ServerCommandSource instance) { 15 | if (instance.hasElevatedPermissions()) { 16 | return true; 17 | } 18 | 19 | var roles = PlayerRolesApi.lookup().bySource(instance); 20 | return roles.overrides().test(PlayerRoles.ENTITY_SELECTORS); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/RoleReader.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api; 2 | 3 | import dev.gegy.roles.api.override.RoleOverrideReader; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | import java.util.Collections; 7 | import java.util.Iterator; 8 | import java.util.stream.Stream; 9 | import java.util.stream.StreamSupport; 10 | 11 | public interface RoleReader extends Iterable { 12 | RoleReader EMPTY = new RoleReader() { 13 | @NotNull 14 | @Override 15 | public Iterator iterator() { 16 | return Collections.emptyIterator(); 17 | } 18 | 19 | @Override 20 | public boolean has(Role role) { 21 | return false; 22 | } 23 | 24 | @Override 25 | public RoleOverrideReader overrides() { 26 | return RoleOverrideReader.EMPTY; 27 | } 28 | }; 29 | 30 | default Stream stream() { 31 | return StreamSupport.stream(this.spliterator(), false); 32 | } 33 | 34 | boolean has(Role role); 35 | 36 | RoleOverrideReader overrides(); 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/mute/TeamMsgCommandMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.mute; 2 | 3 | import dev.gegy.roles.PlayerRoles; 4 | import net.minecraft.entity.Entity; 5 | import net.minecraft.network.message.SignedMessage; 6 | import net.minecraft.scoreboard.Team; 7 | import net.minecraft.server.command.ServerCommandSource; 8 | import net.minecraft.server.command.TeamMsgCommand; 9 | import net.minecraft.server.network.ServerPlayerEntity; 10 | import org.spongepowered.asm.mixin.Mixin; 11 | import org.spongepowered.asm.mixin.injection.At; 12 | import org.spongepowered.asm.mixin.injection.Inject; 13 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 14 | 15 | import java.util.List; 16 | 17 | @Mixin(TeamMsgCommand.class) 18 | public class TeamMsgCommandMixin { 19 | @Inject(method = "execute", at = @At("HEAD"), cancellable = true) 20 | private static void execute(ServerCommandSource source, Entity entity, Team team, List recipients, SignedMessage message, CallbackInfo ci) { 21 | if (!PlayerRoles.trySendChat(source)) { 22 | ci.cancel(); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/permission_level/MinecraftServerMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.permission_level; 2 | 3 | import com.mojang.authlib.GameProfile; 4 | import dev.gegy.roles.PlayerRoles; 5 | import dev.gegy.roles.store.PlayerRoleManager; 6 | import net.minecraft.server.MinecraftServer; 7 | import org.spongepowered.asm.mixin.Mixin; 8 | import org.spongepowered.asm.mixin.injection.At; 9 | import org.spongepowered.asm.mixin.injection.Inject; 10 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 11 | 12 | @Mixin(MinecraftServer.class) 13 | public abstract class MinecraftServerMixin { 14 | @Inject(method = "getPermissionLevel", at = @At("HEAD"), cancellable = true) 15 | public void getPermissionLevel(GameProfile profile, CallbackInfoReturnable ci) { 16 | var roles = PlayerRoleManager.get().peekRoles((MinecraftServer) (Object) this, profile.getId()); 17 | var permissionLevel = roles.overrides().select(PlayerRoles.PERMISSION_LEVEL); 18 | if (permissionLevel != null) { 19 | ci.setReturnValue(permissionLevel); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Gegy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/PlayerManagerMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin; 2 | 3 | import dev.gegy.roles.store.PlayerRoleManager; 4 | import net.minecraft.network.ClientConnection; 5 | import net.minecraft.server.PlayerManager; 6 | import net.minecraft.server.network.ConnectedClientData; 7 | import net.minecraft.server.network.ServerPlayerEntity; 8 | import org.spongepowered.asm.mixin.Mixin; 9 | import org.spongepowered.asm.mixin.injection.At; 10 | import org.spongepowered.asm.mixin.injection.Inject; 11 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 12 | 13 | @Mixin(PlayerManager.class) 14 | public class PlayerManagerMixin { 15 | @Inject(method = "onPlayerConnect", at = @At("HEAD")) 16 | private void onPlayerConnect(ClientConnection connection, ServerPlayerEntity player, ConnectedClientData clientData, CallbackInfo ci) { 17 | var roleManager = PlayerRoleManager.get(); 18 | roleManager.onPlayerJoin(player); 19 | } 20 | 21 | @Inject(method = "remove", at = @At("HEAD")) 22 | private void onPlayerDisconnect(ServerPlayerEntity player, CallbackInfo ci) { 23 | var roleManager = PlayerRoleManager.get(); 24 | roleManager.onPlayerLeave(player); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/ServerPlayerEntityMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin; 2 | 3 | import com.mojang.authlib.GameProfile; 4 | import com.mojang.serialization.Codec; 5 | import dev.gegy.roles.store.PlayerRoleManager; 6 | import net.minecraft.entity.player.PlayerEntity; 7 | import net.minecraft.server.network.ServerPlayerEntity; 8 | import net.minecraft.storage.ReadView; 9 | import net.minecraft.world.World; 10 | import org.spongepowered.asm.mixin.Mixin; 11 | import org.spongepowered.asm.mixin.injection.At; 12 | import org.spongepowered.asm.mixin.injection.Inject; 13 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 14 | 15 | @Mixin(value = ServerPlayerEntity.class, priority = 900) 16 | public abstract class ServerPlayerEntityMixin extends PlayerEntity { 17 | private ServerPlayerEntityMixin(World world, GameProfile profile) { 18 | super(world, profile); 19 | } 20 | 21 | @Inject(method = "readCustomData", at = @At("RETURN")) 22 | private void readLegacyRolesData(ReadView view, CallbackInfo ci) { 23 | view.read("roles", Codec.STRING.listOf()).ifPresent(names -> { 24 | PlayerRoleManager.get().addLegacyRoles((ServerPlayerEntity) (Object) this, names); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/CommandFunctionMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin; 2 | 3 | import com.mojang.brigadier.CommandDispatcher; 4 | import dev.gegy.roles.IdentifiableCommandSource; 5 | import net.minecraft.server.command.AbstractServerCommandSource; 6 | import net.minecraft.server.command.ServerCommandSource; 7 | import net.minecraft.server.function.CommandFunction; 8 | import net.minecraft.util.Identifier; 9 | import org.spongepowered.asm.mixin.Mixin; 10 | import org.spongepowered.asm.mixin.injection.At; 11 | import org.spongepowered.asm.mixin.injection.Inject; 12 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 13 | 14 | import java.util.List; 15 | 16 | @Mixin(CommandFunction.class) 17 | public interface CommandFunctionMixin { 18 | @Inject(method = "create", at = @At("HEAD")) 19 | private static void create( 20 | Identifier id, CommandDispatcher dispatcher, 21 | AbstractServerCommandSource source, List lines, 22 | CallbackInfoReturnable ci 23 | ) { 24 | var identifiableSource = (IdentifiableCommandSource) source; 25 | identifiableSource.player_roles$setIdentityType(IdentifiableCommandSource.Type.FUNCTION); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/VirtualServerCommandSource.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api; 2 | 3 | import net.minecraft.entity.Entity; 4 | import net.minecraft.server.MinecraftServer; 5 | import net.minecraft.server.command.CommandOutput; 6 | import net.minecraft.server.command.ServerCommandSource; 7 | import net.minecraft.server.world.ServerWorld; 8 | import net.minecraft.text.Text; 9 | import net.minecraft.util.math.Vec2f; 10 | import net.minecraft.util.math.Vec3d; 11 | import org.jetbrains.annotations.Nullable; 12 | 13 | /** 14 | * An extension of {@link ServerCommandSource} that implements {@link RoleOwner} 15 | * to allow a custom list of roles to use instead of the default empty set when no 16 | * entity is passed. 17 | */ 18 | public class VirtualServerCommandSource extends ServerCommandSource implements RoleOwner { 19 | private final RoleReader roles; 20 | 21 | public VirtualServerCommandSource(RoleReader roles, CommandOutput output, Vec3d pos, Vec2f rot, ServerWorld world, int level, String simpleName, Text name, MinecraftServer server, @Nullable Entity entity) { 22 | super(output, pos, rot, world, level, simpleName, name, server, entity); 23 | this.roles = roles; 24 | } 25 | 26 | @Override 27 | public RoleReader getRoles() { 28 | return this.roles; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/CommandBlockExecutorMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin; 2 | 3 | import dev.gegy.roles.IdentifiableCommandSource; 4 | import net.minecraft.server.MinecraftServer; 5 | import net.minecraft.server.command.ServerCommandSource; 6 | import net.minecraft.world.CommandBlockExecutor; 7 | import net.minecraft.world.World; 8 | import org.spongepowered.asm.mixin.Mixin; 9 | import org.spongepowered.asm.mixin.injection.At; 10 | import org.spongepowered.asm.mixin.injection.Inject; 11 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 12 | import org.spongepowered.asm.mixin.injection.callback.LocalCapture; 13 | 14 | @Mixin(CommandBlockExecutor.class) 15 | public class CommandBlockExecutorMixin { 16 | @Inject(method = "execute", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/command/CommandManager;executeWithPrefix(Lnet/minecraft/server/command/ServerCommandSource;Ljava/lang/String;)V", shift = At.Shift.BEFORE), locals = LocalCapture.CAPTURE_FAILHARD) 17 | private void executeCommand(World world, CallbackInfoReturnable cir, MinecraftServer server, ServerCommandSource source) { 18 | var identifiableSource = (IdentifiableCommandSource) source; 19 | identifiableSource.player_roles$setIdentityType(IdentifiableCommandSource.Type.COMMAND_BLOCK); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/entity_selectors/EntitySelectorReaderMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.entity_selectors; 2 | 3 | import com.llamalad7.mixinextras.injector.wrapoperation.Operation; 4 | import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; 5 | import dev.gegy.roles.PlayerRoles; 6 | import dev.gegy.roles.api.PlayerRolesApi; 7 | import net.minecraft.command.EntitySelectorReader; 8 | import net.minecraft.command.PermissionLevelSource; 9 | import net.minecraft.server.command.ServerCommandSource; 10 | import org.spongepowered.asm.mixin.Mixin; 11 | import org.spongepowered.asm.mixin.injection.At; 12 | 13 | @Mixin(EntitySelectorReader.class) 14 | public class EntitySelectorReaderMixin { 15 | @WrapOperation(method = "shouldAllowAtSelectors", at = @At(value = "INVOKE", target = "Lnet/minecraft/command/PermissionLevelSource;hasElevatedPermissions()Z")) 16 | private static boolean hasPermissionLevel(PermissionLevelSource source, Operation original) { 17 | if (original.call(source)) { 18 | return true; 19 | } 20 | 21 | if (source instanceof ServerCommandSource serverSource) { 22 | var roles = PlayerRolesApi.lookup().bySource(serverSource); 23 | return roles.overrides().test(PlayerRoles.ENTITY_SELECTORS); 24 | } 25 | 26 | return false; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/command/ServerCommandSourceMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.command; 2 | 3 | import com.llamalad7.mixinextras.injector.wrapoperation.Operation; 4 | import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; 5 | import com.mojang.authlib.GameProfile; 6 | import dev.gegy.roles.PlayerRoles; 7 | import dev.gegy.roles.api.PlayerRolesApi; 8 | import net.minecraft.server.PlayerManager; 9 | import net.minecraft.server.command.ServerCommandSource; 10 | import org.spongepowered.asm.mixin.Mixin; 11 | import org.spongepowered.asm.mixin.injection.At; 12 | 13 | @Mixin(ServerCommandSource.class) 14 | public class ServerCommandSourceMixin { 15 | @WrapOperation( 16 | method = "sendToOps", 17 | at = @At( 18 | value = "INVOKE", 19 | target = "Lnet/minecraft/server/PlayerManager;isOperator(Lcom/mojang/authlib/GameProfile;)Z" 20 | ) 21 | ) 22 | private boolean shouldReceiveCommandFeedback(PlayerManager playerManager, GameProfile profile, Operation original) { 23 | if (original.call(playerManager, profile)) { 24 | return true; 25 | } 26 | 27 | var player = playerManager.getPlayer(profile.getId()); 28 | var roles = PlayerRolesApi.lookup().byPlayer(player); 29 | return roles.overrides().test(PlayerRoles.COMMAND_FEEDBACK); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/store/ServerRoleSet.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.store; 2 | 3 | import dev.gegy.roles.api.Role; 4 | import dev.gegy.roles.api.RoleReader; 5 | import dev.gegy.roles.api.override.RoleOverrideReader; 6 | import dev.gegy.roles.override.RoleOverrideMap; 7 | import it.unimi.dsi.fastutil.objects.ObjectSortedSet; 8 | 9 | import java.util.Iterator; 10 | import java.util.stream.Stream; 11 | 12 | public final class ServerRoleSet implements RoleReader { 13 | private final ObjectSortedSet roles; 14 | private final RoleOverrideMap overrides = new RoleOverrideMap(); 15 | 16 | private ServerRoleSet(ObjectSortedSet roles) { 17 | this.roles = roles; 18 | for (var role : this.roles) { 19 | this.overrides.addAll(role.getOverrides()); 20 | } 21 | } 22 | 23 | public static ServerRoleSet of(ObjectSortedSet roles) { 24 | return new ServerRoleSet(roles); 25 | } 26 | 27 | @Override 28 | public Iterator iterator() { 29 | return this.roles.iterator(); 30 | } 31 | 32 | @Override 33 | public Stream stream() { 34 | return this.roles.stream(); 35 | } 36 | 37 | @Override 38 | public boolean has(Role role) { 39 | return this.roles.contains(role); 40 | } 41 | 42 | @Override 43 | public RoleOverrideReader overrides() { 44 | return this.overrides; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/command/CommandDispatcherMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.command; 2 | 3 | import com.mojang.brigadier.CommandDispatcher; 4 | import com.mojang.brigadier.tree.CommandNode; 5 | import dev.gegy.roles.override.command.CommandTestContext; 6 | import org.spongepowered.asm.mixin.Mixin; 7 | import org.spongepowered.asm.mixin.injection.At; 8 | import org.spongepowered.asm.mixin.injection.Inject; 9 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 10 | 11 | import java.util.Map; 12 | 13 | @Mixin(CommandDispatcher.class) 14 | public class CommandDispatcherMixin { 15 | @Inject( 16 | method = "getSmartUsage(Lcom/mojang/brigadier/tree/CommandNode;Ljava/lang/Object;)Ljava/util/Map;", 17 | remap = false, 18 | at = @At("HEAD") 19 | ) 20 | private void beforeGetSmartUsage(CommandNode node, S source, CallbackInfoReturnable, String>> cir) { 21 | CommandTestContext.startSuggesting(); 22 | } 23 | 24 | @Inject( 25 | method = "getSmartUsage(Lcom/mojang/brigadier/tree/CommandNode;Ljava/lang/Object;)Ljava/util/Map;", 26 | remap = false, 27 | at = @At("RETURN") 28 | ) 29 | private void afterGetSmartUsage(CommandNode node, S source, CallbackInfoReturnable, String>> cir) { 30 | CommandTestContext.stopSuggesting(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/override/RoleOverrideResult.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api.override; 2 | 3 | import com.mojang.serialization.Codec; 4 | import net.minecraft.util.StringIdentifiable; 5 | 6 | import java.util.Locale; 7 | 8 | public enum RoleOverrideResult implements StringIdentifiable { 9 | PASS, 10 | ALLOW, 11 | DENY, 12 | HIDDEN; 13 | 14 | public static final Codec CODEC = StringIdentifiable.createCodec(RoleOverrideResult::values); 15 | 16 | public boolean isDefinitive() { 17 | return this != PASS; 18 | } 19 | 20 | public boolean isAllowed() { 21 | return this == ALLOW || this == HIDDEN; 22 | } 23 | 24 | public boolean isDenied() { 25 | return this == DENY; 26 | } 27 | 28 | public static RoleOverrideResult byName(String name) { 29 | return switch (name.toLowerCase(Locale.ROOT)) { 30 | case "allow", "yes", "true" -> RoleOverrideResult.ALLOW; 31 | case "deny", "no", "false" -> RoleOverrideResult.DENY; 32 | case "hidden", "hide" -> RoleOverrideResult.HIDDEN; 33 | default -> RoleOverrideResult.PASS; 34 | }; 35 | } 36 | 37 | @Override 38 | public String asString() { 39 | return switch (this) { 40 | case ALLOW -> "allow"; 41 | case DENY -> "deny"; 42 | case HIDDEN -> "hidden"; 43 | default -> "pass"; 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/chat_type/ServerPlayNetworkHandlerMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.chat_type; 2 | 3 | import dev.gegy.roles.PlayerRoles; 4 | import dev.gegy.roles.api.PlayerRolesApi; 5 | import dev.gegy.roles.override.ChatTypeOverride; 6 | import net.minecraft.network.message.MessageType; 7 | import net.minecraft.registry.RegistryKey; 8 | import net.minecraft.server.network.ServerPlayNetworkHandler; 9 | import net.minecraft.server.network.ServerPlayerEntity; 10 | import net.minecraft.util.Nullables; 11 | import org.spongepowered.asm.mixin.Mixin; 12 | import org.spongepowered.asm.mixin.Shadow; 13 | import org.spongepowered.asm.mixin.injection.At; 14 | import org.spongepowered.asm.mixin.injection.ModifyArg; 15 | 16 | @Mixin(ServerPlayNetworkHandler.class) 17 | public class ServerPlayNetworkHandlerMixin { 18 | @Shadow 19 | public ServerPlayerEntity player; 20 | 21 | @ModifyArg(method = "handleDecoratedMessage", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/message/MessageType;params(Lnet/minecraft/registry/RegistryKey;Lnet/minecraft/entity/Entity;)Lnet/minecraft/network/message/MessageType$Parameters;")) 22 | private RegistryKey overrideChatType(RegistryKey defaultChatType) { 23 | var roles = PlayerRolesApi.lookup().byPlayer(this.player); 24 | var override = roles.overrides().select(PlayerRoles.CHAT_TYPE); 25 | return Nullables.mapOrElse(override, ChatTypeOverride::chatType, defaultChatType); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/bypass_limit/DedicatedPlayerManagerMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.bypass_limit; 2 | 3 | import com.mojang.authlib.GameProfile; 4 | import dev.gegy.roles.PlayerRoles; 5 | import net.minecraft.registry.CombinedDynamicRegistries; 6 | import net.minecraft.registry.ServerDynamicRegistryType; 7 | import net.minecraft.server.MinecraftServer; 8 | import net.minecraft.server.PlayerManager; 9 | import net.minecraft.server.dedicated.DedicatedPlayerManager; 10 | import net.minecraft.world.PlayerSaveHandler; 11 | import org.spongepowered.asm.mixin.Mixin; 12 | import org.spongepowered.asm.mixin.injection.At; 13 | import org.spongepowered.asm.mixin.injection.Inject; 14 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 15 | 16 | @Mixin(DedicatedPlayerManager.class) 17 | public class DedicatedPlayerManagerMixin extends PlayerManager { 18 | public DedicatedPlayerManagerMixin(MinecraftServer server, CombinedDynamicRegistries registryManager, PlayerSaveHandler saveHandler, int maxPlayers) { 19 | super(server, registryManager, saveHandler, maxPlayers); 20 | } 21 | 22 | @Inject(method = "canBypassPlayerLimit", at = @At("HEAD"), cancellable = true) 23 | private void canPlayerBypassLimitWithRole(GameProfile profile, CallbackInfoReturnable cir) { 24 | if (profile.getId() != null) { 25 | if (PlayerRoles.canBypassPlayerLimit(this.getServer(), profile.getId())) { 26 | cir.setReturnValue(true); 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/command/PlayerRolesEntitySelectorOptions.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.command; 2 | 3 | import com.mojang.brigadier.exceptions.CommandSyntaxException; 4 | 5 | import dev.gegy.roles.PlayerRoles; 6 | import dev.gegy.roles.api.PlayerRolesApi; 7 | import net.fabricmc.fabric.api.command.v2.EntitySelectorOptionRegistry; 8 | import net.minecraft.command.EntitySelectorReader; 9 | import net.minecraft.text.Text; 10 | import net.minecraft.util.Identifier; 11 | 12 | public final class PlayerRolesEntitySelectorOptions { 13 | private static final Identifier ID = PlayerRoles.identifier("role"); 14 | private static final Text DESCRIPTION = Text.literal("Player Role"); 15 | 16 | public static void register() { 17 | EntitySelectorOptionRegistry.register(ID, DESCRIPTION, PlayerRolesEntitySelectorOptions::handle, PlayerRolesEntitySelectorOptions::canUse); 18 | } 19 | 20 | private static void handle(EntitySelectorReader reader) throws CommandSyntaxException { 21 | boolean isNegated = reader.readNegationCharacter(); 22 | String roleName = reader.getReader().readUnquotedString(); 23 | reader.addPredicate(entity -> { 24 | var role = PlayerRolesApi.provider().get(roleName); 25 | return (role != null && PlayerRolesApi.lookup().byEntity(entity).has(role)) != isNegated; 26 | }); 27 | if (!isNegated) { 28 | reader.setCustomFlag(ID, true); 29 | } 30 | } 31 | 32 | private static boolean canUse(EntitySelectorReader reader) { 33 | return !reader.getCustomFlag(ID); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/cache@v4 14 | with: 15 | path: | 16 | ~/.gradle/loom-cache 17 | ~/.gradle/caches 18 | ~/.gradle/wrapper 19 | key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 20 | restore-keys: | 21 | gradle- 22 | 23 | - uses: actions/checkout@v4 24 | - name: Set up JDK 25 | uses: actions/setup-java@v1 26 | with: 27 | java-version: 21 28 | 29 | - name: Grant execute permission for gradlew 30 | run: chmod +x gradlew 31 | 32 | - name: Build and publish with Gradle 33 | run: ./gradlew clean build publish 34 | env: 35 | MAVEN_URL: ${{ secrets.MAVEN_URL }} 36 | MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} 37 | MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} 38 | 39 | - name: Upload GitHub release 40 | uses: AButler/upload-release-assets@v3.0 41 | with: 42 | files: 'build/libs/*.jar;!build/libs/*-sources.jar;!build/libs/*-dev.jar' 43 | repo-token: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: Upload to CurseForge and Modrinth 46 | uses: Kir-Antipov/mc-publish@v3.3 47 | with: 48 | modrinth-id: Rt1mrUHm 49 | modrinth-token: ${{ secrets.MODRINTH_TOKEN }} 50 | 51 | curseforge-id: 394363 52 | curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} 53 | 54 | java: | 55 | 21 56 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/SimpleRole.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles; 2 | 3 | import dev.gegy.roles.api.Role; 4 | import dev.gegy.roles.config.RoleApplyConfig; 5 | import dev.gegy.roles.override.RoleOverrideMap; 6 | 7 | public final class SimpleRole implements Role { 8 | private final String id; 9 | private final RoleOverrideMap overrides; 10 | private final int index; 11 | private final RoleApplyConfig apply; 12 | 13 | public SimpleRole(String id, RoleOverrideMap overrides, int index, RoleApplyConfig apply) { 14 | this.id = id; 15 | this.overrides = overrides; 16 | this.index = index; 17 | this.apply = apply; 18 | } 19 | 20 | public static SimpleRole empty(String id) { 21 | return new SimpleRole(id, new RoleOverrideMap(), 0, RoleApplyConfig.DEFAULT); 22 | } 23 | 24 | @Override 25 | public String getId() { 26 | return this.id; 27 | } 28 | 29 | @Override 30 | public int getIndex() { 31 | return this.index; 32 | } 33 | 34 | @Override 35 | public RoleOverrideMap getOverrides() { 36 | return this.overrides; 37 | } 38 | 39 | public RoleApplyConfig getApply() { 40 | return this.apply; 41 | } 42 | 43 | @Override 44 | public boolean equals(Object obj) { 45 | if (obj == this) return true; 46 | 47 | if (obj instanceof SimpleRole role) { 48 | return this.index == role.index && role.id.equalsIgnoreCase(this.id); 49 | } 50 | 51 | return false; 52 | } 53 | 54 | @Override 55 | public int hashCode() { 56 | return this.id.hashCode(); 57 | } 58 | 59 | @Override 60 | public String toString() { 61 | return "\"" + this.id + "\" (" + this.index + ")"; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/permission/PermissionKeyOverride.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override.permission; 2 | 3 | import com.mojang.serialization.Codec; 4 | import dev.gegy.roles.PlayerRoles; 5 | import dev.gegy.roles.api.PlayerRolesApi; 6 | import dev.gegy.roles.api.override.RoleOverrideType; 7 | import me.lucko.fabric.api.permissions.v0.PermissionCheckEvent; 8 | import net.fabricmc.fabric.api.util.TriState; 9 | import net.minecraft.server.command.ServerCommandSource; 10 | 11 | public record PermissionKeyOverride(PermissionKeyRules rules) { 12 | public static final Codec CODEC = PermissionKeyRules.CODEC.xmap(PermissionKeyOverride::new, override -> override.rules); 13 | 14 | public static void register() { 15 | var override = RoleOverrideType.register(PlayerRoles.identifier("permission_keys"), PermissionKeyOverride.CODEC) 16 | .withChangeListener(player -> { 17 | var server = player.getServer(); 18 | if (server != null) { 19 | server.getCommandManager().sendCommandTree(player); 20 | } 21 | }); 22 | 23 | PermissionCheckEvent.EVENT.register((source, permission) -> { 24 | if (source instanceof ServerCommandSource serverSource) { 25 | var roles = PlayerRolesApi.lookup().bySource(serverSource); 26 | var result = roles.overrides().test(override, permissions -> permissions.rules.test(permission)); 27 | return switch (result) { 28 | case ALLOW -> TriState.TRUE; 29 | case DENY -> TriState.FALSE; 30 | default -> TriState.DEFAULT; 31 | }; 32 | } 33 | 34 | return TriState.DEFAULT; 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/override/RoleOverrideType.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api.override; 2 | 3 | import com.mojang.serialization.Codec; 4 | import dev.gegy.roles.api.PlayerRolesApi; 5 | import dev.gegy.roles.api.util.TinyRegistry; 6 | import net.minecraft.server.network.ServerPlayerEntity; 7 | import net.minecraft.util.Identifier; 8 | import org.jetbrains.annotations.Nullable; 9 | 10 | public final class RoleOverrideType { 11 | public static final TinyRegistry> REGISTRY = TinyRegistry.create(PlayerRolesApi.ID); 12 | 13 | private final Identifier id; 14 | private final Codec codec; 15 | private RoleChangeListener changeListener; 16 | 17 | private RoleOverrideType(Identifier id, Codec codec) { 18 | this.id = id; 19 | this.codec = codec; 20 | } 21 | 22 | public static RoleOverrideType register(Identifier id, Codec codec) { 23 | var type = new RoleOverrideType<>(id, codec); 24 | REGISTRY.register(id, type); 25 | return type; 26 | } 27 | 28 | public RoleOverrideType withChangeListener(RoleChangeListener listener) { 29 | this.changeListener = listener; 30 | return this; 31 | } 32 | 33 | public Identifier getId() { 34 | return this.id; 35 | } 36 | 37 | public Codec getCodec() { 38 | return this.codec; 39 | } 40 | 41 | public void notifyChange(ServerPlayerEntity player) { 42 | if (this.changeListener != null) { 43 | this.changeListener.onRoleChange(player); 44 | } 45 | } 46 | 47 | @Nullable 48 | public static RoleOverrideType byId(Identifier id) { 49 | return REGISTRY.get(id); 50 | } 51 | 52 | @Override 53 | public String toString() { 54 | return "RoleOverrideType(" + this.id + ")"; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/command/MatchableCommand.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override.command; 2 | 3 | import com.mojang.brigadier.tree.CommandNode; 4 | 5 | import java.util.Arrays; 6 | import java.util.regex.Pattern; 7 | 8 | public final class MatchableCommand { 9 | private final String[] nodes; 10 | 11 | private MatchableCommand(String[] nodes) { 12 | this.nodes = nodes; 13 | } 14 | 15 | public static MatchableCommand of(String[] nodes) { 16 | return new MatchableCommand(nodes); 17 | } 18 | 19 | public static MatchableCommand parse(String command) { 20 | return new MatchableCommand(command.split(" ")); 21 | } 22 | 23 | public static MatchableCommand compile(CommandNode[] nodes) { 24 | return new MatchableCommand(Arrays.stream(nodes) 25 | .map(CommandNode::getName) 26 | .toArray(String[]::new) 27 | ); 28 | } 29 | 30 | public boolean matchesAllow(Pattern[] patterns) { 31 | // we match as long as the first nodes match 32 | int length = Math.min(this.nodes.length, patterns.length); 33 | for (int i = 0; i < length; i++) { 34 | if (!patterns[i].matcher(this.nodes[i]).matches()) { 35 | return false; 36 | } 37 | } 38 | 39 | return true; 40 | } 41 | 42 | public boolean matchesDeny(Pattern[] patterns) { 43 | // command must be longer than pattern 44 | if (this.nodes.length < patterns.length) { 45 | return false; 46 | } 47 | 48 | for (int i = 0; i < patterns.length; i++) { 49 | if (!patterns[i].matcher(this.nodes[i]).matches()) { 50 | return false; 51 | } 52 | } 53 | 54 | return true; 55 | } 56 | 57 | @Override 58 | public String toString() { 59 | return Arrays.toString(this.nodes); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/config/RoleConfig.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.config; 2 | 3 | import com.mojang.serialization.Codec; 4 | import com.mojang.serialization.codecs.RecordCodecBuilder; 5 | import dev.gegy.roles.SimpleRole; 6 | import dev.gegy.roles.override.RoleOverrideMap; 7 | import org.jetbrains.annotations.Nullable; 8 | import xyz.nucleoid.codecs.MoreCodecs; 9 | 10 | import java.util.Optional; 11 | import java.util.function.Supplier; 12 | 13 | public final class RoleConfig { 14 | public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( 15 | Codec.intRange(0, Integer.MAX_VALUE).optionalFieldOf("level", 0).forGetter(c -> c.level), 16 | RoleOverrideMap.CODEC.optionalFieldOf("overrides", new RoleOverrideMap()).forGetter(c -> c.overrides), 17 | MoreCodecs.arrayOrUnit(Codec.STRING, String[]::new).optionalFieldOf("includes", new String[0]).forGetter(c -> c.includes), 18 | RoleApplyConfig.CODEC.optionalFieldOf("apply").forGetter(c -> Optional.ofNullable(c.apply)) 19 | ).apply(i, RoleConfig::new)); 20 | 21 | public final int level; 22 | public final RoleOverrideMap overrides; 23 | public final String[] includes; 24 | public final @Nullable RoleApplyConfig apply; 25 | 26 | private RoleConfig(int level, RoleOverrideMap overrides, String[] includes, Optional apply) { 27 | this.level = level; 28 | this.overrides = new RoleOverrideMap(overrides); 29 | this.includes = includes; 30 | this.apply = apply.orElse(null); 31 | } 32 | 33 | public RoleConfig(int level, RoleOverrideMap overrides, String[] includes, @Nullable RoleApplyConfig apply) { 34 | this.level = level; 35 | this.overrides = overrides; 36 | this.includes = includes; 37 | this.apply = apply; 38 | } 39 | 40 | public SimpleRole create(String name, int index) { 41 | return new SimpleRole(name, this.overrides, index, this.apply != null ? this.apply : RoleApplyConfig.DEFAULT); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/java/dev/gegy/roles/CommandMatchingTests.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles; 2 | 3 | import dev.gegy.roles.override.command.MatchableCommand; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.util.regex.Pattern; 7 | 8 | import static org.junit.jupiter.api.Assertions.assertFalse; 9 | import static org.junit.jupiter.api.Assertions.assertTrue; 10 | 11 | final class CommandMatchingTests { 12 | @Test 13 | void testExecuteMatchesForAllow() { 14 | assertTrue(command("execute as").matchesAllow(matcher("execute"))); 15 | assertTrue(command("execute at").matchesAllow(matcher("execute"))); 16 | 17 | assertTrue(command("execute").matchesAllow(matcher("execute as"))); 18 | assertTrue(command("execute").matchesAllow(matcher("execute at"))); 19 | } 20 | 21 | @Test 22 | void testUnrelatedNoMatchesForAllow() { 23 | assertFalse(command("execute").matchesAllow(matcher("time"))); 24 | assertFalse(command("execute as").matchesAllow(matcher("time"))); 25 | 26 | assertFalse(command("time").matchesAllow(matcher("execute"))); 27 | assertFalse(command("time").matchesAllow(matcher("execute as"))); 28 | } 29 | 30 | @Test 31 | void testExecuteMatchesForDeny() { 32 | assertTrue(command("execute as").matchesDeny(matcher("execute"))); 33 | assertTrue(command("execute at").matchesDeny(matcher("execute"))); 34 | 35 | assertFalse(command("execute").matchesDeny(matcher("execute as"))); 36 | assertFalse(command("execute").matchesDeny(matcher("execute at"))); 37 | } 38 | 39 | private static Pattern[] matcher(String matcher) { 40 | String[] patternStrings = matcher.split(" "); 41 | Pattern[] patterns = new Pattern[patternStrings.length]; 42 | for (int i = 0; i < patternStrings.length; i++) { 43 | patterns[i] = Pattern.compile(patternStrings[i]); 44 | } 45 | return patterns; 46 | } 47 | 48 | private static MatchableCommand command(String command) { 49 | return MatchableCommand.parse(command); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/java/dev/gegy/roles/PermissionRulesTests.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles; 2 | 3 | import dev.gegy.roles.api.override.RoleOverrideResult; 4 | import dev.gegy.roles.override.permission.PermissionKeyRules; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import static org.junit.jupiter.api.Assertions.assertEquals; 8 | 9 | final class PermissionRulesTests { 10 | @Test 11 | void testMatchExact() { 12 | PermissionKeyRules rules = PermissionKeyRules.builder() 13 | .add("a.b.c", RoleOverrideResult.ALLOW) 14 | .add("a.b", RoleOverrideResult.DENY) 15 | .build(); 16 | 17 | assertEquals(rules.test("a.b.c"), RoleOverrideResult.ALLOW); 18 | assertEquals(rules.test("a.b"), RoleOverrideResult.DENY); 19 | assertEquals(rules.test("a.b.c.d"), RoleOverrideResult.PASS); 20 | } 21 | 22 | @Test 23 | void testMatchSuffixWildcards() { 24 | PermissionKeyRules rules = PermissionKeyRules.builder() 25 | .add("a.b.c", RoleOverrideResult.ALLOW) 26 | .add("a.b.*", RoleOverrideResult.DENY) 27 | .build(); 28 | 29 | assertEquals(rules.test("a.b.c"), RoleOverrideResult.ALLOW); 30 | assertEquals(rules.test("a.b.c.d"), RoleOverrideResult.DENY); 31 | assertEquals(rules.test("a.b"), RoleOverrideResult.DENY); 32 | assertEquals(rules.test("a.b.f"), RoleOverrideResult.DENY); 33 | } 34 | 35 | @Test 36 | void testMatchPrefixWildcards() { 37 | PermissionKeyRules rules = PermissionKeyRules.builder() 38 | .add("*.b", RoleOverrideResult.ALLOW) 39 | .add("a.b", RoleOverrideResult.DENY) 40 | .build(); 41 | 42 | assertEquals(rules.test("f.b"), RoleOverrideResult.ALLOW); 43 | assertEquals(rules.test("g.b"), RoleOverrideResult.ALLOW); 44 | assertEquals(rules.test("a.b"), RoleOverrideResult.DENY); 45 | assertEquals(rules.test("a.c"), RoleOverrideResult.PASS); 46 | } 47 | 48 | @Test 49 | void testMatchInlineWildcards() { 50 | PermissionKeyRules rules = PermissionKeyRules.builder() 51 | .add("a.*.c", RoleOverrideResult.ALLOW) 52 | .add("a.b.c", RoleOverrideResult.DENY) 53 | .build(); 54 | 55 | assertEquals(rules.test("a.a.c"), RoleOverrideResult.ALLOW); 56 | assertEquals(rules.test("a.a.a.a.a.c"), RoleOverrideResult.ALLOW); 57 | assertEquals(rules.test("a.b.c"), RoleOverrideResult.DENY); 58 | assertEquals(rules.test("b.a.c"), RoleOverrideResult.PASS); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/mixin/name_decoration/ServerPlayerEntityMixin.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.mixin.name_decoration; 2 | 3 | import com.mojang.authlib.GameProfile; 4 | import dev.gegy.roles.PlayerRoles; 5 | import dev.gegy.roles.api.PlayerRolesApi; 6 | import dev.gegy.roles.mixin.TeamAccessor; 7 | import dev.gegy.roles.override.NameDecorationOverride; 8 | import net.minecraft.entity.player.PlayerEntity; 9 | import net.minecraft.server.network.ServerPlayerEntity; 10 | import net.minecraft.text.Text; 11 | import net.minecraft.util.Formatting; 12 | import net.minecraft.world.World; 13 | import org.spongepowered.asm.mixin.Mixin; 14 | import org.spongepowered.asm.mixin.injection.At; 15 | import org.spongepowered.asm.mixin.injection.Inject; 16 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 17 | 18 | // Bump priority to apply name decorations after other mods 19 | @Mixin(value = ServerPlayerEntity.class, priority = 1001) 20 | public abstract class ServerPlayerEntityMixin extends PlayerEntity { 21 | private ServerPlayerEntityMixin(World world, GameProfile profile) { 22 | super(world, profile); 23 | } 24 | 25 | @Override 26 | public Text getDisplayName() { 27 | var displayName = super.getDisplayName(); 28 | 29 | var team = this.getScoreboardTeam(); 30 | if (team == null || ((TeamAccessor) team).getFormattingColor() == Formatting.RESET) { 31 | var roles = PlayerRolesApi.lookup().byPlayer(this); 32 | 33 | var nameDecoration = roles.overrides().select(PlayerRoles.NAME_DECORATION); 34 | if (nameDecoration != null) { 35 | displayName = nameDecoration.apply(displayName.copy(), NameDecorationOverride.Context.CHAT); 36 | } 37 | } 38 | 39 | return displayName; 40 | } 41 | 42 | @Inject(method = "getPlayerListName", at = @At("RETURN"), cancellable = true) 43 | private void getPlayerListName(CallbackInfoReturnable cir) { 44 | var roles = PlayerRolesApi.lookup().byPlayer(this); 45 | 46 | var nameDecoration = roles.overrides().select(PlayerRoles.NAME_DECORATION); 47 | if (nameDecoration != null) { 48 | var currentName = cir.getReturnValue(); 49 | if (currentName != null) { 50 | cir.setReturnValue(nameDecoration.apply(currentName.copy(), NameDecorationOverride.Context.TAB_LIST)); 51 | } else { 52 | cir.setReturnValue(nameDecoration.apply(Text.literal(getGameProfile().getName()), NameDecorationOverride.Context.TAB_LIST)); 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/override/RoleOverrideReader.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api.override; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | import java.util.Collection; 7 | import java.util.Collections; 8 | import java.util.Set; 9 | import java.util.function.Function; 10 | import java.util.stream.Stream; 11 | 12 | public interface RoleOverrideReader { 13 | RoleOverrideReader EMPTY = new RoleOverrideReader() { 14 | @Override 15 | @Nullable 16 | public Collection getOrNull(RoleOverrideType type) { 17 | return null; 18 | } 19 | 20 | @Override 21 | public RoleOverrideResult test(RoleOverrideType type, Function function) { 22 | return RoleOverrideResult.PASS; 23 | } 24 | 25 | @Override 26 | @Nullable 27 | public T select(RoleOverrideType type) { 28 | return null; 29 | } 30 | 31 | @Override 32 | public boolean test(RoleOverrideType type) { 33 | return false; 34 | } 35 | 36 | @Override 37 | public Set> typeSet() { 38 | return Collections.emptySet(); 39 | } 40 | }; 41 | 42 | @Nullable 43 | Collection getOrNull(RoleOverrideType type); 44 | 45 | @NotNull 46 | default Collection get(RoleOverrideType type) { 47 | var overrides = this.getOrNull(type); 48 | return overrides != null ? overrides : Collections.emptyList(); 49 | } 50 | 51 | default Stream streamOf(RoleOverrideType type) { 52 | return this.get(type).stream(); 53 | } 54 | 55 | default RoleOverrideResult test(RoleOverrideType type, Function function) { 56 | var overrides = this.getOrNull(type); 57 | if (overrides == null) { 58 | return RoleOverrideResult.PASS; 59 | } 60 | 61 | for (var override : overrides) { 62 | var result = function.apply(override); 63 | if (result.isDefinitive()) { 64 | return result; 65 | } 66 | } 67 | 68 | return RoleOverrideResult.PASS; 69 | } 70 | 71 | @Nullable 72 | default T select(RoleOverrideType type) { 73 | var overrides = this.getOrNull(type); 74 | if (overrides != null) { 75 | for (var override : overrides) { 76 | return override; 77 | } 78 | } 79 | return null; 80 | } 81 | 82 | default boolean test(RoleOverrideType type) { 83 | var result = this.select(type); 84 | return result != null ? result : false; 85 | } 86 | 87 | Set> typeSet(); 88 | } 89 | -------------------------------------------------------------------------------- /api/src/main/java/dev/gegy/roles/api/util/TinyRegistry.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.api.util; 2 | 3 | import com.google.common.collect.BiMap; 4 | import com.google.common.collect.HashBiMap; 5 | import com.mojang.datafixers.util.Pair; 6 | import com.mojang.serialization.Codec; 7 | import com.mojang.serialization.DataResult; 8 | import com.mojang.serialization.DynamicOps; 9 | import net.minecraft.util.Identifier; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.jetbrains.annotations.Nullable; 12 | 13 | import java.util.Iterator; 14 | import java.util.Set; 15 | 16 | public final class TinyRegistry implements Codec, Iterable { 17 | private final BiMap map = HashBiMap.create(); 18 | private final String defaultNamespace; 19 | 20 | private TinyRegistry(String defaultNamespace) { 21 | this.defaultNamespace = defaultNamespace; 22 | } 23 | 24 | public static TinyRegistry create() { 25 | return new TinyRegistry<>("minecraft"); 26 | } 27 | 28 | public static TinyRegistry create(String defaultNamespace) { 29 | return new TinyRegistry<>(defaultNamespace); 30 | } 31 | 32 | public void register(Identifier id, T value) { 33 | this.map.put(id, value); 34 | } 35 | 36 | @Nullable 37 | public T get(Identifier id) { 38 | return this.map.get(id); 39 | } 40 | 41 | @Nullable 42 | public Identifier getId(T value) { 43 | return this.map.inverse().get(value); 44 | } 45 | 46 | public boolean containsId(Identifier id) { 47 | return this.map.containsKey(id); 48 | } 49 | 50 | @Override 51 | public DataResult> decode(DynamicOps ops, U input) { 52 | return Codec.STRING.decode(ops, input) 53 | .flatMap(pair -> { 54 | var id = this.parseId(pair.getFirst()); 55 | var entry = this.get(id); 56 | if (entry == null) { 57 | return DataResult.error(() ->"Unknown registry key: " + pair.getFirst()); 58 | } 59 | return DataResult.success(Pair.of(entry, pair.getSecond())); 60 | }); 61 | } 62 | 63 | private Identifier parseId(String string) { 64 | if (string.indexOf(Identifier.NAMESPACE_SEPARATOR) != -1) { 65 | return Identifier.of(string); 66 | } else { 67 | return Identifier.of(this.defaultNamespace, string); 68 | } 69 | } 70 | 71 | @Override 72 | public DataResult encode(T input, DynamicOps ops, U prefix) { 73 | var id = this.getId(input); 74 | if (id == null) { 75 | return DataResult.error(() -> "Unknown registry element " + input); 76 | } 77 | return ops.mergeToPrimitive(prefix, ops.createString(id.toString())); 78 | } 79 | 80 | public Set ids() { 81 | return this.map.keySet(); 82 | } 83 | 84 | @NotNull 85 | @Override 86 | public Iterator iterator() { 87 | return this.map.values().iterator(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/command/CommandOverride.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override.command; 2 | 3 | import com.mojang.brigadier.CommandDispatcher; 4 | import com.mojang.serialization.Codec; 5 | import dev.gegy.roles.PlayerRoles; 6 | import dev.gegy.roles.api.PlayerRolesApi; 7 | import dev.gegy.roles.api.override.RoleOverrideResult; 8 | import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; 9 | import net.minecraft.server.command.ServerCommandSource; 10 | 11 | public record CommandOverride(CommandOverrideRules rules) { 12 | public static final Codec CODEC = CommandOverrideRules.CODEC.xmap( 13 | CommandOverride::new, 14 | override -> override.rules 15 | ); 16 | 17 | private static boolean registered; 18 | 19 | public static void initialize() { 20 | // cursed solution to make sure we run our handler after everything else 21 | // worldedit registers commands in the server started listener, so we need to override that 22 | ServerLifecycleEvents.SERVER_STARTING.register(s -> { 23 | if (registered) { 24 | return; 25 | } 26 | registered = true; 27 | 28 | ServerLifecycleEvents.SERVER_STARTED.register(server -> { 29 | hookCommands(server.getCommandManager().getDispatcher()); 30 | }); 31 | }); 32 | 33 | ServerLifecycleEvents.END_DATA_PACK_RELOAD.register((server, resources, success) -> { 34 | hookCommands(server.getCommandManager().getDispatcher()); 35 | }); 36 | } 37 | 38 | private static void hookCommands(CommandDispatcher dispatcher) { 39 | try { 40 | var hooks = CommandRequirementHooks.tryCreate((nodes, parent) -> { 41 | var command = MatchableCommand.compile(nodes); 42 | 43 | return source -> switch (canUseCommand(source, command)) { 44 | case ALLOW -> true; 45 | case DENY -> false; 46 | case HIDDEN -> !CommandTestContext.isSuggesting(); 47 | default -> parent.test(source); 48 | }; 49 | }); 50 | 51 | hooks.applyTo(dispatcher); 52 | } catch (ReflectiveOperationException e) { 53 | PlayerRoles.LOGGER.error("Failed to reflect into command requirements!", e); 54 | } 55 | } 56 | 57 | private static RoleOverrideResult canUseCommand(ServerCommandSource source, MatchableCommand command) { 58 | if (doesBypassPermissions(source)) { 59 | return RoleOverrideResult.PASS; 60 | } 61 | 62 | var roles = PlayerRolesApi.lookup().bySource(source); 63 | return roles.overrides().test(PlayerRoles.COMMANDS, m -> m.test(command)); 64 | } 65 | 66 | public static boolean doesBypassPermissions(ServerCommandSource source) { 67 | return source.hasPermissionLevel(4); 68 | } 69 | 70 | public RoleOverrideResult test(MatchableCommand command) { 71 | return this.rules.test(command); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/store/db/PlayerRoleDatabase.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.store.db; 2 | 3 | import com.mojang.logging.LogUtils; 4 | import com.mojang.serialization.Codec; 5 | import dev.gegy.roles.config.PlayerRolesConfig; 6 | import dev.gegy.roles.store.PlayerRoleSet; 7 | import net.minecraft.nbt.NbtCompound; 8 | import net.minecraft.nbt.NbtIo; 9 | import net.minecraft.nbt.NbtSizeTracker; 10 | import org.slf4j.Logger; 11 | 12 | import java.io.ByteArrayInputStream; 13 | import java.io.ByteArrayOutputStream; 14 | import java.io.Closeable; 15 | import java.io.IOException; 16 | import java.nio.ByteBuffer; 17 | import java.nio.file.Path; 18 | import java.util.UUID; 19 | 20 | public final class PlayerRoleDatabase implements Closeable { 21 | private static final Logger LOGGER = LogUtils.getLogger(); 22 | 23 | private final Uuid2BinaryDatabase binary; 24 | 25 | private PlayerRoleDatabase(Uuid2BinaryDatabase binary) { 26 | this.binary = binary; 27 | } 28 | 29 | public static PlayerRoleDatabase open(Path path) throws IOException { 30 | var binary = Uuid2BinaryDatabase.open(path); 31 | return new PlayerRoleDatabase(binary); 32 | } 33 | 34 | public void tryLoadInto(UUID uuid, PlayerRoleSet roles) { 35 | try { 36 | var bytes = this.binary.get(uuid); 37 | if (bytes != null) { 38 | try { 39 | deserializeRoles(roles, bytes); 40 | } catch (IOException e) { 41 | LOGGER.error("Failed to deserialize roles for {}, dropping", uuid, e); 42 | this.binary.remove(uuid); 43 | } 44 | } 45 | } catch (IOException e) { 46 | LOGGER.error("Failed to load roles for {}", uuid, e); 47 | } 48 | } 49 | 50 | public void trySave(UUID uuid, PlayerRoleSet roles) { 51 | try { 52 | if (!roles.isEmpty()) { 53 | var bytes = serializeRoles(roles); 54 | this.binary.put(uuid, bytes); 55 | } else { 56 | this.binary.remove(uuid); 57 | } 58 | } catch (IOException e) { 59 | LOGGER.error("Failed to save roles for {}", uuid, e); 60 | } 61 | } 62 | 63 | private static ByteBuffer serializeRoles(PlayerRoleSet roles) throws IOException { 64 | var nbt = new NbtCompound(); 65 | nbt.put("roles", roles.serialize()); 66 | 67 | try (var output = new ByteArrayOutputStream()) { 68 | NbtIo.writeCompressed(nbt, output); 69 | return ByteBuffer.wrap(output.toByteArray()); 70 | } 71 | } 72 | 73 | private static void deserializeRoles(PlayerRoleSet roles, ByteBuffer bytes) throws IOException { 74 | var config = PlayerRolesConfig.get(); 75 | 76 | try (var input = new ByteArrayInputStream(bytes.array())) { 77 | var nbt = NbtIo.readCompressed(input, NbtSizeTracker.ofUnlimitedBytes()); 78 | 79 | nbt.get("roles", Codec.STRING.listOf()).ifPresent(names -> { 80 | roles.deserialize(config, names); 81 | }); 82 | } 83 | } 84 | 85 | @Override 86 | public void close() throws IOException { 87 | this.binary.close(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/RoleOverrideMap.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.mojang.serialization.Codec; 5 | import dev.gegy.roles.api.override.RoleOverrideReader; 6 | import dev.gegy.roles.api.override.RoleOverrideType; 7 | import it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap; 8 | import net.minecraft.server.network.ServerPlayerEntity; 9 | import org.jetbrains.annotations.NotNull; 10 | import org.jetbrains.annotations.Nullable; 11 | import xyz.nucleoid.codecs.MoreCodecs; 12 | 13 | import java.util.ArrayList; 14 | import java.util.Collection; 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.Set; 18 | 19 | public final class RoleOverrideMap implements RoleOverrideReader { 20 | @SuppressWarnings("unchecked") 21 | public static final Codec CODEC = Codec.dispatchedMap(RoleOverrideType.REGISTRY, t -> MoreCodecs.listOrUnit((Codec) t.getCodec())) 22 | .xmap(RoleOverrideMap::new, m -> m.overrides); 23 | 24 | private final Map, List> overrides; 25 | 26 | public RoleOverrideMap() { 27 | this.overrides = new Reference2ObjectOpenHashMap<>(); 28 | } 29 | 30 | public RoleOverrideMap(RoleOverrideMap map) { 31 | this(map.overrides); 32 | } 33 | 34 | private RoleOverrideMap(Map, List> overrides) { 35 | this.overrides = new Reference2ObjectOpenHashMap<>(overrides); 36 | } 37 | 38 | public void notifyChange(ServerPlayerEntity player) { 39 | for (var override : this.overrides.keySet()) { 40 | override.notifyChange(player); 41 | } 42 | } 43 | 44 | @Override 45 | @NotNull 46 | @SuppressWarnings("unchecked") 47 | public List get(RoleOverrideType type) { 48 | return (List) this.overrides.getOrDefault(type, ImmutableList.of()); 49 | } 50 | 51 | @Override 52 | @Nullable 53 | @SuppressWarnings("unchecked") 54 | public List getOrNull(RoleOverrideType type) { 55 | return (List) this.overrides.get(type); 56 | } 57 | 58 | @Override 59 | public Set> typeSet() { 60 | return this.overrides.keySet(); 61 | } 62 | 63 | public void clear() { 64 | this.overrides.clear(); 65 | } 66 | 67 | public void addAll(RoleOverrideReader overrides) { 68 | for (var type : overrides.typeSet()) { 69 | this.addAllUnchecked(type, overrides.get(type)); 70 | } 71 | } 72 | 73 | @SuppressWarnings("unchecked") 74 | private void addAllUnchecked(RoleOverrideType type, Collection overrides) { 75 | this.getOrCreateOverrides(type).addAll((Collection) overrides); 76 | } 77 | 78 | public void addAll(RoleOverrideType type, Collection overrides) { 79 | this.getOrCreateOverrides(type).addAll(overrides); 80 | } 81 | 82 | public void add(RoleOverrideType type, T override) { 83 | this.getOrCreateOverrides(type).add(override); 84 | } 85 | 86 | @SuppressWarnings("unchecked") 87 | private List getOrCreateOverrides(RoleOverrideType type) { 88 | return (List) this.overrides.computeIfAbsent(type, t -> new ArrayList<>()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/command/CommandOverrideRules.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override.command; 2 | 3 | import com.mojang.serialization.Codec; 4 | import dev.gegy.roles.api.override.RoleOverrideResult; 5 | 6 | import java.util.ArrayList; 7 | import java.util.Arrays; 8 | import java.util.Comparator; 9 | import java.util.List; 10 | import java.util.regex.Pattern; 11 | import java.util.stream.Collectors; 12 | 13 | public final class CommandOverrideRules { 14 | private static final Codec PATTERN_CODEC = Codec.STRING.xmap( 15 | key -> { 16 | var patternStrings = key.split(" "); 17 | return Arrays.stream(patternStrings).map(Pattern::compile).toArray(Pattern[]::new); 18 | }, 19 | patterns -> { 20 | return Arrays.stream(patterns).map(Pattern::pattern).collect(Collectors.joining(" ")); 21 | } 22 | ); 23 | 24 | public static final Codec CODEC = Codec.unboundedMap(PATTERN_CODEC, RoleOverrideResult.CODEC) 25 | .xmap(map -> { 26 | var rules = CommandOverrideRules.builder(); 27 | map.forEach(rules::add); 28 | return rules.build(); 29 | }, rules -> { 30 | return Arrays.stream(rules.rules).collect(Collectors.toMap(rule -> rule.patterns, rule -> rule.result)); 31 | }); 32 | 33 | private final Rule[] rules; 34 | 35 | CommandOverrideRules(Rule[] commands) { 36 | this.rules = commands; 37 | } 38 | 39 | public static Builder builder() { 40 | return new Builder(); 41 | } 42 | 43 | public RoleOverrideResult test(MatchableCommand command) { 44 | for (var rule : this.rules) { 45 | var result = rule.test(command); 46 | if (result.isDefinitive()) { 47 | return result; 48 | } 49 | } 50 | return RoleOverrideResult.PASS; 51 | } 52 | 53 | @Override 54 | public String toString() { 55 | return Arrays.toString(this.rules); 56 | } 57 | 58 | public static class Builder { 59 | private final List rules = new ArrayList<>(); 60 | 61 | Builder() { 62 | } 63 | 64 | public Builder add(Pattern[] patterns, RoleOverrideResult result) { 65 | this.rules.add(new Rule(patterns, result)); 66 | return this; 67 | } 68 | 69 | public CommandOverrideRules build() { 70 | this.rules.sort(Comparator.comparingInt(Rule::size).reversed()); 71 | var rules = this.rules.toArray(new Rule[0]); 72 | return new CommandOverrideRules(rules); 73 | } 74 | } 75 | 76 | private record Rule(Pattern[] patterns, RoleOverrideResult result) { 77 | RoleOverrideResult test(MatchableCommand command) { 78 | if (this.result.isAllowed()) { 79 | return command.matchesAllow(this.patterns) ? this.result : RoleOverrideResult.PASS; 80 | } else if (this.result.isDenied()) { 81 | return command.matchesDeny(this.patterns) ? this.result : RoleOverrideResult.PASS; 82 | } 83 | return RoleOverrideResult.PASS; 84 | } 85 | 86 | int size() { 87 | return this.patterns.length; 88 | } 89 | 90 | @Override 91 | public String toString() { 92 | return "\"" + Arrays.toString(this.patterns) + "\"=" + this.result; 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/test/java/dev/gegy/roles/CommandRulesTests.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles; 2 | 3 | import dev.gegy.roles.api.override.RoleOverrideResult; 4 | import dev.gegy.roles.override.command.CommandOverrideRules; 5 | import dev.gegy.roles.override.command.MatchableCommand; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.util.regex.Pattern; 9 | 10 | import static org.junit.jupiter.api.Assertions.assertEquals; 11 | 12 | final class CommandRulesTests { 13 | @Test 14 | void testAllowExecuteAsDenyExecute() { 15 | CommandOverrideRules rules = CommandOverrideRules.builder() 16 | .add(matcher("execute as"), RoleOverrideResult.ALLOW) 17 | .add(matcher("execute"), RoleOverrideResult.DENY) 18 | .build(); 19 | 20 | assertEquals(rules.test(command("execute as")), RoleOverrideResult.ALLOW); 21 | assertEquals(rules.test(command("execute at")), RoleOverrideResult.DENY); 22 | assertEquals(rules.test(command("execute")), RoleOverrideResult.ALLOW); 23 | } 24 | 25 | @Test 26 | void testAllowExecuteDenyExecuteAs() { 27 | CommandOverrideRules rules = CommandOverrideRules.builder() 28 | .add(matcher("execute as"), RoleOverrideResult.DENY) 29 | .add(matcher("execute"), RoleOverrideResult.ALLOW) 30 | .build(); 31 | 32 | assertEquals(rules.test(command("execute as")), RoleOverrideResult.DENY); 33 | assertEquals(rules.test(command("execute at")), RoleOverrideResult.ALLOW); 34 | assertEquals(rules.test(command("execute")), RoleOverrideResult.ALLOW); 35 | } 36 | 37 | @Test 38 | void testOverrideAllowWildcard() { 39 | CommandOverrideRules rules = CommandOverrideRules.builder() 40 | .add(matcher("gamemode"), RoleOverrideResult.DENY) 41 | .add(matcher(".*"), RoleOverrideResult.ALLOW) 42 | .build(); 43 | 44 | assertEquals(rules.test(command("gamemode")), RoleOverrideResult.DENY); 45 | assertEquals(rules.test(command("gamemode creative")), RoleOverrideResult.DENY); 46 | assertEquals(rules.test(command("foo")), RoleOverrideResult.ALLOW); 47 | assertEquals(rules.test(command("bar")), RoleOverrideResult.ALLOW); 48 | } 49 | 50 | @Test 51 | void testOverrideAllowSpecificGameMode() { 52 | CommandOverrideRules rules = CommandOverrideRules.builder() 53 | .add(matcher("gamemode (spectator|survival)"), RoleOverrideResult.ALLOW) 54 | .add(matcher("gamemode"), RoleOverrideResult.DENY) 55 | .build(); 56 | 57 | assertEquals(rules.test(command("gamemode creative")), RoleOverrideResult.DENY); 58 | assertEquals(rules.test(command("gamemode survival")), RoleOverrideResult.ALLOW); 59 | assertEquals(rules.test(command("gamemode spectator")), RoleOverrideResult.ALLOW); 60 | } 61 | 62 | @Test 63 | void testOverrideDenyWildcard() { 64 | CommandOverrideRules rules = CommandOverrideRules.builder() 65 | .add(matcher("gamemode"), RoleOverrideResult.ALLOW) 66 | .add(matcher(".*"), RoleOverrideResult.DENY) 67 | .build(); 68 | 69 | assertEquals(rules.test(command("gamemode")), RoleOverrideResult.ALLOW); 70 | assertEquals(rules.test(command("gamemode creative")), RoleOverrideResult.ALLOW); 71 | assertEquals(rules.test(command("foo")), RoleOverrideResult.DENY); 72 | assertEquals(rules.test(command("bar")), RoleOverrideResult.DENY); 73 | } 74 | 75 | private static Pattern[] matcher(String matcher) { 76 | String[] patternStrings = matcher.split(" "); 77 | Pattern[] patterns = new Pattern[patternStrings.length]; 78 | for (int i = 0; i < patternStrings.length; i++) { 79 | patterns[i] = Pattern.compile(patternStrings[i]); 80 | } 81 | return patterns; 82 | } 83 | 84 | private static MatchableCommand command(String command) { 85 | return MatchableCommand.parse(command); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/store/PlayerRoleSet.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.store; 2 | 3 | import dev.gegy.roles.PlayerRoles; 4 | import dev.gegy.roles.api.Role; 5 | import dev.gegy.roles.api.RoleProvider; 6 | import dev.gegy.roles.api.RoleReader; 7 | import dev.gegy.roles.api.override.RoleOverrideReader; 8 | import dev.gegy.roles.override.RoleOverrideMap; 9 | import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet; 10 | import it.unimi.dsi.fastutil.objects.ObjectSortedSet; 11 | import net.minecraft.nbt.NbtList; 12 | import net.minecraft.nbt.NbtString; 13 | import net.minecraft.server.network.ServerPlayerEntity; 14 | import org.jetbrains.annotations.Nullable; 15 | 16 | import java.util.Iterator; 17 | import java.util.List; 18 | import java.util.stream.Stream; 19 | 20 | public final class PlayerRoleSet implements RoleReader { 21 | private final Role everyoneRole; 22 | 23 | @Nullable 24 | private final ServerPlayerEntity player; 25 | 26 | private final ObjectSortedSet roles = new ObjectAVLTreeSet<>(); 27 | private final RoleOverrideMap overrides = new RoleOverrideMap(); 28 | 29 | private boolean dirty; 30 | 31 | public PlayerRoleSet(Role everyoneRole, @Nullable ServerPlayerEntity player) { 32 | this.everyoneRole = everyoneRole; 33 | this.player = player; 34 | 35 | this.rebuildOverrides(); 36 | } 37 | 38 | public void rebuildOverridesAndNotify() { 39 | this.rebuildOverrides(); 40 | if (this.player != null) { 41 | this.overrides.notifyChange(this.player); 42 | } 43 | } 44 | 45 | private void rebuildOverrides() { 46 | this.overrides.clear(); 47 | this.stream().forEach(role -> this.overrides.addAll(role.getOverrides())); 48 | } 49 | 50 | public boolean add(Role role) { 51 | if (this.roles.add(role)) { 52 | this.dirty = true; 53 | this.rebuildOverridesAndNotify(); 54 | return true; 55 | } 56 | 57 | return false; 58 | } 59 | 60 | public boolean remove(Role role) { 61 | if (this.roles.remove(role)) { 62 | this.dirty = true; 63 | this.rebuildOverridesAndNotify(); 64 | return true; 65 | } 66 | 67 | return false; 68 | } 69 | 70 | @Override 71 | public Iterator iterator() { 72 | return this.roles.iterator(); 73 | } 74 | 75 | @Override 76 | public Stream stream() { 77 | return Stream.concat( 78 | this.roles.stream(), 79 | Stream.of(this.everyoneRole) 80 | ); 81 | } 82 | 83 | @Override 84 | public boolean has(Role role) { 85 | return role == this.everyoneRole || this.roles.contains(role); 86 | } 87 | 88 | @Override 89 | public RoleOverrideReader overrides() { 90 | return this.overrides; 91 | } 92 | 93 | public NbtList serialize() { 94 | var list = new NbtList(); 95 | for (var role : this.roles) { 96 | list.add(NbtString.of(role.getId())); 97 | } 98 | return list; 99 | } 100 | 101 | public void deserialize(RoleProvider roleProvider, List names) { 102 | this.roles.clear(); 103 | 104 | for (var name : names) { 105 | var role = roleProvider.get(name); 106 | if (role == null || name.equalsIgnoreCase(PlayerRoles.EVERYONE)) { 107 | this.dirty = true; 108 | PlayerRoles.LOGGER.warn("Encountered invalid role '{}'", name); 109 | continue; 110 | } 111 | 112 | this.roles.add(role); 113 | } 114 | 115 | this.rebuildOverrides(); 116 | } 117 | 118 | public void setDirty(boolean dirty) { 119 | this.dirty = dirty; 120 | } 121 | 122 | public boolean isDirty() { 123 | return this.dirty; 124 | } 125 | 126 | public boolean isEmpty() { 127 | return this.roles.isEmpty(); 128 | } 129 | 130 | public void reloadFrom(RoleProvider roleProvider, PlayerRoleSet roles) { 131 | var names = roles.stream().map(Role::getId).toList(); 132 | this.deserialize(roleProvider, names); 133 | 134 | this.dirty |= roles.dirty; 135 | } 136 | 137 | public void copyFrom(PlayerRoleSet roles) { 138 | this.roles.clear(); 139 | this.roles.addAll(roles.roles); 140 | this.dirty = roles.dirty; 141 | 142 | this.rebuildOverrides(); 143 | } 144 | 145 | public PlayerRoleSet copy() { 146 | PlayerRoleSet copy = new PlayerRoleSet(this.everyoneRole, this.player); 147 | copy.copyFrom(this); 148 | return copy; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/config/RoleConfigMap.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.config; 2 | 3 | import com.google.common.collect.Iterators; 4 | import com.mojang.datafixers.util.Pair; 5 | import com.mojang.serialization.Dynamic; 6 | import dev.gegy.roles.PlayerRoles; 7 | import dev.gegy.roles.override.RoleOverrideMap; 8 | import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; 9 | import org.jetbrains.annotations.NotNull; 10 | import org.jetbrains.annotations.Nullable; 11 | 12 | import java.util.ArrayList; 13 | import java.util.Collections; 14 | import java.util.Comparator; 15 | import java.util.Iterator; 16 | import java.util.List; 17 | import java.util.Locale; 18 | import java.util.Map; 19 | import java.util.stream.Collectors; 20 | import java.util.stream.Stream; 21 | 22 | public final class RoleConfigMap implements Iterable> { 23 | private final Map roles; 24 | private final List roleOrder; 25 | 26 | RoleConfigMap(Map roles, List roleOrder) { 27 | this.roles = roles; 28 | this.roleOrder = roleOrder; 29 | } 30 | 31 | public static RoleConfigMap parse(Dynamic root, ConfigErrorConsumer errorConsumer) { 32 | var roleEntries = root.asMapOpt().result().orElse(Stream.empty()).toList(); 33 | 34 | var roleBuilder = new Builder(); 35 | 36 | for (var entry : roleEntries) { 37 | var name = entry.getFirst().asString(PlayerRoles.EVERYONE).toLowerCase(Locale.ROOT); 38 | RoleConfig.CODEC.parse(entry.getSecond()) 39 | .ifSuccess(role -> roleBuilder.add(name, role)) 40 | .ifError(error -> errorConsumer.report("Failed to parse role config for '" + name + "'", error)); 41 | } 42 | 43 | return roleBuilder.build(errorConsumer); 44 | } 45 | 46 | @Nullable 47 | public RoleConfig get(String name) { 48 | return this.roles.get(name); 49 | } 50 | 51 | @NotNull 52 | @Override 53 | public Iterator> iterator() { 54 | return Iterators.transform(this.roleOrder.iterator(), name -> Pair.of(name, this.roles.get(name))); 55 | } 56 | 57 | static final class Builder { 58 | private final Map roles = new Object2ObjectOpenHashMap<>(); 59 | private final List roleOrder = new ArrayList<>(); 60 | 61 | Builder() { 62 | } 63 | 64 | public Builder add(String name, RoleConfig role) { 65 | this.roles.put(name, role); 66 | this.roleOrder.add(name); 67 | return this; 68 | } 69 | 70 | public RoleConfigMap build(ConfigErrorConsumer error) { 71 | var roles = this.roles; 72 | var order = this.sortRoles(); 73 | 74 | roles = this.resolveIncludes(roles, order, error); 75 | 76 | return new RoleConfigMap(roles, order); 77 | } 78 | 79 | private Map resolveIncludes(Map roles, List order, ConfigErrorConsumer error) { 80 | Map result = new Object2ObjectOpenHashMap<>(roles.size()); 81 | 82 | for (String name : order) { 83 | var role = roles.get(name); 84 | 85 | var resolvedOverrides = new RoleOverrideMap(); 86 | resolvedOverrides.addAll(role.overrides); 87 | 88 | // add includes to our resolved overrides with lower priority than our own 89 | for (var include : role.includes) { 90 | var includeRole = result.get(include); 91 | if (includeRole != null) { 92 | resolvedOverrides.addAll(includeRole.overrides); 93 | } else { 94 | if (roles.containsKey(include)) { 95 | error.report("'" + name + "' tried to include '" + include + "' but it is of a higher level"); 96 | } else { 97 | error.report("'" + name + "' tried to include '" + include + "' but it does not exist"); 98 | } 99 | } 100 | } 101 | 102 | result.put(name, new RoleConfig(role.level, resolvedOverrides, new String[0], role.apply)); 103 | } 104 | 105 | return result; 106 | } 107 | 108 | private List sortRoles() { 109 | List roleOrder = new ArrayList<>(this.roleOrder); 110 | 111 | Collections.reverse(roleOrder); 112 | roleOrder.sort(Comparator.comparingInt(name -> { 113 | var role = this.roles.get(name); 114 | return role.level; 115 | })); 116 | 117 | return roleOrder; 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/permission/PermissionKeyRules.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override.permission; 2 | 3 | import com.mojang.serialization.Codec; 4 | import dev.gegy.roles.api.override.RoleOverrideResult; 5 | import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; 6 | import org.jetbrains.annotations.Nullable; 7 | 8 | import java.util.ArrayList; 9 | import java.util.HashMap; 10 | import java.util.List; 11 | import java.util.Map; 12 | 13 | public final class PermissionKeyRules { 14 | public static final Codec CODEC = Codec.unboundedMap(Codec.STRING, RoleOverrideResult.CODEC).xmap( 15 | map -> { 16 | PermissionKeyRules.Builder rules = PermissionKeyRules.builder(); 17 | map.forEach(rules::add); 18 | return rules.build(); 19 | }, 20 | override -> { 21 | var map = new HashMap<>(override.exactPermissions); 22 | for (var keyMatcher : override.keyMatchers) { 23 | map.put(keyMatcher.asPattern(), keyMatcher.result); 24 | } 25 | return map; 26 | } 27 | ); 28 | 29 | private final Map exactPermissions; 30 | private final KeyMatcher[] keyMatchers; 31 | 32 | private PermissionKeyRules(Map exactPermissions, KeyMatcher[] keyMatchers) { 33 | this.exactPermissions = exactPermissions; 34 | this.keyMatchers = keyMatchers; 35 | } 36 | 37 | public static Builder builder() { 38 | return new Builder(); 39 | } 40 | 41 | public RoleOverrideResult test(String permission) { 42 | var result = this.exactPermissions.get(permission); 43 | if (result != null) { 44 | return result; 45 | } 46 | 47 | var tokens = permission.split("\\."); 48 | for (var matcher : this.keyMatchers) { 49 | result = matcher.test(tokens); 50 | if (result != null) { 51 | return result; 52 | } 53 | } 54 | 55 | return RoleOverrideResult.PASS; 56 | } 57 | 58 | public static class Builder { 59 | private final Map exactPermissions = new Object2ObjectOpenHashMap<>(); 60 | private final List keyMatchers = new ArrayList<>(); 61 | 62 | Builder() { 63 | } 64 | 65 | public Builder add(String key, RoleOverrideResult result) { 66 | if (key.contains("*")) { 67 | this.keyMatchers.add(new KeyMatcher(key, result)); 68 | } else { 69 | this.exactPermissions.putIfAbsent(key, result); 70 | } 71 | return this; 72 | } 73 | 74 | public PermissionKeyRules build() { 75 | return new PermissionKeyRules(this.exactPermissions, this.keyMatchers.toArray(new KeyMatcher[0])); 76 | } 77 | } 78 | 79 | static final class KeyMatcher { 80 | final String[] pattern; 81 | final RoleOverrideResult result; 82 | 83 | KeyMatcher(String permission, RoleOverrideResult result) { 84 | this.pattern = permission.split("\\."); 85 | this.result = result; 86 | } 87 | 88 | @Nullable 89 | RoleOverrideResult test(String[] tokens) { 90 | var pattern = this.pattern; 91 | int patternIdx = 0; 92 | String endWildcard = null; 93 | 94 | for (var token : tokens) { 95 | if (endWildcard == null) { 96 | var match = pattern[patternIdx]; 97 | if (match.equals("*")) { 98 | if (++patternIdx < pattern.length) { 99 | endWildcard = pattern[patternIdx]; 100 | continue; 101 | } else { 102 | return this.result; 103 | } 104 | } 105 | 106 | if (token.equals(match)) { 107 | if (++patternIdx >= pattern.length) { 108 | return this.result; 109 | } 110 | } else { 111 | return null; 112 | } 113 | } else { 114 | if (token.equals(endWildcard)) { 115 | endWildcard = null; 116 | if (++patternIdx >= pattern.length) { 117 | return this.result; 118 | } 119 | } 120 | } 121 | } 122 | 123 | if (endWildcard != null) { 124 | return null; 125 | } 126 | 127 | return this.result; 128 | } 129 | 130 | String asPattern() { 131 | return String.join(".", this.pattern); 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/NameDecorationOverride.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override; 2 | 3 | import com.mojang.serialization.Codec; 4 | import com.mojang.serialization.DataResult; 5 | import com.mojang.serialization.codecs.RecordCodecBuilder; 6 | import net.minecraft.text.HoverEvent; 7 | import net.minecraft.text.MutableText; 8 | import net.minecraft.text.Style; 9 | import net.minecraft.text.Text; 10 | import net.minecraft.text.TextCodecs; 11 | import net.minecraft.text.TextColor; 12 | import net.minecraft.util.Formatting; 13 | import net.minecraft.util.StringIdentifiable; 14 | import org.jetbrains.annotations.Nullable; 15 | import xyz.nucleoid.codecs.MoreCodecs; 16 | 17 | import java.util.ArrayList; 18 | import java.util.EnumSet; 19 | import java.util.List; 20 | import java.util.Optional; 21 | 22 | public record NameDecorationOverride( 23 | Optional prefix, 24 | Optional suffix, 25 | Optional applyStyle, 26 | Optional onHover, 27 | EnumSet contexts 28 | ) { 29 | private static final EnumSet DEFAULT_CONTEXTS = EnumSet.allOf(Context.class); 30 | 31 | public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( 32 | AddPrefix.CODEC.optionalFieldOf("prefix").forGetter(NameDecorationOverride::prefix), 33 | AddSuffix.CODEC.optionalFieldOf("suffix").forGetter(NameDecorationOverride::suffix), 34 | ApplyStyle.CODEC.optionalFieldOf("style").forGetter(NameDecorationOverride::applyStyle), 35 | OnHover.CODEC.optionalFieldOf("hover").forGetter(NameDecorationOverride::onHover), 36 | Context.SET_CODEC.optionalFieldOf("contexts", DEFAULT_CONTEXTS).forGetter(NameDecorationOverride::contexts) 37 | ).apply(i, NameDecorationOverride::new)); 38 | 39 | public MutableText apply(MutableText name, Context context) { 40 | if (!this.contexts.contains(context)) { 41 | return name; 42 | } 43 | if (this.applyStyle.isPresent()) { 44 | name = this.applyStyle.get().apply(name); 45 | } 46 | if (this.onHover.isPresent()) { 47 | name = this.onHover.get().apply(name); 48 | } 49 | if (this.prefix.isPresent()) { 50 | name = this.prefix.get().apply(name); 51 | } 52 | if (this.suffix.isPresent()) { 53 | name = this.suffix.get().apply(name); 54 | } 55 | return name; 56 | } 57 | 58 | public record AddPrefix(Text prefix) { 59 | public static final Codec CODEC = TextCodecs.CODEC.xmap(AddPrefix::new, AddPrefix::prefix); 60 | 61 | public MutableText apply(final MutableText name) { 62 | return Text.empty().append(this.prefix).append(name); 63 | } 64 | } 65 | 66 | public record AddSuffix(Text suffix) { 67 | public static final Codec CODEC = TextCodecs.CODEC.xmap(AddSuffix::new, AddSuffix::suffix); 68 | 69 | public MutableText apply(final MutableText name) { 70 | return name.append(this.suffix); 71 | } 72 | } 73 | 74 | public record ApplyStyle(Formatting[] formats, @Nullable TextColor color) { 75 | public static final Codec CODEC = MoreCodecs.listOrUnit(Codec.STRING).xmap( 76 | formatKeys -> { 77 | List formats = new ArrayList<>(); 78 | TextColor color = null; 79 | 80 | for (String formatKey : formatKeys) { 81 | var format = Formatting.byName(formatKey); 82 | if (format != null) { 83 | formats.add(format); 84 | } else { 85 | var parsedColor = TextColor.parse(formatKey).result(); 86 | if (parsedColor.isPresent()) { 87 | color = parsedColor.get(); 88 | } 89 | } 90 | } 91 | 92 | return new ApplyStyle(formats.toArray(new Formatting[0]), color); 93 | }, 94 | override -> { 95 | List formatKeys = new ArrayList<>(); 96 | if (override.color != null) { 97 | formatKeys.add(override.color.getName()); 98 | } 99 | 100 | for (var format : override.formats) { 101 | formatKeys.add(format.getName()); 102 | } 103 | 104 | return formatKeys; 105 | } 106 | ); 107 | 108 | public MutableText apply(MutableText text) { 109 | return text.setStyle(this.applyStyle(text.getStyle())); 110 | } 111 | 112 | private Style applyStyle(Style style) { 113 | style = style.withFormatting(this.formats); 114 | if (this.color != null) { 115 | style = style.withColor(this.color); 116 | } 117 | return style; 118 | } 119 | } 120 | 121 | public record OnHover(HoverEvent event) { 122 | private static final Codec CODEC = HoverEvent.CODEC.xmap(OnHover::new, OnHover::event); 123 | public MutableText apply(MutableText text) { 124 | return text.setStyle(text.getStyle().withHoverEvent(this.event)); 125 | } 126 | } 127 | 128 | public enum Context implements StringIdentifiable { 129 | CHAT("chat"), 130 | TAB_LIST("tab_list"), 131 | ; 132 | 133 | public static final com.mojang.serialization.Codec CODEC = StringIdentifiable.createCodec(Context::values); 134 | 135 | public static final com.mojang.serialization.Codec> SET_CODEC = Context.CODEC.listOf().comapFlatMap(list -> { 136 | var set = EnumSet.noneOf(Context.class); 137 | for (var context : list) { 138 | if (!set.add(context)) { 139 | return DataResult.error(() -> "Duplicate entry in set: " + context.name()); 140 | } 141 | } 142 | return DataResult.success(set); 143 | }, ArrayList::new); 144 | 145 | private final String name; 146 | 147 | Context(final String name) { 148 | this.name = name; 149 | } 150 | 151 | @Override 152 | public String asString() { 153 | return this.name; 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/store/PlayerRoleManager.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.store; 2 | 3 | import dev.gegy.roles.config.PlayerRolesConfig; 4 | import dev.gegy.roles.store.db.PlayerRoleDatabase; 5 | import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; 6 | import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; 7 | import net.minecraft.entity.Entity; 8 | import net.minecraft.entity.player.PlayerEntity; 9 | import net.minecraft.server.MinecraftServer; 10 | import net.minecraft.server.network.ServerPlayerEntity; 11 | import net.minecraft.util.WorldSavePath; 12 | import org.apache.commons.io.IOUtils; 13 | import org.jetbrains.annotations.Nullable; 14 | 15 | import java.io.IOException; 16 | import java.util.List; 17 | import java.util.Map; 18 | import java.util.Objects; 19 | import java.util.UUID; 20 | import java.util.function.Function; 21 | 22 | public final class PlayerRoleManager { 23 | private static PlayerRoleManager instance; 24 | 25 | private final PlayerRoleDatabase database; 26 | private final Map onlinePlayerRoles = new Object2ObjectOpenHashMap<>(); 27 | 28 | private PlayerRoleManager(PlayerRoleDatabase database) { 29 | this.database = database; 30 | } 31 | 32 | public static void setup() { 33 | ServerLifecycleEvents.SERVER_STARTING.register(server -> { 34 | instance = PlayerRoleManager.open(server); 35 | }); 36 | 37 | ServerLifecycleEvents.SERVER_STOPPED.register(server -> { 38 | var instance = PlayerRoleManager.instance; 39 | if (instance != null) { 40 | PlayerRoleManager.instance = null; 41 | instance.close(server); 42 | } 43 | }); 44 | } 45 | 46 | private static PlayerRoleManager open(MinecraftServer server) { 47 | try { 48 | var path = server.getSavePath(WorldSavePath.PLAYERDATA).resolve("player_roles"); 49 | var database = PlayerRoleDatabase.open(path); 50 | return new PlayerRoleManager(database); 51 | } catch (IOException e) { 52 | throw new RuntimeException("failed to open player roles database"); 53 | } 54 | } 55 | 56 | public static PlayerRoleManager get() { 57 | return Objects.requireNonNull(instance, "player role manager not initialized"); 58 | } 59 | 60 | public void onPlayerJoin(ServerPlayerEntity player) { 61 | var config = PlayerRolesConfig.get(); 62 | var roles = new PlayerRoleSet(config.everyone(), player); 63 | this.database.tryLoadInto(player.getUuid(), roles); 64 | this.onlinePlayerRoles.put(player.getUuid(), roles); 65 | } 66 | 67 | public void onPlayerLeave(ServerPlayerEntity player) { 68 | var roles = this.onlinePlayerRoles.remove(player.getUuid()); 69 | if (roles != null && roles.isDirty()) { 70 | this.database.trySave(player.getUuid(), roles); 71 | roles.setDirty(false); 72 | } 73 | } 74 | 75 | public void onRoleReload(MinecraftServer server, PlayerRolesConfig config) { 76 | for (var player : server.getPlayerManager().getPlayerList()) { 77 | var newRoles = new PlayerRoleSet(config.everyone(), player); 78 | var oldRoles = this.onlinePlayerRoles.put(player.getUuid(), newRoles); 79 | if (oldRoles != null) { 80 | newRoles.reloadFrom(config, oldRoles); 81 | newRoles.rebuildOverridesAndNotify(); 82 | } 83 | } 84 | } 85 | 86 | private void close(MinecraftServer server) { 87 | try { 88 | for (var player : server.getPlayerManager().getPlayerList()) { 89 | this.onPlayerLeave(player); 90 | } 91 | } finally { 92 | IOUtils.closeQuietly(this.database); 93 | } 94 | } 95 | 96 | public void addLegacyRoles(ServerPlayerEntity player, List names) { 97 | var roles = this.onlinePlayerRoles.get(player.getUuid()); 98 | if (roles != null) { 99 | roles.deserialize(PlayerRolesConfig.get(), names); 100 | roles.setDirty(true); 101 | } 102 | } 103 | 104 | public R updateRoles(MinecraftServer server, UUID uuid, Function update) { 105 | var roles = this.onlinePlayerRoles.get(uuid); 106 | if (roles != null) { 107 | return update.apply(roles); 108 | } else { 109 | roles = this.loadOfflinePlayerRoles(uuid); 110 | 111 | try { 112 | return update.apply(roles); 113 | } finally { 114 | if (roles.isDirty()) { 115 | this.database.trySave(uuid, roles); 116 | } 117 | } 118 | } 119 | } 120 | 121 | public PlayerRoleSet peekRoles(MinecraftServer server, UUID uuid) { 122 | var roles = this.onlinePlayerRoles.get(uuid); 123 | return roles != null ? roles : this.loadOfflinePlayerRoles(uuid); 124 | } 125 | 126 | private PlayerRoleSet loadOfflinePlayerRoles(UUID uuid) { 127 | var config = PlayerRolesConfig.get(); 128 | 129 | PlayerRoleSet roles = new PlayerRoleSet(config.everyone(), null); 130 | this.database.tryLoadInto(uuid, roles); 131 | 132 | return roles; 133 | } 134 | 135 | @Nullable 136 | public PlayerRoleSet getOnlinePlayerRoles(Entity entity) { 137 | if (entity instanceof PlayerEntity) { 138 | return this.onlinePlayerRoles.get(entity.getUuid()); 139 | } 140 | return null; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/override/command/CommandRequirementHooks.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.override.command; 2 | 3 | import com.google.common.collect.HashMultimap; 4 | import com.google.common.collect.Multimap; 5 | import com.mojang.brigadier.CommandDispatcher; 6 | import com.mojang.brigadier.tree.CommandNode; 7 | import com.mojang.brigadier.tree.RootCommandNode; 8 | import dev.gegy.roles.PlayerRoles; 9 | 10 | import java.lang.reflect.Field; 11 | import java.util.Arrays; 12 | import java.util.function.BiConsumer; 13 | import java.util.function.Predicate; 14 | 15 | public final class CommandRequirementHooks { 16 | private static final int MAX_CHAIN_LENGTH = 16; 17 | 18 | private final RequirementOverride override; 19 | private final Field requirementField; 20 | 21 | private CommandRequirementHooks(RequirementOverride override, Field requirementField) { 22 | this.override = override; 23 | this.requirementField = requirementField; 24 | } 25 | 26 | public static CommandRequirementHooks tryCreate(RequirementOverride override) throws ReflectiveOperationException { 27 | var requirementField = CommandNode.class.getDeclaredField("requirement"); 28 | requirementField.setAccessible(true); 29 | return new CommandRequirementHooks<>(override, requirementField); 30 | } 31 | 32 | @SuppressWarnings("unchecked") 33 | public void applyTo(CommandDispatcher dispatcher) throws ReflectiveOperationException { 34 | var nodes = dispatcher.getRoot().getChildren(); 35 | 36 | Multimap, Predicate> overrides = HashMultimap.create(); 37 | BiConsumer, Predicate> override = overrides::put; 38 | 39 | for (var node : nodes) { 40 | this.collectRecursive(new CommandNode[] { node }, override); 41 | } 42 | 43 | for (var node : overrides.keySet()) { 44 | var requirements = overrides.get(node); 45 | 46 | var requirement = this.anyRequirement(requirements.toArray(new Predicate[0])); 47 | this.requirementField.set(node, requirement); 48 | } 49 | } 50 | 51 | private void collectRecursive(CommandNode[] nodes, BiConsumer, Predicate> override) { 52 | if (nodes.length >= MAX_CHAIN_LENGTH) { 53 | var chain = new StringBuilder(); 54 | for (var node : nodes) { 55 | chain.append(node.getName()).append(" "); 56 | } 57 | 58 | PlayerRoles.LOGGER.warn("Aborting hooking long command chain with {} nodes: {}", MAX_CHAIN_LENGTH, chain.toString()); 59 | 60 | return; 61 | } 62 | 63 | var tail = nodes[nodes.length - 1]; 64 | var children = tail.getChildren(); 65 | 66 | var requirement = this.createRequirementFor(nodes); 67 | override.accept(tail, requirement); 68 | 69 | var redirect = tail.getRedirect(); 70 | if (redirect != null && children.isEmpty() && this.canRedirectTo(nodes, redirect)) { 71 | var redirectNodes = Arrays.copyOf(nodes, nodes.length); 72 | redirectNodes[redirectNodes.length - 1] = redirect; 73 | 74 | var redirectRequirement = this.createRequirementFor(redirectNodes); 75 | 76 | // set our override on the redirect, and set the redirect override on us 77 | override.accept(tail, redirectRequirement); 78 | override.accept(redirect, requirement); 79 | 80 | // from here, we instead process from the redirect 81 | children = redirect.getChildren(); 82 | } 83 | 84 | for (var child : children) { 85 | if (this.isChildRecursive(nodes, child)) { 86 | continue; 87 | } 88 | 89 | var childNodes = Arrays.copyOf(nodes, nodes.length + 1); 90 | childNodes[childNodes.length - 1] = child; 91 | this.collectRecursive(childNodes, override); 92 | } 93 | } 94 | 95 | private boolean canRedirectTo(CommandNode[] nodes, CommandNode node) { 96 | // we don't want to redirect back to every other command 97 | if (node instanceof RootCommandNode) { 98 | return false; 99 | } 100 | 101 | // we don't want to redirect back to ourself 102 | return !this.isChildRecursive(nodes, node); 103 | } 104 | 105 | private boolean isChildRecursive(CommandNode[] nodes, CommandNode child) { 106 | for (var node : nodes) { 107 | if (node == child) { 108 | return true; 109 | } 110 | } 111 | return false; 112 | } 113 | 114 | private Predicate createRequirementFor(CommandNode[] nodes) { 115 | var chainRequirements = this.requirementForChain(nodes); 116 | return this.override.apply(nodes, chainRequirements); 117 | } 118 | 119 | @SuppressWarnings("unchecked") 120 | private Predicate requirementForChain(CommandNode[] nodes) { 121 | var requirementTree = new Predicate[nodes.length]; 122 | for (int i = 0; i < nodes.length; i++) { 123 | var node = nodes[i]; 124 | requirementTree[i] = node.getRequirement(); 125 | } 126 | 127 | return this.allRequirements(requirementTree); 128 | } 129 | 130 | private Predicate anyRequirement(Predicate[] requirements) { 131 | if (requirements.length == 0) { 132 | return s -> false; 133 | } else if (requirements.length == 1) { 134 | return requirements[0]; 135 | } 136 | 137 | return s -> { 138 | for (var requirement : requirements) { 139 | if (requirement.test(s)) { 140 | return true; 141 | } 142 | } 143 | return false; 144 | }; 145 | } 146 | 147 | private Predicate allRequirements(Predicate[] requirements) { 148 | if (requirements.length == 0) { 149 | return s -> true; 150 | } else if (requirements.length == 1) { 151 | return requirements[0]; 152 | } 153 | 154 | return s -> { 155 | for (var requirement : requirements) { 156 | if (!requirement.test(s)) { 157 | return false; 158 | } 159 | } 160 | return true; 161 | }; 162 | } 163 | 164 | public interface RequirementOverride { 165 | Predicate apply(CommandNode[] nodes, Predicate parent); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/config/PlayerRolesConfig.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.config; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import com.google.common.collect.ImmutableMap; 5 | import com.google.gson.JsonParser; 6 | import com.google.gson.JsonSyntaxException; 7 | import com.mojang.datafixers.util.Pair; 8 | import com.mojang.serialization.Dynamic; 9 | import com.mojang.serialization.JsonOps; 10 | import dev.gegy.roles.PlayerRoles; 11 | import dev.gegy.roles.SimpleRole; 12 | import dev.gegy.roles.api.PlayerRolesApi; 13 | import dev.gegy.roles.api.Role; 14 | import dev.gegy.roles.api.RoleProvider; 15 | import dev.gegy.roles.store.ServerRoleSet; 16 | import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet; 17 | import org.jetbrains.annotations.NotNull; 18 | import org.jetbrains.annotations.Nullable; 19 | 20 | import java.io.IOException; 21 | import java.nio.file.Files; 22 | import java.nio.file.Path; 23 | import java.nio.file.Paths; 24 | import java.util.ArrayList; 25 | import java.util.Iterator; 26 | import java.util.List; 27 | import java.util.function.Predicate; 28 | import java.util.stream.Stream; 29 | 30 | public final class PlayerRolesConfig implements RoleProvider { 31 | private static PlayerRolesConfig instance = new PlayerRolesConfig(List.of(), SimpleRole.empty(PlayerRoles.EVERYONE)); 32 | 33 | private final ImmutableMap roles; 34 | private final SimpleRole everyone; 35 | 36 | private ServerRoleSet commandBlockRoles; 37 | private ServerRoleSet functionRoles; 38 | 39 | private PlayerRolesConfig(List roles, SimpleRole everyone) { 40 | ImmutableMap.Builder roleMap = ImmutableMap.builder(); 41 | for (SimpleRole role : roles) { 42 | roleMap.put(role.getId(), role); 43 | } 44 | this.roles = roleMap.build(); 45 | 46 | this.everyone = everyone; 47 | } 48 | 49 | private ServerRoleSet buildRoles(Predicate apply) { 50 | var roleSet = new ObjectAVLTreeSet(); 51 | this.roles.values().stream() 52 | .filter(role -> apply.test(role.getApply())) 53 | .forEach(roleSet::add); 54 | 55 | return ServerRoleSet.of(roleSet); 56 | } 57 | 58 | public static PlayerRolesConfig get() { 59 | return instance; 60 | } 61 | 62 | public static List setup() { 63 | var path = Paths.get("config/roles.json"); 64 | if (!Files.exists(path)) { 65 | if (!createDefaultConfig(path)) { 66 | return ImmutableList.of(); 67 | } 68 | } 69 | 70 | List errors = new ArrayList<>(); 71 | ConfigErrorConsumer errorConsumer = errors::add; 72 | 73 | try (var reader = Files.newBufferedReader(path)) { 74 | var root = JsonParser.parseReader(reader); 75 | var config = parse(new Dynamic<>(JsonOps.INSTANCE, root), errorConsumer); 76 | instance = config; 77 | 78 | PlayerRolesApi.setRoleProvider(config); 79 | } catch (IOException e) { 80 | errorConsumer.report("Failed to read roles.json configuration", e); 81 | PlayerRoles.LOGGER.warn("Failed to load roles.json configuration", e); 82 | } catch (JsonSyntaxException e) { 83 | errorConsumer.report("Malformed syntax in roles.json configuration", e); 84 | PlayerRoles.LOGGER.warn("Malformed syntax in roles.json configuration", e); 85 | } 86 | 87 | return errors; 88 | } 89 | 90 | private static boolean createDefaultConfig(Path path) { 91 | try { 92 | if (!Files.exists(path.getParent())) { 93 | Files.createDirectories(path.getParent()); 94 | } 95 | 96 | var legacyPath = Paths.get("roles.json"); 97 | if (Files.exists(legacyPath)) { 98 | Files.move(legacyPath, path); 99 | return true; 100 | } 101 | 102 | try (var input = PlayerRoles.class.getResourceAsStream("/data/player-roles/default_roles.json")) { 103 | Files.copy(input, path); 104 | return true; 105 | } 106 | } catch (IOException e) { 107 | PlayerRoles.LOGGER.warn("Failed to load default roles.json configuration", e); 108 | return false; 109 | } 110 | } 111 | 112 | private static PlayerRolesConfig parse(Dynamic root, ConfigErrorConsumer error) { 113 | var roleConfigs = RoleConfigMap.parse(root, error); 114 | 115 | var everyone = SimpleRole.empty(PlayerRoles.EVERYONE); 116 | List roles = new ArrayList<>(); 117 | 118 | int index = 1; 119 | for (Pair entry : roleConfigs) { 120 | String name = entry.getFirst(); 121 | RoleConfig roleConfig = entry.getSecond(); 122 | 123 | if (!name.equalsIgnoreCase(PlayerRoles.EVERYONE)) { 124 | roles.add(roleConfig.create(name, index++)); 125 | } else { 126 | everyone = roleConfig.create(name, 0); 127 | } 128 | } 129 | 130 | return new PlayerRolesConfig(roles, everyone); 131 | } 132 | 133 | @Override 134 | @Nullable 135 | public SimpleRole get(String name) { 136 | return this.roles.get(name); 137 | } 138 | 139 | @NotNull 140 | public SimpleRole everyone() { 141 | return this.everyone; 142 | } 143 | 144 | public ServerRoleSet getCommandBlockRoles() { 145 | var commandBlockRoles = this.commandBlockRoles; 146 | if (commandBlockRoles == null) { 147 | this.commandBlockRoles = commandBlockRoles = this.buildRoles(RoleApplyConfig::commandBlock); 148 | } 149 | return commandBlockRoles; 150 | } 151 | 152 | public ServerRoleSet getFunctionRoles() { 153 | var functionRoles = this.functionRoles; 154 | if (functionRoles == null) { 155 | this.functionRoles = functionRoles = this.buildRoles(RoleApplyConfig::functions); 156 | } 157 | return functionRoles; 158 | } 159 | 160 | @NotNull 161 | @Override 162 | @SuppressWarnings("unchecked") 163 | public Iterator iterator() { 164 | return (Iterator) (Iterator) this.roles.values().iterator(); 165 | } 166 | 167 | @Override 168 | @SuppressWarnings("unchecked") 169 | public Stream stream() { 170 | return (Stream) (Stream) this.roles.values().stream(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/PlayerRoles.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles; 2 | 3 | import com.mojang.logging.LogUtils; 4 | import com.mojang.serialization.Codec; 5 | import dev.gegy.roles.api.PlayerRolesApi; 6 | import dev.gegy.roles.api.RoleLookup; 7 | import dev.gegy.roles.api.RoleOwner; 8 | import dev.gegy.roles.api.RoleReader; 9 | import dev.gegy.roles.api.override.RoleOverrideType; 10 | import dev.gegy.roles.command.PlayerRolesEntitySelectorOptions; 11 | import dev.gegy.roles.command.RoleCommand; 12 | import dev.gegy.roles.config.PlayerRolesConfig; 13 | import dev.gegy.roles.override.ChatTypeOverride; 14 | import dev.gegy.roles.override.NameDecorationOverride; 15 | import dev.gegy.roles.override.command.CommandOverride; 16 | import dev.gegy.roles.override.permission.PermissionKeyOverride; 17 | import dev.gegy.roles.store.PlayerRoleManager; 18 | import net.fabricmc.api.ModInitializer; 19 | import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; 20 | import net.fabricmc.fabric.api.message.v1.ServerMessageEvents; 21 | import net.fabricmc.loader.api.FabricLoader; 22 | import net.minecraft.entity.Entity; 23 | import net.minecraft.network.packet.s2c.play.PlayerListS2CPacket; 24 | import net.minecraft.server.MinecraftServer; 25 | import net.minecraft.server.command.ServerCommandSource; 26 | import net.minecraft.server.network.ServerPlayerEntity; 27 | import net.minecraft.text.Text; 28 | import net.minecraft.util.Formatting; 29 | import net.minecraft.util.Identifier; 30 | import org.jetbrains.annotations.NotNull; 31 | import org.slf4j.Logger; 32 | 33 | import java.util.UUID; 34 | 35 | public final class PlayerRoles implements ModInitializer { 36 | public static final String ID = "player_roles"; 37 | public static final Logger LOGGER = LogUtils.getLogger(); 38 | 39 | public static final String EVERYONE = "everyone"; 40 | 41 | public static final RoleOverrideType COMMANDS = registerOverride("commands", CommandOverride.CODEC) 42 | .withChangeListener(player -> { 43 | var server = player.getServer(); 44 | if (server != null) { 45 | server.getCommandManager().sendCommandTree(player); 46 | } 47 | }); 48 | 49 | public static final RoleOverrideType CHAT_TYPE = registerOverride("chat_type", ChatTypeOverride.CODEC); 50 | public static final RoleOverrideType NAME_DECORATION = registerOverride("name_decoration", NameDecorationOverride.CODEC) 51 | .withChangeListener(player -> { 52 | var packet = new PlayerListS2CPacket(PlayerListS2CPacket.Action.UPDATE_DISPLAY_NAME, player); 53 | player.getServer().getPlayerManager().sendToAll(packet); 54 | }); 55 | public static final RoleOverrideType COMMAND_FEEDBACK = registerOverride("command_feedback", Codec.BOOL); 56 | public static final RoleOverrideType MUTE = registerOverride("mute", Codec.BOOL); 57 | public static final RoleOverrideType PERMISSION_LEVEL = registerOverride("permission_level", Codec.intRange(0, 4)); 58 | public static final RoleOverrideType ENTITY_SELECTORS = registerOverride("entity_selectors", Codec.BOOL); 59 | public static final RoleOverrideType BYPASS_PLAYER_LIMIT = registerOverride("bypass_player_limit", Codec.BOOL); 60 | 61 | private static RoleOverrideType registerOverride(String id, Codec codec) { 62 | return RoleOverrideType.register(PlayerRoles.identifier(id), codec); 63 | } 64 | 65 | static { 66 | PlayerRolesApi.setRoleLookup(new RoleLookup() { 67 | @Override 68 | @NotNull 69 | public RoleReader byEntity(Entity entity) { 70 | var onlineRoles = PlayerRoleManager.get().getOnlinePlayerRoles(entity); 71 | if (onlineRoles != null) { 72 | return onlineRoles; 73 | } 74 | 75 | if (entity instanceof RoleOwner roleOwner) { 76 | return roleOwner.getRoles(); 77 | } 78 | 79 | return RoleReader.EMPTY; 80 | } 81 | 82 | @Override 83 | @NotNull 84 | public RoleReader bySource(ServerCommandSource source) { 85 | var entity = source.getEntity(); 86 | if (entity != null) { 87 | return this.byEntity(entity); 88 | } 89 | 90 | if (source instanceof RoleOwner roleOwner) { 91 | return roleOwner.getRoles(); 92 | } 93 | 94 | if (source instanceof IdentifiableCommandSource identifiable) { 95 | return switch (identifiable.player_roles$getIdentityType()) { 96 | case COMMAND_BLOCK -> PlayerRolesConfig.get().getCommandBlockRoles(); 97 | case FUNCTION -> PlayerRolesConfig.get().getFunctionRoles(); 98 | default -> RoleReader.EMPTY; 99 | }; 100 | } 101 | 102 | return RoleReader.EMPTY; 103 | } 104 | }); 105 | } 106 | 107 | @Override 108 | public void onInitialize() { 109 | registerModIntegrations(); 110 | 111 | var errors = PlayerRolesConfig.setup(); 112 | if (!errors.isEmpty()) { 113 | LOGGER.warn("Failed to load player-roles config! ({} errors)", errors.size()); 114 | for (var error : errors) { 115 | LOGGER.warn(" - {}", error); 116 | } 117 | } 118 | 119 | PlayerRoleManager.setup(); 120 | 121 | CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { 122 | RoleCommand.register(dispatcher); 123 | }); 124 | 125 | PlayerRolesEntitySelectorOptions.register(); 126 | 127 | CommandOverride.initialize(); 128 | 129 | ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> trySendChat(sender)); 130 | ServerMessageEvents.ALLOW_COMMAND_MESSAGE.register((message, source, params) -> trySendChat(source)); 131 | } 132 | 133 | private static void registerModIntegrations() { 134 | if (FabricLoader.getInstance().isModLoaded("fabric-permissions-api-v0")) { 135 | registerPermissionKeyOverride(); 136 | } 137 | } 138 | 139 | private static void registerPermissionKeyOverride() { 140 | PermissionKeyOverride.register(); 141 | } 142 | 143 | public static boolean trySendChat(ServerCommandSource source) { 144 | final ServerPlayerEntity player = source.getPlayer(); 145 | return player == null || trySendChat(player); 146 | } 147 | 148 | public static boolean trySendChat(ServerPlayerEntity player) { 149 | var roles = PlayerRolesApi.lookup().byPlayer(player); 150 | if (roles.overrides().test(PlayerRoles.MUTE)) { 151 | player.sendMessage(Text.literal("You are muted!").formatted(Formatting.RED), true); 152 | return false; 153 | } 154 | return true; 155 | } 156 | 157 | public static boolean canBypassPlayerLimit(MinecraftServer server, UUID playerUuid) { 158 | return PlayerRoleManager.get().peekRoles(server, playerUuid).overrides().test(PlayerRoles.BYPASS_PLAYER_LIMIT); 159 | } 160 | 161 | public static Identifier identifier(String path) { 162 | return Identifier.of(ID, path); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/test/java/dev/gegy/roles/RoleDatabaseTests.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles; 2 | 3 | import dev.gegy.roles.store.db.Uuid2BinaryDatabase; 4 | import org.apache.commons.lang3.StringUtils; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.io.IOException; 8 | import java.nio.ByteBuffer; 9 | import java.nio.charset.StandardCharsets; 10 | import java.nio.file.Files; 11 | import java.nio.file.Path; 12 | import java.nio.file.Paths; 13 | import java.util.Arrays; 14 | import java.util.UUID; 15 | 16 | import static org.junit.jupiter.api.Assertions.*; 17 | 18 | final class RoleDatabaseTests { 19 | private static final UUID FOO = UUID.fromString("61fd2fee-c62a-393f-b126-a9885f9b4361"); 20 | private static final UUID BAR = UUID.fromString("303505c1-798d-3df3-ab8d-6c701f3fe36a"); 21 | private static final UUID BAZ = UUID.fromString("fc74fe37-e9f9-3198-8e46-1eee97cacfa6"); 22 | 23 | private static final Path DATABASE_PATH = Paths.get("test_database"); 24 | 25 | @Test 26 | void testAddOne() throws IOException { 27 | Uuid2BinaryDatabase database = createEmptyDatabase(); 28 | database.put(FOO, encode("foo")); 29 | 30 | assertEquals(decode(database.get(FOO)), "foo"); 31 | assertNull(database.get(BAR)); 32 | } 33 | 34 | @Test 35 | void testAddAndUpdateOne() throws IOException { 36 | Uuid2BinaryDatabase database = createEmptyDatabase(); 37 | database.put(FOO, encode("foo")); 38 | assertEquals(decode(database.get(FOO)), "foo"); 39 | 40 | database.put(FOO, encode("not foo")); 41 | assertEquals(decode(database.get(FOO)), "not foo"); 42 | } 43 | 44 | @Test 45 | void testAddAndRemoveOne() throws IOException { 46 | Uuid2BinaryDatabase database = createEmptyDatabase(); 47 | database.put(FOO, encode("foo")); 48 | assertEquals(decode(database.get(FOO)), "foo"); 49 | 50 | assertTrue(database.remove(FOO)); 51 | assertNull(database.get(FOO)); 52 | } 53 | 54 | @Test 55 | void testAddAndShrinkInMiddle() throws IOException { 56 | Uuid2BinaryDatabase database = createEmptyDatabase(); 57 | database.put(FOO, encode("foo")); 58 | database.put(BAR, encode("bar")); 59 | database.put(BAZ, encode("baz")); 60 | 61 | assertEquals(decode(database.get(FOO)), "foo"); 62 | assertEquals(decode(database.get(BAR)), "bar"); 63 | assertEquals(decode(database.get(BAZ)), "baz"); 64 | 65 | database.put(BAR, encode("b")); 66 | 67 | assertEquals(decode(database.get(FOO)), "foo"); 68 | assertEquals(decode(database.get(BAR)), "b"); 69 | assertEquals(decode(database.get(BAZ)), "baz"); 70 | } 71 | 72 | @Test 73 | void testAddAndGrowInMiddle() throws IOException { 74 | Uuid2BinaryDatabase database = createEmptyDatabase(); 75 | database.put(FOO, encode("foo")); 76 | database.put(BAR, encode("bar")); 77 | database.put(BAZ, encode("baz")); 78 | 79 | assertEquals(decode(database.get(FOO)), "foo"); 80 | assertEquals(decode(database.get(BAR)), "bar"); 81 | assertEquals(decode(database.get(BAZ)), "baz"); 82 | 83 | database.put(BAR, encode("baaar")); 84 | 85 | assertEquals(decode(database.get(FOO)), "foo"); 86 | assertEquals(decode(database.get(BAR)), "baaar"); 87 | assertEquals(decode(database.get(BAZ)), "baz"); 88 | } 89 | 90 | @Test 91 | void testAddAndRemoveInMiddle() throws IOException { 92 | Uuid2BinaryDatabase database = createEmptyDatabase(); 93 | database.put(FOO, encode("foo")); 94 | database.put(BAR, encode("bar")); 95 | database.put(BAZ, encode("baz")); 96 | 97 | assertEquals(decode(database.get(FOO)), "foo"); 98 | assertEquals(decode(database.get(BAR)), "bar"); 99 | assertEquals(decode(database.get(BAZ)), "baz"); 100 | 101 | assertTrue(database.remove(BAR)); 102 | 103 | assertEquals(decode(database.get(FOO)), "foo"); 104 | assertNull(database.get(BAR)); 105 | assertEquals(decode(database.get(BAZ)), "baz"); 106 | } 107 | 108 | @Test 109 | void testPersistent() throws IOException { 110 | Uuid2BinaryDatabase database = createEmptyDatabase(); 111 | database.put(FOO, encode("foo")); 112 | database.put(BAZ, encode("baz")); 113 | 114 | assertEquals(decode(database.get(FOO)), "foo"); 115 | assertEquals(decode(database.get(BAZ)), "baz"); 116 | 117 | database = reopenDatabase(); 118 | assertEquals(decode(database.get(FOO)), "foo"); 119 | assertEquals(decode(database.get(BAZ)), "baz"); 120 | } 121 | 122 | @Test 123 | void testBigDatabaseGrow() throws IOException { 124 | UUID[] uuids = createUuids(30); 125 | 126 | Uuid2BinaryDatabase database = createEmptyDatabase(); 127 | for (UUID uuid : uuids) { 128 | database.put(uuid, encode(uuid.toString())); 129 | } 130 | 131 | String padding = StringUtils.repeat('a', 20); 132 | for (int i = 0; i < uuids.length; i += 4) { 133 | UUID uuid = uuids[i]; 134 | database.put(uuid, encode(uuid.toString() + padding)); 135 | } 136 | } 137 | 138 | @Test 139 | void testBigDatabaseRemove() throws IOException { 140 | UUID[] uuids = createUuids(30); 141 | 142 | Uuid2BinaryDatabase database = createEmptyDatabase(); 143 | for (UUID uuid : uuids) { 144 | database.put(uuid, encode(uuid.toString())); 145 | } 146 | 147 | for (int i = 0; i < uuids.length; i += 4) { 148 | UUID uuid = uuids[i]; 149 | database.remove(uuid); 150 | } 151 | } 152 | 153 | @Test 154 | void testBigDatabaseShrink() throws IOException { 155 | UUID[] uuids = createUuids(30); 156 | 157 | Uuid2BinaryDatabase database = createEmptyDatabase(); 158 | for (UUID uuid : uuids) { 159 | database.put(uuid, encode(uuid.toString())); 160 | } 161 | 162 | for (int i = 0; i < uuids.length; i += 4) { 163 | UUID uuid = uuids[i]; 164 | database.put(uuid, encode("a")); 165 | } 166 | } 167 | 168 | @Test 169 | void testBigDatabaseGrowAndRemoveAndShrink() throws IOException { 170 | UUID[] uuids = createUuids(30); 171 | boolean[] set = new boolean[uuids.length]; 172 | Arrays.fill(set, true); 173 | 174 | Uuid2BinaryDatabase database = createEmptyDatabase(); 175 | for (UUID uuid : uuids) { 176 | database.put(uuid, encode(uuid.toString())); 177 | } 178 | 179 | String padding = StringUtils.repeat('a', 20); 180 | for (int i = 0; i < uuids.length; i += 4) { 181 | UUID uuid = uuids[i]; 182 | database.put(uuid, encode(uuid.toString() + padding)); 183 | set[i] = false; 184 | } 185 | 186 | for (int i = 1; i < uuids.length; i += 4) { 187 | UUID uuid = uuids[i]; 188 | database.remove(uuid); 189 | set[i] = false; 190 | } 191 | 192 | for (int i = 3; i < uuids.length; i += 4) { 193 | UUID uuid = uuids[i]; 194 | database.put(uuid, encode("a")); 195 | set[i] = false; 196 | } 197 | 198 | for (int i = 0; i < uuids.length; i++) { 199 | if (set[i]) { 200 | UUID uuid = uuids[i]; 201 | assertEquals(decode(database.get(uuid)), uuid.toString()); 202 | } 203 | } 204 | } 205 | 206 | private static UUID[] createUuids(int amount) { 207 | UUID[] uuids = new UUID[amount]; 208 | for (int i = 0; i < uuids.length; i++) { 209 | uuids[i] = UUID.nameUUIDFromBytes(("id " + i).getBytes(StandardCharsets.UTF_8)); 210 | } 211 | return uuids; 212 | } 213 | 214 | private static Uuid2BinaryDatabase createEmptyDatabase() throws IOException { 215 | Files.deleteIfExists(DATABASE_PATH); 216 | return Uuid2BinaryDatabase.open(DATABASE_PATH); 217 | } 218 | 219 | private static Uuid2BinaryDatabase reopenDatabase() throws IOException { 220 | return Uuid2BinaryDatabase.open(DATABASE_PATH); 221 | } 222 | 223 | private static ByteBuffer encode(String text) { 224 | return ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8)); 225 | } 226 | 227 | private static String decode(ByteBuffer bytes) { 228 | if (bytes != null) { 229 | return new String(bytes.array(), StandardCharsets.UTF_8); 230 | } else { 231 | return null; 232 | } 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/command/RoleCommand.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.command; 2 | 3 | import com.mojang.authlib.GameProfile; 4 | import com.mojang.brigadier.Command; 5 | import com.mojang.brigadier.CommandDispatcher; 6 | import com.mojang.brigadier.arguments.StringArgumentType; 7 | import com.mojang.brigadier.exceptions.CommandSyntaxException; 8 | import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; 9 | import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; 10 | import com.mojang.brigadier.suggestion.SuggestionProvider; 11 | import dev.gegy.roles.SimpleRole; 12 | import dev.gegy.roles.api.PlayerRolesApi; 13 | import dev.gegy.roles.api.Role; 14 | import dev.gegy.roles.config.PlayerRolesConfig; 15 | import dev.gegy.roles.override.command.CommandOverride; 16 | import dev.gegy.roles.store.PlayerRoleManager; 17 | import dev.gegy.roles.store.PlayerRoleSet; 18 | import net.minecraft.command.CommandSource; 19 | import net.minecraft.command.argument.GameProfileArgumentType; 20 | import net.minecraft.server.MinecraftServer; 21 | import net.minecraft.server.command.ServerCommandSource; 22 | import net.minecraft.text.MutableText; 23 | import net.minecraft.text.Style; 24 | import net.minecraft.text.Text; 25 | import net.minecraft.text.Texts; 26 | import net.minecraft.util.Formatting; 27 | import org.jetbrains.annotations.Nullable; 28 | 29 | import java.util.Collection; 30 | import java.util.Comparator; 31 | import java.util.function.BiPredicate; 32 | 33 | import static net.minecraft.server.command.CommandManager.argument; 34 | import static net.minecraft.server.command.CommandManager.literal; 35 | 36 | public final class RoleCommand { 37 | public static final DynamicCommandExceptionType ROLE_NOT_FOUND = new DynamicCommandExceptionType(arg -> 38 | Text.stringifiedTranslatable("Role with name '%s' was not found!", arg) 39 | ); 40 | 41 | public static final SimpleCommandExceptionType ROLE_POWER_TOO_LOW = new SimpleCommandExceptionType( 42 | Text.literal("You do not have sufficient power to manage this role") 43 | ); 44 | 45 | public static final SimpleCommandExceptionType TOO_MANY_SELECTED = new SimpleCommandExceptionType( 46 | Text.literal("Too many players selected!") 47 | ); 48 | 49 | // @formatter:off 50 | public static void register(CommandDispatcher dispatcher) { 51 | dispatcher.register(literal("role") 52 | .requires(s -> s.hasPermissionLevel(4)) 53 | .then(literal("assign") 54 | .then(argument("targets", GameProfileArgumentType.gameProfile()) 55 | .then(argument("role", StringArgumentType.word()).suggests(roleSuggestions()) 56 | .executes(ctx -> { 57 | var source = ctx.getSource(); 58 | var targets = GameProfileArgumentType.getProfileArgument(ctx, "targets"); 59 | var roleName = StringArgumentType.getString(ctx, "role"); 60 | return updateRoles(source, targets, roleName, PlayerRoleSet::add, "'%s' assigned to %s players"); 61 | }) 62 | ))) 63 | .then(literal("remove") 64 | .then(argument("targets", GameProfileArgumentType.gameProfile()) 65 | .then(argument("role", StringArgumentType.word()).suggests(roleSuggestions()) 66 | .executes(ctx -> { 67 | var source = ctx.getSource(); 68 | var targets = GameProfileArgumentType.getProfileArgument(ctx, "targets"); 69 | var roleName = StringArgumentType.getString(ctx, "role"); 70 | return updateRoles(source, targets, roleName, PlayerRoleSet::remove, "'%s' removed from %s players"); 71 | }) 72 | ))) 73 | .then(literal("list") 74 | .then(argument("target", GameProfileArgumentType.gameProfile()).executes(ctx -> { 75 | var source = ctx.getSource(); 76 | var gameProfiles = GameProfileArgumentType.getProfileArgument(ctx, "target"); 77 | if (gameProfiles.size() != 1) { 78 | throw TOO_MANY_SELECTED.create(); 79 | } 80 | return listRoles(source, gameProfiles.iterator().next()); 81 | })) 82 | ) 83 | .then(literal("reload").executes(ctx -> reloadRoles(ctx.getSource()))) 84 | ); 85 | } 86 | // @formatter:on 87 | 88 | private static int updateRoles(ServerCommandSource source, Collection players, String roleName, BiPredicate apply, String success) throws CommandSyntaxException { 89 | var role = getRole(roleName); 90 | requireHasPower(source, role); 91 | 92 | var roleManager = PlayerRoleManager.get(); 93 | MinecraftServer server = source.getServer(); 94 | 95 | int count = 0; 96 | for (var player : players) { 97 | boolean applied = roleManager.updateRoles(server, player.getId(), roles -> apply.test(roles, role)); 98 | if (applied) { 99 | count++; 100 | } 101 | } 102 | 103 | int finalCount = count; 104 | source.sendFeedback(() -> Text.translatable(success, roleName, finalCount), true); 105 | 106 | return Command.SINGLE_SUCCESS; 107 | } 108 | 109 | private static int listRoles(ServerCommandSource source, GameProfile player) { 110 | var roleManager = PlayerRoleManager.get(); 111 | var server = source.getServer(); 112 | 113 | var roles = roleManager.peekRoles(server, player.getId()).stream().toList(); 114 | source.sendFeedback(() -> { 115 | var rolesComponent = Texts.join(roles, role -> Text.literal(role.getId()).setStyle(Style.EMPTY.withColor(Formatting.GRAY))); 116 | return Text.translatable("Found %s roles on player: %s", roles.size(), rolesComponent); 117 | }, false); 118 | 119 | return Command.SINGLE_SUCCESS; 120 | } 121 | 122 | private static int reloadRoles(ServerCommandSource source) { 123 | var server = source.getServer(); 124 | 125 | server.execute(() -> { 126 | var errors = PlayerRolesConfig.setup(); 127 | 128 | var roleManager = PlayerRoleManager.get(); 129 | roleManager.onRoleReload(server, PlayerRolesConfig.get()); 130 | 131 | if (errors.isEmpty()) { 132 | source.sendFeedback(() -> Text.literal("Role configuration successfully reloaded"), false); 133 | } else { 134 | MutableText errorFeedback = Text.literal("Failed to reload roles configuration!"); 135 | for (String error : errors) { 136 | errorFeedback = errorFeedback.append("\n - " + error); 137 | } 138 | source.sendError(errorFeedback); 139 | } 140 | }); 141 | 142 | return Command.SINGLE_SUCCESS; 143 | } 144 | 145 | private static void requireHasPower(ServerCommandSource source, SimpleRole role) throws CommandSyntaxException { 146 | if (hasAdminPower(source)) { 147 | return; 148 | } 149 | 150 | var highestRole = getHighestRole(source); 151 | if (highestRole == null || role.compareTo(highestRole) <= 0) { 152 | throw ROLE_POWER_TOO_LOW.create(); 153 | } 154 | } 155 | 156 | private static SimpleRole getRole(String roleName) throws CommandSyntaxException { 157 | var role = PlayerRolesConfig.get().get(roleName); 158 | if (role == null) throw ROLE_NOT_FOUND.create(roleName); 159 | return role; 160 | } 161 | 162 | private static SuggestionProvider roleSuggestions() { 163 | return (ctx, builder) -> { 164 | var source = ctx.getSource(); 165 | 166 | boolean admin = hasAdminPower(source); 167 | var highestRole = getHighestRole(source); 168 | Comparator comparator = Comparator.nullsLast(Comparator.naturalOrder()); 169 | 170 | return CommandSource.suggestMatching( 171 | PlayerRolesConfig.get().stream() 172 | .filter(role -> admin || comparator.compare(role, highestRole) > 0) 173 | .map(Role::getId), 174 | builder 175 | ); 176 | }; 177 | } 178 | 179 | @Nullable 180 | private static Role getHighestRole(ServerCommandSource source) { 181 | return PlayerRolesApi.lookup().bySource(source).stream() 182 | .min(Comparator.naturalOrder()) 183 | .orElse(null); 184 | } 185 | 186 | private static boolean hasAdminPower(ServerCommandSource source) { 187 | return source.getEntity() == null || CommandOverride.doesBypassPermissions(source); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Player Roles for Fabric 2 | This is a simple implementation allowing for custom permissions to be assigned to players via Discord-like "roles". 3 | Roles and their permissions are defined within a JSON file, which can be easily modified and reloaded at runtime for rapid iteration. 4 | 5 | The roles.json file is located in the config directory (`/config/roles.json`). An example configuration may look like: 6 | ```json 7 | { 8 | "admin": { 9 | "level": 100, 10 | "overrides": { 11 | "name_decoration": { 12 | "style": ["red", "bold"], 13 | "suffix": {"text": "*"} 14 | }, 15 | "permission_level": 4, 16 | "command_feedback": true, 17 | "commands": { 18 | ".*": "allow" 19 | } 20 | } 21 | }, 22 | "spectator": { 23 | "level": 10, 24 | "overrides": { 25 | "commands": { 26 | "gamemode (spectator|adventure)": "allow" 27 | } 28 | } 29 | }, 30 | "mute": { 31 | "level": 1, 32 | "overrides": { 33 | "mute": true 34 | } 35 | }, 36 | "everyone": { 37 | "overrides": { 38 | "commands": { 39 | "help": "allow", 40 | ".*": "deny" 41 | } 42 | } 43 | } 44 | } 45 | ``` 46 | 47 | But what's going on here? This JSON file is declaring three roles: `admin`, `spectator` and `everyone`. 48 | 49 | `everyone` is the default role: every player will have this role, and it cannot be removed. 50 | The other roles that are specified function as overrides on top of the `everyone` role. 51 | 52 | ### Overrides 53 | Within each role declaration, we list a set of overrides. Overrides are the generic system that this mod uses to change game behavior based on roles. 54 | Currently, the supported override types are `commands`, `name_decoration`, `chat_type`, `mute`, `command_feedback`, `permission_level` and `entity_selectors`. 55 | 56 | It is important to consider how overrides are applied when multiple roles target the same things. Conflicts like this are resolved by always choosing the role with the highest level. 57 | So, in the case of the example: although `everyone` declares every command except `help` to be disallowed, because `admin` and `spectator` have higher levels, they will override this behaviour. 58 | 59 | #### Commands 60 | The `commands` override is used to manipulate the commands that a player is able to use. 61 | Each override entry specifies a regular expression pattern to match, and then a strategy for how to respond when the mod encounters that pattern. 62 | 63 | For example, the pattern `.*` matches every possible command, while `gamemode (spectator|adventure)` would match the gamemode command only with spectator and adventure mode. 64 | The strategies that can then be used alongside these patterns are `allow` and `deny`: 65 | `allow` will make sure that the player is allowed to use this command, while `deny` will prevent the player from using this command. 66 | 67 | For example: 68 | ```json 69 | "commands": { 70 | "gamemode (spectator|adventure)": "allow" 71 | } 72 | ``` 73 | 74 | The commands override can additionally make use of the `hidden` rule result, which will allow the command to be used, 75 | while hiding it from command suggestions. 76 | 77 | #### Name Decoration 78 | The `name_decoration` override modifies how the names of players with a role are displayed. This can be used to override name colors as well as prepend or append text. 79 | This has lower priority than scoreboard team colors. 80 | 81 | Name decoration might be declared like: 82 | ```json 83 | "name_decoration": { 84 | "prefix": {"text": "[Prefix] ", "color": "green"}, 85 | "suffix": {"text": "-Suffix"}, 86 | "style": ["#ff0000", "bold", "underline"], 87 | "hover": {"action": "show_text", "value": "hello!"}, 88 | "contexts": ["chat", "tab_list"] 89 | } 90 | ``` 91 | 92 | Three fields can be optionally declared: 93 | - `style`: accepts a list of text formatting types or hex colors 94 | - `prefix`: accepts a text component that is prepended before the name 95 | - `suffix`: accepts a text component that is appended after the name 96 | - `hover`: accepts an object with an `action` field. Additional properties depend on `action`: 97 | - `action` is `show_text`: accepts a `value` field, containing a text component shown as a tooltip when the name is hovered 98 | - `action` is `show_item`: accepts the following additional properties, to show an ItemStack as a tooltip when the name is hovered: 99 | - `id`: an item 100 | - `count`: optional count for the ItemStack 101 | - `components`: optional data component overrides for the ItemStack 102 | - `action` is `show_entity`: accepts the following additional properties, to show an entity as a tooltip when the name is hovered: 103 | - `id`: an entity type 104 | - `uuid`: the UUID of an entity 105 | - `name`: an optional text component name for the entity 106 | - `contexts`: accepts a set of possible contexts defining where this decoration should be applied 107 | - Accepts: `chat` and `tab_list` 108 | - Default: applies to all possible contexts 109 | 110 | #### Chat Types 111 | The `chat_type` override allows the chat message decorations to be replaced for all players with a role. 112 | This integrates with the Vanilla `minecraft:chat_type` registry, which can be altered with a datapack. 113 | 114 | The `chat_type` override declares simply the `chat_type` that should be used: 115 | ```json 116 | "chat_type": "minecraft:say_command" 117 | ``` 118 | 119 | This example will replace all messages for players with a given role to apply the `say_command` style. 120 | 121 | It is important to note that Vanilla chat type registry is loaded from the datapack on server start, and cannot be hot-reloaded like the player roles config. 122 | 123 | ##### Declaring custom chat types 124 | Custom chat types can be declared with a custom datapack in `data//chat_type/`. 125 | 126 | For example, we might declare a `data/mydatapack/chat_type/admin.json`: 127 | ```json 128 | { 129 | "chat": { 130 | "decoration": { 131 | "parameters": ["sender", "content"], 132 | "style": {}, 133 | "translation_key": "%s: %s <- an admin said this!" 134 | } 135 | }, 136 | "narration": { 137 | "decoration": { 138 | "parameters": ["sender", "content"], 139 | "style": {}, 140 | "translation_key": "chat.type.text.narrate" 141 | }, 142 | "priority": "chat" 143 | } 144 | } 145 | ``` 146 | 147 | Which can be then referenced in an override like: 148 | ```json 149 | "chat_type": "mydatapack:admin" 150 | ``` 151 | 152 | #### Permission Level 153 | The `permission_level` override sets the vanilla [permission level](https://minecraft.gamepedia.com/Server.properties#op-permission-level) for assigned players. 154 | This is useful for interacting with other mods, as well as with vanilla features that aren't supported by this mod. 155 | 156 | Permission level is declared like: 157 | ```json 158 | "permission_level": 4 159 | ``` 160 | 161 | #### Mute 162 | The `mute` override functions very simply by preventing assigned players from typing in chat. 163 | 164 | Mute is declared like: 165 | ```json 166 | "mute": true 167 | ``` 168 | 169 | #### Command Feedback 170 | By default, all operators receive global feedback when another player runs a command. 171 | The `command_feedback` override allows specific roles to receive this same kind of feedback. 172 | 173 | Command feedback is declared like: 174 | ```json 175 | "command_feedback": true 176 | ``` 177 | 178 | #### Entity Selectors 179 | Normally, only command sources with a permission level of two or higher can use entity selectors. 180 | The `entity_selectors` override allows specific roles to use entity selectors. 181 | 182 | Entity selectors can be allowed like: 183 | ```json 184 | "entity_selectors": true 185 | ``` 186 | 187 | ### Other configuration 188 | Roles can additionally be applied to command blocks or function executors through the configuration file. 189 | For example: 190 | ```json 191 | { 192 | "commands": { 193 | "apply": { 194 | "command_block": true, 195 | "function": true 196 | }, 197 | "overrides": { 198 | } 199 | } 200 | } 201 | ``` 202 | 203 | It may also be useful for a role to inherit the overrides from another role. 204 | This can be done with the `includes` declaration by referencing other roles with a lower level. 205 | For example: 206 | ```json 207 | { 208 | "foo": { 209 | "includes": ["bar"], 210 | "overrides": { 211 | "commands": { 212 | ".*": "allow" 213 | } 214 | } 215 | }, 216 | "bar": { 217 | "overrides": { 218 | "name_decoration": { 219 | "style": "red" 220 | } 221 | } 222 | } 223 | } 224 | ``` 225 | 226 | With this configuration, the `foo` role will inherit the red `name_decoration`. 227 | 228 | ### Applying roles in-game 229 | Once you've made modifications to the `roles.json` file, you can reload it by using the `/role reload`. 230 | 231 | All role management goes through this `role` command via various subcommands. For example: 232 | 233 | - `role assign Gegy admin`: assigns the `admin` role to `Gegy` 234 | - `role remove Gegy admin`: removes the `admin` role from `Gegy` 235 | - `role list Gegy`: lists all the roles that have been applied to `Gegy` 236 | - `role reload`: reloads the `roles.json` configuration file 237 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /src/main/java/dev/gegy/roles/store/db/Uuid2BinaryDatabase.java: -------------------------------------------------------------------------------- 1 | package dev.gegy.roles.store.db; 2 | 3 | import it.unimi.dsi.fastutil.objects.Object2LongMap; 4 | import it.unimi.dsi.fastutil.objects.Object2LongMaps; 5 | import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap; 6 | import org.jetbrains.annotations.Nullable; 7 | 8 | import java.io.Closeable; 9 | import java.io.IOException; 10 | import java.nio.ByteBuffer; 11 | import java.nio.ByteOrder; 12 | import java.nio.IntBuffer; 13 | import java.nio.LongBuffer; 14 | import java.nio.channels.FileChannel; 15 | import java.nio.file.Path; 16 | import java.nio.file.StandardOpenOption; 17 | import java.util.UUID; 18 | 19 | /** 20 | * Very simple persistent database indexed by UUID. This implementation is not optimized for performance, but rather 21 | * for simplicity. 22 | */ 23 | public final class Uuid2BinaryDatabase implements Closeable { 24 | private static final int MAX_VALUE_SIZE = 4 * 1024 * 1024; 25 | 26 | private static final int UUID_BYTES = 16; 27 | private static final int SIZE_BYTES = 4; 28 | private static final int HEADER_BYTES = UUID_BYTES + SIZE_BYTES; 29 | 30 | private static final long NULL_POINTER = -1; 31 | 32 | private static final ByteOrder BYTE_ORDER = ByteOrder.BIG_ENDIAN; 33 | 34 | private final FileChannel file; 35 | private final Object2LongMap pointers; 36 | 37 | private final ByteBuffer uuidBytes = ByteBuffer.allocate(16).order(BYTE_ORDER); 38 | private final ByteBuffer sizeBytes = ByteBuffer.allocate(4).order(BYTE_ORDER); 39 | private final LongBuffer uuidBuffer = this.uuidBytes.asLongBuffer(); 40 | private final IntBuffer sizeBuffer = this.sizeBytes.asIntBuffer(); 41 | 42 | private final ByteBuffer[] headerBytes = new ByteBuffer[] { this.uuidBytes, this.sizeBytes }; 43 | 44 | private Uuid2BinaryDatabase(FileChannel file, Object2LongMap pointers) { 45 | this.file = file; 46 | this.pointers = pointers; 47 | this.pointers.defaultReturnValue(NULL_POINTER); 48 | } 49 | 50 | public static Uuid2BinaryDatabase open(Path path) throws IOException { 51 | var channel = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE); 52 | var pointers = buildPointerIndex(channel); 53 | return new Uuid2BinaryDatabase(channel, pointers); 54 | } 55 | 56 | private static Object2LongMap buildPointerIndex(FileChannel channel) throws IOException { 57 | Object2LongMap pointers = new Object2LongOpenHashMap<>(); 58 | 59 | var uuidBytes = ByteBuffer.allocate(16).order(BYTE_ORDER); 60 | var sizeBytes = ByteBuffer.allocate(4).order(BYTE_ORDER); 61 | var uuidBuffer = uuidBytes.asLongBuffer(); 62 | var sizeBuffer = sizeBytes.asIntBuffer(); 63 | 64 | int pointer = 0; 65 | 66 | long fileSize = channel.size(); 67 | while (pointer < fileSize) { 68 | channel.position(pointer); 69 | 70 | uuidBytes.clear(); 71 | sizeBytes.clear(); 72 | channel.read(uuidBytes); 73 | channel.read(sizeBytes); 74 | 75 | var uuid = new UUID(uuidBuffer.get(0), uuidBuffer.get(1)); 76 | int size = validateSize(sizeBuffer.get(0)); 77 | 78 | pointers.put(uuid, pointer); 79 | 80 | pointer += HEADER_BYTES + size; 81 | } 82 | 83 | return pointers; 84 | } 85 | 86 | @Nullable 87 | public synchronized ByteBuffer get(UUID key) throws IOException { 88 | long pointer = this.pointers.getLong(key); 89 | if (pointer == NULL_POINTER) { 90 | return null; 91 | } 92 | 93 | this.file.position(pointer + UUID_BYTES); 94 | 95 | this.sizeBytes.clear(); 96 | this.readToEnd(this.sizeBytes); 97 | 98 | int size = this.sizeBuffer.get(0); 99 | var buffer = ByteBuffer.allocate(size).order(BYTE_ORDER); 100 | this.readToEnd(buffer); 101 | 102 | return buffer; 103 | } 104 | 105 | public synchronized void put(UUID key, ByteBuffer bytes) throws IOException { 106 | validateSize(bytes.capacity()); 107 | 108 | long pointer = this.pointers.getLong(key); 109 | if (pointer == NULL_POINTER) { 110 | this.push(key, bytes); 111 | } else { 112 | this.update(pointer, bytes); 113 | } 114 | } 115 | 116 | public synchronized boolean remove(UUID key) throws IOException { 117 | long pointer = this.pointers.removeLong(key); 118 | if (pointer == NULL_POINTER) { 119 | return false; 120 | } 121 | 122 | this.file.position(pointer + UUID_BYTES); 123 | 124 | this.sizeBytes.clear(); 125 | this.readToEnd(this.sizeBytes); 126 | 127 | int size = validateSize(this.sizeBuffer.get(0)); 128 | 129 | long endPointer = pointer + HEADER_BYTES + size; 130 | this.shiftAfter(endPointer, -(size + HEADER_BYTES)); 131 | 132 | return true; 133 | } 134 | 135 | private void push(UUID key, ByteBuffer bytes) throws IOException { 136 | long pointer = this.file.size(); 137 | this.file.position(pointer); 138 | 139 | this.writeToEnd(this.writeHeader(key, bytes.capacity())); 140 | this.writeToEnd(bytes); 141 | 142 | this.pointers.put(key, pointer); 143 | } 144 | 145 | private ByteBuffer[] writeHeader(UUID key, int size) { 146 | this.uuidBytes.clear(); 147 | this.uuidBuffer.clear(); 148 | this.uuidBuffer.put(key.getMostSignificantBits()).put(key.getLeastSignificantBits()); 149 | 150 | this.sizeBytes.clear(); 151 | this.sizeBuffer.clear(); 152 | this.sizeBuffer.put(size); 153 | 154 | return this.headerBytes; 155 | } 156 | 157 | private void update(long pointer, ByteBuffer bytes) throws IOException { 158 | this.file.position(pointer + UUID_BYTES); 159 | 160 | this.sizeBytes.clear(); 161 | this.readToEnd(this.sizeBytes); 162 | 163 | int lastSize = validateSize(this.sizeBuffer.get(0)); 164 | int newSize = validateSize(bytes.capacity()); 165 | if (lastSize != newSize) { 166 | long endPointer = pointer + HEADER_BYTES + lastSize; 167 | this.shiftAfter(endPointer, newSize - lastSize); 168 | } 169 | 170 | this.sizeBytes.clear(); 171 | this.sizeBuffer.clear(); 172 | this.sizeBuffer.put(newSize); 173 | 174 | this.file.position(pointer + UUID_BYTES); 175 | this.writeToEnd(this.sizeBytes); 176 | this.writeToEnd(bytes); 177 | } 178 | 179 | private void shiftAfter(long source, int amount) throws IOException { 180 | long destination = source + amount; 181 | long length = this.file.size() - source; 182 | 183 | if (amount > 0) { 184 | // make space for the shifted data 185 | this.file.position(this.file.size()); 186 | this.writeToEnd(ByteBuffer.allocate(amount).order(BYTE_ORDER)); 187 | } 188 | 189 | if (length > 0) { 190 | moveBytes(this.file, source, destination, length); 191 | } 192 | 193 | if (amount < 0) { 194 | // shrink the file if it got smaller 195 | this.file.truncate(this.file.size() + amount); 196 | } 197 | 198 | // shift all pointers 199 | for (var entry : Object2LongMaps.fastIterable(this.pointers)) { 200 | long pointer = entry.getLongValue(); 201 | if (pointer >= source) { 202 | entry.setValue(pointer + amount); 203 | } 204 | } 205 | } 206 | 207 | private void writeToEnd(ByteBuffer... buffers) throws IOException { 208 | for (var buffer : buffers) { 209 | this.writeToEnd(buffer); 210 | } 211 | } 212 | 213 | private void writeToEnd(ByteBuffer buffer) throws IOException { 214 | long remaining = buffer.remaining(); 215 | while (remaining > 0) { 216 | remaining -= this.file.write(buffer); 217 | } 218 | } 219 | 220 | private void readToEnd(ByteBuffer buffer) throws IOException { 221 | long remaining = buffer.remaining(); 222 | while (remaining > 0) { 223 | remaining -= this.file.read(buffer); 224 | } 225 | } 226 | 227 | private static void moveBytes(FileChannel file, long source, long destination, long length) throws IOException { 228 | if (source < destination) { 229 | moveBytesForwards(file, source, destination, length); 230 | } else { 231 | moveBytesBackwards(file, source, destination, length); 232 | } 233 | } 234 | 235 | private static void moveBytesForwards(FileChannel file, long source, long destination, long length) throws IOException { 236 | int bufferSize = Math.min(1024, (int) length); 237 | var buffer = ByteBuffer.allocate(bufferSize).order(BYTE_ORDER); 238 | 239 | long backPointer = source + length; 240 | long offset = destination - source; 241 | long remaining = length; 242 | 243 | while (remaining > 0) { 244 | int copySize = bufferSize; 245 | if (remaining < bufferSize) { 246 | copySize = (int) remaining; 247 | buffer = ByteBuffer.allocate(copySize).order(BYTE_ORDER); 248 | } 249 | 250 | long frontPointer = backPointer - copySize; 251 | int read = copyBytes(file, buffer, frontPointer, frontPointer + offset); 252 | 253 | remaining -= read; 254 | backPointer -= read; 255 | } 256 | } 257 | 258 | private static void moveBytesBackwards(FileChannel file, long source, long destination, long length) throws IOException { 259 | int bufferSize = Math.min(1024, (int) length); 260 | var buffer = ByteBuffer.allocate(bufferSize).order(BYTE_ORDER); 261 | 262 | long frontPointer = source; 263 | long offset = destination - source; 264 | long remaining = length; 265 | 266 | while (remaining > 0) { 267 | int read = copyBytes(file, buffer, frontPointer, frontPointer + offset); 268 | remaining -= read; 269 | frontPointer += read; 270 | } 271 | } 272 | 273 | private static int copyBytes(FileChannel file, ByteBuffer buffer, long source, long destination) throws IOException { 274 | file.position(source); 275 | 276 | buffer.clear(); 277 | int read = file.read(buffer); 278 | buffer.flip(); 279 | 280 | file.position(destination); 281 | file.write(buffer); 282 | 283 | return read; 284 | } 285 | 286 | private static int validateSize(int size) throws IOException { 287 | if (size > MAX_VALUE_SIZE) { 288 | throw new IOException("size greater than maximum (" + size + ">" + MAX_VALUE_SIZE + ")"); 289 | } else if (size < 0) { 290 | throw new IOException("size is negative (" + size + "<0)"); 291 | } 292 | return size; 293 | } 294 | 295 | @Override 296 | public synchronized void close() throws IOException { 297 | this.file.close(); 298 | } 299 | } 300 | --------------------------------------------------------------------------------